不懂 S.js 不可能懂信号 Signals!

家好,很高兴又见面了,我是"高级前端‬进阶‬",由我带着大家一起关注前端前沿、深入前端底层技术,大家一起进步,也欢迎大家关注、点赞、收藏、转发!

高级前端‬进阶

前言

最近拜读了一篇文章《JavaScript 中 Signals 的演变》,里面提到了 S.js。重点提到 S.js是今天信号 Signals 术语的来源,以下是文章的部分内容截取:

S.js 是独立于其他大多数解决方案而开发的,它更直接地以数字电路为模型,所有的状态变化都在时钟周期内进行,S.js 将其状态基元称为 "Signals(信号)"。虽然不是第一次使用这个名字,但它是我们今天使用的术语的来源。


更重要的是,S.js 引入了响应式所有权的概念。一个所有者将收集所有的子响应式作用域,并在所有者自己的 deposal 逻辑或在重新执行时管理它们的 deposal 逻辑。响应式视图将从一个根所有者开始,然后每个节点将作为其后代的所有者。这种所有者模式不仅对 deposal 很有用,而且是在响应式视图中建立 Provider / Consumer 上下文的一种机制。

刚好最近也在研究 Signals,对此非常好奇,以下是已经发表文章的传送门:

前端同学应该会比较有体感,Signal 最近越来越受欢迎,并且是许多现代 Web 框架的一部分,例如 Solid.js、Preact、Qwik 和 Vue.js。所以今天特地行文来给大家介绍 S.js,希望大家通过对 S.js 的熟悉来更好的、深入理解 Signals。

目前 S.js 在 Github 上有超过 1.1k 的 star、60+的 fork,和前端大火的其他项目相比确实没有什么明显的热度,但是却也是一个值得长期关注的前端项目。话不多说,直接开始!

1.什么是反应式编程

反应式编程是一种编程思想与方式,是为了简化并发编程而出现的。与传统的处理方式相比,反应式编程能够基于数据流中的事件进行反应处理

例如:在 a+b=c 的场景,在传统编程方式下如果 a、b 发生变化,那么需要重新计算 a+b 来得到 c 值。而反应式编程中,不需要重新计算。a、b 的变化事件会触发 c 的值自动更新。这种方式类似于在消息中间件中常见的发布/订阅模式。由流发布事件,而代码逻辑作为订阅方基于事件进行处理,并且是异步执行的。

反应式编程中,最基本的处理单元是事件流(事件流是不可变的,对流进行操作只会返回新的流)中的事件。核心是基于事件流、无阻塞、异步的,使用反应式编程不需要编写底层的并发、并行代码。并且由于其声明式编写代码的方式,使得异步代码易读且易维护。

常用的反应式编程类库包括:Reactor、RxJava 2、Vert.x 以及 Ratpack 等等。

2.S.js及其特征

S.js 是一个小型的反应式编程库,它将自动生成依赖图与同步执行引擎相结合。 目标是使反应式编程简单、简洁、快速。

S.js 应用程序由数据信号(signals)和计算(computations)组成:

两种信号都表示为小函数:调用数据信号以读取其当前值,将新值传递给数据信号以更新它。除此之外,S.js 还有一些实用程序来控制什么是更改以及 S.js 的响应方式。S.js 具有以下显著特点:

3.S.js的简单示例

下面的例子将页面正文设置为文本“Hello, world!”:

let greeting = "Hello",
    name = "world";
document.body.textContent = `${greeting}, ${name}!`;

现在更改name值:

name = "reactivity";

该页面现在已过时,因为它仍然具有旧名称“Hello,world”,即没有对数据更改做出反应。所以让我们用 S 的包装器来解决这个问题。

let greeting = S.data("Hello"),
    name = S.data("world");
S(() => document.body.textContent = `${greeting()}, ${name()}!`);

包装器的返回值是被称为信号的小函数,它们是随时间变化的值的容器。我们通过调用它来读取信号的当前值,如果是数据信号,可以通过传入它来设置它的下一个值。

name("reactivity");

S.js 感知到在设置页面文本时读取了 name() 的旧值,因此它重新运行该计算,因为 name() 已更改, 该页面现在显示为“Hello, reactivity!”。

我们已经将开始使用的纯代码转换为小型机器,能够检测并及时了解传入的更改。 数据信号定义了可能看到的变化类型,以及如何响应它们的计算。

4.S.js的API详解

4.1 Data Signals

S.data()

数据信号是单个值的小容器,是信息和变化进入系统的地方。 通过调用它来读取数据信号的当前值,通过传入一个新值来设置下一个值:

const name = S.data("sue");
name(); 
// 返回 "sue"
name("emily") 
// 设置 name() 为 "emily" ,返回 "emily"

数据信号定义应用程序中更改的粒度。 根据需要,可以选择使它们变得细粒度,即仅包含一个原子值,如字符串、数字等或粗粒度,如单个数据信号中的整个大对象。

请注意,当设置数据信号时,表示正在设置下一个值:如果在时间冻结的上下文中设置数据信号,例如在 S.freeze() 或计算体中,那么更改将在 time 之前生效, 这是因为 S 统一的原子瞬间全局时间轴。如果改变立即生效,那么会有一个改变前后,将瞬间一分为二:

const name = S.data("sue");
S.freeze(() => {
    name("mary"); 
  // *schedules* next value of "mary" and returns "mary"
    name(); 
  // 依然返回 "sue"
});
name(); 
// 现在返回"mary";

大多数时候,在顶层设置数据信号(在计算或冻结之外),系统会立即生效以说明更改。为数据信号安排两个不同的下一个值是错误的(其中“不同”由 !== 确定):

const name = S.data("sue");
S.freeze(() => {
    name("emily");
    name("emily"); 
    // 可行, "emily" === "emily"
    name("jane"); 
  // 报错: 冲突修改: "emily" !== "jane"
});

由 S.data() 创建的数据信号总是在设置时触发更改事件,即使新值与旧值相同:

const name = S.data("sue"),
    counter = S.on(name, c => c + 1, 0); 
// counts name() change events
counter(); 
// returns 1 to start
name("sue"); 
// 触发三个更改事件,都具有相同的值
name("sue");
name("sue");
counter(); 
// 这里返回 4

S.value()

S.value() 与 S.data() 相同,只是它在设置为相同值时不会触发更改事件。它告诉 S“只有这个数据信号的值是重要的,而不是设置的事件。”

const name = S.value("sue"),
    counter = S.on(name, c => c + 1, 0);
counter(); 
// returns 1 to start
name("sue");
// 设置为同样的值
counter(); 
// 依然返回 1, name() 的值没有修改

默认比较器是 ===,但如果其他更合适,可以将自定义比较器作为第二个参数传递:

const user = S.value(sue, (a, b) => a.userId === b.userId);

4.2 Computations

S(() => )

计算是一段“实时”代码,当数据信号发生变化时,S 将根据需要重新运行该代码。S 立即运行提供的函数,并且在它运行时,S 自动监视它读取的任何信号。 对于 S,函数如下所示:

S(() => {
        ... foo() ...
    ... bar() ...
       ... bleck() ... zog() ...
});

如果这些信号中的任何一个发生变化,S 就会安排重新运行计算。注意:函数主体中由于条件分支而未读取的信号不会被记录。 即使先前的执行在不同的分支下确实读取了它们。因此,只有最后一次运行很重要,因为只有那些信号参与创建当前值。

如果其中一些信号是计算,S 保证它们将始终返回当前值。 永远不会得到“陈旧”值,即受上游更改影响但尚未更新的值。

不仅仅是纯函数

传递给 S 的函数不必是纯函数(即没有副作用)。例如,可以如下记录对 name() 的所有更改:

S(() => console.log(name());

每次 name() 更改时,将重新运行并将值重新输出到控制台。从某种意义上说,这扩展了计算的“价值”是什么的概念,包括它产生的副作用。

提示:在编写执行副作用的计算时,S.cleanup() 和 S.on() 可能是实用的方法。 第一可以帮助计算幂等(有效计算的一个很好的属性),第二可以帮助在它们运行时使其清晰。

Computations 创建 Computations

S允许计算用更多的计算来扩展系统:

const isLogging = S.value(true);
S(() => {
    if (isLogging()) {
        S(() => console.log(foo()));
        S(() => console.log(bar()));
        S(() => console.log(bleck()));
    }
});

在此示例中,外部“父”或“构造函数”计算定义了应该记录日志的时间,而内部“子”计算分别负责记录单个信号。需要注意的两个重要点:

因此,如果 isLogging() 更改为 false,外部计算将重新运行,导致内部计算被处理掉,并且由于它们没有重新创建,将停止记录。

同样的模式允许在没有任何 dispose()、unsubscribe() 或 DidUnmount() 处理程序的情况下构建整个 Web 应用程序。 单个 route() 数据信号可以驱动构建当前视图的 router() 计算,包括使视图动态的所有计算。 当 route() 改变时,所有这些计算都保证自动处理。

Computation Roots

创建的计算在调用 dispose 之前一直有效。如果尝试构建不在根计算或父计算下的计算,S 将输出警告。

// 假设这是顶级代码,而不是在Computation中
const foo = S(() => bar() + bleck()); 
// 导致控制台警告
S.root(() => {
    const foo = S(() => bar() + bleck()); 
  // 无警告
})

如上所述,S 应用程序中的大多数计算都是子计算,它们的生命周期由父级控制。 但有两个例外:

对于第一种情况,S.root() 告诉 S 确实意味着这些计算是顶级的,因此不会记录任何错误。

对于第二种情况,S.root() 让计算脱离其父级。 它们是“孤立的”,并且会一直存在,直到调用 dispose 函数。

5.本文总结

本文主要和大家介绍下 Signals 的起源,即 S.js。除了上文介绍的 S.js 的特性之外,S.js 还有更多高级特性值得深入研究,比如:减少计算(Reducing Computations)、冻结时钟以应用多个更新(S.freeze)、采样信号以避免依赖(S.sample)、清理过时的副作用(S.cleanup)、从计算中设置数据信号等等。

因为篇幅有限,文章并没有过多展开,如果有兴趣,可以在我的主页继续阅读,同时文末的参考资料提供了大量优秀文档以供学习。最后,欢迎大家点赞、评论、转发、收藏!


参考资料

https://github.com/adamhaile/S

https://segmentfault.com/a/1190000043506444

页面更新:2024-03-08

标签:反应式   信号   所有者   视图   应用程序   函数   事件   代码   方式   数据

1 2 3 4 5

上滑加载更多 ↓
Top