细粒度反应性实践介绍
玩家们
反应式生命周期
同步执行
结论
响应式编程已经存在了几十年,但它似乎时而流行,时而过时。在 JavaScript 前端领域,它在过去几年里再次兴起。它超越了框架,对于任何开发人员来说都是一个有用的主题。
然而,事情并不总是那么简单。首先,反应性有多种类型。术语和命名常常包含大量含义,同一个词对不同的人有不同的含义。
其次,它有时看起来像魔术。其实不然,在理解“是什么”之前,很难不被“如何”所困扰。这使得通过实际案例进行教学变得具有挑战性,也需要谨慎的平衡,避免过于理论化。
本文不会关注“如何”。我将尝试以最温和的方式介绍MobX、Vue、Svelte、Knockout和Solid等库所使用的细粒度响应式方法。
注意:这可能与你熟悉的 RxJS 等流式响应式有所不同。它们之间有关联,也有相似之处,但并不完全相同。
虽然本文面向的是初次接触细粒度响应式或响应式编程的读者,但它仍然是一个中级主题,需要读者具备 JavaScript 知识并熟悉一些计算机科学的入门知识。我会尽力详细解释,但欢迎在评论区留言提问。
我将在 Codesandbox 中发布代码片段和示例。我将使用我的 Solid 库来支持这些示例,本文中的语法也将使用它的语法。不过所有库的语法都大同小异。点击链接,在完全交互式的环境中体验这些示例。
玩家们
细粒度的反应性是由一系列原语构成的。我所说的原语指的是一些简单的结构,Promises
而不是 JavaScript 中的字符串或数字之类的原始值。
每个节点都充当图中的节点。你可以把它想象成一个理想化的电路。任何变化都会同时影响所有节点。需要解决的问题是单个时间点的同步。这是我们在构建用户界面时经常遇到的问题。
让我们从了解不同类型的原语开始。
信号
信号是响应式系统最基本的组成部分。它们由 getter、setter 和值组成。虽然在学术论文中通常被称为信号,但它们也被称为可观察对象、原子、主题或引用。
const [count, setCount] = createSignal(0);
// read a value
console.log(count()); // 0
// set a value
setCount(5);
console.log(count()); //5
当然,光是这一点就没什么意思了。它们或多或少只是一些可以存储任何东西的值。重要的细节是 和 都get
可以set
运行任意代码。这对于传播更新至关重要。
函数是实现这一目标的主要方式,但您可能已经看到过通过对象获取器或代理来实现这一目标:
// Vue
const count = ref(0)
// read a value
console.log(count.value); // 0
// set a value
count.value = 5;
或者隐藏在编译器后面:
// Svelte
let count = 0;
// read a value
console.log(count); // 0
// set a value
count = 5;
信号本质上是事件发射器。但关键的区别在于订阅的管理方式。
反应
光有信号,如果没有它的“伙伴”——反应,那就没什么意思了。反应,也称为“效果”、“自动运行”、“监视”或“计算”,会观察我们的信号,并在其值每次更新时重新运行它们。
这些是最初运行的包装函数表达式,以及我们的信号更新时运行的函数表达式。
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);
这乍一看有点像魔术,但这正是我们的信号需要 getter 的原因。每当信号执行时,包装函数都会检测到它并自动订阅它。后面我会更详细地解释这种行为。
重要的是,这些信号可以携带任何类型的数据,并且响应可以对其进行任何操作。在 CodeSandbox 示例中,我创建了一个自定义日志函数,用于将 DOM 元素附加到页面。我们可以利用这些函数来协调任何更新。
其次,更新是同步进行的。在我们记录下一条指令之前,Reaction 已经运行了。
就是这样。我们已经拥有了实现细粒度响应式所需的所有部分。信号和响应,被观察者和观察者。事实上,你只需要这两个就能创建大多数行为。然而,还有一个核心原语我们需要讨论。
衍生品
我们经常需要以不同的方式表示数据,并在多个反应中使用相同的信号。我们可以在反应中编写这些操作,甚至可以提取辅助函数。
console.log("1. Create Signals");
const [firstName, setFirstName] = createSignal("John");
const [lastName, setLastName] = createSignal("Smith");
const fullName = () => {
console.log("Creating/Updating fullName");
return `${firstName()} ${lastName()}`
};
console.log("2. Create Reactions");
createEffect(() => console.log("My name is", fullName()));
createEffect(() => console.log("Your name is not", fullName()));
console.log("3. Set new firstName");
setFirstName("Jacob");
注意:本例中
fullName
是一个函数。这是因为为了读取效果器下方的信号,我们需要将其推迟到效果器运行时才执行。如果它只是一个值,则没有机会进行跟踪,效果器也无法重新运行。
但有时,我们导出值的计算成本很高,我们不想重复计算。因此,我们引入了第三个基本原语,其作用类似于函数记忆化,将中间计算存储为它们自己的信号。这些原语被称为“导出”,但也被称为“备忘录”、“计算”、“纯计算”。
fullName
比较一下我们进行推导时会发生什么。
console.log("1. Create Signals");
const [firstName, setFirstName] = createSignal("John");
const [lastName, setLastName] = createSignal("Smith");
console.log("2. Create Derivation");
const fullName = createMemo(() => {
console.log("Creating/Updating fullName");
return `${firstName()} ${lastName()}`
});
console.log("3. Create Reactions");
createEffect(() => console.log("My name is", fullName()));
createEffect(() => console.log("Your name is not", fullName()));
console.log("4. Set new firstName");
setFirstName("Jacob");
这次fullName
在创建时立即计算其值,并且在 Reactions 读取时不会重新运行其表达式。当我们更新其源信号时,它会再次运行,但仅运行一次,因为该更改会传递到 Reactions。
虽然计算全名并不是一项昂贵的计算,但我们可以看到,派生可以通过将值缓存在独立执行的表达式中来节省我们的工作,而该表达式本身是可跟踪的。
更重要的是,由于它们是派生的,因此保证同步。在任何时候,我们都可以确定它们的依赖关系,并评估它们是否可能过时。使用反应写入其他信号看似等效,但并不能带来这种保证。这些反应不是信号的显式依赖项(因为信号没有依赖项)。下一节我们将更深入地探讨依赖项的概念。
注意:有些库会延迟计算派生值,因为它们只需在读取时计算,这样可以主动处理当前未读取的派生值。这些方法之间存在权衡,超出了本文的讨论范围。
反应式生命周期
细粒度响应式维护着众多响应节点之间的连接。在任何给定的变化中,图的各个部分都会重新评估,并可以创建和移除连接。
注意:像 Svelte 或 Marko 这样的预编译库不使用相同的运行时跟踪技术,而是静态分析依赖项。因此,它们对响应式表达式何时重新运行的控制较少,因此可能会过度执行,但订阅管理的开销较少。
考虑一下当条件改变时用来得出值的数据:
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
在步骤 3 中更改时,我们不会收到新的日志。这是因为每次我们重新运行一个反应式表达式时,我们都会重建它的依赖项。简单来说,当我们更改时,lastName
没有人在监听它。
当我们将其设置为 true 时,值确实发生了变化showFullName
。但是,没有任何通知。这是一个安全的交互,因为要lastName
再次被跟踪,showFullName
必须更改,并且该值已被跟踪。
依赖项是响应式表达式读取并生成其值的信号。反过来,这些信号持有许多响应式表达式的订阅。当它们更新时,会通知依赖它们的订阅者。
我们在每次执行时构建这些订阅/依赖项。并在每次重新运行响应式表达式或最终释放它们时释放它们。您可以使用onCleanup
辅助函数查看执行时间:
console.log("1. Create");
const [firstName, setFirstName] = createSignal("John");
const [lastName, setLastName] = createSignal("Smith");
const [showFullName, setShowFullName] = createSignal(true);
const displayName = createMemo(() => {
console.log("### executing displayName");
onCleanup(() =>
console.log("### releasing displayName dependencies")
);
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);
同步执行
细粒度的响应式系统会同步且立即执行其变更。它们的目标是零故障,因为永远不可能观察到不一致的状态。这带来了可预测性,因为在任何给定的变更中,代码只运行一次。
当我们不能相信我们所观察到的内容来做出决策和执行操作时,不一致的状态可能会导致意外的行为。
演示其工作原理的最简单方法是同时应用两个更改,并将其输入到运行 Reaction 的 Derivation 中。我们将使用一个batch
辅助函数来演示。batch
将更新包装在一个事务中,该事务仅在执行完表达式后才应用更改。
console.log("1. Create");
const [a, setA] = createSignal(1);
const [b, setB] = createSignal(2);
const c = createMemo(() => {
console.log("### read c");
return b() * 2;
});
createEffect(() => {
console.log("### run reaction");
console.log("The sum is", a() + c());
});
console.log("2. Apply changes");
batch(() => {
setA(2);
setB(3);
});
在此示例中,代码按预期自上而下运行,完成创建。然而,批量更新会反转运行/读取日志。
即使 A 和 B 同时应用,当我们更新值时,也需要从某个地方开始,所以我们会先运行 A 的依赖项。因此,effect 会先运行,但检测到 C 已过期后,我们会立即在读取时运行它,这样所有操作都只执行一次,并正确计算结果。
当然,你或许能想出一种方法来按顺序解决这种静态情况,但请记住,依赖关系在任何运行时都可能发生变化。细粒度的响应式库使用混合推送/拉取方法来保持一致性。它们不像事件/流那样纯粹是“推送”,也不像生成器那样纯粹是“拉取”。
结论
本文涵盖的内容很丰富。我们介绍了核心原语,并探讨了细粒度响应式的定义特征,包括依赖解析和同步执行。
如果这些主题看起来还不够清晰,没关系。请阅读文章并尝试操作示例。这些示例旨在以最简洁的方式演示这些概念。但这实际上就是主要内容了。稍加练习,您也能了解如何以精细的方式对数据进行建模。
延伸阅读:
MobX SolidJS 背后的基本原理
:渲染的反应性