从头开始构建反应式库
信号
反应和衍生物
结论
在上一篇文章《细粒度响应式实践入门》中,我通过示例解释了细粒度响应式背后的概念。现在,我们来学习如何自己构建一个响应式库。
有些东西在实际操作时总显得有些神奇,但实际上机制上并没有那么复杂。响应式之所以如此神奇,是因为一旦部署到位,即使在动态场景下也能自行处理。这正是真正的声明式方法的优势,因为只要契约得到遵守,具体实现就无关紧要。
我们将要构建的反应式库不会具有MobX、Vue或Solid等的所有功能,但它应该作为一个很好的例子来让您了解它的工作原理。
信号
信号是我们响应式系统的核心,也是我们开始的正确起点。它包含 getter 和 setter 方法,所以我们可以从以下代码开始:
export function createSignal(value) {
const read = () => value;
const write = (nextValue) => value = nextValue;
return [read, write];
}
这目前还没有做太多的事情,但我们可以看到我们现在有一个简单的容器来保存我们的价值。
const [count, setCount] = createSignal(3);
console.log("Initial Read", count());
setCount(5);
console.log("Updated Read", count());
setCount(count() * 2);
console.log("Updated Read", count());
那么我们还缺少什么呢?管理订阅。信号是事件发射器。
const context = [];
function subscribe(running, subscriptions) {
subscriptions.add(running);
running.dependencies.add(subscriptions);
}
export function createSignal(value) {
const subscriptions = new Set();
const read = () => {
const running = context[context.length - 1];
if (running) subscribe(running, subscriptions);
return value;
};
const write = (nextValue) => {
value = nextValue;
for (const sub of [...subscriptions]) {
sub.execute();
}
};
return [read, write];
}
这里有一些需要解开的内容。我们主要管理两件事。在文件顶部,有一个全局context
堆栈,用于跟踪任何正在运行的反应或派生。此外,每个信号都有自己的subscriptions
列表。
这两件事构成了自动依赖跟踪的基础。执行时,Reaction 或 Derivation 会将自身推送到context
堆栈上。它会被添加到subscriptions
执行期间读取的任何信号的列表中。我们还会将信号添加到运行上下文中,以便进行清理工作,这将在下一节中介绍。
最后,在 Signal 写入时,除了更新值之外,我们还执行所有订阅。我们克隆列表,以便在执行过程中添加的新订阅不会影响本次运行。
这是我们完成的信号,但它只是方程的一半。
反应和衍生物
现在你已经看到了一半,或许能猜出另一半是什么样子了。让我们创建一个基本的反应(或效果)。
function cleanup(running) {
for (const dep of running.dependencies) {
dep.delete(running);
}
running.dependencies.clear();
}
export function createEffect(fn) {
const execute = () => {
cleanup(running);
context.push(running);
try {
fn();
} finally {
context.pop();
}
};
const running = {
execute,
dependencies: new Set()
};
execute();
}
我们在这里创建的是推送到上下文的对象。它包含 Reaction 监听的依赖项(信号)列表,以及我们跟踪并重新运行的函数表达式。
每次循环,我们都会取消订阅该 Reaction 的所有信号,并清除依赖列表以重新开始。这就是我们存储反向链接的原因。这使我们能够在每次运行时动态创建依赖项。然后,我们将该 Reaction 压入堆栈并执行用户提供的函数。
这 50 行代码可能看起来不多,但我们现在可以重新创建上一篇文章中的第一个演示。
console.log("1. Create Signal");
const [count, setCount] = createSignal(0);
console.log("2. Create Reaction");
createEffect(() => console.log("The count is", count()));
console.log("3. Set count to 5");
setCount(5);
console.log("4. Set count to 10");
setCount(10);
添加一个简单的派生并不需要太多复杂度,只需要使用与 中基本相同的代码即可createEffect
。在像 MobX、Vue 或 Solid 这样的真正的响应式库中,我们会内置一个推/拉机制,并跟踪响应图以确保我们没有做额外的工作,但出于演示目的,我将只使用 Reaction。
注意:如果您有兴趣实现他的推/拉方法的算法,我建议您阅读《成为完全反应式:对 MobX 的深入解释》
export function createMemo(fn) {
const [s, set] = createSignal();
createEffect(() => set(fn()));
return s;
}
让我们重新创建条件渲染示例:
console.log("1. Create");
const [firstName, setFirstName] = createSignal("John");
const [lastName, setLastName] = createSignal("Smith");
const [showFullName, setShowFullName] = createSignal(true);
const displayName = createMemo(() => {
if (!showFullName()) return firstName();
return `${firstName()} ${lastName()}`
});
createEffect(() => console.log("My name is", displayName()));
console.log("2. Set showFullName: false ");
setShowFullName(false);
console.log("3. Change lastName");
setLastName("Legend");
console.log("4. Set showFullName: true");
setShowFullName(true);
lastName
如您所见,因为我们每次都会构建依赖图,所以当我们不再监听它时,我们不会重新执行更新时的推导。
结论
这些就是基础知识。当然,我们的库没有批处理、自定义处理方法,也没有针对无限递归的保护措施,而且并非完美无缺。但它包含了所有核心部分。2010年代初的KnockoutJS等库就是这样运作的。
出于上述所有原因,我不建议使用这个库。但是,只需大约 50 行代码,你就拥有了一个简单的响应式库的所有功能。而且,当你考虑到可以用它建模多少行为时,你应该就能理解为什么像Svelte和Solid这样带有编译器的库可以生成如此小的包。
如此少的代码却蕴含如此强大的功能。您可以真正地用它来解决各种各样的问题。只需几行代码,它就能成为您选择的框架的状态库,再多几十行代码,它就能成为框架本身。
希望通过这个练习,您现在可以更好地理解和欣赏细粒度反应库中的自动跟踪如何工作,并且我们已经揭开了其中的一些神秘面纱。
想知道 Solid 如何利用这一点,并将其打造成为一个完整的渲染库。请查看SolidJS:渲染响应式。
文章来源:https://dev.to/ryansolid/building-a-reactive-library-from-scratch-1i0p