Signals 响应式
跨框架响应式设计哲学全景
💡 一句话直觉
响应式系统就像电子表格:你在 A1 格填入数字,B1 格的公式 =A1*2 自动更新。你不需要写 B1.refresh()——框架替你追踪了这个依赖关系。Signals、Hooks、Reactivity 本质上都在回答同一个问题:当数据变化时,谁该被通知、怎么通知、通知多少?
从零理解:50 行代码手写响应式引擎
所有框架的响应式本质上都是同一个模式:一个全局 effect 栈 + getter 中 自动收集订阅 + setter 中 派发通知。下面的代码是所有 Signal 实现的最小原型:
// ── 概念演示: 手写极简响应式系统 ──
let currentEffect: (() => void) | null = null;
class ReactiveSignal<T> {
private _value: T;
private subscribers = new Set<() => void>();
constructor(initial: T) {
this._value = initial;
}
get value(): T {
if (currentEffect) {
this.subscribers.add(currentEffect); // ← 依赖收集
}
return this._value;
}
set value(newVal: T) {
this._value = newVal;
this.subscribers.forEach(fn => fn()); // ← 派发更新
}
}
function effect(fn: () => void) {
currentEffect = fn; // ← 设置当前追踪者
fn(); // ← 执行时触发 getter → 收集依赖
currentEffect = null; // ← 清除
}
// 使用
const count = new ReactiveSignal(0);
effect(() => console.log("Count:", count.value)); // 注册依赖
count.value = 5; // → 输出 "Count: 5"🔑 关键洞察:React 没有 这个 getter 拦截机制,所以它需要 re-render 整个组件函数来重新执行 JSX,再通过 Virtual DOM diff 找出差异。Signals 框架跳过了这一步——它们精确知道哪个 DOM 节点需要更新。
依赖追踪数据流对比
⚡ 步骤减少 50%:没有 VDOM 创建、没有 diff、没有协调——直接 DOM 操作。
代价:组件函数只运行一次,JSX 中的条件分支需要特殊处理(Show/Try/Switch 组件)。
下面用一个 计数器 + 派生值 + 副作用 的最小例子,展示同一功能在五个框架中的实现差异。注意观察每个框架的依赖追踪方式和心智负担。
基准性能对比:1000 组件场景
// Benchmark: 1000 个组件 × 每个组件 1 个 signal/state
// 测试操作: 更新第 500 个组件的值,测量重新渲染的组件数
// SolidJS: 精确更新 → 只重渲染 1 个组件 → ~0.1ms
// Vue 3: 精确更新 → 只重渲染 1 个组件 → ~0.3ms
// Preact Signals: 精确更新 → 只重渲染 1 个 → ~0.2ms
// Angular Signals: 精确更新 → 只重渲染 1 个 → ~0.15ms
// React useState: 整棵子树 diff → 重渲染 ~500 个 → ~8ms
// * 数据来源: js-framework-benchmark 综合测试 (2024)
// * 实际差异取决于组件复杂度和虚拟 DOM 实现细节SolidJS
~0.1ms
Angular
~0.15ms
Preact
~0.2ms
Vue 3
~0.3ms
React
~8ms
以下是一个可运行的 微型响应式引擎 模拟器。修改下方代码中 signal 的值,观察 effect 的执行次数和 computed 的缓存命中情况。
// 🧪 实验场代码 — 在浏览器控制台中运行
const count = signal(0); // 原始信号
const double = computed(() => count.value * 2); // 派生
let effectRuns = 0;
effect(() => {
effectRuns++;
console.log(`effect #${effectRuns}: count=${count.value}, double=${double.value}`);
});
// 实验 1: 改变 count
count.value = 1; // → effect #2: count=1, double=2
count.value = 2; // → effect #3: count=2, double=4
// 实验 2: 设置相同值
count.value = 2; // → 无输出!Signal 的值没变,不派发更新
// 实验 3: 批量更新 (SolidJS 的 batch)
batch(() => {
count.value = 10;
count.value = 20;
}); // → effect 只触发 1 次 (而非 2 次)
console.log("最终:", count.value, double.value);
// → 最终: 20 40🧪 试试看:在 React 中,即使 setState(2) 和当前值相同,组件也会 re-render 一次(React 18+ 会在 diff 阶段跳过 DOM 更新,但组件函数仍会执行)。Signal 在源头就阻止了无意义的传播。
// ❌ React: 闭包陈旧值陷阱
function SearchBox() {
const [query, setQuery] = useState("");
const [results, setResults] = useState([]);
useEffect(() => {
const timer = setTimeout(() => {
// query 可能是过期值!因为闭包捕获了旧的 query
fetchResults(query).then(setResults);
}, 300);
return () => clearTimeout(timer);
}, []); // ← ⚠️ 空依赖数组 → query 永远是初始值 ""
return <input onChange={e => setQuery(e.target.value)} />;
}// ✅ React: 正确的依赖声明
function SearchBox() {
const [query, setQuery] = useState("");
const [results, setResults] = useState([]);
useEffect(() => {
if (!query) return;
const controller = new AbortController();
const timer = setTimeout(() => {
fetchResults(query, { signal: controller.signal })
.then(setResults)
.catch(() => {});
}, 300);
return () => {
controller.abort();
clearTimeout(timer);
};
}, [query]); // ← ✅ 正确声明依赖
return <input onChange={e => setQuery(e.target.value)} />;
}// ❌ Vue: 解构丢失响应性
const user = ref({ name: "Alice", age: 25 });
const { name, age } = user.value; // ← ⚠️ 解构后变成普通值
// name 和 age 不再是响应式的!
// ✅ 正确: 使用 toRefs()
import { toRefs } from "vue";
const { name, age } = toRefs(user.value); // ← ✅ 保持响应性各框架常见陷阱速查
| 框架 | 陷阱 | 解决方案 |
|---|---|---|
| React | useEffect 空依赖数组 → 闭包陈旧值 | ESLint exhaustive-deps 规则 + 正确声明依赖 |
| React | 过度使用 useMemo/useCallback 导致代码噪音 | React 19 Compiler 自动优化(实验中) |
| Vue | 解构 ref/reactive 对象丢失响应性 | 使用 toRefs() 或 toRef() |
| Vue | v-for 中使用 reactive 数组的索引赋值不触发更新 | 使用 splice() 或 Vue 3 的 ref 数组 |
| Solid | 在组件函数顶层解构 props 丢失响应性 | 使用 getter 形式访问 props.xxx 或在 JSX 中直接使用 |
| Solid | 在 createEffect 外部读取 signal 只取一次值 | 在 effect/computed/getter 内读取以建立追踪 |
| Angular | Signal 与旧 Zone.js 模式混用导致变更检测不一致 | 在 Signal-based 组件中禁用 Zone.js(provideExperimentalZonelessChangeDetection) |
响应式方案选型决策矩阵
| 维度 | React Hooks | Vue Reactivity | SolidJS Signals | Angular Signals |
|---|---|---|---|---|
| 学习曲线 | 🟡 中等 | 🟢 低 | 🟡 中等 | 🔴 高(需学 DI/装饰器) |
| 依赖追踪 | 手动声明 ⚠️ | 自动(Proxy) | 自动(getter) | 自动(getter) |
| 更新粒度 | 组件级(子树 diff) | 属性级 | DOM 节点级 | DOM 节点级 |
| SSR 支持 | 🟢 Next.js 生态最强 | 🟢 Nuxt 成熟 | 🟡 SolidStart 发展中 | 🟡 Angular Universal |
| TypeScript | 🟢 优秀 | 🟢 3.2+ 很好 | 🟢 优秀 | 🟢 最佳(原生 TS) |
| 社区规模 | 🟢 最大(npm #1) | 🟢 大(npm #2) | 🟡 成长中 | 🟢 企业级(Google) |
| Bundle 大小 | ~42KB (react+react-dom) | ~33KB (vue runtime) | ~7KB (solid-js) | ~65KB (@angular/core) |
| 未来趋势 | React Compiler 补短板 | Vapor Mode(无 VDOM) | 引领 Signal 范式 | 全面 Signal 化 |
React 19 Compiler (React Forget):补课进行时
React 团队意识到了手动依赖管理的痛点,推出了 React Compiler。它在编译阶段自动分析组件代码,自动插入 useMemo 和 useCallback。但请注意: 它不会改变 React 的 re-render + diff 模型——只是让你不用手写优化注解。
// React 19 + React Compiler (React Forget) 未来展望
// 编译器自动将:
function TodoList({ todos, filter }) {
const visible = todos.filter(t => t.status === filter);
return <ul>{visible.map(t => <li key={t.id}>{t.text}</li>)}</ul>;
}
// 自动编译为类似:
// const visible = useMemo(() => todos.filter(...), [todos, filter]);
// 即: 编译器替你写 useMemo/useCallback,消除手动依赖管理
// 但本质仍是 re-render + diff,不是细粒度追踪一句话记住每个框架的响应式哲学
"给我新值,我重新画整棵树,自己找出哪里变了。"
"你正常写代码,我在 Proxy 背后默默追踪你碰了哪些属性。"
"组件函数只跑一次。之后每次变化,我直接改 DOM 节点——没有中间人。"
"Signal 是新的 Zone.js。精确、可预测、TypeScript 原生。"
最后更新:2024 · Angular 17+ / SolidJS 1.8 / Vue 3.4 / React 19 (RC) / Preact Signals 2.0