反应模型解释
自从我开始开发应用程序和网站以来已经过去了 10 年,但 JavaScript 生态系统从未像今天这样令人兴奋!
2022 年,社区被“信号”的概念所吸引,以至于大多数 JavaScript 框架都将它们集成到自己的引擎中。我正在考虑 Preact,它自 2022 年 9 月以来提供了与组件生命周期分离的反应变量;或者最近的 Angular,它于 2023 年 5 月实验性地实现了 Signals,然后从版本 18 正式开始。其他 JavaScript 库也选择重新考虑他们的方法......
从 2023 年到现在,我一直在各个项目中使用 Signals。它们的实施和使用简单性完全说服了我,以至于我在技术研讨会、培训课程和会议期间与我的专业网络分享了它们的好处。
但最近,我开始问自己这个概念是否真正“革命性”/是否有信号的替代品?因此,我更深入地研究了这种反思,并发现了反应式系统的不同方法。
这篇文章概述了不同的反应模型,以及我对它们如何工作的理解。
注意: 说到这里,你可能已经猜到了,我不会讨论 Java 的“Reactive Streams”;否则,我会把这篇文章的标题定为“背压是什么鬼!?” ?
当我们谈论反应性模型时,我们(首先也是最重要的)谈论“反应性编程”,但特别是“反应性”。
响应式编程是一种开发范例,允许将数据源的更改自动传播给消费者。
因此,我们可以将反应性定义为根据数据的变化实时更新依赖关系的能力。
NB:简而言之,当用户填写和/或提交表单时,我们必须对这些更改做出反应,显示加载组件或任何其他指定正在发生的事情。 .. 另一个例子,当异步接收数据时,我们必须通过显示全部或部分数据、执行新操作等来做出反应
在这种情况下,反应式库提供了自动更新和高效传播的变量,使编写简单且优化的代码变得更加容易。
为了提高效率,当且仅当它们的值发生变化时,这些系统必须重新计算/重新评估这些变量!同样,为了确保广播的数据保持一致和最新,系统必须避免显示任何中间状态(特别是在状态变化的计算期间)。
NB:状态是指程序/应用程序整个生命周期中使用的数据/值。
好吧,但是……这些“反应模型”到底是什么?
第一个反应模型称为“PUSH”(或“渴望”反应)。该系统基于以下原则:
正如您可能已经猜到的,“PUSH”模型依赖于“Observable/Observer”设计模式。
让我们考虑以下初始状态,
let a = { firstName: "John", lastName: "Doe" }; const b = a.firstName; const c = a.lastName; const d = `${b} ${c}`;
使用反应式库(例如 RxJS),这个初始状态看起来更像这样:
let a = observable.of({ firstName: "John", lastName: "Doe" }); const b = a.pipe(map((a) => a.firstName)); const c = a.pipe(map((a) => a.lastName)); const d = merge(b, c).pipe(reduce((b, c) => `${b} ${c}`));
注意:为了这篇文章的目的,所有代码片段都应被视为“伪代码”。
现在,我们假设消费者(例如组件)希望在数据源更新时记录状态 D 的值,
d.subscribe((value) => console.log(value));
我们的组件将订阅数据流;它仍然需要触发改变,
a.next({ firstName: "Jane", lastName: "Doe" });
从那里,“PUSH”系统检测到更改并自动将其广播给消费者。基于上面的初始状态,以下是可能发生的操作的描述:
该系统的挑战之一在于计算顺序。事实上,根据我们的用例,您会注意到 D 可能会被评估两次:第一次使用 C 的先前状态值;第二次使用 C 的值。第二次,C 的值是最新的!在这种反应性模型中,这个挑战被称为“钻石问题” ️。
现在,我们假设该州依赖两个主要数据源,
let a = { firstName: "John", lastName: "Doe" }; const b = a.firstName; const c = a.lastName; const d = `${b} ${c}`;
更新 E 时,系统将重新计算整个状态,这使得系统可以通过覆盖之前的状态来保留单一事实来源。
“钻石问题”再次发生...这次是在数据源 C 上,可能会评估 2 次,并且始终在 D 上。
“钻石问题”并不是“渴望”反应模型中的新挑战。一些计算算法(尤其是 MobX 使用的算法)可以标记“反应式依赖树的节点”以平衡状态计算。通过这种方法,系统将首先评估“根”数据源(在我们的示例中为 A 和 E),然后是 B 和 C,最后是 D。更改状态计算的顺序有助于解决此类问题。
第二个反应模型称为“PULL”。与“PUSH”模型不同,它基于以下原则:
最重要的是要记住最后一条规则:与之前的系统不同,最后一条规则推迟了状态计算,以避免对同一数据源进行多次评估。
让我们保持之前的初始状态...
在这种系统中,初始状态语法将采用以下形式:
let a = observable.of({ firstName: "John", lastName: "Doe" }); const b = a.pipe(map((a) => a.firstName)); const c = a.pipe(map((a) => a.lastName)); const d = merge(b, c).pipe(reduce((b, c) => `${b} ${c}`));
注意: React 爱好者可能会认识这个语法 ?
声明一个反应变量给一个元组“诞生”:一方面是不可变的变量;另一个变量的更新函数。其余语句(在我们的例子中为 B、C 和 D)被视为派生状态,因为它们“监听”各自的依赖关系。
d.subscribe((value) => console.log(value));
“惰性”系统的定义特征是它不会立即传播更改,而是仅在明确请求时传播更改。
let a = { firstName: "John", lastName: "Doe" }; const b = a.firstName; const c = a.lastName; const d = `${b} ${c}`;
在“PULL”模型中,使用effect()(来自组件)来记录反应变量(指定为依赖项)的值会触发状态更改的计算:
在查询依赖项时可以优化该系统。事实上,在上面的场景中,A 被查询了两次以确定它是否已更新。但是,第一个查询可能足以定义状态是否已更改。 C 不需要执行此操作...相反,A 只能广播其值。
让我们通过添加第二个反应变量“root”来使状态稍微复杂一些,
let a = observable.of({ firstName: "John", lastName: "Doe" }); const b = a.pipe(map((a) => a.firstName)); const c = a.pipe(map((a) => a.lastName)); const d = merge(b, c).pipe(reduce((b, c) => `${b} ${c}`));
系统再次推迟状态计算,直到明确请求为止。使用与之前相同的效果,更新新的反应变量将触发以下步骤:
由于 A 的值没有改变,所以不需要重新计算这个变量(同样的情况也适用于 B 的值)。在这种情况下,使用记忆算法可以提高状态计算期间的性能。
最后一个反应模型是“推拉”系统。术语“PUSH”反映了更改通知的立即传播,而“PULL”指的是按需获取状态值。这种方法与所谓的“细粒度”反应性密切相关,它遵循以下原则:
请注意,这种反应性并非“推拉”模型所独有。细粒度反应性是指对系统依赖性的精确跟踪。因此,还有 PUSH 和 PULL 反应模型也以这种方式工作(我正在考虑 Jotai 或 Recoil。
仍然基于之前的初始状态...“细粒度”反应系统中初始状态的声明将如下所示:
let a = { firstName: "John", lastName: "Doe" }; const b = a.firstName; const c = a.lastName; const d = `${b} ${c}`;
注意:signal关键字的使用不仅仅是轶事?
在语法方面,它与“PUSH”模型非常相似,但有一个显着且重要的区别:依赖关系!在“细粒度”反应系统中,没有必要显式声明计算派生状态所需的依赖关系,因为这些状态隐式跟踪它们使用的变量。在我们的例子中,B 和 C 将自动跟踪 A 值的更改,D 将跟踪 B 和 C 的更改。
let a = observable.of({ firstName: "John", lastName: "Doe" }); const b = a.pipe(map((a) => a.firstName)); const c = a.pipe(map((a) => a.lastName)); const d = merge(b, c).pipe(reduce((b, c) => `${b} ${c}`));
在这样的系统中,更新反应变量比基本的“PUSH”模型更有效,因为更改会自动传播到依赖于它的派生变量(仅作为通知,而不是值本身)。
d.subscribe((value) => console.log(value));
然后,根据需要(让我们以 logger 为例),系统内使用 D 将获取关联根状态的值(在我们的例子中为 A),计算值导出状态(B和C),最后评估D。这不是一个直观的操作方式吗?
让我们考虑以下状态,
a.next({ firstName: "Jane", lastName: "Doe" });
再一次,推拉系统的“细粒度”方面允许自动跟踪每个状态。因此,派生状态 C 现在跟踪根状态 A 和 E。更新变量 E 将触发以下操作:
这是反应性依赖关系相互之间的先前关联,使得该模型如此高效!
确实,在经典的“PULL”系统(例如 React 的 Virtual DOM)中,当从组件更新响应式状态时,框架将收到更改通知(触发“ 差异”阶段)。然后,根据需要(和延迟),框架将通过遍历反应式依赖树来计算更改;每次更新变量时!这种对依赖状态的“发现”需要付出巨大的代价......
通过“细粒度”反应性系统(如信号),反应性变量/基元的更新会自动通知与它们相关的任何派生状态的变化。因此,无需(重新)发现关联的依赖关系;状态传播是有针对性的!
到 2024 年,大多数 Web 框架都选择重新思考它们的工作方式,特别是在反应性模型方面。这种转变总体上提高了他们的效率和竞争力。其他人选择(仍然)混合(我在这里考虑的是 Vue),这使他们在许多情况下更加灵活。
最后,无论选择什么模型,在我看来,一个(好的)反应式系统是建立在一些主要规则之上的:
最后一点可以解释为声明式编程的基本原则,这就是我如何看待(好的)反应式系统需要确定性!这就是使反应式模型可靠、可预测且易于在大规模技术项目中使用的“决定论”,无论算法有多复杂。
以上是反应性是什么鬼!?的详细内容。更多信息请关注PHP中文网其他相关文章!