如果你最近关注前端框架的动向,一定注意到一个有趣的现象——Angular团队在大力推广Signal,SolidJS凭借细粒度响应式在一众框架中杀出重围,Preact Signals的npm下载量也在稳步攀升,Vue的ref/runtime实现和Signal有着千丝万缕的联系。TC39甚至已经有一份关于将Signal标准化到JavaScript语言本身的提案在讨论中。Signal这个概念正在从框架的内部机制走向语言层面的基础设施。
但抛开这些框架层面的讨论,Signal的内核到底是什么?它为什么能让状态变更如此高效?我花了一些时间阅读SolidJS和Preact Signals的源码,又参考了TC39提案中的设计思路,决定用最朴素的方式把核心机制复现一遍——不依赖任何框架,就用原生JavaScript。
这篇文章会带你从零开始,一步步实现一个功能完备的Signal响应式系统。我们会先搭出一个最简陋的版本,然后逐步补全computed、effect嵌套、依赖追踪和批量更新这些关键能力。最后用一个购物车的例子把整套机制串起来。整个实现大约200行代码,麻雀虽小但五脏俱全。
从一个简单的想法说起
Signal模式的核心思想用一句话就能概括:当你改变一个值的时候,所有依赖这个值的地方自动更新。这和EventEmitter那种”我变了,你自己来拿”的模式不同,Signal是推拉结合的——值变了主动通知,但具体数据是按需计算的。
实现这个机制需要解决三个问题:第一,怎么知道谁依赖了谁(依赖收集);第二,值变化时怎么通知依赖方(派发更新);第三,如果有中间计算步骤(比如A变了导致B也要变),这个链路怎么自动串起来。
先看一个最简版本的实现,它只完成依赖收集和派发更新这两件事。这个版本虽然简陋,但已经能跑通核心流程了。
// === 最简实现:只有 signal 和 effect ===
// 用一个全局变量标记"当前正在执行的effect"
let currentEffect = null;
function signal(initialValue) {
let value = initialValue;
// 用一个Set存所有依赖这个signal的effect
const subscribers = new Set();
return {
get value() {
// 读取时收集依赖
if (currentEffect !== null) {
subscribers.add(currentEffect);
}
return value;
},
set value(newValue) {
if (newValue === value) return;
value = newValue;
// 写入时通知所有订阅者
for (const sub of subscribers) {
sub();
}
}
};
}
function effect(fn) {
// 把fn设为当前effect,然后立即执行一次
// 执行过程中但凡读到signal的value,就会自动收集
const wrapped = () => {
currentEffect = wrapped;
try {
fn();
} finally {
currentEffect = null;
}
};
wrapped();
return wrapped;
}
上面的代码不到30行,但已经包含了Signal模式最关键的机制。我拆开来讲一下执行过程:
当你调用effect(() => console.log(count.value))时,wrapped函数先把currentEffect指向自己,然后执行回调。回调里读到count.value,触发getter,getter发现currentEffect不为null,就把这个effect函数塞进subscribers集合里。这样一来,后续只要count.value被修改,setter就会遍历subscribers,逐个执行这些effect。
这个机制妙就妙在依赖关系完全由运行时自动推导,不需要你手动声明。你读哪个signal,就自动订阅哪个signal。而且try...finally保证无论回调是否抛错,currentEffect都会被重置,避免污染后续的逻辑。
不过这个版本有个明显的缺陷——如果effect里又嵌套了effect,外层的currentEffect会被内层覆盖,导致依赖收集错乱。另外computed(派生状态)还没实现,光有signal和effect在实际场景中不太够用。
处理嵌套effect
嵌套effect在真实场景中并不罕见。比如你写了一个组件,组件本身有一个effect监听某个状态,组件内部又调用了另一个包含effect的工具函数。这时候effect就嵌套了。
修复方案不复杂,用一个栈来保存currentEffect的上下文就行:
let currentEffect = null;
const effectStack = [];
function effect(fn) {
const wrapped = () => {
// 入栈
effectStack.push(wrapped);
currentEffect = wrapped;
try {
fn();
} finally {
// 出栈,恢复上一层effect
effectStack.pop();
currentEffect = effectStack.length > 0
? effectStack[effectStack.length - 1]
: null;
}
};
wrapped();
return wrapped;
}
用栈结构来管理effect的执行上下文,这样无论嵌套多少层,依赖收集都能精准地绑定到当前正在执行的那个effect上。这个思路其实和React的Hooks实现中用来追踪组件实例的机制有点像,只不过我们这里处理的是effect而非组件。
实现computed:自动追踪的派生值
computed本质上是一个特殊的signal,它的值不是直接赋的,而是通过一个计算函数推导出来的。关键在于:computed需要追踪它依赖了哪些signal,当这些signal变化时,computed要重新计算。同时,computed本身也可以被effect依赖,形成依赖链。
实现computed有一个容易踩坑的地方——它需要处理”脏标记”。如果每次读取computed的值都重新计算,那性能就太差了。合理的做法是:依赖没变的时候直接返回缓存值,只有依赖变了才标记为”脏”,下次读取时重新计算。
function computed(fn) {
let cachedValue;
let isDirty = true; // 初始状态需要计算
const subscribers = new Set();
// 用一个内部effect来追踪computed依赖了哪些signal
const updateEffect = effect(() => {
if (isDirty) {
const newValue = fn();
if (newValue !== cachedValue) {
cachedValue = newValue;
// computed的值变了,通知依赖computed的订阅者
for (const sub of subscribers) {
sub();
}
}
}
isDirty = false;
});
return {
get value() {
// 谁读了computed,就和这个computed绑定
if (currentEffect !== null) {
subscribers.add(currentEffect);
}
// 如果脏了,触发重新计算
if (isDirty) {
updateEffect();
}
return cachedValue;
}
};
}
等等,上面这段代码有个循环逻辑的问题——updateEffect本身是一个effect,它执行时如果读到signal,signal会把updateEffect加入自己的订阅者列表。当signal变化时,updateEffect被调用,但它并不会立即重算fn(),而是把isDirty设为true。真正重算发生在下次读取computed的value时(懒计算)。
不过这段代码还差一个关键环节:signal变化后,怎么让computed知道该标脏了?我们需要在signal的setter中主动通知computed的内部effect。这其实已经通过effect机制自动完成了——updateEffect在初次执行时读了signal的value,signal已经把updateEffect加入了订阅者列表。当signal变化,updateEffect被调用,它把isDirty置为true。
整理一下依赖链:signal → computed的内部effect → computed标脏 → 依赖computed的effect重新读取 → 触发重算。这条链完全自动串联,不需要手动干预。
不过上面的computed实现还需要打磨。目前的问题在于,updateEffect被signal触发后只负责标脏,但依赖computed的外部effect不会自动重新执行——除非computed主动通知它们。我们得在标脏的同时触发computed自身的订阅者。来,重写一版更健壮的:
function computed(fn) {
let cachedValue;
let isDirty = true;
const subscribers = new Set();
// 这个effect在signal变化时被调用
const runner = effect(() => {
// 这里故意读一下fn()来建立依赖
const newValue = fn();
if (!isDirty || newValue === cachedValue) {
// 依赖变了但值没变,不通知下游
isDirty = false;
return;
}
cachedValue = newValue;
isDirty = false;
// 通知依赖这个computed的订阅者
for (const sub of [...subscribers]) {
sub();
}
});
return {
get value() {
if (currentEffect !== null) {
subscribers.add(currentEffect);
}
if (isDirty) {
// 懒计算:读到脏数据时强制刷新
runner();
}
return cachedValue;
}
};
}
这一版的关键改进在于:computed内部的effect(runner)在signal变化时被触发,它不仅重新计算了值,还主动通知了依赖computed的外部订阅者。这样无论是同步读取还是异步更新,依赖链都不会断裂。
有个细节值得注意:[...subscribers]做了一个浅拷贝再遍历。这是为了防止在遍历过程中subscribers被修改(比如某个effect执行时又添加了新的订阅),导致迭代器异常。这种防御性写法在实际项目中经常能救你一命。
batch:把多次更新合并成一次
设想一个场景:你连续修改了三个signal,每个signal都触发了一轮effect执行。如果这三个signal被同一个computed依赖,那computed会被触发三次,而前两次的计算结果根本没机会被用到。这不仅浪费,还可能导致中间状态被错误地暴露给外部。
batch的作用就是圈定一个范围,在这个范围内的所有signal变更先攒着,等batch结束再一次性触发所有受影响的effect。这样中间状态就不会外泄。
let batchDepth = 0;
let pendingEffects = new Set();
function batch(fn) {
batchDepth++;
try {
fn();
} finally {
batchDepth--;
if (batchDepth === 0) {
// 批量执行积攒的effect
const effectsToRun = [...pendingEffects];
pendingEffects.clear();
for (const ef of effectsToRun) {
ef();
}
}
}
}
光有batch函数还不够,signal的setter也得配合——在batch期间不立即触发订阅者,而是把它们塞进pendingEffects,等batch结束再统一处理。我们需要改造signal的setter:
function signal(initialValue) {
let value = initialValue;
const subscribers = new Set();
return {
get value() {
if (currentEffect !== null) {
subscribers.add(currentEffect);
}
return value;
},
set value(newValue) {
if (newValue === value) return;
value = newValue;
if (batchDepth > 0) {
// 在batch中,延迟通知
for (const sub of subscribers) {
pendingEffects.add(sub);
}
} else {
for (const sub of subscribers) {
sub();
}
}
}
};
}
这样一来,下面的代码就安全了:
batch(() => {
firstName.value = 'Zhang';
lastName.value = 'San';
age.value = 28;
});
// batch结束后,依赖这些signal的effect只执行一次
batch在框架层面非常有用。比如组件初始化时一口气设置多个状态,或者处理一批用户输入时,batch可以显著减少不必要的重复计算。
防止循环依赖和无限递归
实际使用中,循环依赖是个绕不开的问题。比如computed A依赖signal B,而signal B的effect又间接读了computed A,这就形成了一个环。不处理的话,setter触发effect,effect触发computed重算,computed又触发setter……直接栈溢出。
一个轻量级的防护手段是给effect执行加一个递归深度限制,或者在effect执行前检查它是否已经在调用栈中。这里用一个简单的标记位来处理:
function effect(fn) {
let running = false;
const wrapped = () => {
if (running) return; // 防止重入
effectStack.push(wrapped);
currentEffect = wrapped;
running = true;
try {
fn();
} finally {
running = false;
effectStack.pop();
currentEffect = effectStack.length > 0
? effectStack[effectStack.length - 1]
: null;
}
};
wrapped();
return wrapped;
}
这个running标记确保同一个effect不会在尚未执行完毕时被再次触发。虽然不能从根源上解开循环依赖,但至少避免了栈溢出,给开发者留出了排查问题的时间。
如果你想做得更完善,可以在computed内部维护一个依赖图,检测到环时在控制台打印警告。不过生产级别的循环检测会带来不小的性能开销,大部分Signal库选择把这个责任交给开发者——设计合理的状态结构,避免循环依赖。
清理effect:别让过期的订阅一直留着
在实际应用中,effect可能会被创建又销毁。比如一个组件卸载了,它注册的effect就应该停止响应状态变更。如果不清理,不仅浪费内存,还可能引发”在已卸载组件上更新状态”的经典bug。
给effect加上清理能力很简单——返回一个dispose函数就行:
function effect(fn) {
let disposed = false;
let running = false;
const wrapped = () => {
if (disposed || running) return;
effectStack.push(wrapped);
currentEffect = wrapped;
running = true;
try {
fn();
} finally {
running = false;
effectStack.pop();
currentEffect = effectStack.length > 0
? effectStack[effectStack.length - 1]
: null;
}
};
wrapped();
return () => {
disposed = true;
// 注意:这里没有从signal的subscribers中移除wrapped
// 完整实现需要在signal侧维护反向引用,篇幅所限这里先略过
// 实际生产中Preact Signals用了一个巧妙的节点链表来解决这个问题
};
}
说实话,从signal的subscribers集合中精确移除某个effect是个有点头疼的问题。如果signal存的是effect引用,而effect被dispose后这些引用就成了僵尸数据。Preact Signals的解决方案是让每个effect维护一个”它订阅了哪些signal”的列表,dispose时遍历这个列表把自己从各个signal中摘除。这套机制需要一个双向链表结构,代码量会翻倍。上面的简化版用disposed标记来阻止已销毁的effect再次执行,虽然没清理引用,但在大多数场景下已经够用了。
完整的Signal引擎代码
把前面所有的代码整合起来,得到一个功能基本完备的Signal引擎:
// ===== 完整实现 =====
let currentEffect = null;
const effectStack = [];
let batchDepth = 0;
const pendingEffects = new Set();
function signal(initialValue) {
let value = initialValue;
const subscribers = new Set();
return {
get value() {
if (currentEffect !== null) {
subscribers.add(currentEffect);
}
return value;
},
set value(newValue) {
if (Object.is(newValue, value)) return;
value = newValue;
if (batchDepth > 0) {
for (const sub of subscribers) {
pendingEffects.add(sub);
}
} else {
for (const sub of [...subscribers]) {
sub();
}
}
},
// 暴露订阅者数量,调试用
get _subscriberCount() {
return subscribers.size;
}
};
}
function effect(fn) {
let disposed = false;
let running = false;
const wrapped = () => {
if (disposed || running) return;
effectStack.push(wrapped);
currentEffect = wrapped;
running = true;
try {
fn();
} finally {
running = false;
effectStack.pop();
currentEffect = effectStack.length > 0
? effectStack[effectStack.length - 1]
: null;
}
};
wrapped();
return () => {
disposed = true;
};
}
function computed(fn) {
let cachedValue;
let isDirty = true;
const subscribers = new Set();
const runner = effect(() => {
const newValue = fn();
if (!isDirty && Object.is(newValue, cachedValue)) {
isDirty = false;
return;
}
cachedValue = newValue;
isDirty = false;
for (const sub of [...subscribers]) {
sub();
}
});
return {
get value() {
if (currentEffect !== null) {
subscribers.add(currentEffect);
}
if (isDirty) {
runner();
}
return cachedValue;
}
};
}
function batch(fn) {
batchDepth++;
try {
fn();
} finally {
batchDepth--;
if (batchDepth === 0 && pendingEffects.size > 0) {
const effectsToRun = [...pendingEffects];
pendingEffects.clear();
for (const ef of effectsToRun) {
ef();
}
}
}
}
全部加在一起不到100行。这套代码可以直接复制到浏览器控制台或者Node.js里运行。接下来用购物车的例子来验证它好不好使。
实战案例:一个响应式购物车
我们用刚写好的Signal引擎来构建一个购物车的状态管理层。需求很常见:商品列表、添加/移除商品、修改数量、自动计算总价和总数量,外加一个”是否包邮”的判断(满99包邮)。
// ===== 购物车案例 =====
// 商品数据
const cartItems = signal([
{ id: 1, name: '机械键盘', price: 349, quantity: 1 },
{ id: 2, name: '鼠标垫', price: 29, quantity: 2 },
{ id: 3, name: 'Type-C数据线', price: 19, quantity: 1 },
]);
// 派生状态:总数量
const totalQuantity = computed(() => {
return cartItems.value.reduce((sum, item) => sum + item.quantity, 0);
});
// 派生状态:总价
const totalPrice = computed(() => {
return cartItems.value.reduce(
(sum, item) => sum + item.price * item.quantity,
0
);
});
// 派生状态:是否包邮
const freeShipping = computed(() => totalPrice.value >= 99);
// 派生状态:还差多少包邮
const gapToFreeShipping = computed(() => {
if (freeShipping.value) return 0;
return Math.round((99 - totalPrice.value) * 100) / 100;
});
// 副作用:当总价变化时打印摘要
const logEffect = effect(() => {
const summary = [
`商品数: ${totalQuantity.value} 件`,
`总价: ¥${totalPrice.value}`,
freeShipping.value ? '✅ 已包邮' : `📦 还差 ¥${gapToFreeShipping.value} 包邮`
].join(' | ');
console.log('[购物车]', summary);
});
// 模拟操作
console.log('=== 初始状态 ===');
// (effect在创建时已自动执行一次,所以初始状态已经打印了)
console.log('n=== 增加数据线数量 ===');
const currentItems = cartItems.value;
currentItems[2].quantity = 3;
cartItems.value = [...currentItems]; // 触发更新
console.log('n=== 再买一个耳机 ===');
cartItems.value = [
...cartItems.value,
{ id: 4, name: '蓝牙耳机', price: 199, quantity: 1 }
];
console.log('n=== 使用batch批量修改 ===');
batch(() => {
const items = cartItems.value;
items[0].quantity = 2;
items[1].quantity = 1;
cartItems.value = [...items];
});
// batch结束后effect只触发一次
// 清理
logEffect();
在浏览器控制台运行这段代码,你会看到类似这样的输出:
[购物车] 商品数: 4 件 | 总价: ¥426 | ✅ 已包邮
[购物车] 商品数: 6 件 | 总价: ¥446 | ✅ 已包邮
[购物车] 商品数: 8 件 | 总价: ¥645 | ✅ 已包邮
[购物车] 商品数: 8 件 | 总价: ¥996 | ✅ 已包邮
注意batch那一步——我们同时修改了键盘和鼠标垫的数量,但日志只打印了一次。这就是batch在起作用。如果没有batch,两个signal变更会触发两次effect执行,中间那次计算其实是无效的。
细心的你可能发现了一个问题:修改数组内元素的属性时,我需要用cartItems.value = [...cartItems.value]这种”整体替换”的方式来触发更新。这是因为我们的signal用Object.is做新旧值比较,直接cartItems.value[0].quantity = 2不会改变数组引用,signal认为值没变,就不会通知订阅者。这是一个设计取舍——深比较会带来性能开销,浅比较则需要开发者在修改嵌套对象时显式创建新引用。SolidJS和Preact Signals都选择了浅比较,配合不可变数据的实践来使用。
如果你想让signal支持深层响应,可以在setter里做一层浅比较后强制触发,或者像Vue那样用Proxy包裹整个对象。不过那就进入了另一个话题——基于Proxy的响应式系统,它的实现复杂度和本文讲的Signal模式有本质区别。
这套实现和框架里的Signal有什么差距
坦诚地讲,上面这套代码和Preact Signals或SolidJS的production实现之间还有不少距离。主要的差距集中在这些方面:
依赖追踪的精确度。我们的实现中,effect重新执行时会清掉旧的依赖、重建新的依赖。但如果effect内部有分支逻辑(if-else),执行路径变了,旧分支的依赖可能还残留着。Preact Signals用了一个精妙的双向链表结构,每次effect执行前先”清理”上一轮的依赖关系,确保不会有僵尸依赖。
内存管理。前面提到过,dispose一个effect后,signal的subscribers里还留着它的引用。短生命周期的应用无所谓,但长期运行的SPA中这会导致内存缓慢泄漏。生产级的Signal库会维护完整的反向依赖图。
异步和微任务调度。我们的effect是同步执行的,signal一变化立刻触发。但在真实场景中,把effect的执行推迟到微任务队列(用queueMicrotask或Promise.resolve())能带来更好的批量效果,也能避免在DOM操作中途触发重排。SolidJS的调度器在这方面做了大量优化。
错误边界。如果某个effect抛了异常,不应该影响其他effect的执行。我们的实现用try...finally保护了currentEffect的恢复,但没有隔离错误——一个effect炸了,同一批次的后续effect都不会执行。加一个try-catch包裹每个effect调用就能解决。
不过这些”不足”恰恰是学习Signal最有价值的部分。当你理解了基础实现,再去看Preact Signals或SolidJS的源码时,会发现那些复杂的数据结构和优化策略都有了清晰的动机——它们都是在解决上述这些具体问题。
Signal解决了什么问题,没有解决什么问题
Signal模式最适合的场景是:状态之间有明确的派生关系,而且这些关系可以用有向无环图来描述。购物车总价依赖每个商品的价格和数量,包邮状态依赖总价——这种依赖链用Signal表达非常自然。相比之下,用Redux处理同样的逻辑需要写reducer、action、selector,样板代码多出不少。
但Signal不是万能药。它管的是”数据怎么变”,不管”数据从哪来”。异步请求、服务端推送、WebSocket消息这些外部数据源,还是需要你自己封装。另外,Signal的依赖图在运行时动态构建,如果状态结构过于复杂(成百上千个相互关联的signal),调试时会比较痛苦——你很难一眼看清完整的依赖拓扑。
还有一个容易被忽略的点:Signal是同步的、精确的,但DOM更新往往需要批量和异步。把Signal直接接上DOM操作(像我们在购物车例子中做的那样)在小规模场景下完全可行,但在大规模应用中,你需要在Signal层和渲染层之间加一个调度层。这正是SolidJS的渲染器在做的事情。
写在最后
用不到100行原生JavaScript实现一个Signal响应式系统,这件事本身就说明了Signal模式的核心并不复杂。它的精髓在于把”状态-依赖-更新”这个循环自动化了,让开发者只需要声明状态和计算规则,剩下的事情交给运行时处理。
TC39的Signal提案如果最终落地,意味着浏览器会原生提供Signal构造函数和相关的Computed、WatcherAPI。到那时,框架可以站在语言原语的基础上做更高层的抽象,不同框架之间的状态甚至可以互通——因为底层是同一套Signal机制。这对前端生态的影响可能比当年Promise/A+规范统一异步接口还要深远。
如果你对Signal的内部机制还有好奇,推荐去读Preact Signals的源码,它的实现非常紧凑,核心逻辑也就几百行。SolidJS的源码也很值得研究,特别是它的调度器和渲染器如何与Signal协同工作。理解了这些,你会对”响应式”三个字有全新的认识。
附录:在浏览器中运行完整示例
将以下代码复制到浏览器控制台即可运行完整的购物车示例(包含上面实现的Signal引擎和购物车逻辑):
// 复制上面的"完整实现"代码段(signal、effect、computed、batch四个函数)
// 然后粘贴,接着复制"购物车案例"代码段
// 就能在控制台看到完整的响应式效果
如果你想在此基础上扩展,可以尝试加上这些功能:从localStorage持久化购物车数据、添加商品库存校验、或者把日志输出换成实际的DOM更新。你会发现,Signal引擎本身不需要任何修改——它只负责状态管理,展示层的事情交给展示层。

