响应式框架的模型
rxjs 是一个推的模型,由 subscribe 对 observerable 和 subscriber 进行关联,当 observerable 发生变化时,执行 subscriber 的方法。
基于 signal 的响应式由自动依赖跟踪、标脏加重新执行组成。是一种自动化拉取的模型。其由 signal, computed, effect 再加 computation 组成。signal 是原始数据,computed 对数据进行组合,effect 则在数据变更时对外界做出反应。computation 用于对一段计算逻辑进行抽象,在 computation 中读取的 signal 和 computed 都会进行依赖跟踪,当数据发生变更时重新执行 computation。effect 是创建 computarion 的一种方式。
signal
signal 是一个包装了数据的对象,提供对数据的读写能力。
对数据的修改默认是不可变的,通过 === 检查相等性(后续添加 option 控制相等判断逻辑),若相等则不会标脏。
使用 mutate 方法不检查相等性,强制标脏。这个方法只适用于对性能要求较高的场合或较复杂的对象中,且需要确保下游不会误会。这可能会提高调试的复杂性。一种比较安全的用法是无数据的 signal,仅用于强制通知下游,例如定时刷新。
被标脏的 signal 会在下一个微任务中让跟踪它的 effect 执行,让跟踪它的 computed 更新状态。这意味着对 effect 和 computed 都是异步的。
interface ReadableSignal<T> {
(): T;
}
type setFunction<T> = (value: T) => void;
type updateFunction<T> = (fn: (lastValue: T) => T) => void;
type mutateFunction<T> = (fn: (value: T) => void) => void;
interface Signal<T> extends ReadableSignal<T> {
set: setFunction;
update: updateFunction;
mutate: mutateFunction;
}
function signal<T>(initialValue?: T): Signal<T>;
用例
const signal1 = signal(1);
signal1();
signal1.set(2);
signal1.update(v => v + 1);
const signal2 = signal({ key: 'value' });
signal2();
signal2.mutate(v => { v.key = 'value2' });
高级 signal
当我们不希望 signal 能被外部修改时,需要一个只读的 signal。
只读 signal 是只有内部可变性的 signal,一般用于把外界随时间变化的数据抽象为 signal。例如定时器和网络状态。
可以由普通 signal 转化而来,也需要提供一个方便的构造器。但一旦提供了构造器后,由于构造器可能具有副作用,这就需要考虑资源的释放和异步的问题,以及是否需要具有响应式的问题。另外也需要考虑何时执行构造器(立即调用或延时调用)。这些部分尚未考虑清楚,暂不过多叙述。初步的 API 设计如下:
type ReadOnlySignalUpdater<T> = (
set: setFunction,
update?: updateFunction,
mutate?: mutateFunction
) => () => void
function readOnly<T>(updater: ReadOnlySignalUpdater<T>, initial?: T): ReadableSignal<T>;
function toReadOnly<T>(signal: Signal<T>): ReadableSignal<T>;
用例
const signal3 = readOnly((_set, update) => {
// 这种 case 包括定时器、监听网络状态变化、localstorage 变更等。
const id = setInterval(() => {
update(lastValue => lastValue + 1);
});
return () => clearInterval(id);
}, 0);
signal3();
effect
effect 是在数据变更后要执行的操作。现代响应式框架一般都会自动跟踪依赖而无需手动 subscribe。
effect 方法会创建一个 computation。嵌套的 computation 会形成父子关系,父是子的owner,在父被销毁时,子也会被一并销毁。销毁意味着解除了它与上游数据的依赖关系。
untrack 用于在 effect 当中排除不想被跟踪的数据,例如只想临时读取一下某些状态。在 untrack 方法体内读取的任何数据都不会被跟踪。
effect 执行阶段,如果有对 signal 的修改,可能会引起死循环,而且这也不是合适的做法。当发现这种情况时,框架需要抛出错误。
function effect<T>(fn: () => T): void;
function untrack<T>(fn: () => T): T;
用例
const signal1 = signal(1);
effect(() => console.log(signal1()));
signal1.set(2);
// log: 1, 2
const signal2 = signal('hello');
effect(() => {
console.log(signal1() + ' ' + untrack(signal2));
});
signal2('world');
// log: 1 hello
高级 effect
高级 effect 要考虑异步的情况。由于 async/await 是由系统调度的,如果 effect 被销毁时刚好执行到中间,难以取消后续调用,因此考虑使用 generator 的形式(类似 co 库)。或者在 await 后添加一个包装,如 await cancelable(myAsyncFunction())。
对于需要显示指定跟踪的情况,也在高级 effect 的设计中,预计命名为 on。
对于不希望使用父作为 owner 的情况,需要根据 case 看是否需要有 api 支持。也在高级 effect 中再考虑。
computed
computed 是由 signal 及其他 computed 经由无副作用的计算得来。在 computed 更新阶段所有 signal 变更,框架需要抛出错误。
computed 不应该存在循环依赖,一旦发现,框架需要抛出错误。
会对数据进行缓存,上游数据不变更,不会重复计算。
具有延迟计算特性。没有被读取的 computed 不会进行运算。
computed 更新时需要按照被依赖的顺序执行。避免读取旧值。
computed 的更新和 effect 在同一个微任务中执行。但 computed 的计算要早于 effect 的执行,避免读取旧值。
api 中的 initValue 和 lastValue 是用于方便进行累计的。第一次执行时会用 initValue 传给 fn,fn 的计算结果作为 computed 的内容。以后更新时,会用上一次的值作为 fn 的参数。
function computed<T>(fn: (lastValue?: T) => T, initValue?: T): ReadableSignal<T>
用例
const signal1 = signal(1);
const signal2 = signal('hello');
const signal3 = computed(() => {
return signal1() + ' ' + signal2()
});
track(() => {
console.log(signal3());
});
signal1(2);
signal2('world');
// log:
// 1 hello
// 2 world
高级 computed
高级 api 可能需要考虑异步 computed 的情况,但这种场景可能更适合用 readOnly。
总结
以上4个 api (signal/effect/untrack/computed)已经能大致能完整描绘出一个响应式框架的轮廓。如果有同学感兴趣,可以自己实现一个。实现了的记得私信或者评论回复我 ^_^