原文:https://playfulprogramming.com/posts/what-are-signals
什么是 Signals? Signals 现在似乎无处不在。在许多现代框架中,都有类似 Signals 的概念,比如:
Angular
Vue
Preact
Solid.js
Svelte
Qwik
以及更多
甚至有人在尝试将 Signals 引入到 JavaScript 本身。
随着这一概念的流行,很多人都在疑惑:
Signals 究竟是什么?
这是个好问题!让我们一起探索 Signals 的概念、它们如何运作以及我们如何在今天的库中使用它们。
Signals 基础 在最基本的形式下,Signals 是一种保存状态并订阅该状态的方法。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 const counter = signal (0 );counter.subscribe (() => { console .log (counter.get ()); }); counter.set (1 ); setInterval (() => { counter.set (counter.get () + 1 ); }, 1000 );
上面的代码展示了 Signals 的基本实现方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 function signal (initialValue ) { let value = initialValue; const subscribers = new Set (); return { get : () => value, set : (newValue ) => { value = newValue; subscribers.forEach ((fn ) => fn ()); }, subscribe : (listener ) => { subscribers.add (listener); return () => subscribers.delete (listener); } }; }
这段代码展示了 Signals 如何存储值并将更新通知到监听器。通过 setter 更新状态后,监听器会接收到通知。
我们可以看到,Signal 具备了以下特点:
初始值
获取值的方法
更新值并通知订阅者的方法
订阅信号的方式
这对我们来说非常有用,例如,当你的状态发生变化时,你希望运行一段代码。比如,你可以将一个 DOM 节点的文本值绑定到 JavaScript 状态:
1 2 3 4 5 6 7 8 9 10 11 12 13 <button id ="clicker" > 0</button > <script > const clickerBtn = document .getElementById ("clicker" ); const countSignal = signal (0 ); countSignal.subscribe (() => { clickerBtn.innerText = countSignal.get (); }); clickerBtn.addEventListener ("click" , () => { countSignal.set (countSignal.get () + 1 ); }); </script >
在这个例子中,Signal 充当了一个原始的 JavaScript 响应性机制,用来同步 DOM 和 JavaScript 状态。
计算属性(Computed Properties) 在软件开发中,基于其他状态计算出新的状态是常见的做法。例如,考虑以下的求和函数:
1 2 3 4 5 6 7 function sum (a, b ) { return a + b; } const num1 = 12 ;const num2 = 24 ;const output = sum (num1, num2);
如果我们能够在 num1
或 num2
改变时,自动计算 output
的值,那就太方便了。
幸运的是,我们可以基于 Signals 实现一个简单的 API 来实现这一点:
1 2 3 4 5 6 7 8 9 10 11 function computed (fn, signals ) { let value = fn (); for (let signal of signals) { signal.subscribe (() => { value = fn (); }); } return { get : () => value }; }
通过这种方式,我们可以以更优雅的方式计算派生状态:
1 2 3 4 5 6 7 8 const num1 = signal (1 );const num2 = signal (2 );const output = computed (() => num1.get () + num2.get (), [num1, num2]);console .log (output.get ()); num1.set (3 ); console .log (output.get ());
我们还可以像 Signal 一样,允许订阅计算属性的更新:
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 function computed (fn, signals ) { let value = fn (); const subscribers = new Set (); for (let signal of signals) { signal.subscribe (() => { value = fn (); subscribers.forEach ((sub ) => sub ()); }); } return { get : () => value, subscribe : (listener ) => { subscribers.add (listener); return () => subscribers.delete (listener); }, }; } const num1 = signal (1 );const num2 = signal (2 );const output = computed (() => num1.get () + num2.get (), [num1, num2]);output.subscribe (() => { console .log (output.get ()); }); num1.set (3 );
这里,computed
方法和 Signal 类似,不同的是它没有自己的可写状态,而是通过读取基础 Signals 来生成状态。
使用 Signal 和 Computed 简单创建加法器 我们可以继续利用这些 API 创建一个简单的加法器:
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 <label > <div > Number 1:</div > <input id ="num1" type ="number" value ="0" /> </label > <label > <div > Number 2:</div > <input id ="num2" type ="number" value ="0" /> </label > <p > The sum of these numbers is: <span id ="output" > 0</span > </p > <script > const num1 = document .getElementById ("num1" ); const num2 = document .getElementById ("num2" ); const output = document .getElementById ("output" ); const num1Signal = signal (0 ); const num2Signal = signal (0 ); const outputSignal = computed (() => num1Signal.get () + num2Signal.get (), [num1Signal, num2Signal]); num1.addEventListener ("input" , (e ) => { num1Signal.set (e.target .valueAsNumber ); }); num2.addEventListener ("input" , (e ) => { num2Signal.set (e.target .valueAsNumber ); }); outputSignal.subscribe (() => { output.innerText = outputSignal.get (); }); </script >
Signal 内部的 Computed 我们实际上可以进一步简化 computed
的实现,让它使用 Signal 作为基础数据存储:
1 2 3 4 5 6 7 8 9 10 11 12 function computed (fn, signals ) { const valueSignal = signal (fn ()); effect (() => { valueSignal.set (fn ()); }); return { get : valueSignal.get , subscribe : valueSignal.subscribe , }; }
这就是计算属性如何在 Signal 内部工作。在这里,我们简化了实现,避免了手动订阅。
Effects(副作用) 为了追踪一个 Signal 或计算值,我们通常使用 subscribe
方法。但如果我们观察其他 API,它们是函数而不是方法。
为了保持一致性并为以后增加功能,我们可以创建一个方法,允许我们不使用 subscribe
来订阅 Signals。
例如:
1 2 3 4 5 6 7 function effect (fn, signals ) { for (let signal of signals) { signal.subscribe (() => { fn (); }); } }
通过 effect
,我们可以完全去掉之前在加法器示例中的手动订阅代码。
自动追踪(Auto-tracking) 当前的 Signals API 看起来是这样的:
1 2 3 signal (init);computed (fn, deps);effect (fn, deps);
但显式地使用依赖数组存在一些挑战和缺点:
它比较冗长
computed
和 effect
需要依赖数组,而 Signal 不需要
容易忘记在依赖数组中遗漏某个 Signal
如果我们能够在不显式指定依赖的情况下,自动追踪 computed
和 effect
中使用的 Signal 呢?
这就是自动追踪(Auto-tracking)的方法。它可以通过以下方式实现:
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 34 35 var Listener = null ;function signal (initialValue ) { let value = initialValue; const subscribers = new Set (); return { get : () => { if (Listener ) { subscribers.add (Listener ); } return value; }, set : (newValue ) => { value = newValue; subscribers.forEach ((fn ) => fn ()); }, }; } function computed (fn ) { const valueSignal = signal (fn ()); effect (() => { valueSignal.set (fn ()); }); return { get : valueSignal.get }; } function effect (fn ) { Listener = fn; fn (); Listener = null ; }
通过这种方式,Signal、Computed 和 Effect 都可以自动追踪依赖,而无需显式传入依赖数组。
如何解决 Glitches(闪烁问题) Signal 的一个潜在问题是,可能会出现一个暂时不正确的值,直到所有依赖计算完成并更新。
为了避免这种“闪烁”问题,我们可以让 Effect 等待所有依赖都解决后再运行。
例如:
1 2 3 4 5 6 7 8 9 const count = signal (0 );const evenOdd = computed (() => (count.get () % 2 ? "Odd" : "Even" ));effect (() => { console .log (`${count.get()} is ${evenOdd.get()} ` ); }); count.set (2 ); count.set (123 );
Signals 在 JavaScript 生态中的位置 最后,我们来看看 Signals 在 JavaScript 生态中的位置。它们结合了状态、可写性和可订阅性,成为一种非常强大的原语。
与其他概念(如 Observable、Subject)相比,Signal 提供了更强的原始响应能力。