作为现代前端框架中的一员,SolidJS 为开发者提供了极细粒度的响应式系统。
在我看来,响应式系统是现代前端框架的基础,没有响应式系统,意味着开发者需要花费额外的精力去处理数据的读写,而不能将有限的精力集中在视图本身。
本文将深入讲解 SolidJS 中实现响应式系统的方式,带大家探索 SolidJS 的响应式系统实现,一起动手实现 SolidJS 的响应式系统。
学习路径
要想深入探索 SolidJS 的响应式系统实现,不可避免地需要克隆代码仓库到本地,从源码的角度来学习。但是大多数情况下,直接上手读源码是一件投入产出比非常低的事情,为了了解一个简单的知识点,通常需要花费几个星期甚至数月的时间。
事实上,源码阅读也符合帕累托法则(又称二八效应/犹太法则),意思就是源码中 80% 的代码是在做各种优化,亦或是处理各种边界情况,而真正实现 Feature 的核心代码往往只有 20% 甚至更少。
因此在本文中,我不会带大家直接上手源码,而是通过循序渐进的形式,从核心响应式原语的表现入手,一步一步带大家探索 SolidJS 的响应式系统实现,并抽丝剥茧,一起实现简易的 SolidJS 响应式系统。
核心响应式原语
Signals
Signals 是 SolidJS 的响应式系统中最主要的部分,通常由 getter、setter 和 value 组成。在学术上称其为 Signals,在一些框架中也被称为 Observables(MobX)、Atoms(Recoil)、Refs(Vue.js)等。
从 SolidJS 的官方文档中我们已经了解了 Signals 如何创建,如何获取 value 和修改 value:
const [count, setCount] = createSignal(0);
console.log(count()); // 0
setCount(5);
console.log(count()); // 5
不难看出,核心是 createSignal,它创建了一个响应式数据,并返回了响应式数据的 getter 和 setter。
现在一起动手来实现一下:
const createSignal = (value) => {
// 获取 Signal value
const getter = () => {
return value;
};
// 修改 Signal value
const setter = (newValue) => {
value = newValue;
};
return [getter, setter];
};
非常简单,通过闭包将 value 保存,当调用 setter 修改 value 时,会修改闭包中的 value,当调用 getter 获取 value 时,又会返回闭包中的 value。
但是只实现 Signals 的创建、获取和修改还不够,因为 getter 和 setter 在这里都是显式调用,要实现响应式还有一个非常关键的能力——通知依赖了 Signals 的地方更新 Signal value。
要实现通知依赖了 Signals 的地方更新 Signal value,就涉及到 SolidJS 中的另一个核心响应式原语——Effects。
Effects
响应式数据创建出来是需要被消费的,而 Effects 的作用就是消费响应式数据,同时执行一些存在副作用的代码。在 SolidJS 和 Recoil 中称其为 Effects,在一些框架中也被称为 Reactions/Autoruns(MobX)、Watches(Vue.js)等。
最经典的例子就是打日志:
const [count, setCount] = createSignal(0);
// 当 count 发生变化时
// 打印当前 count value
createEffect(() => {
console.log('count is', count());
});
不难看出,核心是 createEffect,它创建了一个具有副作用的函数,当副作用函数中依赖的响应式数据发生变化时,自动执行副作用。
结合上文中提到的 Signals 的行为,是否有些熟悉?这就是观察者模式,响应式数据为被观察者,而副作用函数为观察者。
要实现 createEffect 的能力,不可避免地需要结合 createSignal 来实现,当 Signal getter 被触发时,将其所在的副作用函数存储起来,待 Signal setter 被触发后,将所有的副作用函数一次性都取出来并执行。
现在一起动手来实现一下:
const observers = [];
const getCurrentObserver = () => {
return observers[observers.length - 1];
};
const createSignal = (value) => {
const subscribers = new Set();
// 获取 Signal value
const getter = () => {
// 获取此次 Signal getter 调用时的 Observer
const currentObserver = getCurrentObserver();
if (currentObserver) {
// 如果此次 Signal getter 调用时的 Observer 存在
// 则将其存储至当前 Signal 的 subscribers
// 订阅当前 Signal 的变化
subscribers.add(currentObserver);
}
return value;
};
// 修改 Signal value
const setter = (newValue) => {
value = newValue;
// 将所有订阅了当前 Signal 变化的 Observer 一次性都取出来并执行
subscribers.forEach((subscriber) => subscriber());
};
return [getter, setter];
};
const createEffect = (effect) => {
const execute = () => {
// 无论是否在副作用函数中调用了 Signal getter
// 先假设副作用函数为某个 Signal 的 Observer
// 将其存储至 observers 中
observers.push(execute);
try {
// 执行副作用函数
// 若副作用函数确实为某个 Signal 的 Observer(即副作用函数中调用了 Signal getter)
// 则在 Signal getter 中会将 execute 存储至内部的 subscribers 中
// 否则不执行任何操作
effect();
} finally {
// 删除副作用函数
observers.pop();
}
};
// 副作用函数立即执行
execute();
};
稍微有点复杂,纯粹使用文字和代码进行描述非常枯燥,因此我给大家做了一个动画,通过动画帮助大家来理解这个过程:
可能部分同学会发现了一个稍显奇怪的地方:为什么 Signal subscribers 使用的是 Set,而 observers 却是一个数组?
原因在于,每次将 Signal subscribers 取出来并执行的过程中会再一次触发 Signal getter,导致同一个 Observer 被第二次存储到 observers,然后被添加到 Signal subscribers 中。这个行为意味着每执行一个副作用函数,Signal subscribers 的长度就会翻倍一次,因此需要使用 Set 过滤掉相同的 Observer。
Memos
使用函数包裹 Signals 进行运算并返回的操作被称为 Signals 的派生:
const [count, setCount] = createSignal(0);
// double 为 count 的派生值
const double = () => count() * 2;
而 Memos 也属于 Signals 的派生,与直接使用函数包裹 Signals 进行运算不同,Memos 会将运算结果进行缓存,直到它所依赖的 Signals 更新。在 SolidJS 中称其为 Memos,在一些框架中也被称为 Computeds(MobX/Vue.js)、Pure Computeds(KnockoutJS) 等。
在生产环境中,往往有一些非常消耗性能的操作,会长时间占用主线程,导致应用卡顿。举个非常形象的例子:
function Counter() {
const [count, setCount] = createSignal(10);
const fib = () => {
console.count('fibonacci run');
return fibonacci(count());
};
createEffect(() => {
Array(50)
.fill(fib)
.forEach((l) => l());
});
return (
<div>
<h2>You can see the logs in the console.</h2>
<button onClick={() => setCount(count() + 1)}>count fibonacci</button>
</div>
);
}
不难看出,这段代码就是用来计算斐波那契数列的,如果直接使用函数包裹 count(),你会发现控制台日志中,fib 函数被调用了 50 次,且每次点击按钮都会再次被调用 50 次。
但是如果使用 createMemo 包裹,则 fib 函数只会被调用一次,且每次点击按钮也只会被调用一次。
现在一起动手来实现一下:
const createMemo = (memo) => {
const [_value, _setValue] = createSignal();
createEffect(() => {
_setValue(memo());
});
return _value;
};
非常简单,仅仅只是 createSignal 和 createEffect 的组合,为什么?
结合上文中提到的 Effects 的行为,当 Effects 所依赖的 Signals 更新时,Effects 会自动执行,而这个自动执行的行为也正是 Memos 所需要的,再创建一个内部的 Signals 对派生值进行缓存,就实现了 Memos 的效果。
但是,真正跟着我们动手操作的同学会发现一个问题:即使 Signal value 没有任何变化,仅仅只是调用了 Signal setter,Memos 也会重新执行一次。显然,这个行为是不符合预期的,那么问题出在哪呢?
回到上文中的 createSignal 中,不难发现,setter 函数无论传入什么值,都会通知所有的 Observer 更新。
既然如此,修改一下 setter 的逻辑,加入 newValue 和 value 的比较逻辑,当 newValue 与 value 的值不一致时才通知 Observers 更新:
const setter = (newValue) => {
if (value !== newValue) {
value = newValue;
subscribers.forEach((subscriber) => subscriber());
}
};
至此,Memos 的能力完美实现。
Playground
本文中涉及到的所有代码,均充分注释,并可直接在 StackBlitz 中在线编辑和预览:
implement-createsignal-by-myself
总结
本文深入讲解了 SolidJS 中实现响应式的方式,带大家动手实现了 SolidJS 核心响应式原语 Signal、Memo、Effect,同时也抛出了一个观点:响应式是现代前端框架的基础。
为什么说响应式是现代前端框架的基础?
在我看来,前端开发的本质就是响应用户操作,这样的行为模式本质上与观察者模式/发布订阅模式十分相似,开发者会在页面上编写各种各样的监听器以响应用户操作。这意味着要想简单高效地开发,就需要一个响应式系统对页面数据做自动化处理,减少开发者分散在数据流控制上的精力,让开发者能够更加专注于用户体验本身。
拓展学习
在 SolidJS 早期的几个版本中,响应式系统直接基于 S.js 进行封装实现,感兴趣的同学可以尝试使用 S.js 构建一个具有响应式数据能力的原生 Web 应用。
直到现在,SolidJS 的响应式系统中的核心代码仍然与 S.js 基本一致。
参考资料
响应式编程(Reactive Programming)介绍 - 知乎
Finding Fine-Grained Reactive Programming - JavaScript inDepth
A Hands-on Introduction to Fine-Grained Reactivity - DEV Community