前阵子接了个老项目,技术栈还停在 jQuery + 模板字符串。为了在几个页面之间同步一个全局弹窗状态,我在原生 JS 里写了个很小的响应式模块。写完发现,这个模块和现在前端圈子里常说的 Signals 是一路货色。后来我把这玩意儿不断简化,最后只剩一百多行。
这篇文章就把这个过程完整拆给你看,不借助任何库,用原生 JS 实现一个能用的 Signals 系统。
先把 API 定下来
我需要的不是完整框架,而是三个函数:
signal(value):创建信号,返回一个读函数和一个写函数。computed(fn):根据已有信号生成派生值,自动更新。effect(fn):声明副作用,当函数里读过的信号变化时,自动重跑。
期望的使用方式长这样:
const count = signal(1);
const double = computed(() => count() * 2);
effect(() => console.log(count(), double()));
count(2);
// 2 4
signal 的核心逻辑
每个信号内部只维护一个订阅集合 subs。读信号时把当前正在运行的 effect 加进集合,写信号时把集合里的 effect 挨个跑一遍。
function signal(value) {
const subs = new Set();
function read() {
if (activeEffect) {
if (!subs.has(activeEffect)) {
subs.add(activeEffect);
activeEffect.deps.push(subs);
}
}
return value;
}
function write(next) {
if (Object.is(value, next)) return;
value = next;
for (const sub of [...subs]) {
sub();
}
}
return [read, write];
}
这里有个细节:同一个 effect 读同一个信号很多次,不应该在 deps 里重复记录,所以我加了 subs.has(activeEffect) 的判断。
effect 要处理依赖清理
上文里的 activeEffect 是“当前正在执行的 effect”。effect 函数接收一个回调,先把它立即跑一次,在执行之前把全局变量指向自己:
let activeEffect = null;
function effect(fn) {
const run = () => {
if (run.deps) {
for (const dep of run.deps) {
dep.delete(run);
}
run.deps.length = 0;
}
const prev = activeEffect;
activeEffect = run;
try {
fn();
} finally {
activeEffect = prev;
}
};
run.deps = [];
run();
}
为什么要清理依赖?看这个例子:
const isAdmin = signal(false);
const name = signal('小张');
effect(() => {
if (isAdmin()) {
console.log(name());
}
});
isAdmin(true);
name('老李');
当 isAdmin 为 false 时,effect 根本没读 name。等 isAdmin 变成 true 后才开始读 name。如果不清理旧依赖,反过来的场景会出问题:第一次读了 name,第二次因为条件变化不再读 name,那 name 变化时这个 effect 还是会白白跑一次。所以每次重跑前清空上一次的订阅关系最稳妥。
顺手提一句:prev 保存了外层 effect,而不是直接重置为 null,是为了支持嵌套 effect。computed 内部会再次调用 effect,这时候内层跑完必须恢复外层的上下文。
computed 最省事的写法
直接拿 signal 和 effect 拼出来:
function computed(fn) {
const [read, set] = signal();
effect(() => {
set(fn());
});
return read;
}
这段代码初看有点绕,因为它在 effect 里调 set。实际上,computed 内部挂了一个 effect,effect 读到的所有信号都会成为它的依赖;依赖变化时,effect 重跑并调用 set(fn()),用新值更新内部信号。由于 signal 的 write 里有 Object.is 判断,计算结果和上次一样时,不会触发外层的订阅者。
这是一种“积极求值”的 computed,跟 Solid 那种“惰性计算”不太一样。真实框架里为了性能普遍会做惰性求值,这里为了教学先用最简单的实现。
让多次触发合并成一次
目前写信号是同步触发所有 effect,连续改三个信号,effect 就重跑三次。多数时候没必要,所以加一个微任务批处理。
let queued = false;
const queue = new Set();
function flush() {
const runs = [...queue];
queue.clear();
queued = false;
for (const run of runs) run();
}
function schedule(run) {
queue.add(run);
if (queued) return;
queued = true;
Promise.resolve().then(flush);
}
然后把 signal.write 里调用订阅者的那句换成:
for (const sub of [...subs]) {
schedule(sub);
}
queue 是 Set,同一个 effect 被触发几十次也只会在队列里出现一次。Promise 的微任务机制保证当前同步代码跑完后统一执行。
注意:批处理只影响副作用的重跑时机,不影响读值。同步写完信号后立刻读,拿到的就是新值。
一个能跑的 todo 案例
下面这个文件可以直接保存成 html 打开。它包含一个待办列表和完成数量统计,全部基于前面实现的 Signals:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>Signals 待办演示</title>
</head>
<body>
<input id="input" placeholder="随手记下要做的事" />
<button id="add">添加</button>
<ul id="list"></ul>
<p id="summary"></p>
<script>
function signal(value) {
const subs = new Set();
function read() {
if (activeEffect) {
if (!subs.has(activeEffect)) {
subs.add(activeEffect);
activeEffect.deps.push(subs);
}
}
return value;
}
function write(next) {
if (Object.is(value, next)) return;
value = next;
for (const sub of [...subs]) schedule(sub);
}
return [read, write];
}
let activeEffect = null;
function effect(fn) {
const run = () => {
if (run.deps) {
for (const dep of run.deps) dep.delete(run);
run.deps.length = 0;
}
const prev = activeEffect;
activeEffect = run;
try {
fn();
} finally {
activeEffect = prev;
}
};
run.deps = [];
run();
}
function computed(fn) {
const [read, set] = signal();
effect(() => set(fn()));
return read;
}
let queued = false;
const queue = new Set();
function flush() {
const runs = [...queue];
queue.clear();
queued = false;
for (const run of runs) run();
}
function schedule(run) {
queue.add(run);
if (queued) return;
queued = true;
Promise.resolve().then(flush);
}
const [todos, setTodos] = signal([]);
const doneCount = computed(() => todos().filter((t) => t.done).length);
const input = document.getElementById('input');
const addBtn = document.getElementById('add');
addBtn.addEventListener('click', () => {
const text = input.value.trim();
if (!text) return;
setTodos([
...todos(),
{ id: Date.now(), text, done: false }
]);
input.value = '';
});
effect(() => {
const list = document.getElementById('list');
list.innerHTML = '';
for (const todo of todos()) {
const li = document.createElement('li');
li.textContent = todo.text;
const toggle = document.createElement('input');
toggle.type = 'checkbox';
toggle.checked = todo.done;
toggle.onchange = () => {
setTodos(
todos().map((t) =>
t.id === todo.id ? { ...t, done: toggle.checked } : t
)
);
};
li.prepend(toggle);
list.appendChild(li);
}
});
effect(() => {
const total = todos().length;
document.getElementById('summary').textContent =
'共 ' + total + ' 条,已完成 ' + doneCount() + ' 条';
});
</script>
</body>
</html>
上面代码里用了 innerHTML = '',很多人会担心事件丢失。这里不会,因为每个待办项的事件是在每次重跑时绑到新建的 DOM 节点上的。
它离生产级还差什么
这个迷你系统能用来做小页面实验,但跟工程级框架比,至少差三件事:
- computed 不是惰性求值。真实实现会用脏标记和版本号,只有在读取时才重新计算。
- effect 没有销毁接口。组件卸载后应该停止订阅,否则容易内存泄漏。
- 缺少异常处理和异步取消机制。真实框架对 effect 出错后的恢复有更精细的逻辑。
写在最后
把这个迷你系统写完,你会发现 Vue 的 ref、Solid 的 createSignal、甚至 Angular 的新型响应式,内里都是同一个模型:读时记录依赖,写时触发依赖。框架真正复杂的是缓存、调度、类型推导、边界回收这些“收尾”工作。
如果你也想动手验证一下自己的理解,我给一个小任务:给这个 todo 加上一个“清除已完成”按钮。改完你基本就掌握这套体系了。

