引言
在主流的前端开发框架中,无论是React
、Vue
还是Svelte
,核心都是围绕着更高效地进行UI
渲染展开的。
为了实现高性能,基于DOM总是比较慢这个假设前提,其最核心的要解决的问题有两个:
- 响应式更新
- 细粒度更新
为了将响应式更新
、细粒度更新
优化到极致,各种框架是八仙过海,各显神通。以最流行的React
和Vue
为例,
- 首先两者均引入了
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.xxx
的DOM
进行精准更新,而是通过Virtual DOM
的diff
算法比较算出需要更新的DOM
元素,然后再进行patch
操作。
问题是,这种diff
可能会有误伤,可能会比较出不需要重新渲染的DOM
,需要针对各种边界情况进行各处算法优化,对开发者也有一定的心智负担,比如在在大型React
应用中对React.memo
的使用,或者在Vue
中的模板优化等等。
- Q: 为什么说在大型应用中使用
React.memo
是一种心智负担? - A: 实际上
React.memo
的逻辑本身很简单,无论老手或小白均可以轻松掌握。但是在大型应用中,一方面组件的嵌套层级很深,组件之间的依赖关系很复杂,另外一方面,组件数量成百上千。如果都要使用React.memo
来优化渲染,就是一种很大的心智负担。如果采用后期优化,则问题更加严重,往往需要使用一些性能分析工具才可以进行针对性的优化。简单地说,当应用复杂后,React.memo
才会成为负担。
因此框架的最核心的问题就是能根据状态的变化
快速找到依赖于该状态的DOM
的进行重新渲染,即所谓的细粒度更新
。
即然基于Virtual DOM
的diff
算法在解决细粒度更新方面存在问题,那么是否可以不进行diff
算法,直接找到state.xxx
对应的DOM
进行更新呢?
方法是有的,就是前端最红的signal
的概念。
事实上signal
概念很早就有了,但是自出了Svelte
之类的框架,它不使用Virtual DOM
,不需要diff
算法,而是引入signal
概念,可以在信号触发时只更新变化的部分,真正的细粒度更新,并且性能也非常好。
这一下子就把React
和Vue
之类的Virtual DOM
玩家们给打蒙了,一时间signal
成了前端开发的新宠。
所有的前端框架均在signal
靠拢,Svelte
和solidjs
成了signal
流派的代表,就连Vue
也不能免俗,Vue Vapor
就是Vue
的signal
实现(还没有发布)。
什么是信号?
引用卡颂老师关于signal
的一篇文章Signal:更多前端框架的选择。
卡颂老师说signal的本质,是将对状态的引用以及对状态值的获取分离开。
大神就是大神,一句话就把signal
的本质说清楚了。但是也把我等普通人给说懵逼了,这个概念逼格太高太抽象了,果然是大神啊。
下面我们按凡人的思维来理一理signal
,构建一套signal
机制的基本流程原理如下:
- 第1步: 让状态数据可观察
让状态数据变成响应式
或者可观察
,办法就是使用Proxy
或者Object.defineProperty
等方法,将状态数据变成一个可观察
对象,而不是一个普通的数据对象。
可观察
对象的作用就是拦截对状态的访问,当状态发生读写变化时,就可以收集依赖信息。
让数据可观察有多种方法,比如mobx
就不是使用Proxy
,而是使用Class
的get
属性来实现的。甚至你也可以用自己的一套API
来实现。只不过现在普遍使用Proxy
实现。核心原理就是要拦截对状态的访问,从而收集依赖信息。
:::warning{title=注意}
让状态数据可观察的目的是为了感知状态数据的变化,这样才能进行下一步的响应。感知的颗粒度越细,就越能实现细粒度更新。
:::
- 第2步:信号发布/订阅
由于可以通过拦截对状态的访问,因此,我们就可以知道什么时候读写状态了,那么我们就可以在读写状态时,发布一个信号
,通知订阅者,状态发生了变化。
因此,我们就需要一个信号发布/订阅
的机制,来登记什么信号发生了变化,以及谁订阅了这个信号。
您可以使用类似mitt
、EventEmitter
之类的库来构建信号发布/订阅
,也可以自己写一个。
信号发布/订阅
最核心的事实上就是一个订阅表,记录了谁订阅了什么信号,在前端就是哪个DOM渲染函数,依赖于哪个信号(状态变化)。
:::warning{title=提示}
建立一个发布/订阅机制的目的是为了建立渲染函数
与状态数据
之间的映射关系,当态数据发生变化时,根据此来查询到依赖于该状态数据的渲染函数
,然后执行这些渲染函数
,从而实现细粒度更新
。
:::
- 第3步:渲染函数
接下来我们编写DOM
的渲染函数,如下:
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
发生变化时,我们更新countElement
的textContent
。
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();
<h1>计数器: <span id="count">0</span></h1>
<button id="increment">增加</button>
在React中使用信号
那么我们如何在React
中使用signal
呢?
从上面我们可以知道,signal
驱动的前端框架是完全不需要Virtual DOM
的。
而本质上React
并不是一个Signal
框架,其渲染调度是基于Virtual DOM
、fiber
和diff
算法的。
因此,React
并不支持signal
的概念,除排未来React
像Vue
一样升级Vue Vapor mode
进行重大升级,抛弃Virtual DOM
,否则在React
在中是不能真正使用如同solidjs
和Svelte
的signal
概念的。
但是无论是Virtual DOM
还是signal
,核心均是为了解决细粒度更新
的问题,从而提高渲染性能。
因此,我们可以结合React
的React.memo
和useMemo
等方法来模拟signal
的概念,实现细粒度更新
。
这样我们就有了信号组件的概念,其本质上是使用React.memo
包裹的ReactNode
组件,将渲染更新限制在较细的范围内。
- 核心是一套依赖收集和事件分发机制,用来感知状态变化,然后通过事件分发变化。
- 信号组件本质上就是一个普通的是React组件,但使用
React.memo(()=>{.....},()=>true)
进行包装,diff
总是返回true
,用来隔离DOM
渲染范围。 - 然后在该信号组件内部会从状态分发中订阅所依赖的状态变化,当状态变化时重新渲染该组件。
- 由于
diff
总是返回true
,因此重新渲染就被约束在了该组件内部,不会引起连锁反应,从而实现了细粒度更新
。
信号组件
AutoStore是最近开源的一个响应式状态库,其提供了非常强大的状态功能,主要特性如下:
- 响应式核心:基于
Proxy
实现,数据变化自动触发视图更新。 - 就地计算属性:独有的就地计算特性,可以在状态树中任意位置声明
computed
属性,计算结果原地写入。 - 依赖自动追踪:自动追踪
computed
属性的依赖,只有依赖变化时才会重新计算。 - 异步计算:强大的异步计算控制能力,支持
超时、重试、取消、倒计时、进度
等高级功能。 - 状态变更监听:能监听
get/set/delete/insert/update
等状态对象和数组的操作监听。 - 信号组件:支持
signal
信号机制,可以实现细粒度的组件更新。 - 调试与诊断:支持
chrome
的Redux DevTools Extension
调试工具,方便调试状态变化。 - 嵌套状态:支持任意深度的嵌套状态,无需担心状态管理的复杂性。
- 表单绑定:强大而简洁的双向表单绑定,数据收集简单快速。
- 循环依赖:能帮助检测循环依赖减少故障。
- Typescript: 完全支持Typescript,提供完整的类型推断和提示
- 单元测试:提供完整的单元测试覆盖率,保证代码质量。
AutoStore可以为React
引入信号组件,实现细粒度的更新渲染,让React
也可以享受signal
带来的丝滑感受。
以下是AutoStore
中的信号组件
的一个简单示例:
/**
* 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。
小结
由于React
沉重的历史包袱,在可以预见的未来,React
应该不会支持真正意义上的signal
。
在卡颂老师\`的Signal:更多前端框架的选择中也提到,
React团队成员对此的观点是:
- 有可能引入类似
Signal
的原语 Signal
性能确实好,但不太符合React
的理念
而AutoStore
所支持的信号组件
的概念,可以视为模拟signal
或者类似Signal
的原语,使得我们可以在React
中实现细粒度更新
,而不用再去纠结React.memo
的使用。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。