发布于 2026-01-06 1 阅读
0

反应性中的调度推导

反应性中的调度推导

大多数开发者将响应式视为一个事件系统。你拥有某种状态。你更新该状态,由此派生出的所有变量都会重新评估。最终,这种变化会以副作用的形式体现出来。

let name = state("John");
const upperName = memo(() => name.toUpperCase());

effect(() => console.log(upperName));
Enter fullscreen mode Exit fullscreen mode

我们将使用伪代码,而不是为了迎合特定库或框架的语法。

但这是一种过于简化的说法。正如我们在上一篇文章中了解到的,这种变化可以通过多种方式在系统中传播,例如“推送”、“拉动”,甚至是“推送-拉动”:

虽然我们在讨论响应式编程时往往会想到一个更简单的“推送”模型,但几乎没有现代框架使用纯粹的“推送”系统。它无法提供我们所期望的那些保障。

一旦不再局限于“推送”事件,调度就成为解决方案中必不可少的一部分。如果工作不能立即完成,就必须稍后执行。调度哪些任务以及何时执行都会产生影响。


立即执行 vs 懒惰执行 vs 计划执行

在创建响应式事物时,我们在评估它时有 3 种选择。

首先,我们可以直接运行它。对于会产生其他效果的效果,我们可能希望采用深度优先而非广度优先的方式执行。我们可能希望一次性评估整个决策树。这在渲染过程中并不罕见。

我们可能希望延迟评估该值,直到确定该值本身会被读取。也许我们有一个派生值,它永远不会被读取。也许它计算的是一些耗时的操作,只有在 UI 中的其他状态发生变化时才会用到。那么,如果我们不会立即或永远使用它,为什么要评估它呢?

最后,我们可能需要安排节点稍后运行。我们希望确保所有中间结果在运行前都已排序。也许它是一个副作用,因此它本身不会被读取。只有可读取的节点才能延迟执行。因此,我们将其添加到队列中以便稍后执行。

更新时,我们也有类似的选项。我们不会在“推送”操作之外立即执行任何操作,但我们同样可以选择是调度节点还是依赖读取操作来进行评估。

乍一看,似乎很明显,我们应该尽可能延迟执行某些任务,而将必须执行的任务安排到日程中。否则,我们可能会安排不必要的工作。派生状态是延迟求值的理想对象,因为它必须先被读取才能使用。但是,在决定安排哪些任务时,还有其他需要考虑的因素吗?


被动所有权

除了降低不必要的工作风险之外,了解惰性求值的另一个好处也很有用:惰性求值生成的代码可以被自动垃圾回收。

在像 Signals 这样遵循观察者模式的响应式系统中,内存泄漏问题一直备受关注。这是因为通常情况下,订阅者和依赖项的实现是双向的。当一个响应式表达式运行时,它会订阅源信号并将其添加到自身的依赖项中。之所以需要双向关联,是因为信号更新时需要通知其依赖节点,而受影响的节点需要重置其所访问的所有节点的依赖关系。这样一来,依赖关系在每次执行时都会动态变化。

但这同时也意味着,仅仅失去对这些节点之一的引用并不足以触发垃圾回收。如果你有一个信号(Signal)和一个效应(Effect),即使你不再使用效应,信号仍然会保留对它的引用,而效应也会保留对它的引用。如果它们都不再被引用,它们或许可以被释放,但状态的生命周期超过其副作用的情况并不少见。

通常情况下,副作用需要手动释放。但是,派生状态如果无人读取,则可以自行释放。如果将来某个程序读取它,则可以在那时重新运行并构建其依赖项,就像它最初创建时无需运行,直到被读取一样。

相反,调度派生状态意味着无论是否读取,节点和依赖项都会被立即创建。在这种系统中,我们在调度时无法预知派生值是否会被读取,因此无论如何都会对其进行求值并创建依赖项。因此,要实现自动释放就困难得多。

使用需要手动释放资源的系统来创建用户界面非常繁琐。大多数外部状态库只关注状态和派生状态,而将副作用留给渲染库处理。因此,两者都不需要显式释放资源这一点非常有利。

但如果没有渲染库怎么办?

这就是为什么 S.js 首创了响应式所有权模型,该模型已成为 SolidJS 等细粒度渲染器的核心组件。如果在父响应式上下文中手动创建了可释放的节点,那么当父上下文重新执行时,就像其依赖项一样,我们会释放这些子节点。

这是响应式依赖关系图的辅助图,它将我们的 Effects 和其他调度节点连接起来,从而实现所有资源的自动释放。它也是 Context API 等功能的基础机制,并支持对 Errors 或 Suspense 等状态边界进行分组。它类似于虚拟 DOM,但节点数量较少。其节点由动态决策(条件判断)决定,而非元素和组件的数量。

但无论如何,调度决定了哪些内容可以舒适地存在于树的内部和外部,因为它会影响节点的处置方式。


分阶段实施

代码应该在可预测的时间运行吗?有了响应式设计,我们就能模拟各种各样的系统,而不再受限于传统的时间观念和进程顺序。一行代码不必紧接着另一行运行。但开发者毕竟是人,事情发生的时机可能会产生影响。

副作用无法挽回。一旦决定展示某些内容,就必须完整展示,否则就会前后矛盾。如果出现错误,就需要屏蔽所有相关内容。这就是为什么会有错误边界和悬念之类的概念。这也是为什么我们倾向于有目的地安排任务运行时间的原因。


React 的三个阶段

React 推广了一种包含三个执行阶段的模型。

  1. 纯用户代码执行(组件、计算)
  2. 渲染 - VDOM 已进行差异比较,DOM 已进行修补
  3. 渲染后 - 用户特效执行

我之所以采用这种命名方式,是因为 React 对“render”一词的使用与其他框架的工作方式不一致。我使用“render”来表示更新 DOM,而不是运行组件代码。

作为开发者,除了特效之外,你的所有代码都在 Pure 阶段执行,特效是在 Post-Render 阶段执行的。

这包括依赖数组。React 的模型在执行任何内部或外部副作用之前,就已经了解了所有更新所需的依赖项。这种在准备提交之前可以中止更新周期的能力,正是并发等特性的基石。某些代码可以随时抛出 Promise,而不会影响屏幕上当前显示的内容。

这种模型在 React 的“拉取式”响应式架构中效果很好,组件会重复运行。每次运行,代码都会完整执行完毕,因此每次运行的结果都相同。


具有颗粒渲染的阶段

使用“推送-拉取”模式,也可以采用类似上述的系统,但这样就无法充分利用其“推送”更细粒度更新的能力。不过,还有其他方法可以实现类似的阶段性执行。

但首先,我们应该认识到,如果不加干预,惰性求值的派生值会在最早读取它们的副作用类型运行时执行。如果您引入一个renderEffect在用户定义effect副作用类型之前运行的副作用类型,那么相应的派生值就会在那时运行。

改变响应式表达式或派生值的读取位置,可能会改变其在渲染之前或之后运行的时机。此外,如果通过依赖关系将其添加到新的阶段,可能会改变原本无关代码的当前行为。

八年前我刚创建 SolidJS 时,并不太在意这种惰性行为。我们调度了所有计算节点,包括派生节点和效应节点。虽然这确实可能会导致额外的工作,但组件中的很多状态都是层级式的,所以如果某些元素未使用,它们通常会被卸载。而调度机制意味着我们可以实现以下行为:

与上述情况略有不同,但这意味着我们所有的纯粹计算都发生在效应计算之前。

但有一点不同。`post getFirstLetter-Render` 期间会运行。任何在特效运行期间首次出现的、未被调度的依赖项,都会因为发生得太晚而无法在任何特效运行之前被发现。由于我们的异步原语也是调度节点,因此这几乎不会造成什么影响,但这仍然是一个虽小但可以理解的差异。

Solid 和 React 一样,定义了三个阶段。这或许就是 Solid 是唯一支持并发渲染的基于 Signals 的框架的原因。您可能已经注意到,与 Solid 不同,几乎所有较新的 Signals 库都采用惰性求值的方式获取状态。我们也在考虑在下一个主要版本中实现这一点。

但是放弃分阶段实施方法带来的好处是不可接受的。所以,让我们探讨一下替代方案。


重新思考依赖关系

适用于“拉动”模式的方法也适用于“推拉”模式。

可能大家都不希望看到“依赖数组”的回归。但如果将效果拆分为纯跟踪部分和效果部分,那么除了效果本身之外的所有用户代码都可以在渲染之前的纯跟踪阶段执行。

与上述情况类似:

  1. 纯粹 - 运行所有跟踪上下文:渲染效果和效果的前半部分,读取(并可能评估)所有派生值。
  2. 渲染 - 运行 renderEffects 的后半部分
  3. 后期渲染 - 运行特效的后半部分

这与依赖数组仍然有所不同,因为组件不会重新运行,而且它们可以是动态的,每次运行时读取不同的依赖项。没有 Hook 规则。但是,如果想要使用延迟派生值,同时又要确保遵循阶段以实现一致的调度,那么就可以采用这种方法。


派生异步

考虑调度机制的另一个原因是异步。大多数响应式系统都是同步的。异步操作在系统外部进行。你创建一个 effect,它会在准备就绪时更新你的状态。

let userId = state(1);
let user = state();
effect(() => {
  fetchUser(userId).then(value => user = value);
});
Enter fullscreen mode Exit fullscreen mode

user但就像同步同步一样,我们会丢失依赖于异步更新的信息userId。如果我们能将异步更新表示为派生,那么我们就能确切地知道哪些信息依赖于异步更新。

let userId = state(1);
const user = asyncMemo(() => fetchUser(userId));
Enter fullscreen mode Exit fullscreen mode

这不仅适用于直接依赖项,也适用于任何下游依赖项:

let userId = state(1);
const user = asyncMemo(() => fetchUser(userId));

const upperName = memo(() => user.firstName.toUpperCase());
Enter fullscreen mode Exit fullscreen mode

upperName这取决于user具体情况userId,而且它可能是异步的。

如果您想实现类似 Suspense 的系统,这些信息很有用。我们需要能够在userId数据更新时触发 Suspense。因此,我们需要知道它是异步操作的依赖项。此外,最好在数据最终被使用的位置附近暂停操作,而不是在第一个继承自该数据的节点处立即暂停。我们希望在读取数据时暂停,而upperName不是在upperName定义数据的位置暂停。您希望能够自由地从树的更高层获取数据,以便在下面的更多位置使用,而不是阻塞该点下方整个树的渲染。


异步操作应该采用惰性加载还是定时加载?

let userId = state(1);
const user = asyncMemo(() => fetchUser(userId));

const upperName = memo(() => user.firstName.toUpperCase());
Enter fullscreen mode Exit fullscreen mode

如果fetchUser在评估时间之前问题仍未解决会发生什么upperName

user初始状态未定义。您可能会遇到"Cannot find property 'firstName' on undefined"错误。

我们可以解决这个问题。你可以提供默认值。但并非所有数据都需要默认值,而且对于嵌套很深的数据,你可能需要模拟比预期更多的数据。

你可以到处进行空值检查,这当然没问题。但这确实意味着你的应用程序中会有大量代码用于检查值是否存在。为了避免额外的检查,你常常需要在代码树的更高层级进行检查。

或者,你可以抛出一个特殊的错误,并在值解析后重新运行。React 首创了在这种情况下抛出 Promise 的方法。这样做的好处在于,你无需进行空值检查或提供默认值,并且可以确信在最终提交时所有内容都已就绪。

但一个老问题再次浮现:

const A = asyncState(() => fetchA(depA));
const B = asyncState(() => fetchB(depB));

const C = memo(() => A + B)
Enter fullscreen mode Exit fullscreen mode

如果你使用抛出异常或其他类型的条件短路,并且派生值是惰性求值的,那么我的朋友,你无意中创建了一个瀑布式调用。当我们读取数据时,C它会首先计算 `a` A。它可以开始获取数据,A但由于 `a` 尚未解析,它会抛出异常。B只有在 `a` 解析后再次运行时,才会读取 `b` A。只有那时,它才会开始获取数据B

但是,如果已安排执行AB则会立即开始获取数据,无论是否C已读取。这意味着即使A抛出异常,B也可能在问题解决之前完成获取,A因为所有数据都是并行获取的。

一般来说,异步值应该进行调度。虽然利用代码路径来确定获取哪些值,从而实现异步值的延迟解析功能很强大,但这很容易导致性能问题。在采用抛出异常来管理未解析异步值的系统中,很容易出现瀑布式性能问题,因此,利用调度和我们对响应式图的了解是避免这种情况的一种方法。


结论

我希望通过这次探索,您能明白调度在响应式系统中扮演着重要角色。“推拉”架构实际上是在一个“推”式系统中构建的“拉”式系统。惰性派生状态会带来许多在完全调度所有操作的系统或纯粹的“拉”式系统中不会出现的问题。即使试图优化惰性,仍然有一些操作需要调度。

然而,精心构建的“推拉​​”模式威力无穷,因为它为典型的“拉式”响应机制增添了新的维度。它既能带来所有一致性和可预测性方面的优势,又能实现更精细化的应用。

这仍然是一个开放的研究领域。除了推进 Solid 2.0 的工作之外,我之所以更多地思考这个问题,是因为 TC-39 的信号提案取得了进展,而且更广泛的社区也要求将调度功能集成到浏览器和 DOM API 中。我们在这方面仍有很多不理解或尚未达成共识的地方,因此过早着手可能会造成灾难性的后果。

下次我们将深入探讨异步响应的本质。除了调度之外,异步还对响应式的定义提出了有趣的挑战。

文章来源:https://dev.to/this-is-learning/scheduling-derivations-in-reactivity-4687