深入浅出 Signals:下一代 Web/JS 响应式编程基石?

在 Web 开发的江湖里,状态管理一直是各大门派(框架)潜心修炼的核心内功。从早期的手动 DOM 操作,到后来的 MVC/MVVM,再到 Redux、Vuex 等集中式状态管理,我们一直在寻找更优雅、更高效的方式来处理 UI 与数据的同步问题。

近几年,“响应式编程”的理念异军突起,而 Signals 作为其一种重要的实现模式,在众多现代前端框架(如 Solid, Qwik, Preact, Vue, Angular 等)中崭露头角,甚至可以说是蔚然成风。现在,TC39(负责制定 ECMAScript 标准的委员会)也正式将其纳入议程,提出了 JavaScript Signals 标准提案

这葫芦里卖的什么药?它跟我们熟悉的 useState, ref, computed, watchEffect 有什么异同?它真的能成为下一代 Web 响应式编程的统一基石吗?

别急,让我们一起深入浅出地探索 Signals 的世界。

一、灵魂拷问:我们为何需要 Signals?

技术总是在解决痛点中前进。要理解 Signals 为何诞生并受到青睐,我们得先看看没有它的时候,开发者们(尤其是框架开发者们)遇到了哪些“不爽”。

想象一下,我们要实现一个简单的计数器,并根据计数器的奇偶性显示文本。用原生 JavaScript,我们可能会这么写(参考提案中的例子):

let counter = 0;
const element = document.getElementById("parity-display");

// 状态变更函数,耦合了渲染逻辑
const setCounter = (value: number) => {
  counter = value;
  render(); // 每次变更都得手动调用渲染
};

// 计算派生状态
const isEven = () => (counter & 1) == 0;
const parity = () => (isEven() ? "even" : "odd");

// 渲染函数,直接依赖派生状态
const render = () => {
  console.log("Rendering..."); // 方便观察渲染次数
  if (element) {
    element.innerText = parity();
  }
};

// 初始化渲染
render();

// 模拟外部更新
setInterval(() => {
  setCounter(counter + 1);
}, 1000);

问题来了:

  1. 大量的模板代码:每个状态都需要 getter/setter 和手动触发更新。
  2. 紧密耦合:状态变更逻辑(setCounter)和渲染逻辑(render)绑死。
  3. 低效更新:即使 parity 没变(比如从 2 变到 4),isEven 和 parity 也会重新计算,DOM 也会被无效更新。
  4. 缺乏粒度:如果其他 UI 部分只关心 isEven 呢?或者只关心 counter 本身?它们都得“知道”要去依赖 counter,并且自己实现一套更新逻辑。

这还只是冰山一角。如果我们尝试引入发布/订阅模式,给 counterisEvenparity 都加上订阅机制,代码量会急剧膨胀,手动管理订阅和取消订阅将变成一场噩梦,极易导致内存泄漏和逻辑混乱。

Signals 的哲学:精细化、自动化的响应式原语

Signals 说:“别挣扎了,状态管理的本质是‘响应’。当一个数据源变化时,依赖它的计算或副作用应该自动、高效地更新。”

它提供了一种一等公民的响应式数据类型,我们称之为 Signal

它们的核心思想是:

  1. 自动依赖追踪 (Automatic Dependency Tracking): 当一个 Computed Signal 被读取(.get())时,它会自动记录下计算过程中读取了哪些其他的 Signal(包括 State 和其他 Computed)。当这些依赖项发生变化时,系统“知道”这个 Computed Signal 可能需要重新计算。
  2. 惰性求值 (Lazy Evaluation): Computed Signal 不会在其依赖变化时立即重新计算,而是在它被实际读取(.get())时才计算。这避免了不必要的计算。
  3. 记忆化 (Memoization): Computed Signal 会缓存它的计算结果。如果它的依赖项没有实际变化(即使依赖项本身被 set 了一个相同的值),再次读取它时会直接返回缓存值,无需重新计算。
  4. 无毛刺执行 (Glitch-Free Execution): 保证在任何时刻读取 Signal,都能得到一致、最新的状态,不会出现读取到中间过程的不稳定状态("glitches")。这是通过精确的依赖图更新和惰性求值实现的。

用 Signals 重写计数器例子:

import { Signal } from "signal-polyfill";
import { effect } from "signal-utils/subtle/microtask-effect";

const counter = new Signal.State(0);
// Computed Signal 自动追踪依赖 (counter)
const isEven = new Signal.Computed(() => (counter.get() & 1) == 0);
// Computed Signal 自动追踪依赖 (isEven)
const parity = new Signal.Computed(() => (isEven.get() ? "even" : "odd");

const cleanupRender = effect(() => {
  console.log("Effect running (rendering)...");
  if (element) {
    element.innerText = parity.get(); // 读取 parity,建立依赖
  }
});

// 模拟外部更新
setInterval(() => {
  // 只需更新源头状态
  counter.set(counter.get() + 1);
  // isEven, parity, effect 会在需要时自动、懒惰地更新
}, 1000);

// 如果需要停止副作用,调用清理函数
// cleanupRender();

看到了吗?

这就是 Signals 解决问题的核心思路:提供底层的、自动化的、高效的响应式原语,让状态管理和 UI 更新变得简单、精确。

二、幕后故事:Signals 的“群雄逐鹿”与“走向统一”

不像 Observable 那样在 TC39 和 WHATWG 之间反复横跳,Signals 的故事更多是关于前端框架实践趋同寻求底层统一的故事。

  1. 星星之火 (Early Days): 响应式原语的概念由来已久。提案中提到,类似 Signals 的一等公民响应式值,在开源 JS 框架中的早期流行可以追溯到 2010 年的 Knockout.js。之后,不同的框架都在探索自己的响应式实现。
  2. 百花齐放 (Framework Implementations): 近年来,几乎所有主流或新兴的前端框架都或多或少地引入了类似 Signals 的机制,虽然名字可能不同(ref/computed in Vue, createSignal/createMemo in Solid, signal/computed in Preact/Angular, $ in Svelte 5 Runes 等)。这表明 Signals 这种模式确实解决了实际问题,并且在实践中被证明是有效的。
  3. 趋同与痛点 (Convergence & Pain Points): 尽管实现各异,但这些框架在 Signals 的核心机制上(如自动追踪、惰性求值、记忆化)表现出了惊人的相似性。然而,这种“各自为政”也带来了新的问题:
    • 互操作性差 (Lack of Interoperability): 响应式模型通常与框架的渲染引擎或其他部分紧密耦合。你想在 Angular 项目里用一个基于 Solid Signals 的组件库?或者在 Vue 里使用 Ember 的响应式工具?几乎不可能。这阻碍了代码、组件和库的共享。
    • 重复造轮子 (Reinventing the Wheel): 每个框架都在实现一套类似的底层响应式核心,造成了生态系统的碎片化和重复劳动。
    • 学习成本 (Learning Curve): 开发者在不同框架间切换时,需要学习不同的响应式 API 和心智模型。
  4. Promises/A+ 的启示 (Inspiration from Promises/A+): 提案明确提到了 Promises/A+。当年在 Promise 被标准化之前,社区也存在多种 Promise 实现(Q, Bluebird, when.js 等)。Promises/A+ 作为一个社区规范,统一了核心行为和 API 接口,为 ES2015 标准化 Promise 铺平了道路。Signals 提案希望借鉴这种模式。
  5. 目标:底层核心,而非表层 API (Goal: Core Semantics, Not Surface API): 与 Promises/A+ 不同的是,Signals 提案的首要目标不是统一开发者直接使用的 API(比如是 .value 还是 .get() 或是函数调用 ()),而是统一 Signals 底层的核心语义和自动追踪机制。它旨在提供一个框架可以构建其上的可互操作的基础信号图 (Signal Graph)。API 本身是为框架作者设计的,而不是最终的应用开发者。
  6. 强大的阵容 (Strong Collaboration): 该提案的 Champion 和贡献者阵容堪称豪华,包含了来自 Angular, Ember, FAST, MobX, Preact, Qwik, RxJS, Solid, Starbeam, Svelte, Vue, Wiz 等几乎所有主流响应式库/框架的作者或核心维护者。这种广泛的合作是提案成功的关键,也表明了生态系统对底层统一的迫切需求。
  7. 保守的推进策略 (Conservative Advancement): 提案组非常谨慎,强调在进入 Stage 2 之前,需要进行大量的原型设计、框架集成验证、性能基准测试,确保这个标准真正实用、高效,并且能被多个主流框架采用。他们不希望重蹈某些“标准化后没人用”的覆辙。

总而言之,Signals 的标准化之路,更像是一次由众多框架“豪门”共同发起的“武林大会”,旨在为各派赖以生存的“内功心法”(响应式核心)制定一套通用的“经络图谱”(底层标准),以促进“武学交流”(互操作性)和整个“武林”(Web 生态)的繁荣。

三、初窥门径:Signals 核心 API 概览

提案提供的 API 旨在作为底层基础,而非直接面向应用开发者的最终形态。我们来看一下核心的两个类:

1. Signal.State<T> - 可写状态信号

这是最基础的信号单元,代表一个可以直接读取和写入的值。

import { Signal } from "signal-polyfill";

// 创建一个初始值为 0 的 State Signal
const count = new Signal.State(0);

// 读取信号的值
console.log(count.get()); // 输出: 0

// 写入新值
count.set(1);
console.log(count.get()); // 输出: 1

// 再次写入
count.set(2);
console.log(count.get()); // 输出: 2

// 写入相同的值
count.set(2); // 值未改变
console.log(count.get()); // 输出: 2

关键点:

2. Signal.Computed<T> - 计算派生信号

这种信号的值是根据其他信号计算得出的。

import { Signal } from "signal-polyfill";

const firstName = new Signal.State("Zhang");
const lastName = new Signal.State("San");

// 创建一个 Computed Signal,它的值依赖于 firstName 和 lastName
const fullName = new Signal.Computed(() => {
  console.log("Computing fullName..."); // 方便观察计算次数
  // 在计算函数内部调用 .get(),自动追踪依赖
  return `${firstName.get()} ${lastName.get()}`;
});

// 读取 fullName,触发第一次计算
console.log(fullName.get()); // 输出: Computing fullName... \n Zhang San
console.log(fullName.get()); // 输出: Zhang San (直接返回缓存,不重新计算)

// 更新依赖项 lastName
lastName.set("Si");

// 再次读取 fullName,依赖项变了,触发重新计算
console.log(fullName.get()); // 输出: Computing fullName... \n Zhang Si

// 更新依赖项 firstName
firstName.set("Li");

// 再次读取 fullName
console.log(fullName.get()); // 输出: Computing fullName... \n Li Si

// 尝试设置一个 Computed Signal?不行!它是只读的。
// fullName.set("Wang Wu"); // TypeError: fullName.set is not a function

关键点:

这两个核心类构成了 Signals 响应式系统的基础。通过它们,我们可以构建出复杂的、自动更新的状态依赖图。

四、登堂入室:深入理解 Signals 核心机制

了解了基本 API,我们还需要深入理解 Signals 背后的核心工作机制,才能更好地利用它(或者基于它构建框架)。

1. 自动依赖追踪 (Automatic Dependency Tracking)

这是 Signals 的魔法核心。它是如何实现的呢?通常(包括提案的算法描述),会依赖一个全局(或上下文相关)的状态,我们称之为 computingactiveObserver

这样,一次计算过后,Computed Signal 就“知道”了它依赖哪些 Signal,而被依赖的 Signal 也“知道”了谁依赖它,依赖图就自动建立起来了。

2. 惰性求值 (Lazy Evaluation) & 记忆化 (Memoization)

当一个 State Signal 被 .set() 一个新值时:

  1. 它会检查新值是否与旧值实际不同(通过 equals 函数,默认为 Object.is)。
  2. 如果值不同:
    • 它会通知所有依赖它的 Computed Signal(它的 sinks),将它们的状态标记为“可能过时”(在提案中可能是 ~dirty~~checked~ 状态)。注意:此时并不会立即重新计算这些 Computed Signal。
    • 这个“过时”标记会沿着依赖图向“下游”传播。
  3. 如果值相同: 什么也不做。

当一个 Computed Signal 的 .get() 被调用时:

  1. 它会检查自己的状态。
  2. 如果是 ~clean~(干净的,已缓存且不过时):直接返回缓存的值。
  3. 如果是 ~dirty~~checked~(可能过时):
    • 它会递归地检查它的所有依赖项(sources)的状态,并触发必要的重新计算(也是懒惰的)。
    • 当所有依赖项都更新到最新状态后,它会执行自己的 computeFn,重新计算值。
    • 将新计算的值与缓存的旧值进行比较(用 equals)。
    • 如果值实际改变了,则更新缓存,将自己的状态标记为 ~clean~,并通知自己的 sinks(下游 Computed)它们的状态也需要更新(标记为 ~dirty~)。
    • 如果值没有改变,则仅更新自己的状态标记为 ~clean~不会通知下游。
    • 最后返回(可能是新的或未变的)值。

这个过程保证了只有在必要时才进行计算,并且计算结果会被缓存,依赖项未实际改变时不会触发下游更新。

3. 无毛刺执行 (Glitch-Free Execution)

由于计算是惰性的,并且状态更新是精确标记和传播的,当你读取一个 Signal 时,系统会确保其所有(必要的)上游依赖都已经更新到一致的状态,然后才进行计算。这避免了在一个更新周期内,你可能读取到一个依赖 A 更新了但依赖 B 还没更新的“中间态”或“毛刺”状态。你总是能得到当前一致的最终结果。

4. 同步执行 (Synchronous Execution)

与 Promise 不同,Signals 的所有核心操作(.get(), .set(), 依赖追踪,状态标记)都是同步发生的。当你 set 一个值后,依赖它的 Computed 的状态会立即(同步地)被标记为可能过时。当你随后 .get() 这个 Computed 时,计算(如果需要)也是同步完成的。这使得 Signals 的行为更具确定性,更容易推理,并且对于需要即时反馈的 UI 更新非常重要。当然,副作用(如下文的 Watcher)通常会被安排在稍后的时间点(如微任务或渲染帧)执行,以进行批处理和避免布局抖动。

5. 动态依赖 (Dynamic Dependencies)

Computed Signal 的依赖关系不是在创建时固定的,而是在每次重新计算时动态确定的。

const useX = new Signal.State(true);
const x = new Signal.State(1);
const y = new Signal.State(100);

const value = new Signal.Computed(() => {
  if (useX.get()) {
    // 依赖 useX
    return x.get(); // 当 useX 为 true 时,依赖 x
  } else {
    return y.get(); // 当 useX 为 false 时,依赖 y
  }
});

console.log(value.get()); // 依赖 useX 和 x, 输出 1

// 更新 y, 但当前 value 不依赖 y,所以 value 不会重新计算
y.set(200);
console.log(value.get()); // 仍然输出 1 (无重新计算日志)

// 更新 useX, value 的依赖可能改变
useX.set(false);
console.log(value.get()); // 重新计算,现在依赖 useX 和 y, 输出 200

// 更新 x, 但当前 value 不依赖 x
x.set(2);
console.log(value.get()); // 仍然输出 200 (无重新计算日志)

这种动态性使得 Signals 更加高效,因为它只追踪当前计算实际需要的依赖。

五、高级兵器:Watcher 与副作用处理

Signals 本身是纯粹的数据状态和计算,它们不应该直接执行副作用(比如修改 DOM、发送网络请求、打印日志)。那么,如何响应 Signals 的变化来执行这些副作用呢?答案是 Watcher

Watcher 是提案中提供的一个底层机制,用于观察一组 Signal 的变化,并在变化发生时安排 (schedule) 副作用的执行。它位于 Signal.subtle 命名空间下,表明它主要是给框架作者使用的。

import { Signal } from "signal-polyfill";

const name = new Signal.State("Alice");
const age = new Signal.State(30);

// 1. 创建 Watcher,传入一个 notify 回调
const watcher = new Signal.subtle.Watcher(() => {
  // 这个回调在被观察的 Signal (或其依赖) 首次发生变化时
  // (自上次 watch 或上次 notify 后) 同步触发
  console.log("Watcher notified! Something might have changed.");

  // !!! 重要:notify 回调内部禁止读写任何 Signal !!!
  // 错误示范: console.log(name.get()); // 会抛出错误
  // 错误示范: name.set("Bob");        // 会抛出错误

  // 正确做法:安排一个任务稍后执行,例如使用微任务
  if (!isWorkScheduled) {
    isWorkScheduled = true;
    queueMicrotask(() => {
      isWorkScheduled = false;
      console.log("Microtask running: Performing the actual effect.");
      // 在这里可以安全地读取 Signal
      performEffect(name.get(), age.get());

      // 可能需要重新 watch 来接收下一次通知
      // (取决于你的 effect 逻辑和 watcher 实现)
      // watcher.watch(); // 如果 effect 本身不包含 signal 读取,可能需要手动 watch
      // 但更常见的模式是 effect 函数内部读取 signal,
      // watch() 会在 effect 首次运行时自动完成。
      // 这里仅作示例,具体看 effect 实现。
    });
  }
});

let isWorkScheduled = false;

function performEffect(currentName: string, currentAge: number) {
  console.log(`Effect executed: Name is ${currentName}, Age is ${currentAge}`);
  // 在这里执行实际的副作用,比如更新 DOM
  document.getElementById(
    "info"
  )!.textContent = `Name: ${currentName}, Age: ${currentAge}`;
}

// 2. 告诉 Watcher 要观察哪些 Signal
// 通常,这不是手动调用,而是由 effect 函数管理的
// watcher.watch(name, age); // 手动观察 name 和 age

// --- 更真实的 Effect 函数实现模式 ---
function effect(cb: () => void): () => void {
  let cleanup: (() => void) | undefined;

  // 创建一个 Computed 来包装副作用回调
  // 当这个 Computed 被读取时,副作用会执行
  const effectSignal = new Signal.Computed(() => {
    console.log("Running computed for effect...");
    // 先执行上一次的清理函数(如果有)
    cleanup?.();
    // 执行新的副作用回调,并获取清理函数
    cleanup = cb();
  });

  // 让 Watcher 观察这个 Computed Signal
  watcher.watch(effectSignal);

  // 立即触发一次计算,执行首次副作用并建立依赖
  console.log("Initial effect run trigger:");
  effectSignal.get(); // 读取 Computed,执行 cb,自动追踪 cb 内部的依赖

  // 返回一个清理函数,用于停止观察和执行最后的清理
  return () => {
    console.log("Effect cleanup: Unwatching and running final cleanup.");
    watcher.unwatch(effectSignal);
    cleanup?.();
  };
}

// 使用 effect 函数
const cleanupInfoEffect = effect(() => {
  const currentName = name.get(); // 自动被 effectSignal 追踪
  const currentAge = age.get(); // 自动被 effectSignal 追踪
  performEffect(currentName, currentAge);
  // 如果副作用需要清理(比如移除事件监听),返回清理函数
  // return () => console.log("Cleaning up info effect");
});

// --- 模拟更新 ---
console.log("\nSetting name to Bob...");
name.set("Bob"); // 触发 watcher.notify -> queueMicrotask -> performEffect

console.log("\nSetting age to 31...");
age.set(31); // 触发 watcher.notify -> queueMicrotask -> performEffect

console.log("\nSetting name to Bob again (no change)...");
name.set("Bob"); // 值未变,不会触发 notify

// 停止 effect
setTimeout(() => {
  console.log("\nStopping effect...");
  cleanupInfoEffect();
  console.log("\nSetting name after effect stopped...");
  name.set("Charlie"); // 不会再触发 performEffect
}, 2000);

关键点:

通过 Watcher,Signals 体系获得了一个与外部世界(DOM、网络等)交互的桥梁,同时将副作用的执行时机交给了上层框架或开发者来控制。

六、精妙之处与“禁术”:subtle 命名空间与其他

Signal.subtle 这个命名空间就像一个“高阶玩家俱乐部”,里面放着一些不常用但对框架或工具开发者至关重要的 API。

被暂时省略的功能 (Omitted for now):

subtle 里的工具和省略的功能都指向同一个事实:这个提案专注于定义最核心、最通用、最无争议的同步响应式原语,将更复杂或特定于场景的功能留给框架层去实现和探索。

七、融会贯通:Signals 的价值与未来展望

Signals,这个在众多现代框架中悄然兴起并逐渐走向标准化的概念,它究竟意味着什么?它的价值仅仅在于为框架提供一个可互操作的底层吗?不,远不止于此。让我们深入探讨 Signals 为不同开发者群体带来的直接价值,以及提案背后更深层次的考量与未来的可能性。

(一)超越框架:Signals 为每一位 JavaScript 开发者赋能

虽然提案的初衷和许多讨论都围绕着框架间的互操作性,但将 Signals 仅仅视为“框架的底层工具”会大大低估其普适价值。标准化的 Signals 为 所有 JavaScript 开发者提供了一套开箱即用的、强大的声明式响应式编程原语

  1. 对于前端开发者(即便不使用重型框架):

    • 告别手动: 你不再需要手动追踪状态依赖、手动调用更新函数、或者挣扎于复杂的订阅/取消订阅逻辑。Signals 的自动依赖追踪和惰性求值机制,让你能以极其简洁的方式构建细粒度、自更新的状态逻辑。
    • 提升代码质量: 即便是在原生 JS 项目或轻量级库中,引入 Signals 也能显著提高代码的可维护性和鲁棒性。状态变更的流转变得清晰可循,逻辑内聚性增强,错误排查也更为容易。想象一下,无需引入整个框架,就能拥有核心的响应式能力!
  2. 对于全栈开发者与算法场景:

    • 内置的智能缓存 (Signal.Computed): 许多高性能算法依赖于“空间换时间”的策略,需要开发者小心翼翼地设计和维护缓存结构及其失效逻辑。Signal.Computed 提供了一个自动化的、依赖驱动的计算缓存机制
    • 简化复杂计算: 对于那些计算成本高昂的操作(例如,对复杂数据结构如图、树的遍历与聚合,数据转换流水线,甚至某些模拟计算),你可以将计算逻辑封装在 Computed 中。只有当其依赖的原始数据(State Signals)实际发生改变,并且该 Computed 被读取时,计算才会重新执行。这极大地简化了带有缓存的复杂计算逻辑的实现,开发者只需关注计算本身(如同一位数学家专注于数学公式本身一样),缓存管理交给 Signals。
  3. 对于更广泛的 JS 生态(IoT, 命令行工具, 游戏等):

    • 符合直觉的声明式模型: Signals 的核心是“当 X 变了,依赖 X 的 Y 应该自动更新”。这种因果关系和自动响应的模式,非常符合人类的思维直觉。它提供了一种声明式地描述系统状态及其相互关系的方式,而不是命令式地指定更新步骤。
    • 通用状态管理范式: 这种声明式的状态管理范式并非 Web UI 独有。在任何需要管理随时间变化的状态、并根据状态变化执行计算或触发行为的 JS 环境中(无论是控制硬件的 IoT 设备、处理用户输入的命令行工具,还是管理游戏状态的引擎脚本),Signals 都能提供一个更清晰、更不易出错的模型,帮助开发者写出更高质量的代码,即使不依赖任何特定领域的框架
  4. 拥抱 AI 编程的未来:

    • AI 友好的原语: 人工智能(尤其是大型语言模型)在生成遵循明确模式和规则的代码方面表现出色,它们更擅长声明式编程。标准化的 Signals 提供了一套清晰、明确的声明式响应式原语。这为 AI 生成更健壮、更可靠、自带响应式能力的 JavaScript 代码提供了基础,可能成为未来 AI 辅助开发的重要一环。

因此,Signals 标准化的意义远超框架范畴。它为整个 JavaScript 语言带来了一种内建的、通用的、声明式的状态与计算管理能力,有望提升所有领域 JS 代码的质量和开发效率。

(二)未来展望:基石已备,静待花开

截止目前(2025-04-09),JavaScript Signals 标准提案,仍处于 TC39 的 Stage 1 阶段。目前我们可以通过 signal-polyfill 提前体验。

它是一次重要的“合流”尝试。如果成功,我们将看到一个底层更统一、上层更繁荣的响应式生态。开发者工具的创新(如可视化调试)、跨框架组件库的发展、乃至 HTML 与响应式状态的原生集成,都将拥有更坚实的基础。