深入解析:React中的信号组件与细粒度更新

在主流的前端开发框架中,无论是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 DOM****的 diff****算法加持下, 状态的变化****总是难以精准地与 DOM****对应匹配。

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

问题是,这种diff可能会有误伤,可能会比较出不需要重新渲染的DOM,需要针对各种边界情况进行各处算法优化,对开发者也有一定的心智负担,比如在在大型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机制的基本流程原理如下:

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

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

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

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

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

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

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

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

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

:::warning{title=提示}

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

:::

  • 第3步:渲染函数

接下来我们编写DOM的渲染函数,如下:
登录后复制

ada 复制代码
function render() {
      element.textContent = countSignal.value.toString();
  }

在此渲染函数中:

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

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

  • 第3步:注册渲染函数

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

手撸信号

按照上述信号的基本原理,下面是一个简单的signal的示例,我们创建一个signal对象countSignal,并且创建一个DOM元素countElement,当countSignal发生变化时,我们更新countElementtextContent
登录后复制

plain 复制代码
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();

登录后复制

plain 复制代码
<h1>计数器: <span id="count">0</span></h1>
<button id="increment">增加</button>

在React中使用信号

那么我们如何在 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组件,将渲染更新限制在较细的范围内。

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

信号组件

AutoStore是最近开源的一个响应式状态库,其提供了非常强大的状态功能,主要特性如下:

  • 响应式核心 :基于Proxy实现,数据变化自动触发视图更新。
  • 就地计算属性 :独有的就地计算特性,可以在状态树中任意位置声明computed属性,计算结果原地写入。
  • 依赖自动追踪 :自动追踪computed属性的依赖,只有依赖变化时才会重新计算。
  • 异步计算 :强大的异步计算控制能力,支持超时、重试、取消、倒计时、进度等高级功能。
  • 状态变更监听 :能监听get/set/delete/insert/update等状态对象和数组的操作监听。
  • 信号组件 :支持signal信号机制,可以实现细粒度的组件更新。
  • 调试与诊断 :支持chromeRedux DevTools Extension调试工具,方便调试状态变化。
  • 嵌套状态:支持任意深度的嵌套状态,无需担心状态管理的复杂性。
  • 表单绑定:强大而简洁的双向表单绑定,数据收集简单快速。
  • 循环依赖:能帮助检测循环依赖减少故障。
  • Typescript: 完全支持Typescript,提供完整的类型推断和提示
  • 单元测试:提供完整的单元测试覆盖率,保证代码质量。

AutoStore可以为React引入信号组件,实现细粒度的更新渲染,让React也可以享受signal带来的丝滑感受。

以下是AutoStore中的信号组件的一个简单示例:
登录后复制

plain 复制代码
/**
* title: 信号组件
* description: 通过`state.age=n`直接写状态时,需要使用`{$('age')}`来创建一个信号组件,内部会订阅`age`的变更事件,用来触发局部更新。
*/
import { createStore } from '@autostorejs/react';
import { Button,ColorBlock } from "x-react-components"

const { state , $ } = createStore({
  age:18
})

export default () => {

  return <div>
      {/* 引入Signal机制,可以局部更新Age */}
      <ColorBlock>Age+Signal :{$('age')}</ColorBlock>
      {/* 当直接更新Age时,仅在组件当重新渲染时更新 */}
      <ColorBlock>Age :{state.age}</ColorBlock>
      <Button onClick={()=>state.age=state.age+1}>+Age</Button>
    </div>
}
  • 信号组件仅仅是模拟signal实现了细粒度更新,其本质上是使用React.memo包裹的ReactNode组件。

  • 创建$来创建信号组件时,$signal的快捷名称。因此上面的{$('age')}等价于{signal("age")}

  • 更多的信号组件的用法请参考signal

相关推荐
蜗牛快跑2139 分钟前
面向对象编程 vs 函数式编程
前端·函数式编程·面向对象编程
Dread_lxy10 分钟前
vue 依赖注入(Provide、Inject )和混入(mixins)
前端·javascript·vue.js
涔溪1 小时前
Ecmascript(ES)标准
前端·elasticsearch·ecmascript
榴莲千丞1 小时前
第8章利用CSS制作导航菜单
前端·css
奔跑草-1 小时前
【前端】深入浅出 - TypeScript 的详细讲解
前端·javascript·react.js·typescript
羡与1 小时前
echarts-gl 3D柱状图配置
前端·javascript·echarts
guokanglun1 小时前
CSS样式实现3D效果
前端·css·3d
咔咔库奇1 小时前
ES6进阶知识一
前端·ecmascript·es6
前端郭德纲2 小时前
浏览器是加载ES6模块的?
javascript·算法
JerryXZR2 小时前
JavaScript核心编程 - 原型链 作用域 与 执行上下文
开发语言·javascript·原型模式