SolidJS walkthrough 01 - Signal and reactive programming
Saturday, Mar 11, 2023
Signal
Signal 是一个随时间变化的值。当 signal 发生变化时,依赖他的下游会自动发生变化。
基于 signal 的响应式编程,我们不需要关注系统的输入和输出,只需要定义 signal 之间关系,这样当发生变化后,整个系统会自动更新。
Signal 并非是一个确切的值,准确的说 signal 是一个时间线先上的值,例如下面的表达式:
c = a + b
在响应式编程中,需要以 stream 的方式考虑 c = a + b 的过程:
Stream a: --1-----------2------------3-------->
Stream b: --10----20----------30-------------->
Stream c: --11----21----22----32-----33------->
Stream 上有多个节点,每个节点表示当前值。响应式编程就是面向 Steam 的编程, c = a + b 等同于 Stream c = Stream a + Stream b。
在前端响应式框架中,实现 Stream 之间的操作,主要通过 Event 的方式,一个 Signal 就是一个事件源,通过订阅事件源的变更,来衍生出其他 Signal 或者执行副作用。
在前端 Signal 响应式编程中,有以下三要素:
- Signals:如同上面所描述的,Signals 是一个随时间变化的值,并且也是一个可观察的对象。在不同框架中有着不同的叫法:Observables,Atoms,Refs 等
- Reactions:反应也叫副作用,即:定义能对 signal 变化做出反应的行为。
- Derivations:一个 Signal 能衍生出另一个 Signal,这是一种很常见的行为。当一个 Signal 变化时,其衍生的 signal 也会自发更新,这个过程可以被缓存、懒加载。
以 SolidJS 为例,实现上面要素的 API 如下:
- createSignal:创建一个 Signal,Signal 可以被追踪
- createMemo:创建一个衍生的只读 Signal,只有依赖的 Signal 发生变化时,才会重新求值。
- createEffect:创建一个副作用,依赖的 signal 发生变化时,会执行回调。
Signal 实现
主流框架的 Signal 都是基于 js Proxy 特性实现的:
const p = new Proxy(target, {
get: function (target, property, receiver) {},
set: function (target, property, receiver) {},
})
Proxy 可以代理对象,并拦截某些操作。一般会在 get 时,添加对响应式对象的监听,在 set 时,通知订阅者。基于 Proxy 的好处是:可以让依赖追踪变得自然和简单,只需要调用特定 API(createMemo、createEffect),然后正常取值和赋值,就可以完成数据和副作用触发。
但也有一些响应式框架并非基于 Proxy,而是基于事件和回调,例如:rxjs、remesh。为了实现响应式,必须显示的 subscribe、set、get 数据源:
const var1 = new Subject()
var1.next(1)
const var2 = var1.pipe(
map((var1) => {
return var1 * 2
})
)
var2.subscribe((var2) => {
console.log(var2)
})
var1.next(2)
Reactive
在 A Survey on Reactive Programming 一文中,作者从 6 大维度对比了多种 Reactive Programming 实践。
Evaluation model(求值模型)
Evaluation model 分位两种:拉取(Pull)和推送(Push)。 | -- | 生产者 | 消费者 | | ---- | ---------------------------- | ---------------------------- | | 拉取 | 被动的:当被请求时产生数据 | 主动的:决定何时请求数据 | | 推送 | 主动的:按自己的节奏产生数据 | 被动的:对收到的数据做出反应 | 在拉取体系中,有消费者来决定何时从生产者那里接收数据。生产者本身不知道数据是何时交付到消费者手中的:
- 普通 Javascript 函数。函数是生产者,消费者主动调用函数“取出”一个数据。
- Generator 函数。另一种拉取体系,调用 iterator.next()的代码是消费者,从 iterator(生产者)中取出多个值。
在推送体系中,由生产者来决定何时把数据发送给消费者。消费者本身不知道何时会接收到数据。
- Promise。通过注册回调函数的方式(消费者),由 Promise(生产者)决定何时把值“推送”给回调函数。
- Event。
Vue、SolidJS、preact、rxjs、remesh 实现的都是 Push 体系,即:Push-based reactivity。
另一方面,pull-based reactivity 表现在:当上游变化时,并不会主动推送数据给下游计算,而是当下游在使用时,在从上游取数据,重新计算,所以 pull-based reactivity 是 lazy 的。
pull-based reactivity 一般采用定时轮询的方式实现,这会存在两个缺点:
- 资源浪费,每次轮询到来时,即使依赖未发生变化,也会重新求值。
- 更新会有一定延时。
Lifting(提升)
Lifting 的含义为:将函数通过一个配置,转换为另一个通用函数。 Lifting is a concept which allows you to transform a function into a corresponding function within another (usually more general) setting.
对于响应式编程,如果有一个 add 函数是处理两个 int 类型的相加,那么通过 lift,可将这个 add 函数用来处理两个 Stream 的相加,并能够自动跟踪依赖变化。
Lift 有三种表现:显式的、隐式的、手动的。
- 显式的:通过一个特定的 Lift 函数,将 add 函数转换为能处理两个 Stream 之间的相加的函数
- 隐式的:这个 add 函数能自发处理两个 Stream 相加。
- 手动的:手动从 Stream 中取出当前值,传入 add 函数相加
Explicit lift(add): (Stream, Stream) -> Stream
Implicit add: (Int, Int) -> Int
s3 = add(stream1, stream2)
Manual x = add(get(stream1), get(stream2))
基于 Proxy 的前端响应式框架,是 Implicit 和 Manual 的结合:
- 对于 Signal 的值是 object,这个过程是隐式的
- 对于 Signal 的值是基础类型,这个过程是 Manual 的,需要使用
.value
或者stream()
的方式获取当前值。
Glitch avoidance(闪烁避免)
Glitch avoidance 是指响应式实现需要规避一个问题:两个上游依赖拥有相同依赖,当共同依赖变更时,会产生重复的计算过程,从而暴露 inconsistent data 给下游。
var1 = 1
var2 = var1 * 2
var3 = var1 + var2
// var1 变成2,var3会计算几次?
var3 依赖 var1 两次,一次直接依赖,一次间接依赖(通过 var2)。那么当 var1 变化时,通知 var3 重计算可能会发生两次,这取决于框架的实现:
- 第一次计算取的是最新的 var1 和旧的 var2,
- 第二次计算取的是最新的 var1 和最新的 var2
响应式需要规避这一现象,成为 glitch avoidance。
未做到 Glitch Avoidance 会导致意料之外的问题,例如:var3 是一个转账金额,那当 watch var3 作转账时,这种中间态的错误数据会导致 bug。
从上面的描述,Glitch Avoidance 一定是 push-based reactivity 所存在的问题,对于 pull-based reactivity 因为是消费者主动触发的重计算,是 lazy 的,所以不会有这样的问题。
解决方案
既然是中间态数据导致了问题,那么只要处理好中间态数据,就能实现 Glitch Avoidance,有两种思路:
- 跳过(合并)过渡数据。
- 延迟更新
跳过(合并)过渡数据
上面的例子如果 var3 能在 var1 更新之后判断出 var2 还未更新,需要跳过这次更新。需要做到这样,需要知道某个值的上游依赖是否有相同依赖。 但要做到这种分析并不容易,原因在于:
- 2 个上游依赖并不一定直接依赖某个相同依赖。
- 会存在多个上游依赖有相同依赖
延迟更新
当 var1 变化时,通知到 var3,var3 不会立刻更新(无论是计算值还是副作用回调执行),而是延迟更新。
实现延迟更新有两种方式:
- 开启一个异步队列来更新。
- 主动提供 batch 函数。在 batch 函数中更新,都不会立刻执行而是被标记 dirty(preact 的实现),或是推入队列(solidjs 的实现)。当传入 batch 函数执行完毕后,一次性 apply 所有更新。
问题: 延迟更新会破坏事务的原子性,原因如下: 当我们延迟更新 var3 的值后,相应的 var2 的更新则不能延迟,因为只有这样,var3 更新是才能获取到最新的 var2 的值。如果强行把 var2 的值也延迟更新,那 var2 的上游就不能延迟更新。即:不能将所有的更新延迟。 现在主流框架大多采用的“延迟更新”方案,但它们都有自己的办法去解决“事务原子性”的问题。
解决:
- Lazy Computed: Lazy Computed 指 Computed 是懒加载模式,即只在访问 computed 时才去重新计算 computed 的值。
- Computed 优先执行: 对于 solid 来说,solid 的 memo 和 effect 会放入两个队列, memo 回调总是会先于 effect 执行。使用这种策略,solid 也实现了 Glitch Avoidance 。