目前响应式框架非常之多,有 react/vue/solidjs/svelte 等绑定 UI 的框架,也有 Rxjs/mobx/zustand 等 UI 无关的响应式状态管理的框架。本文对这些响应式框架的设计进行整理,分析他们的共性和差异,方便大家理解响应式的原理,同时也为新的响应式框架设计提供路线指引。
基本概念
当前的响应式框架大致可以分为以下几个基本概念。本文采用的术语会尽可能的使用大多数框架的通用说法,并且会提供其他框架中与该概念相近的术语以供参考。
signal(信号)
signal 是 solidjs/preact/angular-v16 的术语,其他说法有:
- react/mobx: state(状态)
- vue: reactive
- svelte: local variable assignment/store
- Rxjs: subject 较为接近
以上框架,除了 svelte 的变量赋值采用编译时响应式以外,都是把数据包装在一个运行时可观察对象当中。当数据被修改时,观察者会得到通知。
signal 根据可变性可以分为可写与只读。可写指的是外部可写。只读是外部只读不可写,但 signal 内部,例如构造函数是可以修改数据的。
effect(副作用)
全称是 side effect。不同框架的叫法分别是:
- react/solidjs: effect
- vue: watch
- svelte:
$:label ($: console.log(count);) - mobx: reaction
- Rxjs: observer 较为接近
副作用可以理解为对外部世界的修改,可以是修改全局数据、进行 dom 操作,甚至一个 log 操作也可以认为是副作用。
副作用有几点要注意的:
- 副作用不应该修改状态本身,否则会引起死循环。这种场景应该使用 derived 代替。框架可以做一些检查和限制,例如在运行副作用时修改 signal 抛出错误。
- 副作用的范围应该尽可能的小,依赖的响应式数据也尽可能的少,避免因为无关状态的变更导致副作用的意外触发。
- 副作用中的异步操作搭配自动依赖搜集和清理需要注意一些场景,后面详述。
derived value(衍生数据)
derived 是绝大部分框架的术语,但他们在创建 derived 的时候会使用不同名称的方法或语法。例如:
- react/solid: memo
- vue/mobx: computed
- svelte:
$:label ($: doubled = count * 2;) - Rxjs: pipe 较为接近,需要搭配
distinctUntilChanged和combineLatest
衍生数据是由 signal 或其他衍生数据通过无副作用的计算得来的,一般具有缓存值避免重复计算的能力,所以被叫做 memo 或 computed,这两个名称都分别描述了衍生数据的不同特点,不好说哪个名称更准确。本文直接称呼为 derived。
derived 的几大特点:
- 缓存。上游数据无变更的情况下读取衍生数据不会重复计算。此外,在上游数据变更了但衍生数据不变的情况下,也不会通知下游。例如一个数组内容发生了变更,但数组长度不变,而下游只依赖数组长度,这种情况下使用衍生数据不会引发下游变更。
- 纯计算,无副作用。这个需要开发者保证。但框架可以做一些检查和限制,例如在计算 derived 时修改 signal 抛出错误。
derived 的底层一般由只读 signal 实现。derived 监听上游的数据变更,在 effect 中修改自身内部状态。
依赖的自动搜集机制
这个机制并非大部分框架都有,例如 react 和 Rxjs 都是只能手动指定依赖的。支持自动搜集依赖的框架一般都有 computation 的概念,用于指代一段响应式的代码,例如 effect 的执行和对 computed 的赋值,对应到代码中一般就是一个函数。
在 computation 中读取 signal 和 computed 会被记录下来,一旦 signal 或 computed 发生变更,会重新运行 computation。这是解放手工搜集依赖的关键。另外,每次 computation 重新运行会重新搜集依赖,因为 computation 中有分支语句的话会对依赖有影响。
而要实现这个自动搜集的能力,需要记录两个对象:当前运行的 computation 和当前读取的 signal(含 computed)。
记录当前的 computation 通过全局变量去记录,在运行 computation 前一刻会把该 computation 记录在全局变量中,在读取 signal 的时候,通过全局变量取回,以对他们进行关联(subscribe)。在运行下一次的 computation 前,会对上一次记录的关联进行解除(unsubscribe)。
这种做法有两个需要注意的地方。
1. computation 中的异步代码
当前的 computation 通过全局变量去记录的机制只能运行在同步的代码中。computation 可以嵌套,每次获取到的 computation 都是最近的设置的。但在异步代码中,由于异步的代码是由系统运行的,并不能设置正确的全局变量,所以是无法进行跟踪的。解决方法可以在同步的代码中读取状态放到局部变量中,在异步代码中直接读取。
在异步代码中修改状态与执行副作用需要特别注意,避免
- computation 已经失效了但依然执行;
- 两个异步操作的竞态行为,例如先后两个网络请求回来都去修改同一个外部的 signal。
我认为响应式框架应该提供一个机制避免这两种情况,但目前并未发现文章中提到的库有相关能力。
读取 signal 状态的方式
上一步还有一个关键,在读取 signal 状态的时候,需要知道当前读取的是哪个 signal,这样才能进行关联。目前的实现方式主要分成两大阵营:
- 函数调用
state(),通过闭包记录下来当前修改的状态。 - 属性读取
state.value/obj.stateX。依赖 proxy 或 defineProperty 来获取当前修改的 signal。
两种实现均有优劣。例如函数调用的问题:
- 不直观
- 如果状态的内容是函数,会出现
fnState()()这样的奇怪代码出现。
属性读取则有以下问题:
- 不支持 string/number 等原始值,需要用 ref 来包裹
- 不支持解构,解构后会丢失响应性
- 如果庞大的深层次对象递归都用 proxy 来转换可能会引起性能问题。
副作用的自动清理
上一节提到,在 computation 中读取的 signal 都会自动监听。但 computation 是有生命周期的。例如关联到 UI 组件的 effect,在组件卸载后,即使 signal 再次发生变化,也不应该重新执行 effect。所以 computation 需要在它关联的所有者销毁的时候一并销毁(解除监听)。这里就引出了一个 owner 的概念。
前面提到,computation 可以嵌套。在实践中,owner 一般是父 computation(root owner较为特殊,可以不是 computation),也可以在执行 computation 的时候指定 owner。指定为父 computation 可以做到递归销毁,一旦某个 computation 被销毁,其下的所有 computation 都先被销毁。
新框架的设计
我理想中的响应式框架,需要满足以下几个特点:
- 不强耦合在 UI 框架上。UI 渲染应该作为框架内置的 effect,而 signal 应该可以单独使用。反例:react 只在组件中具有响应性
- 不强耦合在编译器上。反例:svelte 的变量响应性依赖编译,只在 .svelte 文件中生效
- 响应式的语法应该保持统一。框架可以支持多种语法,但应该以其中一种为基调,在任何场景下都可用。另一种语法应该通过特殊的方式去声明。反例:solidjs 的 signal 和组件的 props 都是响应式的,但一个采用函数调用语法,一个采用属性读取语法。
- 对 signal 的同步修改应该任何时候都是批处理的,避免修改多个 signal 导致依赖这些 signal 的 computation 重复执行。这里的任何时候包括各种异步操作后的回调。反例:react18 前,setTimeout 回调里面每次 setState 都会引起一次 render,但在 v18 中已经改成批处理了。
- effect 对异步有良好的支持,有机制可以避免竞态。例如同一个 effect 在重新触发前把上一个没完成的异步回调取消掉。可能难以和 Promise 和 async/await 配合,因为这些都是系统来触发回调的。可以考虑使用 generator,由框架来触发回调,在 effect 销毁的时候取消回调。