在过去的几年里,一种名为“信号”(Signals)的响应式编程模式悄然席卷了前端框架领域。从 Solid.js 的高效渲染,到 Preact 最近引入的 Signals 集成,再到 Angular 的深度采用,信号正在成为组件状态管理的新标准。但信号的魔力并不局限于框架内部——它本质上是一种纯粹的 JavaScript 设计模式,完全可以在无框架环境下运行。本文将带你从零开始,亲手实现一个功能完备的信号库,并借助多个实际案例展示其如何化简 UI 状态同步、副作用管理以及复杂数据流的处理。
一、理解信号:响应式编程的最小单元
在传统的前端开发中,状态变化通常需要通过事件回调、命令式赋值或不可变数据更新来触发界面重绘。而信号提供了一种声明式的自动追踪机制:当某个值(信号)发生变化时,所有依赖它的计算逻辑和副作用都会自动重新执行。这种模式消除了手动订阅、取消订阅以及状态同步带来的模板代码。
一个信号系统通常包含三个核心概念:
- Signal(信号):可以被读取和写入的值容器,通常通过 getter/setter 函数暴露。
- Computed(派生信号):基于其他信号计算得出的只读值,当依赖变化时自动重新计算。
- Effect(副作用):根据信号变化自动执行的操作,例如更新 DOM、发送网络请求或输出日志。
与传统的发布/订阅模式不同,信号的追踪是自动的,且依赖关系在运行时通过读取操作动态收集,这大大减少了手动管理订阅的负担。下面我们将按照这个层次,从零构建一个轻量但完整的信号库。
二、从零搭建信号系统:核心算法实现
为了彻底理解信号的工作原理,我们直接编写一个名为 TinySignals 的迷你库。整个过程仅使用原生 JavaScript,不依赖任何第三方模块。
2.1 全局上下文与依赖收集栈
所有魔法都基于一个运行时的“当前执行上下文”。当某个副作用或派生信号正在计算时,我们将其推入一个全局栈,这样任何被访问的信号就知道谁在依赖它。
// 全局上下文栈:存储当前正在运行的 Effect 或 Computed
const contextStack = [];
// 获取当前正在执行的依赖上下文
function getCurrentContext() {
return contextStack[contextStack.length - 1] ?? null;
}
// 临时将一个上下文推入栈中并执行函数
function executeInContext(context, fn) {
contextStack.push(context);
try {
return fn();
} finally {
contextStack.pop();
}
}
2.2 实现基础 Signal(信号)
信号是一个封装了值的函数,调用它不传参时获取当前值,传入参数时设置新值并通知所有订阅者。这里我们使用闭包来保持私有状态。
function createSignal(initialValue) {
let value = initialValue;
const subscribers = new Set(); // 存储所有依赖此信号的上下文
function signal(newValue) {
if (arguments.length === 0) {
// 读取操作:将当前运行的上下文加入订阅者集合
const currentCtx = getCurrentContext();
if (currentCtx) {
subscribers.add(currentCtx);
}
return value;
} else {
// 写入操作:更新值并通知所有订阅者重新执行
if (value !== newValue) {
value = newValue;
// 使用副本遍历,防止订阅者在回调中修改集合
const subs = [...subscribers];
for (const ctx of subs) {
ctx.execute();
}
}
return value;
}
}
// 附加一个 peek 方法,用于不产生依赖的读取
signal.peek = () => value;
return signal;
}
这里的关键在于:当读取信号时,如果存在当前上下文,就自动将该上下文注册为信号的订阅者。这使得后续写入时可以准确通知所有依赖方更新。
2.3 实现 Computed(派生信号)
派生信号本身是一个只读信号,它的值由一个计算函数决定。该计算函数可以读取其他信号,当任一依赖信号变化时,派生信号会自动重新计算。
function createComputed(computeFn) {
const signal = createSignal(); // 内部使用一个信号存储计算结果
let isStale = true; // 标记是否需要重新计算
// 创建派生上下文,当依赖变化时重新执行
const context = {
execute() {
// 标记失效,但并不立即计算(惰性求值)
isStale = true;
// 通知所有依赖此派生信号的消费者更新
// 这里通过内部信号的通知机制触发
},
dependencies: new Set(),
};
// 包装原有的 execute 以便收集依赖
const originalExecute = context.execute;
context.execute = () => {
// 重新计算前,先清除旧的依赖关系(简化版未处理,实际需要)
executeInContext(context, () => {
const newValue = computeFn();
// 直接调用内部信号的 setter(跳过通知,因为派生的通知由 context 负责)
if (signal.peek() !== newValue) {
signal(newValue);
}
});
isStale = false;
};
// 初始计算
context.execute();
// 返回一个只读信号包装,实际读取内部信号的值
const computed = () => {
if (isStale) {
context.execute();
}
// 读取内部信号以触发依赖收集
return signal();
};
computed.peek = () => signal.peek();
return computed;
}
注意,上述实现为了简洁,省略了部分依赖清理逻辑。在实际生产中,当一个派生信号重新计算时,需要取消对旧依赖的订阅,以避免内存泄漏和不必要的重新计算。不过对于理解核心机制,这段代码已经足够。
2.4 实现 Effect(副作用)
副作用是信号系统的最终消费者,它通常用于执行与外部世界交互的操作,例如更新 DOM、写入控制台或发起网络请求。副作用函数会在其依赖信号变化时自动重新运行。
function createEffect(effectFn) {
let cleanupFn = null; // 存储上一次运行的清理函数
const context = {
execute() {
// 先执行上一次的清理函数
if (cleanupFn) {
cleanupFn();
cleanupFn = null;
}
// 在上下文中运行副作用函数
executeInContext(context, () => {
cleanupFn = effectFn();
});
},
dependencies: new Set(),
};
// 立即执行一次以收集依赖
context.execute();
// 返回一个取消订阅的函数(用于手动停止副作用)
return () => {
if (cleanupFn) {
cleanupFn();
cleanupFn = null;
}
context.execute = () => {}; // 禁用后续更新
};
}
这段代码实现了副作用的清理机制:每次重新运行副作用前,会调用上一次返回的清理函数,这在处理事件监听器或定时器时非常有用。
三、实战案例一:构建一个实时计数器与UI更新器
现在我们用刚刚实现的信号库来构建一个简单的计数器应用。这个例子将展示信号如何与 DOM 更新解耦,同时保持代码的简洁性。
// 创建两个基础信号
const count = createSignal(0);
const multiplier = createSignal(2);
// 创建派生信号:计算加倍后的值
const doubled = createComputed(() => count() * multiplier());
// 创建副作用:当 doubled 变化时自动更新页面
const disposeDOM = createEffect(() => {
document.getElementById('count-display').textContent = count();
document.getElementById('doubled-display').textContent = doubled();
console.log(`UI已更新:count=${count()}, doubled=${doubled()}`);
});
// 按钮事件绑定
document.getElementById('increment-btn').addEventListener('click', () => {
count(count() + 1);
});
document.getElementById('multiply-btn').addEventListener('click', () => {
multiplier(multiplier() + 1);
});
// 停止更新(页面卸载时调用)
// disposeDOM();
对应的 HTML 结构可以是:
<div>
<span id="count-display">0</span>
<span id="doubled-display">0</span>
<button id="increment-btn">增加数字</button>
<button id="multiply-btn">增加倍数</button>
</div>
这个案例虽然简单,但已经体现了信号的核心优势:派生状态 doubled 会自动保持与 count 和 multiplier 的同步,而副作用则负责将最新值渲染到 DOM。整个过程没有出现任何手动调用更新函数或检查状态的逻辑。
四、实战案例二:基于信号的待办事项应用
接下来,我们构建一个更贴近实际应用的待办事项列表。信号系统不仅能管理单一数值,还能优雅地处理数组和过滤逻辑。
// 定义任务信号(数组)
const todos = createSignal([]);
// 定义过滤条件信号:'all' | 'active' | 'completed'
const filter = createSignal('all');
// 派生信号:根据过滤条件返回对应的任务列表
const filteredTodos = createComputed(() => {
const all = todos();
const currentFilter = filter();
if (currentFilter === 'active') return all.filter(t => !t.completed);
if (currentFilter === 'completed') return all.filter(t => t.completed);
return all;
});
// 派生信号:统计未完成任务数量
const activeCount = createComputed(() => {
return todos().filter(t => !t.completed).length;
});
// 副作用:将过滤后的任务列表渲染到DOM
const disposeList = createEffect(() => {
const listEl = document.getElementById('todo-list');
const tasks = filteredTodos();
listEl.innerHTML = '';
tasks.forEach((task, index) => {
const li = document.createElement('li');
li.textContent = task.text;
li.style.textDecoration = task.completed ? 'line-through' : 'none';
li.addEventListener('click', () => toggleTodo(index));
listEl.appendChild(li);
});
});
// 操作函数
function addTodo(text) {
todos([...todos.peek(), { text, completed: false }]);
}
function toggleTodo(index) {
const current = [...todos.peek()];
current[index] = { ...current[index], completed: !current[index].completed };
todos(current);
}
function setFilter(newFilter) {
filter(newFilter);
}
// 初始数据
addTodo('学习信号模式');
addTodo('写一篇技术文章');
setFilter('all');
这个案例中,当任务列表或过滤条件发生变化时,DOM 更新会自动触发,无需手动编写任何渲染调度代码。派生信号 filteredTodos 和 activeCount 始终与基础信号保持一致,副作用则负责将变化反映到 UI 上。这种模式让业务逻辑与渲染逻辑彻底分离,极大地提高了代码的可测试性和可维护性。
五、实战案例三:异步数据获取与信号集成
信号本身是同步的,但现代应用离不开异步操作。我们可以通过将 Promise 状态封装为信号来优雅地处理数据加载、成功、失败等状态。
// 创建异步请求状态管理器
function createAsyncSignal(fetchFn) {
const data = createSignal(null);
const loading = createSignal(false);
const error = createSignal(null);
async function execute() {
loading(true);
error(null);
try {
const result = await fetchFn();
data(result);
} catch (err) {
error(err);
} finally {
loading(false);
}
}
// 立即执行
execute();
return {
data: data,
loading: loading,
error: error,
refetch: execute,
};
}
// 使用:获取用户列表
const userResource = createAsyncSignal(async () => {
const response = await fetch('https://jsonplaceholder.typicode.com/users');
if (!response.ok) throw new Error('请求失败');
return response.json();
});
// 派生信号:判断是否加载完成且无错误
const isReady = createComputed(() => !userResource.loading() && !userResource.error());
// 副作用:根据状态更新UI
createEffect(() => {
const statusEl = document.getElementById('status');
const dataEl = document.getElementById('data-display');
if (userResource.loading()) {
statusEl.textContent = '加载中...';
} else if (userResource.error()) {
statusEl.textContent = `错误:${userResource.error().message}`;
} else {
statusEl.textContent = '加载完成';
dataEl.textContent = JSON.stringify(userResource.data(), null, 2);
}
});
异步信号封装模式让数据获取的状态管理变得声明化。开发者只需关注数据本身,而 loading、error 和 data 三个信号之间的联动由系统自动处理,避免了常见的“忘记设置 loading 为 false”或“未清除 error”等 bug。
六、进阶探索:批处理更新与性能优化
在上面的实现中,每次信号写入都会立即触发所有订阅者的重新计算。当短时间内发生多个信号变更时,这可能导致不必要的重复计算和 DOM 更新。生产级信号库(如 @preact/signals-core)通常会引入批处理(batching)机制:将多个同步更新合并,仅在当前微任务或下一个事件循环中执行一次副作用。
我们可以通过一个简单的批处理包装器来实现这一点:
let pending = false;
const effectQueue = new Set();
function batch(fn) {
const prevPending = pending;
pending = true;
try {
fn();
} finally {
if (!prevPending) {
pending = false;
flushEffects();
}
}
}
function scheduleEffect(context) {
if (pending) {
effectQueue.add(context);
} else {
context.execute();
}
}
function flushEffects() {
const effects = [...effectQueue];
effectQueue.clear();
for (const ctx of effects) {
ctx.execute();
}
}
然后,修改信号写入时的通知逻辑,改为调用 scheduleEffect 而不是直接 execute。这样,在 batch 回调中连续修改多个信号时,副作用只会执行一次,显著提升复杂场景下的性能。
七、信号与主流框架的关系及选型建议
理解了原生信号实现后,再去看框架中的信号 API 就会豁然开朗。例如:
- Solid.js:其
createSignal、createMemo和createEffect与我们的实现几乎一致,只是在内部与 JSX 渲染深度集成。 - Preact:通过
@preact/signals提供信号支持,可以在不升级到函数式组件重写的情况下渐进式引入。 - Angular:从 Angular 17 开始内置信号原语,并计划逐步替代基于 Zone.js 的变更检测。
- Vue 3:其
ref和computed本质上也是信号模式,只是与响应式系统和虚拟 DOM 调度深度绑定。
如果你的项目尚未迁移到这些框架,或者正在构建一个轻量的自定义前端工具集,那么基于原生 JavaScript 实现信号库是一种极具性价比的解决方案。它不依赖任何特定框架,可以运行在任何 JavaScript 环境中,甚至可以在 Node.js 服务端用于状态管理。
八、总结
本文通过从零实现一个完整的信号库,展示了信号响应式编程背后的核心概念:自动依赖收集、惰性求值派生以及声明式副作用。这三个模式的组合提供了一种极简而强大的状态管理方式,彻底消除了手动订阅和更新协调的复杂性。
信号模式并非要完全取代 Redux 或 Zustand 等成熟状态管理库,而是提供了一种更细粒度、更贴近组件更新粒度的选择。在实际项目中,你可以根据场景将信号用于局部组件状态,或结合其他库管理全局应用状态。
建议读者将本文的迷你信号库代码复制到本地运行,并尝试为其增加更多特性(如资源清理、相等性检查跳过更新、异步派生信号等),这不仅能加深对响应式系统的理解,还可能催生出你对前端架构的新思路。

