从头开始实现信号
什么是信号?
最近,JavaScript 社区对信号(Signal)的讨论一直很热烈。信号(Signal)的兴起可以追溯到 Solid.js,它从 Knockout.js 的可观察对象(Observables)中汲取灵感,设计出了自己的信号版本。不久之后,Preact、Angular 和 Qwik 等知名框架将信号集成到了其核心中。Vue 3 引入了独特的信号处理方式,使用了ref
and reactive
(尽管它们与 Solid.js 的信号并非同一语境中的信号),而 Svelte 5 则推出了 Svelte Runes,它基本上就是基于这种响应式结构构建的。在本文中,我将使用“信号”一词来描述这些响应式系统。那么,信号到底是什么呢?
信号是数据的基本单位,当其保存的数据发生变化时,它可以自动向函数或计算发出警报。这种警报功能允许系统的各个部分在数据发生变化时自动立即更新,使系统感觉动态且实时。它解决的问题是,当某些数据在后台发生变化时,如何以可视化的方式更新某些内容。
当数据发生变化时,会触发一个函数来更新 DOM 上的特定元素。Solid.js 通过细粒度的响应式机制实现了这一点。这可确保您的代码仅直接更新指定的值,从而避免不必要的副作用或其他 DOM 元素的冗余重新渲染。有了定义好的响应式系统,您可以轻松构建大规模且易于维护的 Web 应用程序。
信号如何工作?
让我们来看看信号背后的工作原理。我将主要参考 Solid 的函数式信号处理方法,尽管基于类的解决方案也大同小异。我们今天要创建的信号函数不会像许多框架那样性能卓越或功能齐全,而应该作为理解信号底层原理的起点。
函数和闭包
在研究信号之前,我们有必要先了解一下 JavaScript 如何处理函数。让我们深入研究一下它们的工作原理,从以下代码开始:
function createSignal() {
}
让我们深入研究一下。createSignal 函数存储在 JavaScript 的全局内存中。很简单,对吧?
接下来,我们将在函数中嵌入一个变量并返回另一个函数来检索该值。
function createSignal() {
let value = "Hello, World";
return function() {
return value;
}
}
现在,我们的函数变得更加复杂,并展示了 JavaScript 富有创意的内部工作原理。通过调用:
let signal = createSignal();
signal();
我们为 createSignal 初始化一个新的执行上下文。在该上下文中,字符串“Hello, World”被赋值到 value 标签下的上下文内存中。当我们返回新函数时,会创建一个闭包来保存数据value
,并将其与返回的函数一起存储。这使我们能够value
跨执行上下文持久化存储数据。
在调用返回的函数时,JavaScript 会设置一个新的执行环境。由于它不会立即识别值变量,因此它会查询闭包,找到值并及时返回。
现在,让我们修改一下函数。现在我们将返回一个带有 setter 函数和值的对象。然后,我们将添加一个参数,该参数也接收我们值的默认参数。
function createSignal(initialValue) {
let value = initialValue;
return {
value,
set: (v) => { value = v; },
}
}
我们有一个问题。因为我们在对象中返回value
变量,所以即使调用 set 函数后,它仍然保持不变。发生这种情况是因为我们传递给对象的值是函数返回对象时该值的副本。因此,我们需要为该值编写一个专用的 getter 函数。
function createSignal(initialValue) {
let value = initialValue;
return {
get: () => { return value; },
set: (v) => { value = v; },
}
}
组装好了!我们试试看吧。
let signal = createSignal(10);
console.log(signal.get()); // 10
signal.set(20);
console.log(signal.get()); // 20
有一点比较突出,每次读取或写入变量时都需要调用 set 和 get 函数value
。让我们使用 JavaScript 的 get 和 set 函数来改进这一点。
function createSignal(initialValue) {
let _value = initialValue;
return {
get value() { return _value; },
set value(v) { _value = v; },
}
}
现在我们可以这样使用我们的函数:
let signal = createSignal(10);
console.log(signal.value); // 10
signal.value = 20;
console.log(signal.value); // 20
是不是更易读一些了?不过还有一个问题:它不是响应式的。_value
调用 set 函数时,除了改变状态之外,没有任何“效果”。这里我们来创建一个订阅者。
订阅者
订阅者会“订阅”一个函数,以便在我们发生更改时运行一些代码_value
。为此,我们将使用 get 函数。
function createSignal(initialValue) {
let _value = initialValue;
function notify() {
}
return {
get value() { return _value; },
set value(v) {
_value = v;
notify();
},
}
}
这里发生了什么?每当 set 函数被调用(也就是我们重新赋值signal.value = "hello";
)时,我们都会运行一个函数。这个函数随后会调用订阅函数……这意味着我们也需要一个 subscribe 函数作为返回值的一部分。既然如此,让我们来处理多个订阅函数,然后在通知函数中调用它们。
function createSignal(initialValue) {
let _value = initialValue;
let subscribers = [];
function notify() {
for (let subscriber of subscribers) {
subscriber(_value);
}
}
return {
get value() { return _value; },
set value(v) {
_value = v;
notify();
},
subscribe: (subscriber) => {
subscribers.push(subscriber);
}
}
}
完成的信号
这样,我们就有了一个(非常)基本的信号!让我们看看如何使用它:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Signals from Scratch</title>
</head>
<body>
<span id="mySpan"></span>
<script>
function createSignal(initialValue) {
let _value = initialValue;
let subscribers = [];
function notify() {
for (let subscriber of subscribers) {
subscriber(_value);
}
}
return {
get value() { return _value; },
set value(v) {
_value = v;
notify();
},
subscribe: (subscriber) => {
subscribers.push(subscriber);
}
}
}
const mySignal = createSignal("");
mySignal.subscribe((value) => {
document.getElementById("mySpan").innerHTML = value;
});
mySignal.value = "Hello World!";
</script>
</body>
</html>
这里发生的事情是,我们定义了一个变量mySignal
来保存我们的响应信号。我们subscribe
在返回值上调用该方法,并绑定一个函数,该函数将在value
调用 setter 时被调用,从而更新 DOM。现在,每当我们设置信号的值时,我们的订阅者都会收到通知,DOM 也会更新!
从根本上来说,信号就是这样的。当然,框架会实现很多额外的功能,比如派生和效果。就 Solid 而言,他们利用订阅者清理和编译步骤来进一步提升性能,编译步骤会检查 JSX 中使用 getter 的位置,并据此创建细粒度的更新代码。
就这样!如果你发现任何错误,请在评论中告诉我,我会尽力修复。也欢迎你提出你的想法和改进建议!
文章来源:https://dev.to/ratiu5/implementing-signals-from-scratch-3e4c