原文: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` 更新时,都会重新运行
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);

如果我们能够在 num1num2 改变时,自动计算 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()); // 3

num1.set(3);
console.log(output.get()); // 5

我们还可以像 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); // 输出 "5"

这里,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);

但显式地使用依赖数组存在一些挑战和缺点:

  • 它比较冗长
  • computedeffect 需要依赖数组,而 Signal 不需要
  • 容易忘记在依赖数组中遗漏某个 Signal

如果我们能够在不显式指定依赖的情况下,自动追踪 computedeffect 中使用的 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 提供了更强的原始响应能力。