• SolidJs节点级响应性


    前言

    随着组件化、响应式、虚拟DOM等技术思想引领着前端开发的潮流,相关的技术框架大行其道,就以目前主流的Vue、React框架来说,它们都基于组件化、响应式、虚拟DOM等技术思想的实现,但是具有不同开发使用方式以及实现原理,这里就不再赘述了相关内容,这里关注的焦点在于虚拟DOM。

    无论是Vue还是React都应用虚拟DOM,通过虚拟DOM从而来减少频繁的DOM操作,优化页面性能。随着虚拟DOM应用到实际生产中后,无论是Vue还是React都少不了增加虚拟DOM对象以及相应Diff过程,特别是Diff过程恰恰是影响速度的重要点。人们渐渐思考虚拟DOM真的就比直接操作DOM要快吗?渐渐出现了放弃虚拟DOM的现代化技术框架,例如Solid、Svelte等。

    Svelte和Solid都是放弃虚拟DOM但是应用组件化、响应式等技术的现代化框架,Svelte则比较偏向于Vue形式风格,Solid框架则偏向于React形式风格,但是底层响应式实现完全不同,这篇文章就聊聊Solid框架以及背后相关思想。

    Solid基本说明

    实际上最初Solid吸引我的点就是所谓号称比React还react的言论,作为多年的React框架使用者,我了解背后的实现思想以及实际开发中的痛点,而Solid框架有很多让我想要探索的点:

    • 放弃虚拟DOM下的快速更新效率,没有虚拟DOM 或广泛的差异对比
    • 保持React Hooks风格下的没有所谓的Hook规则
    • 细粒度的响应性控制,函数组件只执行一次以及节点级的UI视图更新策略

    Solid最吸引我的点就是细粒度的响应性,相比于React以及Vue更新策略,Solid可以做到节点级的视图更新:

    • React更新策略基本描述:父组件的状态变更会导致当前子树下所有组件重新运行,需要对无需更新的子组件进行memo操作
    • Vue更新策略的基本描述:每一个组件都对应一个Watcher对象,组件响应性状态与视图Watcher对象关联,组件的状态变更只会影响与之关联的对应视图Watcher对象,即只更新与状态建立联系的组件
    • Solid更新策略的基本描述:组件的状态变更只会影响使用该状态的节点UI视图的更新

    对于Solid的使用可以查看其官网,Solid的基本使用案例如下:

    import { render } from 'solid-js/web';
    import { createSignal } from 'solid-js';
    
    function App() {
      const [loggedIn, setLoggedIn] = createSignal(false);
      const toggle = () => setLoggedIn(!loggedIn())
      
      return (
        <>
          <button onClick={toggle}>Log out</button>
          <button onClick={toggle}>Log in</button>
        </>
      );
    }
    
    render(() => <App />, document.getElementById('app'))
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16

    render处理

    组件经过Solid相关工具编译处理得到的最终代码逻辑如下:

    // render(() => , document.getElementById("app")!);
    render(() => _$createComponent(Counter, {}), document.getElementById("app"));
    
    • 1
    • 2

    上面实例初始化阶段的处理逻辑,render函数的逻辑如下:

    function render(code, element, init, options = {}) {
      let disposer;
      createRoot(dispose => {
        disposer = dispose;
        element === document ? code() : insert(element, code(), element.firstChild ? null : undefined, init);
      }, options.owner);
      return () => {
        disposer();
        element.textContent = "";
      };
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    从上面逻辑可以如下调用链:

    render -> createRoot -> runUpdates -> 顺序执行createRoot传入的回调函数、completeUpdates

    createRoot回调逻辑主要逻辑如下:

      element === document ? code() : insert(element, code(), element.firstChild ? null : undefined, init);
    
    • 1

    主要就是两点逻辑:

    • 执行code函数:code就是render传入的第一个参数,这里就会被执行,通常来说就是函数组件,即函数组件会被执行
    • 执行insert函数:实现组件内容挂载到页面上
    insert函数

    insert函数具体处理逻辑如下:

    function insert(parent, accessor, marker, initial) {
      if (marker !== undefined && !initial) initial = [];
      if (typeof accessor !== "function") return insertExpression(parent, accessor, initial, marker);
      createRenderEffect(current => {
      	insertExpression(parent, accessor(), current, marker);
      }, initial);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    insert函数的accessor参数有两种情况:

    • 非函数类型:意味着是DOM挂载点,就会调用insertExpression,即将节点插入到页面DOM中,从而显示视图
    • 函数类型:意味着是响应性状态,需要调用createRenderEffect函数

    Solid响应性原理

    类比于React的useState、useEffect,Solid提供createSignal、createEffect这两个API:

    • createSignal提供响应性状态定义
    • createEffect提供监听响应性状态变更后需要运行的副作用处理

    createSignal是响应性状态定义来源,这里以下面实例来看看其背后的响应性原理逻辑:

    import { render } from "solid-js/web";
    import { createSignal } from "solid-js";
    
    function Counter() {
      const [getCount, setCount] = createSignal(1);
      const increment = () => setCount(getCount() + 1);
      // 只会执行一次
      console.log('hello');
      
      return (
        <>
          <button type="button" onClick={increment}>
            点击
          </button>
          <div>{getCount()}</div>
        </>
      );
    }
    
    render(() => <Counter />, document.getElementById("app")!);
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20

    很简单的点击按钮增加计数的逻辑,当点击按钮后计数加1,而对应的div节点就会更新但是button节点不会更新。

    实际上上面实例中Counter组件经过处理会变成下面形式:

    unction Counter() {
      const [getCount, setCount] = createSignal(1);
      const increment = () => setCount(getCount() + 1);
      return [(() => {
        const _el$ = _tmpl$();
        _el$.$$click = increment;
        return _el$;
      })(), (() => {
        const _el$2 = _tmpl$2();
        _$insert(_el$2, getCount);
        return _el$2;
      })()];
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    Counter组件的视图部分被处理成一个个自执行函数了,即button节点和div节点分别对应一个自执行函数,如果嵌套多级子节点呢?难道每一个节点都对应一个自执行函数吗?实际上并不是如此逻辑,从实际调试得到的两点逻辑如下:

    • 只有组件的一级子节点才会一一对应一个自执行函数,次级子节点不会
    • 只要节点包含响应性状态内容则会调用_$insert函数来处理

    实际上render过程会触发根组件,即Counter函数组件执行,而函数组件内部中对于Signal状态的节点的处理需要调用_$insert,实际上该函数就是insert函数。此时insert函数的accessor参数就是响应性状态的getter函数,整个调用链如下:

    • 函数组件内部调用insert函数 -> createRenderEffect -> 顺序执行createComputation 、 updateComputation
    • updateComputation -> runComputation -> 执行createRenderEffect传递的回调函数

    createRenderEffect回调函数的逻辑如下:

      createRenderEffect(current => {
      	insertExpression(parent, accessor(), current, marker);
      }, initial);
    
    • 1
    • 2
    • 3

    其逻辑分为两点:

    • accessor函数执行:对应着Signal状态下上文getter函数,即响应性状态的getter函数会在runComputation后被调用处理
    • insertExpression:会将Signal状态值生成的DOM插入到父节点中

    在accessor函数执行之前,需要了解createSignal创建响应性状态的具体逻辑,该API主要处理逻辑代码如下:

    function createSignal(value, options) {
      ...
      const s = {
        value,
        observers: null,
        observerSlots: null,
        comparator: options.equals || undefined
      };
      const setter = value => {
      	...
        return writeSignal(s, value);
      };
      return [readSignal.bind(s), setter];
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    createSignal的主要逻辑很简单,就是返回响应的getter、setter函数并没有其他复杂逻辑执行:

    • 定义包含初始值的s对象,表示上下文对象,后面的getter函数的this对象就是该上下文对象,setter函数也需要使用该上下文对象
    • 上下文对象中的observers本意是观察者,必然是发布订阅模式实现的节点级更新逻辑,后面会与相关对应进行关联从而实现的,和Vue底层逻辑相似,但是不像Vue使用Proxy来实现,Solid仅仅就是单纯的函数
    getter函数执行

    当accessor函数执行时本质上就是执行Signal状态的getter函数,此时就会执行readSignal函数,下面是简化的逻辑:

    function readSignal() {
      ...
      if (Listener) {
        const sSlot = this.observers ? this.observers.length : 0;
        if (!Listener.sources) {
          Listener.sources = [this];
          Listener.sourceSlots = [sSlot];
        } else {
          Listener.sources.push(this);
          Listener.sourceSlots.push(sSlot);
        }
        if (!this.observers) {
          this.observers = [Listener];
          this.observerSlots = [Listener.sources.length - 1];
        } else {
          this.observers.push(Listener);
          this.observerSlots.push(Listener.sources.length - 1);
        }
      }
      ...
      return this.value;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22

    主要逻辑就是:当Listener全局变量存在的情况下,就会将Listener存入上下文对象的observers属性中,那么Listener什么时候有值呢?源码中全局查找Listenerd的赋值操作,就有一个地方,即updateComputation函数的处理,但是该函数的调用来源很多。在初始化阶段updateComputation函数的调用链上面就已经描述清楚了,其来源于createRenderEffect。

    updateComputation中Listener的相关处理逻辑如下:

    function updateComputation(node) {
      if (!node.fn) return;
      ...
      Listener = Owner = node;
      runComputation(node, Transition && Transition.running && Transition.sources.has(node) ? node.tValue : node.value, time);
      ...
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    这里的node参数就是createComputation函数的对象,即Computation对象,该对象的属性有:

      const c = {
        fn,
        state: state,
        updatedAt: null,
        owned: null,
        sources: null,
        sourceSlots: null,
        cleanups: null,
        value: init,
        owner: Owner,
        context: Owner ? Owner.context : null,
        pure
      };
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    所以Listener本质上就是Computation对象,其中该对象的fn属性存放的就是触发响应性状态getter函数的回调函数。

    所以当初始化调用链触发getter函数执行时,Listener就已经存在,之后的处理逻辑就是:

    • 对应Signal状态的上下文对象中保存Listener,即Signal状态上下文中保存Listener到Observes属性中
    • Listener指向的Computation对象的sources属性会保存对应Signal状态的上下文

    此时Computation对象与Signal状态上下文对象就互相关联起来了,后续初始化的处理逻辑就是将生成的节点通过DOM插入方法添加到节点中,没有虚拟DOM Diff的过程,这里就不继续说明了。

    故而函数组件在初始化阶段内部的处理逻辑就非常清晰,具体如下:

    • insert -> createRenderEffect -> 顺序执行createComputation、updateComputation
    • updateComputation -> runComputation -> 执行createRenderEffect传递的回调函数
    • createRenderEffect传递的回调函数内部逻辑会顺序执行Signal状态getter函数、insertExpression,insertExpression会将Signal状态节点插入到父节点中
    更新阶段

    现在点击了按钮,此时上面实例中:首先是先调用getter函数,然后再调用setter函数。此时Signal状态的getter再次被执行,但是Listener会在每次赋值操作后被重置为之前的状态,即初始化阶段updateComputation最后处理时被重置为null,所以getter函数此时执行仅仅返回当前值而已。

    setter函数执行

    执行了setter函数,此时就会执行writeSignal函数,去看看该函数做了什么处理,下面是简化的逻辑(移除了Transition相关的逻辑):

    function writeSignal(node, value, isComp) {
      let current = node.value;
      if (!node.comparator || !node.comparator(current, value)) {
      	...
      	node.value = value;
        if (node.observers && node.observers.length) {
          runUpdates(() => {
            for (let i = 0; i < node.observers.length; i += 1) {
              const o = node.observers[i];
              ...
              if (TransitionRunning ? !o.tState : !o.state) {
                if (o.pure) Updates.push(o);else Effects.push(o);
                if (o.observers) markDownstream(o);
              }
              if (!TransitionRunning) o.state = STALE;else o.tState = STALE;
            }
            if (Updates.length > 10e5) {
              Updates = [];
              if (false) ;
              throw new Error();
            }
          }, false);
        }
      }
      return value;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26

    在初始化处理阶段,Signal状态对应的上下文对象中observers已经保存了Listener对应的Computation对象。在更新阶段时setter函数被调用,此时observers是有值的。故而其调用栈逻辑如下:

    • writeSignal -> runUpdates -> 顺序执行传入的回调函数、completeUpdates
    • 这里的回调函数的逻辑实际上就是将Computation对象添加到Updates、Effects队列中
    • completeUpdates -> runQueue(对Updates、Effects 进行处理) -> 循环遍历队列依次处理,即依次调用runTop -> 一般情况下就会调用updateComputation函数

    需要注意的是更新阶段updateComputation的处理有一个重要的逻辑,即cleanNode函数调用:

    function updateComputation(node) {
      ...
      cleanNode(node);
      ...
      Listener = Owner = node;
      ...
    }
    
    function cleanNode() {
      if (node.sources) {
        while (node.sources.length) {
          const source = node.sources.pop(),
          index = node.sourceSlots.pop(),
          obs = source.observers;
          if (obs && obs.length) {
           const n = obs.pop(), s = source.observerSlots.pop();
           if (index < obs.length) {
             n.sourceSlots[s] = index;
             obs[index] = n;
             source.observerSlots[index] = s;
           }
          }
        }
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25

    Computation对象与Signal状态上下文对象建立的关联在cleanNode对象中被解绑了,即当某个Signal状态更新后,Solid会将对应Signal状态的旧Computation解绑,这就是更新阶段cleanNode主要处理逻辑。

    updateComputation之后的处理就是设置新的Listener对象,之后的处理逻辑就更初始化时相同,即:

    updateComputation -> runComputation -> 执行createRenderEffect传递的回调函数

    从而实现最新的Computation对象与Signal状态上下文关联,之后更新到对应的DOM节点,这个过程就实现了节点级别的视图更新。

    在初始化阶段因为createRenderEffect传递的回调函数的参数中就有当前响应性状态所在位置的父节点,通过闭包特性保证了每次更新都是同一节点位置,从而实现Solid的节点级别的视图更新逻辑。

    副作用处理

    createEffect API是Solid用来处理副作用的方法,该方法类比React的useEffect,不同于useEffect的是不需要指明依赖项,它会自动收集依赖项。

    通过下面实例来了解createEffect的执行过程:

    import { render } from "solid-js/web";
    import { createSignal } from "solid-js";
    
    function Counter() {
      const [getCount, setCount] = createSignal(1);
      const increment = () => setCount(getCount() + 1);
      createEffect(() => {
      	console.log(getCount());
      });
      
      return (
        <>
          <button type="button" onClick={increment}>
            点击
          </button>
          <div>{getCount()}</div>
        </>
      );
    }
    
    render(() => <Counter />, document.getElementById("app")!);
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    初始化阶段

    createEffect方法的逻辑具体如下:

    function createEffect(fn, value, options) {
      runEffects = runUserEffects;
      const c = createComputation(fn, value, false, STALE),
        s = SuspenseContext && useContext(SuspenseContext);
      if (s) c.suspense = s;
      if (!options || !options.render) c.user = true;
      Effects ? Effects.push(c) : updateComputation(c);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    在之前分析createSignal就梳理了初始化过程的关键处理过程,知道相关函数的作用:

    • createComputation:就是创建新的Computation对象
    • updateComputation:就是解绑对应Signal状态上下文对象与旧的Computation对象之间关联,之后将当前新的Computation对象重新与Signal状态上下文对象绑定

    在初始化阶段createEffect逻辑主要就是两点:

    • 创建一个新的Computation对象,即每一个createEffect都会对应一个Computation对象
    • 判断Effects队列是否存在,Effects为数组就会将Computation对象保存到Effects队列中

    当render函数执行时其内部会调用runUpdates函数,之后会执行completeUpdates函数,该函数内部逻辑就是处理Effects、Updates队列中内容。根据render函数的处理过程可知:

    createEffect内部保存在Effects队列中副作用逻辑,会在组件挂载到节点之后被执行,初始化阶段createEffect副作用函数会立即执行,此时并没有于Signal状态上下文建立关联

    当副作用逻辑执行时,如果内部存在Signal状态对象就会执行其getter函数,从而将当前Computation对象与Signal状态上下文建立关联,从而实现依赖的收集。

    更新阶段

    当createEffect对应的Computation与Signal状态对象建立关联后,所谓的更新阶段就是Signal状态更新时的处理逻辑流程了,实际上就是处理createEffect对应的Computation对象而已。这里需要注意的是更新阶段createEffect的处理有以下几点说明:

    • createEffect对应的Computation对象的处理总是在视图节点的Computation对象之后的
    • Solid中createEffect整个的处理过程是同步而非异步,不同于React useEffect的异步处理

    总结

    实际上从Solid的风格以及底层原理实现,你可以看到其他框架的影子:

    • Solid底层实现和Vue底层响应性实现非常相似,都是基于发布订阅模式实现两个对象的绑定从而构建响应的基石,只不过在Vue中Dep对象与Watcher对象,Solid中是Signal状态上下文对象和Computation对象
    • 除了采用React Hooks,Solid只支持函数组件这一种形式,还支持并发渲染即时间分片,底层实现方式与React相似,这里不再赘述了

    这里梳理下Solid整体的主要处理流程,具体如下图:

    Solid流程

  • 相关阅读:
    大语言模型(LLM)过拟合问题
    解决微信小程序回调地狱问题
    SQL - 函数
    数据库生产架构( 一 ) 主从复制架构
    Spring cloud学习笔记(服务注册与发现框架Eureka)
    LCP 01.猜数字
    什么是跨域?及7种跨域解决方法
    Rust专属开发工具——RustRover发布
    java游戏制作-飞翔的鸟游戏
    Jmeter命令执行生成HTML格式报告详解
  • 原文地址:https://blog.csdn.net/s1879046/article/details/133139337