穷人的信号


原文:Poor man’s signals

信号(Signals)目前正风靡一时。所有人都开始使用它们:Angular、Solid、Preact,对于那些还没有内置信号的框架,也可以使用第三方包。甚至还有将它们添加到语言规范中的提案,如果该提案通过,那么所有框架内置信号就只是时间问题了。

消息闭塞

如果你一直对这些新技术一无所知,下面是一个来自 Preact 文档的例子,它简洁地概括了信号的作用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { signal, computed, effect } from "@preact/signals";

const name = signal("Jane");
const surname = signal("Doe");

const fullName = computed(() => `${name.value} ${surname.value}`);

// 每次 name 变化时都会打印:
effect(() => console.log(fullName.value));
// 打印: "Jane Doe"

// 更新 `name` 会更新 `fullName`,从而再次触发 effect:
name.value = "John";
// 打印: "John Doe"

简而言之,信号将值和计算结果包裹起来,使我们能够轻松响应这些值和结果的每一个变化,而不像我们在 React 中那样必须重新渲染整个应用程序。总而言之,信号是一种高效且有针对性地响应变化的方法,无需进行状态比较和 DOM 差异(DOM-diffing)计算。

好的,既然信号如此强大,我为什么要在 Vanilla Web 开发博客上向你推销它们呢?别担心!Vanilla Web 开发者也可以拥有信号。

仅仅是一个包装器

信号的本质不过是一个值的包装器,当这个值发生变化时,它会发送事件。使用一个不太为人所知但非常方便的基类 EventTarget,就可以轻松解决这个问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Signal extends EventTarget {
#value;

get value() {
return this.#value;
}

set value(value) {
if (this.#value === value) return;
this.#value = value;
this.dispatchEvent(new CustomEvent("change"));
}

constructor(value) {
super();
this.#value = value;
}
}

这为我们提供了一个最基本的信号体验:

1
2
3
const name = new Signal("Jane");
name.addEventListener("change", () => console.log(name.value));
name.value = "John"; // 打印: John

但这有点丑陋。new 关键字在十年前就过时了,而且 addEventListener 确实很笨重。所以,我们来添加一点语法糖。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
class Signal extends EventTarget {
#value;

get value() {
return this.#value;
}

set value(value) {
if (this.#value === value) return;
this.#value = value;
this.dispatchEvent(new CustomEvent("change"));
}

constructor(value) {
super();
this.#value = value;
}

effect(fn) {
fn();
this.addEventListener("change", fn);
return () => this.removeEventListener("change", fn);
}

valueOf() {
return this.#value;
}

toString() {
return String(this.#value);
}
}
const signal = (_) => new Signal(_);

现在,我们的基本示例使用起来舒服多了:

1
2
3
const name = signal("Jane");
name.effect(() => console.log(name.value)); // 打印: Jane
name.value = "John"; // 打印: John

effect(fn) 方法会调用指定的函数,并将其订阅到信号值的变化。

它还会返回一个 dispose 函数,可用于注销 effect。然而,使用 EventTarget 和浏览器内置事件作为响应性原语的一个很好的副作用是,当信号超出作用域(比如所在页面组件被销毁了,没有变量再指向这个信号)时,浏览器可以智能地对信号及其 effect 进行垃圾回收。这意味着即使我们从不调用 dispose 函数,也减少了内存泄漏的机会。

最后,toStringvalueOf 魔法方法允许我们在大多数使用信号值的地方省略 .value。(但在本例中不行,因为控制台会打印出整个对象的内部细节。)

无法计算

这个信号实现已经很有用了,但在某些时候,基于多个信号的 effect 可能会很方便。这意味着需要支持计算值(computed values)。基础信号是对值的包装器,而计算信号是对函数的包装器。

1
2
3
4
5
6
7
8
9
10
class Computed extends Signal {
constructor(fn, deps) {
super(fn(...deps));
for (const dep of deps) {
if (dep instanceof Signal)
dep.addEventListener("change", () => (this.value = fn(...deps)));
}
}
}
const computed = (fn, deps) => new Computed(fn, deps);

计算信号根据一个函数计算其值。它也依赖于其他信号,当它们变化时,它会重新计算其值。必须将它所依赖的信号作为附加参数传递有点烦人,但是,嘿,我这篇文章的标题可不是“富人的信号”。

这使得将 Preact 的信号示例移植到原生 JS 成为可能。

1
2
3
4
5
6
7
8
9
const name = signal("Jane");
const surname = signal("Doe");
const fullName = computed(() => `${name} ${surname}`, [name, surname]);

// 每次 name 变化时都会打印:
fullName.effect(() => console.log(fullName.value)); // -> Jane Doe

// 更新 `name` 会更新 `fullName`,从而再次触发 effect:
name.value = "John"; // -> John Doe

你能把它用在实际场景中吗?

你可能会想,所有这些 console.log 示例都很好,但你如何在实际的 Web 开发中使用这些东西呢?这个简单的加法器演示了信号如何与 Web Components 结合:

adder.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import { signal, computed } from "./signals.js";

customElements.define(
"x-adder",
class extends HTMLElement {
a = signal(1);
b = signal(2);
result = computed((a, b) => `${a} + ${b} = ${+a + +b}`, [this.a, this.b]);

connectedCallback() {
if (this.querySelector("input")) return;
this.innerHTML = `
<input type="number" name="a" value="${this.a}">
<input type="number" name="b" value="${this.b}">
<p></p>
`;

this.result.effect(
() => (this.querySelector("p").textContent = this.result)
);

this.addEventListener(
"input",
(e) => (this[e.target.name].value = e.target.value)
);
}
}
);

这是一个实时演示: (略)

如果你想知道,这里的 if 语句是为了防止在组件已经渲染时调用 connectedCallback 导致 effect 被添加两次。

完整的“穷人的信号”代码(总共 36 行)可以在 Github 上的 tiny-signals 仓库中找到。