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'))

render处理

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

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

上面实例初始化阶段的处理逻辑,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 = "";
  };
}

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

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

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

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

主要就是两点逻辑:

  • 执行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);
}

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,而对应的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;
  })()];
}

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);

其逻辑分为两点:

  • 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];
}

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;
}

主要逻辑就是:当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);
  ...
}

这里的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
  };

所以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;
}

在初始化处理阶段,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;
       }
      }
    }
  }
}

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")!);
初始化阶段

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);
}

在之前分析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整体的主要处理流程,具体如下图:

SolidJs节点级响应性_第1张图片

你可能感兴趣的:(React相关,前端,javascript,solid.js,节点级响应原理)