Skip to content

关于✨

什么是信号?

在主流的前端开发框架中,无论是ReactVue还是Svelte,核心都是围绕着更高效地进行UI渲染展开的。

为了实现高性能,基于DOM总是比较慢这个假设前提,其最核心的要解决的问题有两个:

  • 响应式更新
  • 细粒度更新

为了将响应式更新细粒度更新优化到极致,各种框架是八仙过海,各显神通。以最流行的ReactVue为例,

  • 首先两者均引入了Virtual DOM的概念。
  • Vue的静态模板编译,通过编译时的静态分析,来优化细粒度更新逻辑,在编译阶段尽可能地分析出该渲染的DOM。
  • React使用JSX动态语法,本质上是一个函数,难以进行静态分析,所以React只能在运行时想办法。
    • 因此React就有了Fiber的概念,通过Fiber的调度来实现优化渲染逻辑,但是Fiber的调度逻辑很复杂,官方搞这玩意折腾了有一年。
    • 然后就是一堆的React.memo的优化手段,但是应用复杂时,驾驭起来也有比较大的心智负担
    • 因此,官方又搞了个React Compiler,通过编译时的静态分析,来为代码自动添加React.memo逻辑,但这玩意从提出到现在怎么也有两年了,还在实验阶段。估计也是不太好搞。

由于Virtual DOM的特性,无论是React还是Vue,本质上都是在Virtual DOM上进行diff算法,然后再进行patch操作,差别就是diff算法的实现方式不同。

但是无论怎么整, 在Virtual DOMdiff算法加持下,状态的变化总是难以精准地与DOM对应匹配。

通俗说,就是当state.xxx更新时,不是直接找到使用state.xxxDOM进行精准更新,而是通过Virtual DOMdiff算法比较算出需要更新的DOM元素,然后再进行patch操作。

问题是,这种diff算法比较复杂,需要进行各处优化,对开发者也有一定的心智负担,比如在在大型React应用中对React.memo的使用,或者在Vue中的模板优化等等。

注意

  • Q: 为什么说在大型应用中使用React.memo是一种心智负担?
  • A: 实际上React.memo的逻辑本身很简单,无论老手或小白均可以轻松掌握。但是在大型应用中,一方面组件的嵌套层级很深,组件之间的依赖关系很复杂,另外一方面,组件数量成百上千。如果都要使用React.memo来优化渲染,就是一种很大的心智负担。如果采用后期优化,则问题更加严重,往往需要使用一些性能分析工具才可以进行针对性的优化。简单地说,当应用复杂后,React.memo才会成为负担。

因此框架的最核心的问题就是能根据状态的变化快速找到依赖于该状态的DOM的进行重新渲染,即所谓的细粒度更新

即然基于Virtual DOMdiff算法在解决细粒度更新方面存在问题,那么是否可以不进行diff算法,直接找到state.xxx对应的DOM进行更新呢?

方法是有的,就是前端最红的signal的概念。

事实上signal概念很早就有了,但是自出了Svelte之类的框架,它不使用Virtual DOM,不需要diff算法,而是引入signal概念,可以在信号触发时只更新变化的部分,真正的细粒度更新,并且性能也非常好。

这一下子就把ReactVue之类的Virtual DOM玩家们给打蒙了,一时间signal成了前端开发的新宠。 所有的前端框架均在signal靠拢,Sveltesolidjs成了signal流派的代表,就连Vue也不能免俗,Vue Vapor就是Vuesignal实现(还没有发布)。

那么什么是信号?

引用卡颂老师关于signal的一篇文章Signal:更多前端框架的选择

卡颂老师说signal的本质,是将对状态的引用以及对状态值的获取分离开。

大神就是大神,一句话就把signal的本质说清楚了。但是也把我等普通人给说懵逼了,这个概念逼格太高太抽象了,果然是大神啊。

下面我们按凡人的思维来理一理signal,构建一套signal机制的基本流程原理如下:

  • 第1步: 让状态数据可观察

让状态数据变成响应式或者可观察,办法就是使用Proxy或者Object.defineProperty等方法,将状态数据变成一个可观察对象,而不是一个普通的数据对象。

可观察对象的作用就是拦截对状态的访问,当状态发生读写变化时,就可以收集依赖信息。

让数据可观察有多种方法,比如mobx就不是使用Proxy,而是使用Classget属性来实现的。甚至你也可以用自己的一套API来实现。只不过现在普遍使用Proxy实现。核心原理就是要拦截对状态的访问,从而收集依赖信息

注意

让状态数据可观察的目的是为了感知状态数据的变化,这样才能进行下一步的响应。感知的颗粒度越细,就越能实现细粒度更新。

  • 第2步:信号发布/订阅

由于可以通过拦截对状态的访问,因此,我们就可以知道什么时候读写状态了,那么我们就可以在读写状态时,发布一个信号,通知订阅者,状态发生了变化。

因此,我们就需要一个信号发布/订阅的机制,来登记什么信号发生了变化,以及谁订阅了这个信号。

您可以使用类似mittEventEmitter之类的库来构建信号发布/订阅,也可以自己写一个。

信号发布/订阅最核心的事实上就是一个订阅表,记录了谁订阅了什么信号,在前端就是哪个DOM渲染函数,依赖于哪个信号(状态变化)。

提示

建立一个发布/订阅机制的目的是为了建立渲染函数状态数据之间的映射关系,当态数据发生变化时,根据此来查询到依赖于该状态数据的渲染函数,然后执行这些渲染函数,从而实现细粒度更新

  • 第3步:渲染函数

接下来我们编写DOM的渲染函数,如下:

js
  function render() {
      element.textContent = countSignal.value.toString();
  }

在此渲染函数中:

  • 我们直接更新DOM元素,没有任何的diff算法,也没有任何的Virtual DOM
  • 函数使用访问状态数据count来更新DOM元素,由于状态是可观察的,因此当执行countSignal.value时,我们就可以拦截到对count的访问,也就是说我们收集到了该DOM元素依赖于count状态数据。
  • 有了这个DOM Render状态数据的依赖关系,我们就可以在signal的信号发布/订阅机制中登记这个依赖关系.

INFO

收集依赖的作用就是建立渲染函数与状态之间的关系。

  • 第4步:注册渲染函数

最后我们将render函数注册到signal的订阅者列表中,当count状态数据发生变化时,我们就可以通知render函数,从而更新DOM元素。

简单示例

下面是一个简单的signal的示例,我们创建一个signal对象countSignal,并且创建一个DOM元素countElement,当countSignal发生变化时,我们更新countElementtextContent

js
        class Signal<T> {
          private _value: T;
          private _subscribers: Array<(value: T) => void> = [];
          constructor(initialValue: T) {
              this._value = initialValue;
          }
          get value(): T {
              return this._value;
          }
          set value(newValue: T) {
              if (this._value !== newValue) {
                  this._value = newValue;
                  this.notifySubscribers();
              }
          }
          subscribe(callback: (value: T) => void): () => void {
              this._subscribers.push(callback);
              return () => {
                  this._subscribers = this._subscribers.filter(subscriber => subscriber !== callback);
              };
          }

          private notifySubscribers() {
              this._subscribers.forEach(callback => callback(this._value));
          }
      }

      const countSignal = new Signal<number>(0);
      const countElement = document.getElementById('count')!;
      const incrementButton = document.getElementById('increment')!;

      function render() {
          countElement.textContent = countSignal.value.toString();
      }
      function increment() {
          countSignal.value += 1;
      }
      countSignal.subscribe(render);
      incrementButton.addEventListener('click', increment);
      render();
html
<h1>计数器: <span id="count">0</span></h1>
<button id="increment">增加</button>

信号组件

那么我们如何在React中使用signal呢?

从上面我们可以知道,signal驱动的前端框架是完全不需要Virtual DOM的。

而本质上React并不是一个Signal框架,其渲染调度是基于Virtual DOMfiberdiff算法的。

因此,React并不支持signal的概念,除排未来ReactVue一样升级Vue Vapor mode进行重大升级,抛弃Virtual DOM,否则在React在中是不能真正使用如同solidjsSveltesignal概念的。

但是无论是Virtual DOM还是signal,核心均是为了解决细粒度更新的问题,从而提高渲染性能。

因此,我们可以结合ReactReact.memouseMemo等方法来模拟signal的概念,实现细粒度更新

这样我们就有了信号组件的概念,其本质上是使用React.memo包裹的ReactNode组件,将渲染更新限制在较细的范围内。

  • 核心是一套依赖收集和事件分发机制,用来感知状态变化,然后通过事件分发变化。
  • 信号组件本质上就是一个普通的是React组件,但使用React.memo(()=>{.....},()=>true)进行包装,diff总是返回true,用来隔离DOM渲染范围。
  • 然后在该信号组件内部会从状态分发中订阅所依赖的状态变化,当状态变化时重新渲染该组件。
  • 由于diff总是返回true,因此重新渲染就被约束在了该组件内部,不会引起连锁反应,从而实现了细粒度更新

以下是AutoStore中的signal的一个简单示例:

  • 上例中,当更新Age时,渲染被限制在信号组件内部,不会引起连锁反应。

注意

  • 信号组件仅仅是模拟signal实现了细粒度更新,其本质上是使用React.memo包裹的ReactNode组件。
  • 创建$来创建信号组件时,$signal的快捷名称。因此上面的{$('age')}等价于{signal("age")}
  • 更多的信号组件的用法请参考signal

小结

由于React沉重的历史包袱,在可以预见的未来,React应该不会支持真正意义上的signal

在卡颂老师`的Signal:更多前端框架的选择中也提到,

React团队成员对此的观点是:

  • 有可能引入类似Signal的原语
  • Signal性能确实好,但不太符合React的理念

AutoStore所支持的信号组件的概念,可以视为模拟signal或者类似Signal的原语,使得我们可以在React中实现细粒度更新,而不用再去纠结React.memo的使用。

INFO

React 19开始,React官方推出Compiler,帮助用户解决React.memo的问题,减少用户的心智负担。但是其并不是为关于决细粒度更新的问题,而是优化提高React的性能。 本人对Compiler的使用并不是很看好,有待进一步研究。