深入浅出 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

  • Signal.State: 代表一个可写的基础状态单元。
  • Signal.Computed: 代表一个从其他 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();

看到了吗?

  • 没有手动订阅/取消订阅。
  • 没有手动的 render() 调用。
  • 依赖关系是自动建立的 (isEven 依赖 counter, parity 依赖 isEven, effect 依赖 parity)。
  • 更新是精细化的:只有当 parity 的值实际改变时,effect 才会重新运行。如果 counter 从 2 变到 4,isEvenparity 的值不变,effect 不会执行。

这就是 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

关键点:

  • new Signal.State(initialValue, options?): 创建信号,可传入初始值和可选的配置项(如自定义比较函数 equals)。
  • .get(): 读取信号当前的值。重要:在 Computedeffect 中调用 .get() 会自动建立依赖关系。
  • .set(newValue): 设置信号的新值。如果新值与旧值通过 equals 比较后相等,则依赖该信号的 Computedeffect 不会被标记为需要更新。

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

关键点:

  • new Signal.Computed(computeFn, options?): 创建信号,传入一个计算函数 computeFn 和可选配置项。
  • computeFn: 这个函数定义了如何计算信号的值。它内部调用的 .get() 会被自动追踪。
  • 自动追踪: 无需手动声明依赖,Computed 在执行 computeFn 时自动发现依赖。
  • 惰性: computeFn 只有在 fullName.get() 被调用,并且其依赖项(firstNamelastName)自上次计算以来实际发生变化时,才会重新执行。
  • 缓存: 计算结果会被缓存。如果依赖项未变,多次调用 .get() 只会执行一次 computeFn
  • 只读: Computed Signal 不能被直接 .set()。它的值完全由其依赖项和计算函数决定。

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

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

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

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

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

  • Signal.Computed.get() 被调用,并且需要重新计算时:
    1. 系统会将当前的 Computed Signal 实例设置为全局的 computing
    2. 然后执行该 Computed 的计算函数 (computeFn)。
    3. computeFn 执行期间,任何 SignalState 或其他 Computed)的 .get() 方法被调用时,它会检查全局 computing 是否有值。
    4. 如果有值(意味着正处于一个 Computed 的计算过程中),这个被读取的 Signal 就会将当前的 computing (也就是外层的 Computed Signal) 添加到自己的“订阅者”列表(在提案中称为 sinks,即“下游”)。同时,外层的 Computed Signal 也会将被读取的 Signal 添加到自己的“依赖”列表(在提案中称为 sources,即“上游”)。
    5. computeFn 执行完毕,系统清除全局 computing 状态。

这样,一次计算过后,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 是底层: 它是实现 effect (如 Vue 的 watchEffect, Solid 的 createEffect) 的基础。
  • notify 回调: 在依赖变化时同步触发,但其目的是安排工作,而不是执行工作。
  • notify 限制: 禁止notify 内部读写 Signal,以防破坏一致性。
  • watch(...signals): 开始观察指定的 Signal。通常由 effect 函数在首次运行时,通过读取 Computed Signal 隐式调用,或者直接调用。
  • unwatch(...signals): 停止观察指定的 Signal。必须effect 不再需要时调用,以避免内存泄漏和不必要的通知。这是 effect 函数返回清理函数的主要原因。
  • 调度 (Scheduling): Watcher 本身不执行调度,它只是发出通知。具体的调度逻辑(使用 queueMicrotask, requestAnimationFrame, 还是框架自己的调度器)由基于 Watcher 构建的 effect 实现来决定。提案中的 effect 示例用了 queueMicrotask,但这只是一个简单例子。
  • getPending(): 可以获取哪些被 watch 的 Signal 处于“待定”状态(即触发了 notify 但其关联的工作尚未完成),这对于更精细的调度控制可能有用。

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

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

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

  • untrack(cb): 这个函数允许你在其回调 cb 内部读取 Signal 的值,但不会建立依赖关系。

    const count = new Signal.State(0);
    const doubled = new Signal.Computed(() => {
      // 在 untrack 中读取 count,所以 doubled 不依赖 count
      const currentCount = Signal.subtle.untrack(() => count.get());
      console.log(`Untracked count is: ${currentCount}`); // 只是读取,不建立依赖
      return count.get() * 2; // 这里正常读取,建立依赖
    });
    
    console.log(doubled.get()); // 输出 Untracked count is: 0 \n Computing... \n 0
    count.set(1);
    console.log(doubled.get()); // 输出 Untracked count is: 1 \n Computing... \n 2

    用途: 当你确定某个读取操作不应该影响当前计算的响应式依赖时使用。比如,在日志记录、调试或者某些特定优化场景下。 警告: 它是“不安全”的,因为滥用它会导致计算结果与依赖不同步。如果 doubled 的计算逻辑错误地依赖了 untrack 里的值,那么当 count 改变时,doubled 可能不会按预期更新。

  • currentComputed(): 返回当前正在执行计算的 Computed Signal 实例,如果没有则返回 null。主要用于调试或实现更高级的响应式模式。

  • 内省 API (Introspection):

    • introspectSources(computed | watcher): 返回一个 ComputedWatcher 上次计算/运行时所依赖的 Signal 列表。
    • introspectSinks(state | computed): 返回依赖于给定 Signal 的 ComputedWatcher 列表。
    • hasSinks(state | computed): 判断一个 Signal 是否被任何“活跃”的下游(最终连接到 Watcher)所依赖。
    • hasSources(computed | watcher): 判断一个 ComputedWatcher 是否依赖于其他 Signal。 用途: 主要用于构建开发者工具(例如可视化依赖图)、调试、或可能的 SSR(服务器端渲染)场景下序列化/恢复 Signal 图状态。
  • watched / unwatched 钩子: SignalOptions 里可以传入 [Signal.subtle.watched][Signal.subtle.unwatched] 回调。当一个 Signal 首次被 Watcher 观察或不再被任何 Watcher 观察时,这些回调会被触发。 用途: 允许 Signal 在被“激活”或“休眠”时执行一些设置或清理逻辑。例如,一个表示 WebSocket 连接状态的 Computed Signal,可以在 watched 时建立连接,在 unwatched 时断开连接,实现资源的按需管理。

  • 子类化 (Subclassing): 提案的 API 设计(使用 Class)允许框架继承 Signal.StateSignal.Computed,添加自己的方法或(私有)字段,这有助于优化性能(避免额外包装对象)和集成框架特定的逻辑。

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

  • 异步 Signals (Async Signals): 提案目前只包含同步 Signals。如何优雅地处理异步操作(如 fetch)并将其结果表示为 Signal,以及如何处理加载/错误状态,社区有多种模式,但尚未形成统一的最佳实践纳入标准。目前可以通过将 Promise 或异步函数的结果 setState Signal 中来处理。
  • 事务 (Transactions): 在复杂的 UI 过渡或并发渲染场景下,可能需要支持“事务性”的 Signal 更新,即能够创建状态的一个“分支”,在分支中进行一系列更改,然后原子性地“提交”或“回滚”。这增加了相当大的复杂性,目前被排除在外。

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 与响应式状态的原生集成,都将拥有更坚实的基础。