细粒度反应性实践介绍参与者反应性生命周期同步执行结论

2025-05-26

细粒度反应性实践介绍

玩家们

反应式生命周期

同步执行

结论

响应式编程已经存在了几十年,但它似乎时而流行,时而过时。在 JavaScript 前端领域,它在过去几年里再次兴起。它超越了框架,对于任何开发人员来说都是一个有用的主题。

然而,事情并不总是那么简单。首先,反应性有多种类型。术语和命名常常包含大量含义,同一个词对不同的人有不同的含义。

其次,它有时看起来像魔术。其实不然,在理解“是什么”之前,很难不被“如何”所困扰。这使得通过实际案例进行教学变得具有挑战性,也需要谨慎的平衡,避免过于理论化。

本文不会关注“如何”。我将尝试以最温和的方式介绍MobXVueSvelteKnockoutSolid等库所使用的细粒度响应式方法

注意:这可能与你熟悉的 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


Enter fullscreen mode Exit fullscreen mode

当然,光是这一点就没什么意思了。它们或多或少只是一些可以存储任何东西的值。重要的细节是 和 都get可以set运行任意代码。这对于传播更新至关重要。

函数是实现这一目标的主要方式,但您可能已经看到过通过对象获取器或代理来实现这一目标:



// Vue
const count = ref(0)
// read a value
console.log(count.value); // 0

// set a value
count.value = 5;


Enter fullscreen mode Exit fullscreen mode

或者隐藏在编译器后面:



// Svelte
let count = 0;
// read a value
console.log(count); // 0

// set a value
count = 5;


Enter fullscreen mode Exit fullscreen mode

信号本质上是事件发射器。但关键的区别在于订阅的管理方式。

反应

光有信号,如果没有它的“伙伴”——反应,那就没什么意思了。反应,也称为“效果”、“自动运行”、“监视”或“计算”,会观察我们的信号,并在其值每次更新时重新运行它们。

这些是最初运行的包装函数表达式,以及我们的信号更新时运行的函数表达式。



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);


Enter fullscreen mode Exit fullscreen mode

这乍一看有点像魔术,但这正是我们的信号需要 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");


Enter fullscreen mode Exit fullscreen mode

注意:本例中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");


Enter fullscreen mode Exit fullscreen mode

这次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);


Enter fullscreen mode Exit fullscreen mode

需要注意的是,当我们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);


Enter fullscreen mode Exit fullscreen mode


同步执行

细粒度的响应式系统会同步且立即执行其变更。它们的目标是零故障,因为永远不可能观察到不一致的状态。这带来了可预测性,因为在任何给定的变更中,代码只运行一次。

当我们不能相信我们所观察到的内容来做出决策和执行操作时,不一致的状态可能会导致意外的行为。

演示其工作原理的最简单方法是同时应用两个更改,并将其输入到运行 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);
});


Enter fullscreen mode Exit fullscreen mode

在此示例中,代码按预期自上而下运行,完成创建。然而,批量更新会反转运行/读取日志。

即使 A 和 B 同时应用,当我们更新值时,也需要从某个地方开始,所以我们会先运行 A 的依赖项。因此,effect 会先运行,但检测到 C 已过期后,我们会立即在读取时运行它,这样所有操作都只执行一次,并正确计算结果。

当然,你或许能想出一种方法来按顺序解决这种静态情况,但请记住,依赖关系在任何运行时都可能发生变化。细粒度的响应式库使用混合推送/拉取方法来保持一致性。它们不像事件/流那样纯粹是“推送”,也不像生成器那样纯粹是“拉取”。


结论

本文涵盖的内容很丰富。我们介绍了核心原语,并探讨了细粒度响应式的定义特征,包括依赖解析和同步执行。

如果这些主题看起来还不够清晰,没关系。请阅读文章并尝试操作示例。这些示例旨在以最简洁的方式演示这些概念。但这实际上就是主要内容了。稍加练习,您也能了解如何以精细的方式对数据进行建模。


延伸阅读:
MobX SolidJS 背后的基本原理
:渲染的反应性

文章来源:https://dev.to/ryansolid/a-hands-on-introduction-to-fine-grained-reactivity-3ndf
PREV
从头开始构建反应库信号反应和衍生结论
NEXT
使用一行 Markdown GitHub Profile README Generator 在您的 GitHub 个人资料上添加访客计数