如果你最近关注前端圈子的动态,大概率绕不开一个词——Signals。Solid.js用它、Preact用它、Vue 3的ref本质上也是它、Svelte 5全面转向它、Angular团队也在积极整合它。更值得关注的是,TC39(负责JavaScript语言标准的委员会)已经在2024年正式接纳了Signals的标准化提案,也就是说,在不久的将来,Signals会成为JavaScript语言本身的一部分,就像Promise、Proxy那样直接内建在浏览器里。
这件事的意义不小。一直以来,响应式状态管理都是框架层面的”私产”,每个框架都有自己的实现方式和心智模型。而Signals的标准化意味着,未来无论你用哪个框架——甚至不用框架——都能用同一套原语来管理响应式状态。
这篇文章会带你从零开始,用纯JavaScript实现一个完整的Signals系统。我们会先搞清楚它的核心机制,然后逐步实现createSignal、computed和effect,最后用这套自己写的Signals构建一个功能齐全的Todo应用。读完你应该能对Signals的底层运作方式有一个相当清晰的认识。
先搞清楚:Signals到底解决了什么问题?
如果你写过React,一定对useState和useEffect不陌生。状态变了,组件重新渲染,这是React的心智模型。但这个模型有个特点:它是以组件为单位进行更新的。状态变化时,整个组件函数都会重新执行一遍,然后通过虚拟DOM diff来决定哪些DOM需要实际更新。
Signals走的是另一条路——细粒度响应式更新。一个信号(Signal)知道谁在读取它、谁在订阅它。当信号的值发生变化时,它只通知那些真正依赖它的订阅者,不会触发大范围的重新计算。这种机制在性能敏感的場景中优势非常明显。
用更直白的话说:React的模式是”状态变了,我把整个组件重新跑一遍,然后对比看看哪里需要改”;Signals的模式是”状态变了,我直接找到依赖这个状态的那几个具体的地方,精准更新”。两者的取舍不同,但在很多场景下,Signals的方式更加高效和直观。
我们先来看一个最简单的Signals使用示例,感受一下它的API长什么样:
// 创建一个信号,初始值为0
const [count, setCount] = createSignal(0);
// 创建一个派生信号,它的值依赖于count
const doubled = computed(() => count() * 2);
// 创建一个副作用,当依赖变化时自动执行
effect(() => {
console.log(`count: ${count()}, doubled: ${doubled()}`);
});
// 修改信号的值
setCount(5); // 输出: count: 5, doubled: 10
setCount(10); // 输出: count: 10, doubled: 20
API非常简洁。但在这简洁的表面之下,藏着几个关键问题需要回答:信号是如何追踪依赖的?computed怎么知道它依赖了count?effect又是如何在依赖变化时重新执行的?下面我们就来逐一拆解。
第一步:实现最基础的Signal
先不考虑依赖追踪,我们来实现一个最简单的信号——它有一个getter函数读取值,一个setter函数设置值并通知订阅者。
function createSignal(initialValue) {
let value = initialValue;
const subscribers = new Set();
const getter = () => {
// 这里暂时留空,后面会在这里做依赖收集
return value;
};
const setter = (newValue) => {
if (value === newValue) return; // 值没变就不触发更新
value = newValue;
// 通知所有订阅者
subscribers.forEach(fn => fn());
};
return [getter, setter];
}
这个版本很简单:subscribers是一个Set,存储了所有订阅这个信号的函数。每次调用setter时,遍历所有订阅者并执行它们。
但这里有个明显的缺陷:我们还没有任何机制来自动收集依赖。目前subscribers始终是空的,因为没有任何地方向其中添加订阅者。这就引出了Signals系统中最核心的概念——依赖追踪。
第二步:依赖追踪——Signals的灵魂
依赖追踪的核心思路是:在执行一个函数(比如effect的回调或computed的计算函数)期间,如果它读取了某个信号的值,就把这个函数记录为该信号的订阅者。
要实现这一点,我们需要一个全局变量来标记”当前正在执行的是哪个函数”。在信号被读取时,检查这个全局变量,如果存在,就把当前函数加入到信号的订阅者集合中。
这个全局变量通常被命名为activeSubscriber或currentEffect。我们来实现它:
// 全局变量,指向当前正在执行的effect或computed回调
let currentSubscriber = null;
function createSignal(initialValue) {
let value = initialValue;
const subscribers = new Set();
const getter = () => {
// 关键:如果当前有正在执行的订阅者函数,就把它收集起来
if (currentSubscriber !== null) {
subscribers.add(currentSubscriber);
}
return value;
};
const setter = (newValue) => {
if (value === newValue) return;
value = newValue;
// 创建副本再遍历,避免在遍历过程中subscribers被修改导致的问题
const subs = [...subscribers];
subs.forEach(fn => fn());
};
return [getter, setter];
}
现在,当某个函数正在执行期间读取了信号,这个函数就会被自动添加到信号的订阅者集合中。接下来我们需要一个effect函数来利用这个机制。
function effect(fn) {
const wrappedEffect = () => {
// 将当前effect设为全局的currentSubscriber
currentSubscriber = wrappedEffect;
try {
fn(); // 执行effect回调,期间读取的任何信号都会收集wrappedEffect
} finally {
// 执行完毕后重置,避免影响外部代码
currentSubscriber = null;
}
};
// 立即执行一次,完成初始的依赖收集
wrappedEffect();
}
来测试一下这个版本:
const [name, setName] = createSignal('张三');
const [age, setAge] = createSignal(25);
effect(() => {
console.log(`${name()}今年${age()}岁`);
});
// 输出: 张三今年25岁
setName('李四');
// 输出: 李四今年25岁
setAge(30);
// 输出: 李四今年30岁
这就work了!effect在执行回调时,把自己设为currentSubscriber,回调中读取name()和age()时,两个信号都把wrappedEffect收集到了自己的subscribers中。之后任意一个信号变化,都会重新执行effect。
不过这里有一个细节值得注意:每次effect重新执行时,依赖关系可能会发生变化。比如effect的回调里有个条件分支,第一次执行时读取了信号A,第二次因为条件变了可能读取的是信号B。如果不清除旧的依赖关系,信号A变化时effect还会被触发,即使它已经不再依赖A了。
要解决这个问题,需要在每次effect执行前清空它之前的所有订阅关系,然后重新收集。我们来改进一下:
function effect(fn) {
const wrappedEffect = () => {
// 在执行前,先从所有信号的订阅者列表中移除自己
cleanupEffect(wrappedEffect);
currentSubscriber = wrappedEffect;
try {
fn();
} finally {
currentSubscriber = null;
}
};
// 用于存储这个effect订阅了哪些信号,方便清理
wrappedEffect._dependencies = [];
wrappedEffect();
}
function cleanupEffect(effectFn) {
// 遍历effect订阅过的所有信号,从它们的subscribers中移除自己
effectFn._dependencies.forEach((signalSubscribers) => {
signalSubscribers.delete(effectFn);
});
effectFn._dependencies = [];
}
相应的,在createSignal的getter中,收集依赖的同时也要记录这个双向关系:
const getter = () => {
if (currentSubscriber !== null) {
subscribers.add(currentSubscriber);
// 记录:这个effect订阅了当前信号
currentSubscriber._dependencies.push(subscribers);
}
return value;
};
这样每次effect重新执行前,会先从所有之前订阅过的信号中注销自己,然后通过新一轮的执行重新建立依赖关系。这个机制保证了依赖关系始终是准确的。
第三步:实现computed——派生信号
computed本质上是一个只读信号,它的值由一个计算函数派生出来。当计算函数中依赖的信号发生变化时,computed的值也会自动更新。
一个直接的想法是:把computed实现为一个内部使用effect的信号。当依赖变化时,effect重新执行计算函数,更新computed的值,然后通知computed的订阅者。
但这里有个性能问题需要考虑:如果计算函数中依赖的信号连续多次变化,computed应该只在”最终值”发生变化时才通知它的订阅者。否则会造成不必要的更新传递。
另外还有一个重要的优化点——惰性求值。如果一个computed没有被任何人读取,它的计算函数就不应该执行。同样,如果computed的依赖变了但没有人读取它,也没必要立即重新计算。计算应该推迟到有人实际读取computed值的时候。
下面是computed的实现:
function computed(fn) {
let cachedValue;
let isDirty = true; // 标记缓存是否失效
const [getter, setter] = createSignal(undefined);
// 使用effect来追踪fn中依赖的信号
effect(() => {
if (isDirty) {
// 缓存失效,需要重新计算
const newValue = fn();
setter(newValue);
cachedValue = newValue;
isDirty = false;
} else {
// 缓存有效,但依赖变了,标记为脏
// 注意:这里不立即计算,等到有人读取时才计算(惰性求值)
isDirty = true;
}
});
// computed返回的是一个getter
return () => {
if (isDirty) {
const newValue = fn();
setter(newValue);
cachedValue = newValue;
isDirty = false;
}
return getter();
};
}
等等,上面的实现有个逻辑问题。effect在依赖变化时会执行回调,但我们的回调逻辑在isDirty为true和false时的行为不一致。让我重新梳理一下。
实际上,computed的工作流程应该是这样的:
- 第一次读取computed时,执行计算函数,缓存结果,并建立对依赖信号的订阅。
- 当依赖信号变化时,标记缓存为脏(dirty),并通知computed的订阅者。
- 下次读取computed时,如果缓存是脏的,重新计算并更新缓存。
改进后的实现:
function computed(fn) {
let cachedValue;
let isDirty = true;
const subscribers = new Set();
// 内部effect用于追踪fn中的依赖,并在依赖变化时标记dirty
const markDirty = () => {
if (!isDirty) {
isDirty = true;
// 通知computed的订阅者:值可能变了
const subs = [...subscribers];
subs.forEach(s => s());
}
};
// 用一个内部effect来追踪fn的依赖
const trackEffect = () => {
currentSubscriber = trackEffect;
try {
// 这里只是读取fn中的信号来建立依赖追踪
// 实际计算在getter中按需进行
fn();
} finally {
currentSubscriber = null;
}
};
// 重写trackEffect的行为,使其在依赖变化时调用markDirty
const originalCleanup = cleanupEffect;
trackEffect._dependencies = [];
trackEffect._onInvalidate = markDirty;
// 手动执行首次依赖收集
trackEffect();
const getter = () => {
// 收集computed的订阅者
if (currentSubscriber !== null) {
subscribers.add(currentSubscriber);
currentSubscriber._dependencies.push(subscribers);
}
if (isDirty) {
cachedValue = fn();
isDirty = false;
}
return cachedValue;
};
return getter;
}
老实说,上面的实现开始变得有些复杂了。核心问题在于,我们需要在”追踪fn的依赖”和”实际执行fn获取值”之间做一个分离。一个更简洁的做法是直接复用effect机制,让effect在依赖变化时标记dirty并通知订阅者。让我们重构一下:
function computed(fn) {
let cachedValue;
let isDirty = true;
const subscribers = new Set();
// 通知computed的所有订阅者
const notifySubscribers = () => {
const subs = [...subscribers];
subs.forEach(s => s());
};
// 使用effect追踪fn中的依赖
// 当依赖变化时,标记dirty并通知computed的订阅者
effect(() => {
// 读取fn中依赖的信号(建立追踪)
const newValue = fn();
if (isDirty) {
// 首次执行或dirty状态下,更新缓存
cachedValue = newValue;
isDirty = false;
} else {
// 依赖变化了,标记dirty
isDirty = true;
notifySubscribers();
}
});
return () => {
if (currentSubscriber !== null) {
subscribers.add(currentSubscriber);
currentSubscriber._dependencies.push(subscribers);
}
if (isDirty) {
cachedValue = fn();
isDirty = false;
}
return cachedValue;
};
}
这个版本逻辑上清晰了一些,但effect的首次执行和后续依赖变化时的执行混在了一起,判断逻辑还是有些别扭。实际上在工业级的Signals实现中(比如Solid.js),computed的处理会更加精细。不过对我们理解原理来说,这个版本已经足够说明问题了。
核心要点就两个:第一,computed通过内部effect来追踪计算函数依赖了哪些信号;第二,computed本身也是一个信号,可以被其他effect或computed订阅,从而形成依赖链。
第四步:处理嵌套effect和防止循环
在实际使用中,effect可能会嵌套——比如在一个effect中读取了computed,而computed内部又依赖了其他信号。更复杂的情况是,effect中修改信号,信号的变化又触发同一个effect,形成无限循环。
对于嵌套effect,我们的currentSubscriber需要能够处理嵌套的情况。当一个effect内部又创建或触发了另一个effect时,我们需要保存和恢复currentSubscriber的上下文。用栈来解决这个问题是最直接的:
const subscriberStack = [];
function getCurrentSubscriber() {
return subscriberStack.length > 0
? subscriberStack[subscriberStack.length - 1]
: null;
}
function pushSubscriber(sub) {
subscriberStack.push(sub);
}
function popSubscriber() {
subscriberStack.pop();
}
然后把createSignal中的currentSubscriber替换为getCurrentSubscriber(),effect中的赋值和清理也相应地使用pushSubscriber和popSubscriber。这样就安全地处理了嵌套场景。
对于循环更新问题,一个简单的防护措施是设置一个最大迭代次数,超过阈值就抛出错误。在生产级的Signals实现中,通常会有更完善的调度机制来避免这类问题,但作为学习项目,了解这个边界情况就够了。
第五步:完整的Todo应用案例
理论讲了不少,现在把上面的代码整合起来,用它来构建一个完整的Todo应用。这个应用会包含以下功能:
- 添加新的待办事项
- 标记事项为完成/未完成
- 删除事项
- 统计总数、已完成数和未完成数(用computed实现)
- 按状态过滤(全部、进行中、已完成)
- 清空已完成的事项
下面就是整合了Signals实现和Todo应用逻辑的完整代码。你可以把它保存为一个HTML文件直接在浏览器中打开运行。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Signals Todo App</title>
</head>
<body>
<div id="app"></div>
<script>
// ========== Signals 核心实现 ==========
const subscriberStack = [];
function getCurrentSubscriber() {
return subscriberStack.length > 0
? subscriberStack[subscriberStack.length - 1]
: null;
}
function pushSubscriber(sub) {
subscriberStack.push(sub);
}
function popSubscriber() {
subscriberStack.pop();
}
function cleanupEffect(effectFn) {
if (effectFn._dependencies) {
effectFn._dependencies.forEach((depSet) => {
depSet.delete(effectFn);
});
effectFn._dependencies = [];
}
}
function createSignal(initialValue) {
let value = initialValue;
const subscribers = new Set();
const getter = () => {
const current = getCurrentSubscriber();
if (current !== null) {
subscribers.add(current);
if (!current._dependencies) {
current._dependencies = [];
}
current._dependencies.push(subscribers);
}
return value;
};
const setter = (newValue) => {
if (value === newValue) return;
value = newValue;
const subs = [...subscribers];
subs.forEach(fn => fn());
};
return [getter, setter];
}
function effect(fn) {
const wrappedEffect = () => {
cleanupEffect(wrappedEffect);
pushSubscriber(wrappedEffect);
try {
fn();
} finally {
popSubscriber();
}
};
wrappedEffect._dependencies = [];
// 首次执行,建立依赖追踪
wrappedEffect();
return wrappedEffect;
}
function computed(fn) {
const [getter, setter] = createSignal();
let isDirty = true;
let cachedValue;
effect(() => {
const newValue = fn();
if (isDirty) {
cachedValue = newValue;
isDirty = false;
setter(newValue);
} else {
isDirty = true;
}
});
return () => {
const current = getCurrentSubscriber();
// 读取底层signal时会自动收集依赖
const val = getter();
if (isDirty) {
cachedValue = fn();
isDirty = false;
setter(cachedValue);
return cachedValue;
}
return val;
};
}
// ========== Todo 应用逻辑 ==========
// 状态信号
const [todos, setTodos] = createSignal([]);
const [filter, setFilter] = createSignal('all'); // 'all' | 'active' | 'completed'
// 自增ID
let nextId = 1;
// 派生状态:过滤后的todos
const filteredTodos = computed(() => {
const list = todos();
const currentFilter = filter();
if (currentFilter === 'active') {
return list.filter(t => !t.completed);
}
if (currentFilter === 'completed') {
return list.filter(t => t.completed);
}
return list;
});
// 派生状态:统计数据
const totalCount = computed(() => todos().length);
const activeCount = computed(() => todos().filter(t => !t.completed).length);
const completedCount = computed(() => todos().filter(t => t.completed).length);
// 操作函数
function addTodo(text) {
if (!text.trim()) return;
const newTodo = {
id: nextId++,
text: text.trim(),
completed: false
};
setTodos([...todos(), newTodo]);
}
function toggleTodo(id) {
const updated = todos().map(t =>
t.id === id ? { ...t, completed: !t.completed } : t
);
setTodos(updated);
}
function deleteTodo(id) {
setTodos(todos().filter(t => t.id !== id));
}
function clearCompleted() {
setTodos(todos().filter(t => !t.completed));
}
// ========== DOM 渲染 ==========
const appEl = document.getElementById('app');
function render() {
const list = filteredTodos();
const total = totalCount();
const active = activeCount();
const completed = completedCount();
const currentFilter = filter();
let html = '';
// 标题
html += '<h1>📋 待办事项</h1>';
// 输入区域
html += '<div>';
html += '<input id="todo-input" type="text" placeholder="输入新的待办事项..." />';
html += '<button id="add-btn">添加</button>';
html += '</div>';
// 过滤按钮
html += '<div style="margin:12px 0;">';
html += `<button id="filter-all">全部(${total})</button> `;
html += `<button id="filter-active">进行中(${active})</button> `;
html += `<button id="filter-completed">已完成(${completed})</button> `;
html += `<button id="clear-completed">清空已完成</button>`;
html += '</div>';
// 列表
if (list.length === 0) {
html += '<p>暂无待办事项</p>';
} else {
html += '<ul>';
list.forEach(t => {
const style = t.completed ? 'text-decoration:line-through;color:#888;' : '';
html += `<li style="margin:8px 0;${style}">`;
html += `<input type="checkbox" class="toggle-todo" data-id="${t.id}" ${t.completed ? 'checked' : ''} /> `;
html += `${t.text} `;
html += `<button class="delete-todo" data-id="${t.id}">删除</button>`;
html += '</li>';
});
html += '</ul>';
}
// 统计信息
html += '<hr>';
html += `<p>总计: ${total} | 进行中: ${active} | 已完成: ${completed}</p>`;
appEl.innerHTML = html;
// 绑定事件
document.getElementById('add-btn').onclick = () => {
const input = document.getElementById('todo-input');
addTodo(input.value);
input.value = '';
input.focus();
};
document.getElementById('todo-input').onkeydown = (e) => {
if (e.key === 'Enter') {
document.getElementById('add-btn').click();
}
};
document.getElementById('filter-all').onclick = () => setFilter('all');
document.getElementById('filter-active').onclick = () => setFilter('active');
document.getElementById('filter-completed').onclick = () => setFilter('completed');
document.getElementById('clear-completed').onclick = () => clearCompleted();
document.querySelectorAll('.toggle-todo').forEach(checkbox => {
checkbox.onchange = (e) => {
toggleTodo(Number(e.target.dataset.id));
};
});
document.querySelectorAll('.delete-todo').forEach(btn => {
btn.onclick = (e) => {
deleteTodo(Number(e.target.dataset.id));
};
});
}
// 使用effect将渲染函数与状态关联
// render内部读取的所有信号变化时,都会触发重新渲染
effect(() => {
// 读取所有用到的信号,建立依赖追踪
filteredTodos();
totalCount();
activeCount();
completedCount();
filter();
// 执行渲染
render();
});
// 初始渲染
render();
// 添加一些初始数据便于体验
addTodo('学习JavaScript Signals的原理');
addTodo('阅读TC39 Signals提案');
addTodo('用Signals重构个人项目');
</script>
</body>
</html>
上面的代码可以直接保存运行。我们来梳理一下其中Signals的工作流程:
todos和filter是两个基础信号。filteredTodos、totalCount、activeCount、completedCount都是派生信号,它们内部依赖了todos或filter。render函数在effect中执行,effect自动追踪了所有被读取的信号(包括基础信号和派生信号)。- 当用户添加、删除或切换todo时,
todos信号更新,先触发派生信号重新计算,再触发effect重新渲染DOM。 - 当用户切换过滤条件时,
filter信号更新,filteredTodos重新计算,effect重新渲染。
整个数据流是单向且自动的——你只需要修改信号,UI会自动跟上。不需要手动调用渲染函数,不需要管理订阅和取消订阅,框架层(在这里就是我们自己实现的Signals)自动处理了这一切。
关于TC39标准化:这意味着什么?
TC39的Signals提案目前处于Stage 1阶段(截至2024年底),目标是定义一个标准的Signal API,让浏览器原生提供信号原语。提案的核心API大致如下:
// 未来的JavaScript标准(提案中,API可能变化)
const signal = new Signal.State(0);
signal.get(); // 读取值
signal.set(5); // 设置值
const doubled = new Signal.Computed(() => signal.get() * 2);
Signal.subtle.watch(() => {
console.log(doubled.get());
});
如果这个提案最终落地,意味着:
- 框架无关的响应式原语:不同框架可以共享同一套底层信号机制,状态可以在框架之间无缝传递。
- 更好的性能:浏览器可以针对信号做底层优化,这是框架层面无法做到的。
- 更低的學習成本:学会Signals后,这个概念可以跨框架复用,不需要每换一个框架就重新学习一套状态管理方案。
当然,标准化之路还很长。提案需要经过多个阶段的讨论和完善,最终落地可能还需要两三年时间。但方向是明确的——响应式编程正在从框架特性演变为语言特性,这对整个前端生态的影响会是深远的。
实际项目中的权衡
写完这套实现并在Todo应用中用起来之后,有几点感受值得分享:
第一,依赖追踪的粒度决定了更新效率。在我们的实现中,effect在重新执行时会先清理所有旧依赖再重新收集。这意味着每次effect执行都有一定的开销。在生产级的Signals实现中(比如Solid.js),依赖追踪的实现要复杂得多,包括对条件分支中依赖变化的精细处理、对嵌套effect的高效调度等。
第二,Signals并不是要取代React或Vue。它更适合作为底层原语,被框架集成。React团队也在探索将Signals集成到React中(通过useSignal等实验性API),但他们面临的最大挑战是如何让Signals的细粒度更新与React的组件模型和谐共处。
第三,对于小型项目或特定场景,用我们自己实现的轻量Signals就完全够用了。上面的代码总共不到200行,但已经能处理相当复杂的状态管理需求。如果不需要虚拟DOM、不需要组件生命周期,纯Signals + 直接DOM操作可以是一个非常轻量和高效的方案。

