去年八月,我提到我想开始追求 TC39 中 Signal 的潜在标准。今天,我很高兴地分享 这个提案的 v0 草案 已经公开可用,还有 符合规范的 polyfill。
什么是 Signal?
Signal 是一种数据类型,通过建模状态单元和从其他状态/计算派生的计算来实现单向数据流。这些状态和计算形成一个无环图,其中每个节点都有从其值派生状态(汇点)和/或向其值贡献状态(源点)的其他节点。一个节点也可以被标记为“干净”或“脏”。
但是这意味着什么呢?让我们看一个简单的例子。
假设我们有一个我们想要跟踪的 计数器 。我们可以将其表示为状态:
const counter = new Signal.State(0);
我们可以用 get()
读取当前值:
console.log(counter.get()); // 0
而且我们可以用 set()
改变当前值:
counter.set(1);
console.log(counter.get()); // 1
现在,让我们想象我们想要有另一个 Signal,指示我们的计数器是否持有偶数。
const isEven = new Signal.Computed(() => (counter.get() & 1) == 0);
计算不可写,但我们始终可以读取它们的最新值:
console.log(isEven.get()); // false
counter.set(2);
console.log(isEven.get()); // true
在上面的例子中,isEven
是 counter
的一个 汇点 ,而 counter
是 isEven
的一个 源点 。
我们可以添加另一个计算,提供我们计数器的奇偶性:
const parity = new Signal.Computed(() => isEven.get() ? "even" : "odd");
所以,现在我们有 parity
来源于 isEven
,而 isEven
是 parity
的一个汇点。我们可以改变原始的 counter
,状态将单向流向 parity
。
counter.set(3);
console.log(parity.get()); // odd
到目前为止,我们所做的一切似乎可以通过正常的函数组合完成。但是如果以这种方式实现,没有 Signal,后台将没有源点/汇点图。那么,我们为什么要那个图呢?它对我们有什么作用?
回想一下,我提到过 Signal 可以是“干净”的或“脏”的。当我们改变 counter
的值时,它就变得 脏 。由于我们有一个图形关系,我们可以将 counter
的所有汇点(可能)标记为脏,并且它们的所有汇点,依此类推。
这里有一个重要的细节需要理解。 Signal 算法不是 推送 模型。对 counter
进行更改不会急切地推送更新到 isEven
的值,然后通过图形,更新到 parity
。它也不是纯 拉取 模型。读取 parity
的值并不总是计算 parity
或 isEven
的值。相反,当 counter
改变时,它只推送 脏标志的变化 到图形中。任何可能的重新计算都会延迟,直到特定 Signal 的值被 显式拉取 。
我们称之为“推送后拉取”模型。 脏标志被急切地更新(推送),而计算是懒惰地评估的(拉取)。
将无环图数据结构与“推送后拉取”算法相结合会产生许多优点。以下是一些:
Signal.Computed
自动进行了记忆化处理。如果源值没有改变,那么就没有必要重新计算。- 即使源发生变化,也不会重新计算不需要的值。如果计算是脏的,但没有任何地方读取其值,则不会进行重新计算。
- 可以避免错误或“过度更新”。例如,如果我们将
counter
从 2 改为 4,是的,它是脏的。但是当我们拉取parity
的值时,它的计算将不需要重新运行,因为一旦拉取,isEven
对于 4 的结果与对于 2 的结果相同。 - 我们可以在 Signal 变为脏时收到通知,并选择如何反应。
当有效地更新用户界面时,这些特性变得非常重要。为了了解其中的原因,我们可以引入一个虚构的 effect
函数,当其源之一变为脏时,将调用一些操作。例如,我们可以使用 parity
更新 DOM 中的文本节点:
effect(() => node.textContent = parity.get());
// effect 的回调被执行,节点的文本被更新为 "odd"
// effect 监视回调的源(parity)以检测脏变化。
counter.set(2);
// 计数器使其汇点变脏,导致 effect 被标记为可能脏,所
以调度了一个“拉取”。
// 调度器开始通过拉取 parity 重新评估 effect 回调。
// parity 开始通过拉取 isEven 进行评估。
// isEven 拉取 counter,导致 isEven 的值发生变化。
// 因为 isEven 发生了变化,必须重新计算 parity。
// 因为 parity 发生了变化,effect 运行并且文本更新为 "even"
counter.set(4);
// 计数器使其汇点变脏,导致 effect 被标记为可能脏,所以调度了一个“拉取”。
// 调度器开始通过拉取 parity 重新评估 effect 回调。
// parity 开始通过拉取 isEven 进行评估。
// isEven 拉取 counter,导致 isEven 的值与之前相同。
// isEven 被标记为干净。
// 因为 isEven 是干净的,所以 parity 被标记为干净。
// 因为 parity 是干净的,effect 不运行,文本不受影响。
希望这能带来对 Signal 是什么的一些清晰认识,并理解无环源点/汇点图与其“推送后拉取”算法结合的重要性。
谁在开发这个?
2023 年末,我与 Daniel Ehrenberg、Ben Lesh 和 Dominic Gannaway 合作,试图招募尽可能多的 Signal 库作者和前端框架的维护者。表达了兴趣的任何人都被邀请帮助我们开始探索将 Signal 作为标准的可行性。
我们从一对一的问卷调查和面试开始,寻找共同的主题、想法、用例、语义等。我们不知道是否有共同的模型可以找到。令我们高兴的是,我们发现从一开始就存在相当多的一致性。
在过去的 6 到 7 个月里,我们逐个讨论了一个又一个细节,试图从一般的一致性转向数据结构、算法和初始 API 的具体内容。在这个过程中,你可能会认识到一些在整个过程中不时提供设计输入的库和框架:Angular、Bubble、Ember、FAST、MobX、Preact、Qwik、RxJS、Solid、Starbeam、Svelte、Vue、Wiz 等等……
这是一个相当长的列表!而且我可以诚实地说,回顾我过去十年在 Web 标准领域的工作,这是我有幸参与的最令人惊奇的合作之一。这是一个真正特别的团队,拥有我们继续推动网络发展所需的精确类型的集体经验。
重要提示:如果我们错过了你的库或框架,仍然有很多机会可以参与进来!一切都没有确定。我们仍然处于这个过程的开始阶段。请滚动到标题为“如何参与提案?”的部分,了解更多信息。
Signal 提案中包含什么?
这个提案,可以在 GitHub 上找到,包括:
- 背景、动机、设计目标和常见问题解答。
- 用于创建状态和计算 Signal 的建议 API。
- 用于监视 Signal 的建议 API。
- 各种额外的建议实用 API,如用于内省的 API。
- 对各种 Signal 算法的详细描述。
- 一个符合规范的 polyfill,涵盖所有提议的 API。
Signal 提案不包括 effect
API,因为这类 API 通常与呈现和批处理策略紧密集成,而这些策略高度依赖于框架/库。但是,该提案确实试图定义一组原语和实用程序,供库作者使用来实现自己的效果。
在这一点上,该提案的设计方式是承认有两个广泛的 Signal 使用者类别:
- 应用程序开发者
- 库/框架/基础设施开发者
旨在由应用程序开发人员使用的 API 直接从 Signal
命名空间公开。这些包括 Signal.State()
和 Signal.Computed()
。应该很少甚至从不在应用程序代码中使用的 API,更可能涉及微妙的处理,通常在基础架构层,通过 Signal.subtle
命名空间公开。这些包括 Signal.subtle.Watcher
、Signal.subtle.untrack()
和内省 API。
另外:以前没有见过类似
subtle
命名空间的 JavaScript ?查看 Crypto.subtle。
与资深 UI 架构师和工程师 Rob Eisenberg 学习 Web 组件工程。
我们打断了关于 Signal 的博客文章,提醒您 Web 组件工程课程 ,受到 Adobe、Microsoft、Progress 和 Reddit 等巨头的信任。如果您想学习现代 UI 工程,了解数十种 Web 标准,甚至看看 Signal 如何与 Web 组件集成,这就是适合您的课程。团队价格和 PPP 定价根据要求可用。
现在,回到您的常规节目……
作为应用程序开发者,我如何使用 Signal?
如今许多流行的组件和渲染框架已经在使用 Signal。在接下来的几个月里,我们希望框架维护者将尝试在 Signal 提案的基础上重新构建其系统,途中提供反馈,并帮助我们验证是否可以利用潜在的 Signal 标准。
如果这样做成功,许多应用程序开发者将通过他们选择的组件框架使用 Signal;他们的模式不会改变。然而,他们的框架将更具互操作性(响应式数据互操作性)、更小(Signal 已内置,无需作为 JavaScript 运行时发布)且希望更快(原生 Signal 作为 JS 运行时的一部分)。
然后,库作者将能够编写使用 Signal 的代码,该代码与任何理解标准的组件或渲染库原生集成,减少了 Web 生态系统中的碎片化。应用程序开发人员将能够构建与其当前渲染技术分离的模型/状态层,从而使他们拥有更多的架构灵活性,并且能够在不重写整个应用程序的情况下尝试和发展其视图层。
那么,假设你是一个想要使用 Signal 创建库的开发者,或者想要在这些基元上构建应用程序状态层的开发者。那么这段代码会是什么样子呢?
好吧,当我以上面解释 Signal 的基础知识时,我们已经看到了一些。Signal.State()
和 Signal.Computed()
上述 API 是应用程序开发人员会使用的两个主要 API,如果不是通过框架的 API 间接使用它们的话。它们可以单独使用来表示独立的反应状态和计算,也可以与其他 JavaScript 构造结合使用,例如类。以下是使用 Signal 表示其内部状态的 Counter
类:
export class Counter {
#value = new Signal.State(0);
get value() {
return this.#value.get();
}
increment() {
this.#value.set(this.#value.get() + 1);
}
decrement() {
if (this.#value.get() > 0) {
this.#value.set(this.#value.get() - 1);
}
}
}
const c = new Counter();
c.increment();
console.log(c.value);
一种特别好的使用 Signal 的方法是与装饰器结合使用。我们可以创建一个 @signal
装饰器,将访问器转换为 Signal,如下所示:
export function signal(target) {
const { get } = target;
return {
get() {
return get.call(this).get();
},
set(value) {
get.call(this).set(value);
},
init(value) {
return new Signal.State(value);
},
};
}
然后我们可以使用它来减少 Counter
类的样板代码并提高可读性,如下所示:
export class Counter {
@signal accessor #value = 0;
get value() {
return this.#value;
}
increment() {
this.#value++;
}
decrement() {
if (this.#value > 0) {
this.#value--;
}
}
}
还有许多其他使用 Signal 的方法,但希望这些示例为那些想要在这个阶段进行实验的人提供了一个很好的起点。
另外:某些特定 Signal 库的用户可能不会赞同我在上面的代码中使用
this.#value.set(this.#value.get() + 1)
。在某些 Signal 实现中,当在计算或效果中使用时,这可能会导致无限循环。这在当前提案的计算中不会造成问题,也不会在下面示范的示例效果中造成
问题。它应该会引发循环吗?还是应该抛出异常?行为应该是什么?这是需要解决的许多细节之一,以便标准化这样的 API。
作为库/基础设施开发人员,如何集成 Signal?
我们希望视图和组件库的维护者尝试将此提案集成到他们的系统中,以及创建状态管理和数据相关库的人。第一步集成是更新库的 Signal,内部使用 Signal.State()
和 Signal.Computed()
而不是其当前特定于库的实现。当然,这还不够。常见的下一步是更新任何 effect
或等效基础设施。正如我上面提到的,该提案没有提供 effect
的实现。我们的研究显示,这与渲染和批处理的细节过于相关,无法在这一点上进行标准化。相反,Signal.subtle
命名空间提供了一个框架可以使用的原语来构建自己的效果。让我们看一下一个简单的 effect
函数的实现,它将更新批处理到微任务队列中。
let needsEnqueue = true;
const w = new Signal.subtle.Watcher(() => {
if (needsEnqueue) {
needsEnqueue = false;
queueMicrotask(processPending);
}
});
function processPending() {
needsEnqueue = true;
for (const s of w.getPending()) {
s.get();
}
w.watch();
}
export function effect(callback) {
let cleanup;
const computed = new Signal.Computed(() => {
typeof cleanup === "function" && cleanup();
cleanup = callback();
});
w.watch(computed);
computed.get();
return () => {
w.unwatch(computed);
typeof cleanup === "function" && cleanup();
};
}
effect
函数首先通过用户提供的回调函数创建了一个 Signal.Computed()
。然后它可以使用 Signal.subtle.Watcher
来监视计算的源。为了使观察者能够“看到”这些源,我们需要至少执行一次计算,我们通过调用 get()
来实现。您还可以注意到我们的 effect
实现还支持基本的机制,让回调提供清理函数以及通过返回的函数停止监视。
查看 Signal.subtle.Watcher
的创建,构造函数接受一个回调,当其监视的任何 Signal 变为脏时,将同步调用该回调。由于 Watcher
可以监视任意数量的 Signal,我们将所有脏 Signal 的处理调度到微任务队列中。一些基本的防护逻辑确保只调度一次,直到处理完挂起的 Signal。
在 processPending()
函数中,我们遍历观察者跟踪的所有挂起 Signal,并通过调用 get()
重新评估它们。然后我们要求观察者重新开始监视其跟踪的所有 Signal。
这就是基础知识。大多数框架将以与其渲染或组件系统集成的方式处理队列,他们可能还会进行其他实现更改,以支持其系统的工作模型。
其他 API
基础设施中可能会使用的另一个 API 是 Signal.subtle.untrack()
助手。此函数接受要执行的回调,并确保回调内部读取的 Signal 不会被跟踪。
我需要提醒读者:Signal.subtle
命名空间指定的 API 应谨慎使用,大多由框架或基础设施作者使用。不正确或轻率地使用诸如 Signal.subtle.untrack()
这样的 API 可能会导致应用程序出现难以追踪的问题。
有了这个声明,让我们看一个这个 API 的合理用法。
许多视图框架都有一种方法来渲染项目列表。通常,您会将框架一个数组和一个“模板”或 HTML 片段,它应该为数组中的每个项目呈现一个条目。作为应用程序开发人员,您希望对该数组的任何交互都受到反应系统的跟踪,以便列表的呈现输出与数据保持同步。但是框架本身呢?它必须访问数组以进行呈现。如果框架对数组的访问受到依赖系统的跟踪,那么图中将会创建各种不必要的连接,导致虚假或过度更新……更不用说性能问题和奇怪的 bug 了。Signal.subtle.untrack()
API 为库作者提供了处理此挑战的简单方法。例如,让我们看一下从 SolidJS 中提取的渲染数组的小段代码,我稍作修改以使用提议的标准。为简单起见,我删除了大部分代码。希望查看高级大纲有助于解释用例。
export function mapArray(list, mapFn, options = {}) {
let items = [],
mapped = [],
len = 0,
/* ...other variables elided... */;
// ...ellided...
return () => {
let newItems = list() || [], // 访问数组会被跟踪
i,
j;
// 访问长度会被跟踪
let newLen = newItems.length;
// 以下回调中的任何内容都不会被跟踪。我们不
// 希望我们的框架渲染的工作影响 Signal 图!
return Signal.subtle.untrack(() => {
let newIndices,
/* ...other variables elided... */;
// 空数组的快速路径
if (newLen === 0) {
// ... 从数组读取;不被跟踪 ...
}
// 创建新对象的快速路径
else if (len === 0) {
// ... 从数组读取;不被跟踪 ...
} else {
// ... 从数组读取;不被跟踪 ...
}
return mapped;
});
};
}
即使我省略了大部分 Solid 算法,您也可以看到主要的工作都是在访问数组的未跟踪代码块中完成的。
Signal.subtle
命名空间中还有其他 API,您可以自行探索。希望上述示例有助于说明此部分提案设计的场景类型。
如何参与提案?
只需加入进来!一切都在 GitHub 上。您会在仓库的根目录找到提案,以及在 packages
文件夹中找到 Polyfill。您还可以在 Discord 上与我们交流。
以下是您可以开始贡献的一些想法:
- 在您的框架或应用程序中尝试使用 Signal。
- 改进 Signal 的文档/学习材料。
- 记录使用情况(无论 API 是否支持良好)。
- 编写更多的测试,例如,通过从其他 Signal 实现移植它们。
- 将其他 Signal 实现移植到此 API。
- 为 Signal 编写基准测试,包括合成和真实世界应用程序。
- 在 Polyfill 错误、您的设计思路等方面提交问题。
- 尝试在 Signal 上开发反应性数据结构/状态管理抽象。
- 在 JavaScript 引擎中原生实现Signal(在标志后/作为 PR,而不是发布!)
还有许多问题已经被创建,以跟踪正在进行的辩论领域、算法的潜在修改、其他 API、用例、Polyfill 错误等。请查看其中的问题,并看看您可以提供哪些见解。如果您是框架或库的作者,我们真的希望您帮助我们了解任何您认为对当前提案构成挑战的场景或用例。
接下来是什么?
我们仍处在努力的开始阶段。在接下来的几周里,Daniel Ehrenberg(Bloomberg)和 Jatin Ramanathan(Google/Wiz)将把提案提交给 TC39,寻求 Stage 1。Stage 1 意味着该提案正在考虑中。现在,我们甚至还没有到那一步。您可以将 Signal 视为 Stage 0。最早期的阶段。在 TC39 提出后,我们将根据来自该会议的反馈以及通过 GitHub 参与的人们的意见继续发展提案。
我们的方法是慢慢来,通过原型验证想法。我们希望确保不会标准化任何人无法使用的东西。我们需要您的帮助来实现这一目标。通过您上述的贡献,我们相信我们将能够完善此提案,并使其适用于所有人。
我相信 Signal 标准对于 JavaScript 和 Web 有着巨大的潜力。与行业中深度投入反应式系统的一群伟大的人一起工作是令人兴奋的。让我们期待更多的未来合作,以及为所有人打造更好的 Web。 🎊