原文: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}`);
effect(() => console.log(fullName.value));
name.value = "John";
|
简而言之,信号将值和计算结果包裹起来,使我们能够轻松响应这些值和结果的每一个变化,而不像我们在
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";
|
但这有点丑陋。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)); name.value = "John";
|
effect(fn)
方法会调用指定的函数,并将其订阅到信号值的变化。
它还会返回一个 dispose 函数,可用于注销
effect。然而,使用 EventTarget
和浏览器内置事件作为响应性原语的一个很好的副作用是,当信号超出作用域(比如所在页面组件被销毁了,没有变量再指向这个信号)时,浏览器可以智能地对信号及其
effect 进行垃圾回收。这意味着即使我们从不调用
dispose 函数,也减少了内存泄漏的机会。
最后,toString 和 valueOf
魔法方法允许我们在大多数使用信号值的地方省略
.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]);
fullName.effect(() => console.log(fullName.value));
name.value = "John";
|
你能把它用在实际场景中吗?
你可能会想,所有这些 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
仓库中找到。