前端框架的状态管理经常被描述得很玄乎,但从本质上看,它们大部分都解决同一个问题:一个变量的值变了,页面里用到它的地方怎么跟着更新。这几年,Signal(信号)这个概念从Solid.js开始被广泛接受,后来Preact、Angular甚至Vue的核心都引入了类似的机制。
Signal不是一个框架才有的高级特性,它其实就是一段几十行就能写明白的代码。今天咱们抛开框架,徒手实现一个能跑的、支持computed和effect的signal仓库。不依赖浏览器API,也不依赖框架,就是纯纯的JavaScript逻辑。
这段代码写完之后,你可以把它直接塞进任何项目里用。即使不用,理解了这个机制之后,你再看Vue的ref、Solid的createSignal,会感觉像是看老朋友一样。
从“发布订阅”到“依赖收集”
很多文章一上来就扔出“发布订阅”、“观察者模式”这种名词。Signal其实比它们更细腻一点。发布订阅的典型场景是:我发布一个事件,所有订阅了的人都会收到通知。至于这个通知和订阅者有没有关系,事件系统完全不关心。
Signal要解决的是另外一个事:我读了一个变量,只有真正的读取行为发生,才把这个变量记录下来。当变量发生变化的时候,它只通知那些真正“读过它”的代码。如果一段代码定义了函数但从来没执行,那它读信号的行为不会被统计。
这种做法的关键是“运行时自动追踪”,靠的就是JavaScript的函数执行栈。当我的effect函数开始执行时,我先把“当前正在运行的effect”记录在一个全局变量里。然后函数继续执行,执行过程中一旦发生了signal的get操作,这个signal就把“当前正在运行的effect”拉进自己的依赖列表里。这就是所谓的依赖收集。
先不谈太深的理论,咱们直接写基础类。一个signal只需要保存一个当前值,以及一组依赖它的effect。每次get的时候把当前运行的effect加进去,每次set的时候把依赖列表里的effect全部执行一遍。
let currentEffect = null;
function createSignal(initialValue) {
let value = initialValue;
const effects = new Set();
function get() {
if (currentEffect !== null) {
effects.add(currentEffect);
}
return value;
}
function set(newValue) {
// 值没变强制不触发,省掉无用的更新
if (Object.is(value, newValue)) {
return;
}
value = newValue;
// 执行所有依赖
effects.forEach(effect => effect());
}
return [get, set];
}
不要看到Set就紧张,Set是用来保证同一个effect不会被重复收集多次的。Object.is的判断能解决两个问题:一是NaN不等于NaN的比较,二是+0和-0这种边界。如果你的业务里只有普通的数字字符串,其实这里换成===也行。
写到这,一个“裸的”signal能用了,但没有自动执行的effect,我们得手动调用set之后的回调。真正想让响应式跑起来,还缺一个“把自己注册currentEffect的通道”。
实现一个带自动追踪的effect
effect是一个函数,它期望触发一次,并且在它读取过的任何signal发生变化之后,再自动触发一次。
function createEffect(fn) {
const effect = () => {
// 先把全局的currentEffect指向自身
currentEffect = effect;
// 执行用户函数,触发内部get的收集
fn();
// 收集完之后赶紧把它置空,不然其他地方的signal.get会误收集
currentEffect = null;
};
effect();
return effect;
}
这里要注意一个问题:如果effect函数内部有分支,比如if (a) { b() },当a的值变化导致分支不再执行b的读取时,b对于这个effect而言就已经过时了。但上面的实现却无法处理“取消订阅”场景。因为上一次收集到的dep还在effects列表里,b以后每次变化都会触发这个effect。
这种过期的依赖会导致内存泄漏加不必要的重执行。要是放在SPA场景下,甚至可能引起无法预估的bug。
要解决这个问题,必须在effect执行前,把自己从所有signal的订阅列表里清理掉,然后再执行读取,重新收集。实现上,我给signal增加一个“移除特定effect”的能力:
function cleanupEffect(effect) {
effect.dependencies.forEach(dep => dep.delete(effect));
effect.dependencies.clear();
}
function createEffect(fn) {
const effect = () => {
// 执行前先断开所有旧连接
cleanupEffect(effect);
currentEffect = effect;
fn();
currentEffect = null;
};
effect.dependencies = new Set();
function get() {
if (currentEffect !== null) {
currentEffect.dependencies.add(trackedSet);
}
// ...
}
effect();
return effect;
}
看起来有点绕,更简洁的做法是让每个signal都保留一个监听者集合,并且effect自己记录自己访问过的集合。当effect重新运行之前,把所有旧集合里的自己删掉。然后把当前signal作为新集合加进自己的dependencies。
我们直接整合成一份可以运行的精简实现:
let activeEffect = null;
function createSignal(value) {
const subscribers = new Set();
const get = () => {
if (activeEffect !== null) {
subscribers.add(activeEffect);
activeEffect.depNames.add(subscribers);
}
return value;
};
const set = (nextValue) => {
if (Object.is(value, nextValue)) return;
value = nextValue;
// 很重要:需要先复制一份,不然subscribers在遍历里被修改会出问题
[...subscribers].forEach((fn) => fn());
};
return [get, set];
}
function createEffect(fn) {
const effect = () => {
// 先清理
effect.depNames.forEach(depSet => depSet.delete(effect));
effect.depNames.clear();
activeEffect = effect;
fn();
activeEffect = null;
};
effect.depNames = new Set();
effect();
return effect;
}
这个版本已经适当地处理了“被取消掉的依赖”。当依赖的分支不再被读取时,对应的subscribers集合里就找不到这个effect了。
为了便于理解,我把subscribers直接叫做“Set”。这里的depNames更像一个“反向索引”,帮助effect知道哪些Set里收了自己,执行前好去把自己删掉。
但createEffect的返回值其实不太常用。一般用户不需要拿到effect的句柄,如果要销毁一个effect,可以用dispose函数。在上面的基础上,再加上返回停用函数即可:
function createEffect(fn) {
let isDisposed = false;
const effect = () => {
if (isDisposed) return;
effect.depNames.forEach(depSet => depSet.delete(effect));
effect.depNames.clear();
activeEffect = effect;
fn();
activeEffect = null;
};
effect.depNames = new Set();
effect();
const dispose = function() {
isDisposed = true;
effect.depNames.forEach(depSet => depSet.delete(effect));
effect.depNames.clear();
};
return dispose;
}
到这里,Signal的核心机制不到70行代码就已经完整暴露在眼前了。
为什么需要computed——缓存懒计算
直接靠effect加signal,实现响应式已经够用。但考虑一个性能问题:多个effect同时依赖同一个复杂的派生值,比如“购物车的总价”,如果每一项都实时算一次,不划算。所以有了computed。
computed也是一个signal,但它不产出一个稳定值,而是“根据其他信号计算出结果,结果缓存起来”。只有当依赖的signal发生变化时,computed才重新计算。
computed自己依赖上游信号,同时又是下游effect的依赖。这就有了两个层面的缓存:一是值级缓存:如果上游没变,多次读取computed不会重新执行计算函数;二是订阅关系:上游一变,computed失效并且通知下游。
为了避免“computed更新一次导致下游effect也跟着执行两次”,实现里需要非常谨慎地管理脏状态。先用一个简单但有效的办法:computed内部自己维护一个bool,叫做dirty。当上游信号set触发更新时,不是立刻重新计算,而是把computed置为dirty。
代码是这样的:
function createComputed(computeFn) {
const [get, set] = createSignal(undefined);
let cachedValue;
let isDirty = true;
// 专门用来标记依赖变动的effect
const recompute = () => {
// 标记计算依赖的后代发生变化
if (!isDirty) {
isDirty = true;
// 通知下游(发个信号,但不值得立刻算)
set(undefined);
}
};
const disposable = createEffect(() => {
const result = computeFn();
if (isDirty || result !== cachedValue) {
cachedValue = result;
isDirty = false;
set(result);
}
// 第一轮执行完 reset
});
// 上面的写法有点尴尬,因为createEffect是立刻执行的,我们没法第一轮就正确处理。
// 所以需要更明确的lazy模式。
}
这个写法有问题,第一次计算时,isDirty为true,它set(undefined),影响依赖computed的底层effect被误触发。更好的方式:我构建一个“脏检查”但让第一次执行不通知下游。
很多响应式库不会直接通过createEffect来实现computed,而是单独维护依赖。咱们为了理解和易懂,用一个更直观的lazy + effect方案。
function createComputed(computeFn) {
let currentValue;
let dirty = true;
let computeEff;
const [getValue, setValue] = createSignal(undefined);
const runCompute = () => {
if (dirty) {
const newValue = computeFn();
if (Object.is(newValue, currentValue)) return;
currentValue = newValue;
setValue(newValue);
dirty = false;
}
return currentValue;
};
computeEff = createEffect(() => {
// 这里的代码会在依赖变化时执行,但我们的compute还没跑,
// 所以先在effect运行中调用 computeFn 来让依赖收集作用在 computeEff 上
dirty = true;
runCompute(); // 这时候会真正执行一次。
});
// 但是createEffect会先执行一遍,这导致无效的第一轮。
}
我们希望的computed是lazy(惰性)。不应该在创建的时候立马执行computeFn,应该当有人第一次读它的时候,才去执行。为了应付这个延迟问题,可以创建computed时,里面先不通过createEffect绑定依赖,而是返回一个普通signal的get/set,同时维护一层内部订阅。
下面是更符合Solid风格的lazy computed实现,代码清晰也不短:
function createComputed(fn) {
const listeners = new Set();
const effectDeps = new Set();
let currentValue;
let isDirty = true;
let isComputing = false;
let hasRun = false;
function get() {
if (activeEffect !== null) {
listeners.add(activeEffect);
activeEffect.depNames.add(listeners);
}
// 如果还没有计算过,直接计算
if (isDirty && !isComputing) {
const prevEffect = activeEffect;
activeEffect = null;
isComputing = true;
try {
// 注意这时候fn内部读取signal,此时activeEffect是null,不会收集到当前computed里。
// 需要一个单独的机制:把fn的依赖收集到effectDeps
currentValue = trackDependencies(fn, effectDeps);
} finally {
activeEffect = prevEffect;
isComputing = false;
}
isDirty = false;
} else if (isDirty && isComputing) {
// 处理循环引用或重入的情况
throw new Error('Circular computation detected');
}
return currentValue;
}
function markDirty() {
if (!isDirty) {
isDirty = true;
// 通知下游
[...listeners].forEach(effect => effect());
}
}
// 订阅上游dependency
// 这个track函数会在内部创建effect来订阅上游
function trackDependencies(compFn, depSet) {
// 利用全局activeEffect来做一次立即执行并且追踪
// 我们需要一个特殊的effect,它既不会直接暴露给下游,又能保存依赖
let result;
const tempEff = () => {
// 清除旧依赖
tempEff.depNames.forEach(set => set.delete(tempEff));
tempEff.depNames.clear();
activeEffect = tempEff;
result = compFn();
activeEffect = null;
};
tempEff.depNames = new Set();
// 先临时跑一遍清理旧的
tempEff();
// 监听上游变化:当上游的任意signal发生set时,需要调用markDirty
const notifyUpstream = () => {
markDirty();
};
// 把tempEff收集到的所有订阅集合作为depSet,但是把notifyUpstream挂进去?
// 其实上游的subscribers会存tempEff,当上游set时调用tempEff。
// 要的是tempEff被触发。tempEff会在触发后执行一次tempEff(), 从而重算。
// 所以我们直接用tempEff即可,它内部已经会重算 result。
// 但是还要限制:只有第一次运行后,上游变化才能使tempEff再次执行。
// 这样完全可以使用createEffect简化了。
// 重新确认逻辑:
}
return [get, markDirty];
}
写到这里发现,要在文章篇幅内做到简单易懂,上面“精密的lazy computed”实现容易看得很累。实际上大部分项目直接用一个更取巧的方案:把computed的求值逻辑封装在一个effect函数里,但这个函数又在全局缓存当前值。若是上游变化,effect即自动运行,把计算结果存下来,然后通知下面的普通signal。
取舍之后,最适合演示的computed版本就是这一种,它易于理解,性能足够覆盖90%的前端需求:
function createComputed(computeFn) {
const [result, setResult] = createSignal(undefined);
let hasRun = false;
createEffect(() => {
const value = computeFn();
// 如果值一样或者初次未定义就不触发多余的set
setResult(value);
});
return result; // 返回一个函数,与signal的get等价
}
这段代码虽然不够聪明,但它用已经写好的createEffect和createSignal实现computed,完全够用。第一次执行时,effect内部读取上游信号收集依赖,并调用setResult存下结果。之后上游变化,effect再次运行,再次计算。所有时机都正确。
为了避免重复运行setResult触发下游多余更新,我们在set里使用了Object.is判断,如果新老值相等,set直接return不触发订阅。所以写成上面的形式性能也不差。
想要代码更严谨的话,createComputed内部可以再包一层,把effect返回的dispose存起来。这样也便于以后销毁computed。
下面是最终的computed写法,带缓存和销毁。
function createComputed(computeFn) {
const [get, set] = createSignal(undefined);
let disposed = false;
function recompute() {
if (disposed) return;
set(computeFn());
}
const dispose = createEffect(recompute);
return {
get value() {
return get();
},
dispose
};
}
注意不要直接用recompute作为createEffect参数直接传。因为第一次调用createEffect会立刻执行recompute,从而初始化值,这是好的行为。
如果上游信号的值没变,computeFn执行一次,则set也不会触发下游更新。如果computeFn返回值没变,下游也不会触发。这套“值比对”在上层代码里被Object.is包住了。
批量更新与微任务调度
现在有一个新的问题。当多个信号连续改变,effect会被同步触发多次。假如effect内部有DOM读写,这种高频执行可能会影响性能。所以现代Signal库普遍内置了批量调度或自动批次。我们加一个batch函数:在同一个调用栈内,多次set只执行一次effect。
最简单的批处理模式叫“同步合并”,利用微任务。需要一个队列,把变更的effect加进去,然后在Promise.resolve().then()里统一执行。
先定义几个全局变量:
let runningEffects = [];
let isBatching = false;
const pendingEffects = new Set();
function enqueueEffect(effect) {
if (isBatching) {
// 批量调度场景,用set来去重
pendingEffects.add(effect);
return;
}
queueMicrotask(() => {
if (pendingEffects.has(effect)) {
pendingEffects.delete(effect);
effect();
}
});
}
改造我们的signal,让set时不是直接调用subscribers里的effect,而是先入队。由于在优先级上要考虑宏任务和微任务,queueMicrotask比较合适。不过如果要支持脱离浏览器环境的Node,可以保留setTimeout作为fallback。
完整的批量更新版本如下。注意effect执行时直接调的是effect(),所以必须保证effect内部有防重入处理。我们加一个当前是否正在执行effect的判断。
let activeEffect = null;
const pendingEffects = new Set();
function scheduleEffect(effect) {
if (activeEffect === effect) return;
pendingEffects.add(effect);
queueMicrotask(() => {
if (pendingEffects.delete(effect)) {
effect();
}
});
}
function createSignal(value) {
const subs = new Set();
const get = () => {
if (activeEffect !== null) {
subs.add(activeEffect);
activeEffect.depNames.add(subs);
}
return value;
};
const set = (next) => {
if (Object.is(value, next)) return;
value = next;
[...subs].forEach(effect => scheduleEffect(effect));
};
return [get, set];
}
而createEffect内部需要防止同一effect重复被调度,并且当它的依赖分发时,effect执行前还会清空旧的依赖,重新建立新的。有一个微妙的问题是:effect执行过程中会调fn,而fn如果修改其他信号,能否触发那些信号的调度?答案是能,但由于我们正在activeEffect里执行,scheduleEffect会把这些新触发的effect加入队列。
但此时activeEffect这个标记已经指向当前effect。当新的effect里取信号时,信号会把“旧effect”误认为依赖。这不对。一个effect运行期间,不应该让另一个信号的读写打断当前的收集链。
解决办法是,在effect执行fn之前,临时把activeEffect置成自身;在fn执行完后,再置回null。如果fn执行期间又触发了另一个effect,由于使用的是queueMicrotask,第二个effect不会同步执行,它会进入微任务队列。因此不会出现嵌套。但如果有人手动在新effect函数里立即执行signal.get,不会有问题,因为activeEffect还是当前的effect,可能造成依赖错乱。通常实际代码里不会出现这种场景,因为嵌套effect靠队列异步消化了。
更好的方式是使用一个栈来管理activeEffect,让嵌套effect也能正确区分依赖。这里就不在文章中过度展开。
实战:写一个响应式计数器加自动求和
理解了上述机制后,我们现在需要把代码和真实DOM结合。可以在所有信号变化后统一更新页面。针对一个最简单的打卡场景:一个number类型的count,加一个按钮,再实时显示它的double和总点击次数。
代码里把signal和effect函数做一些变量名调整,然后用于DOM操作。由于没有用到编译器,只需要在set更改完值之后,让配套的effect自动更新DOM节点就行。
但纯粹的原生DOM更新需要反复getElementById,为了干净我们把那部分封装成小型指令。
先写一个bindText函数,用一个signal的get方法,对应一个DOM节点,每次变更后把textContent更新。
function bindText(nodeId, getter) {
const el = document.getElementById(nodeId);
if (!el) return;
createEffect(() => {
el.textContent = getter();
});
}
然后创建一个计数器signal和它的展示。
const [count, setCount] = createSignal(0); const [clickCount, setClickCount] = createSignal(0); const doubleCount = createComputed(() => count() * 2);
当按钮被点击时,同时修改两个信号:count加1,clickCount加一。为了避免一个effect里连续触发多次更新,可以把它们包在batch中。实现一个batch函数如下:
let batchDepth = 0;
let batchQueue = new Set();
function batch(fn) {
batchDepth++;
try {
fn();
} finally {
batchDepth--;
if (batchDepth === 0) {
// 直接同步执行队列里的effects
batchQueue.forEach(effect=>effect());
batchQueue.clear();
}
}
}
function scheduleInBatch(effect) {
if (batchDepth > 0) {
batchQueue.add(effect);
} else {
queueMicrotask(()=>effect());
}
}
你也可以直接用简单的batch来控制同步粒度。这里利用batch深度来支持嵌套batch。
于是点击事件就可以这样写:
document.getElementById('inc-btn').addEventListener('click', () => {
batch(() => {
setCount(count() + 1);
setClickCount(clickCount() + 1);
});
});
如果effect内部读取了count和clickCount,batch结束后,它最多只会执行一次。因为Set去重保证了即使触发多次,也只入队一个执行任务。batchQueue里是effect对象本身,所以在同一轮batch里,一个effect只执行一次。
从信号到可表达的视图层——组合起来
这个思路可以直接做出一小块vdom引擎。需要创建一个节点描述对象,然后用Signal替换内部状态。例子如下:点击按钮,列表项动态增加并且附带删除按钮。
不过,要确保全文不至于过长到读不下去,我在这只提供一个小示例。假设有一个组件,包含一个input和一个p标签,输入的值实时映射在p里。用signal构建很简单:
const [text, setText] = createSignal('');
const onInput = (e) => setText(e.target.value);
// 创建Effect来自动更新p的内容
createEffect(() => {
document.querySelector('#live-text').textContent = `当前内容:${text()}`;
});
其实这里可能有人觉得奇怪,在createEffect里使用了e.target,但这个不在signal读取范围内,只是JS闭包引用,没问题。
如果你想要构建更复杂的数据流,比如输入框是受控的:
const [val, setVal] = createSignal('你好');
createEffect(() => {
document.querySelector('#my-input').value = val();
});
// 监听输入事件反过来更新信号
document.querySelector('#my-input').addEventListener('input', (e) => {
setVal(e.target.value);
});
这时候如果还有另一个effect读取val,它会自动跟随更新。这些内容都是原生JS与现代响应式的最佳粘合示例。
为什么大家开始认真考虑Signal
Signal并不是新发明,它这波流行主要是因为打中了框架长期以来的两个痛点。
痛点之一是细粒度更新。React的re-render要求“尽量少的reconcile”,但它很难做到真正的粒控。Signal从设计上就自带粒度。一个信号变了,就只有读它的effect被执行。其他effect无需关心。
痛点之二是性能调优的心理负担。什么memo、useCallback、selector都成了过去式。Signal把缓存、依赖追踪、脏值检查放入运行时,开发者不需要手动标记依赖关系。在一个模块里直接使用一个signal的get,就可以读写到state和action里,共享通信非常自然。
Signal带来的另一大变化是“框架无关”。你可以先写一个signal.ts,不去依赖Vue或React上下文。随后在Vue组件里,通过effectScope包裹使用;在React里,通过useSyncExternalStore接入。状态核心逻辑就能跨框架复用。这在单体仓库中尤其有价值。
最近像Angular也推出了基于signal的响应式API,Preact Signals库已经能独立于Preact运行。这就说明单纯的signal机制成为一个稳定的可复用状态模型,完全成为独立工具。对我来说,它最大的吸引力是“不需要那么多元件层级来传播变更”。
我预测Signal会在后续的JS标准或库生态里占据更核心的位置,而在那之前,把这几十行原理看懂,怎么都不亏。
边界话题:Signal与Immutable哪个更优雅
如果之前的代码看得仔细,应该能发现基础的Signal结构虽然灵活,但缺少结构化数据的细粒度追踪。比如一个对象里有几十个属性,改动其中一个属性会触发所有读取该对象任意属性的effect。这在原生实现里很难优化。
要解决这问题,有两个方向。一是采用更细粒度的signal,每个属性都拆成一个信号。二是采用不可变数据,每次set时生成新对象,利用“对象引用变化”来更新所有相关effect。但用不可变数据也有损耗,因为计算diff或者整个组件的重跑不可避免。
在当前最前沿的实现中,很多库做“自动跟踪属性粒度”的响应式。比如把getter function包在React.memo外,或者使用代理进行属性级追踪。但这样会让代码体积变大,复杂度也更高。大部分真实场景下的信号粒度已经足够满足性能。
如果你关心更细的粒度,可以尝试“map of signals”设计:
const state = {
users: createSignal([]),
current: createSignal(null)
}
这并不复杂,而且符合直觉。
顺带聊聊effect里的异步陷阱
例如,如果effect内部读取一个信号,然后立刻用setTimeout回调再读一次信号,那回调里读取与effect依赖无关联。因为setTimeout执行时,activeEffect已经为null,等执行到回调时,当前线程的effect栈肯定为空。换句话说,延迟中读取的信号不会被视为依赖。这个设计是好事,否则异步循环会导致依赖爆炸。
然而很多新手以为写个effect然后里面随手fetch就能实现响应式,实际上是做不到。必须在fetch过程中,把需要的状态先提取出来,再在回调中调用set。可能他们会觉得“为什么不更新了”,这其实是正常现象,不是bug。要理解执行时机。
如果希望在异步回调里重新订阅,那就需要手动调用effect所属的依赖收集函数。但那样容易搞出死循环,基本不建议。
反之,有一个技巧很好用:在effect里监听数据变化,然后节流更新。比如effect中读取信号,但不直接在effect里做开销大的操作,而是设一个debounce定时器。这种情况下,effect自身的重复开发不会取消上一次的定时器,所以需要手动处理。很容易造成副作用错乱。可以配合清理函数实现取消之前的未完成任务。
function createEffectWithCleanup(fn) {
let cleanup;
const dispose = createEffect(() => {
if (typeof cleanup === 'function') cleanup();
cleanup = fn();
});
return dispose;
}
// 用法:
createEffectWithCleanup(() => {
const data = state.apiData();
const timer = setTimeout(() => console.log('debounced', data), 500);
return () => clearTimeout(timer);
});
这样即使信号频繁变,上一次的timer会被清除,只会执行最后一次。这是不是比在useEffect里手动写依赖数组要舒服得多?
真正的微型Signal库,全部代码集合
现在把所有核心代码整合一下,组成一个完整且健壮的小型库,约120行(不计注释)。它对大多数demo、工具库和简单应用都足够用。
let activeEffect = null;
let batchDepth = 0;
const batchUpdateQueue = new Set();
function scheduleEffect(effect) {
if (batchDepth > 0) {
batchUpdateQueue.add(effect);
} else {
queueMicrotask(() => {
if (batchUpdateQueue.delete(effect)) {
effect();
}
});
}
}
function batch(fn) {
batchDepth++;
try {
fn();
} finally {
batchDepth--;
if (batchDepth === 0) {
batchUpdateQueue.forEach(effect => effect());
batchUpdateQueue.clear();
}
}
}
function createSignal(initialValue) {
let value = initialValue;
const subscriptions = new Set();
const get = () => {
if (activeEffect !== null) {
subscriptions.add(activeEffect);
activeEffect.depSources.add(subscriptions);
}
return value;
};
const set = (next) => {
if (Object.is(value, next)) return;
value = next;
const effectsToRun = [...subscriptions];
effectsToRun.forEach(effect => scheduleEffect(effect));
};
return [get, set];
}
function cleanupEffect(effect) {
effect.depSources.forEach(c => c.delete(effect));
effect.depSources.clear();
}
function createEffect(fn) {
const effect = () => {
cleanupEffect(effect);
const prev = activeEffect;
activeEffect = effect;
try {
fn();
} finally {
activeEffect = prev;
}
};
effect.depSources = new Set();
effect();
const dispose = () => {
cleanupEffect(effect);
};
return dispose;
}
function createComputed(fn) {
const [get, set] = createSignal(undefined);
let disposed = false;
const update = () => {
if (!disposed) set(fn());
};
const dispose = createEffect(update);
return Object.freeze({
get value() {
return get();
},
dispose() {
disposed = true;
dispose();
}
});
}
export { createSignal, createEffect, createComputed, batch };
这份代码里没有处理异常导致activeEffect回滚的情况,依赖清理也还算干净。在实际使用中,如果想兼容旧浏览器,把queueMicrotask换成Promise.resolve().then即可。
在真实项目中使用这个小库
你可以直接把这个库和DOM操作结合,做一个类似ToDoList。用signal保存todos数组,同时用一个computed来统计未完成数量。每次修改通过生成新数组代替push。触发更新时,由于计算函数返回新的数组,而存储signal的值被替换成新数组,所有依赖todos列表的effect都会被触发。
这里注意用新数组代替原地push,是为了保证触发signal的变化。否则数组内存地址没变,effect收不到变更。
let idSeq = 1;
const [todos, setTodos] = createSignal([
{ id: 0, title: '学信号', done: false },
{ id: 1, title: '手写实现', done: true }
]);
function addTodo(title) {
setTodos([...todos(), { id: idSeq++, title, done: false }]);
}
function toggleTodo(id) {
setTodos(todos().map(todo =>
todo.id === id ? { ...todo, done: !todo.done } : todo
));
}
渲染部分也可以复用effect。监听列表区域,每次todos变化时,重新生成innerHTML。
const listEl = document.getElementById('todo-list');
createEffect(() => {
const items = todos().map(todo => `
<li data-id="${todo.id}" class="${todo.done ? 'done' : ''}">
${todo.title}
<button data-id="${todo.id}">切换</button>
</li>
`).join('');
listEl.innerHTML = `<ul>${items}</ul>`;
});
但每次重建整个DOM会影响事件监听和输入框聚焦,效率不高。可以使用document.createDocumentFragment来优化,或者只更新变化部分。这里为了方便演示就直接重绘,但真实项目应该用keyed diff。
点击和切换事件用事件委托绑定在listEl上,这样即使重绘后的事件监听还在。
listEl.addEventListener('click', (e) => {
const button = e.target.closest('button');
if (!button) return;
const id = Number(button.dataset.id);
toggleTodo(id);
});
这样一个小应用已经拥有完整的dependency tracking和自动更新能力,所有代码都是纯原生。没有加style,但可运行。
怎么理解signal背后的心智模型
不用把它看得太高级。想象你的数据流是一条有方向的管道,每个signal节点维护着一批依赖它的effect和computed节点。数据一变,就会顺着这条管道自动流向末端UI。你的代码不再是指令式地“把这个值赋给那个DOM”,而是声明式地表达“这个节点的内容等于某个signal取反”之类的关系。
由于管道方向是固定的,你不需要去手动“通知”某个节点更新。这就是signal消除大量样板代码的原因。
但也别以为它万能。signal不适合存储超大集合并频繁做全量更新,因为创建订阅关系本身也有成本。前端应用里,只要把它应用到组件粒度的状态管理上,效果就很显著。
最后说一下依赖循环的危险:当computed依赖自己,比如一个computed读取自身的value,会造成无限循环。在最终的实现代码中,监测这种循环很难,只能靠开发者自律。在一些框架里会有明确的警告,我们的minilibrary则没有。
如果要用这个思路构建大型应用,需要实现内存泄漏检测和动态依赖图的调度策略。真正的框架代码里,会为每个计算加入effect版本号、位掩码等更复杂的数据结构,避免使用Set的开销。但核心思路和今天写的完全一致。
总结性废话我们不说了
本文试图用不到两百行代码说明新型javascript响应式系统的底层机制。从createSignal、effect依赖收集,到computed和batch更新,再到真实demo。没有什么神秘黑魔法,只要你愿意去打开浏览器的控制台调试,看订阅关系怎么建立、怎么清除,就会很快掌握它。
想在自己的下一个开源库或小工具里使用,完全可以在这份代码的基础上扩展。如果想深入框架级别,也可以去读Preact/Solid的signal源码,一定会在里面看到眼熟的结构。

