在前端开发中,状态管理常常被等同于引入 Redux、Zustand 等成熟框架,但并非所有场景都需要这些库带来的体积和概念负担。对于中小型后台、嵌入式页面或实验性项目,使用原生 JavaScript 的发布订阅模式,完全可以构建一个无依赖、轻量且可扩展的状态管理库。这类库通常只关注三件事:集中存储状态、通过统一入口修改状态、状态变化后通知所有相关订阅者。理解这一实现过程,也有助于更深入地掌握前端框架状态模块的底层工作方式。

一、用发布订阅建立最小状态容器
发布订阅模式是这类状态库的核心。所谓“发布”,指的是状态发生变更时向外部广播;所谓“订阅”,则是外部代码注册一个回调函数,表示希望在未来状态变化时收到通知。为了不让外部随意修改内部状态,通常将状态保存在闭包中,只暴露 getState、subscribe 和 setState 三个方法。
基础实现非常直接:使用一个 Set 保存监听器函数,subscribe 负责把监听器加入集合并返回取消函数,setState 负责根据传入的对象或更新函数生成新状态,再遍历监听器逐个通知。这样外部就无法绕过统一入口修改状态,为后续的追踪和优化打下基础。
function createStore(initialState) {
let state = initialState;
const listeners = new Set();
function getState() {
return state;
}
function subscribe(listener) {
listeners.add(listener);
return function unsubscribe() {
listeners.delete(listener);
};
}
function setState(partial) {
// 支持直接传对象,也支持根据旧状态计算新状态
const next = typeof partial === 'function'
? partial(state)
: partial;
state = Object.assign({}, state, next);
listeners.forEach(function (fn) {
fn(state);
});
}
return { getState, subscribe, setState };
}
这个版本已经可以用于简单场景:所有订阅者会在任意状态字段变化时收到完整状态。它的优点是逻辑简单、容易调试,缺点则是无法区分订阅者真正关心的数据。如果视图只依赖 count 字段,当 name 字段修改时也会被通知,可能引发无意义的渲染。
二、引入选择器订阅与浅比较
为了解决“所有通知无差别发送”的问题,可以在订阅时接收一个选择器函数。选择器负责从完整状态中取出订阅者关注的片段,例如 function(s) { return s.count; }。内部保存该片段的上一次值,在通知前用新状态重新计算,并通过浅比较判断片段是否真正发生变化。
这种做法让状态库从“广播完整状态”升级为“按需通知”。组件只会在自己依赖的数据变化时执行回调,从而有效避免重复渲染。实现上,订阅项不再只是一个回调函数,而是一个包含 selector、listener 和上一次选中值的对象。
function createStore(initialState) {
let state = initialState;
const entries = new Set();
function getState() {
return state;
}
function subscribe(selector, listener) {
// 记录初始选中值,作为后续比较基准
let previous = selector(state);
const entry = {
selector: selector,
listener: listener,
previous: previous
};
entries.add(entry);
return function unsubscribe() {
entries.delete(entry);
};
}
function setState(partial) {
const next = typeof partial === 'function'
? partial(state)
: partial;
state = Object.assign({}, state, next);
entries.forEach(function (entry) {
const current = entry.selector(state);
// 浅比较差异,只有变化时才通知
if (current !== entry.previous) {
entry.previous = current;
entry.listener(current, state);
}
});
}
return { getState, subscribe, setState };
}
需要特别注意的是,浅比较并不能识别对象或数组内部属性的变化。如果选择器返回一个新对象,即使内容相同,引用不同也会触发通知。因此建议选择器尽可能返回原始值或稳定引用,避免不必要的更新。
三、与主流状态方案对比
与 Redux 相比,自研轻量库缺少中间件、时间旅行、开发者工具等高级特性,但换来的是接近零依赖和更低的认知负担。对于没有复杂副作用、不需要严格流程控制的项目,这类库往往会让代码更直观,也更容易交接维护。
与 React 的 useState 相比,自研 store 是跨组件共享的全局状态容器,不需要通过 props 逐层传递。它并不绑定任何 UI 框架,只要在合适生命周期里调用订阅和取消订阅,就可以在原生 JavaScript、Vue、React 甚至小程序中复用。
下表从依赖体积、学习成本和适用场景等方面做了简单比较。
| 方案 | 依赖体积 | 学习成本 | 适用场景 |
|---|---|---|---|
| Redux | 约 2KB 以上加中间件 | 高 | 大型复杂应用 |
| 自研轻量库 | 小于 1KB | 低 | 中小项目、嵌入式 |
| 框架内置 State | 随框架 | 中 | 单组件或局部 |
从表中可以看出,自研轻量库并不是为了替代成熟框架,而是在特定边界内提供更简洁的选择。当项目逐渐扩大,出现跨模块异步流程、严格调试需求时,再迁移到成熟方案也不会因为当前结构简单而变得困难。
四、内存管理与工程实践
发布订阅模式最常见的隐患是内存泄漏。当组件或模块销毁时,如果没有及时移除注册的回调,监听器集合会持续持有已经无用的函数引用,导致内存无法被回收。因此 subscribe 必须返回一个取消订阅函数,并在组件卸载生命周期中调用。
另一个实践要点是不要绕过 setState 直接修改状态对象。虽然 getState 返回的是内部状态引用,但一旦外部直接修改其属性,后续通知和选择器比较就失去了准确性。可以通过约定和代码审查来保证所有变更走统一入口,也可以在 getState 中返回浅拷贝来隔离内部对象,代价是额外的性能损耗。
下面示例展示了组件化使用时的基本流程:创建 store、订阅某个字段变化、更新状态、最后在组件卸载时取消订阅。
// 创建全局 store
const store = createStore({ count: 0, name: 'test' });
// 订阅 count 字段变化
const unsubscribe = store.subscribe(
function (state) { return state.count; },
function (count, state) {
console.log('count changed:', count);
}
);
// 修改状态
store.setState({ count: 1 });
store.setState(function (state) {
return { count: state.count + 1 };
});
// 组件卸载时取消订阅
unsubscribe();
在这个流程中,即使内部状态还包括 name 字段,回调也只会在 count 的浅比较结果发生变化时执行。这
五、在 React 中使用选择器订阅
这意味着如果 name 字段发生变化而 count 没有变化,订阅回调不会执行。这种精确性对减少无谓的渲染和副作用处理非常有价值。在 React 等组件框架中,手动订阅虽然可用,但每次组件挂载与卸载都要重复编写注册和清理逻辑。React 18 提供的 useSyncExternalStore 可以将这一过程标准化。
借助该 API,可以把 store 的订阅逻辑封装成自定义 Hook,组件只需声明自己关心的选择器即可。下面是一个基础封装:
function useSelector(store, selector) {
const subscribe = React.useCallback(
function (onStoreChange) {
return store.subscribe(selector, onStoreChange);
},
[store, selector]
);
const getSnapshot = function () {
return selector(store.getState());
};
return React.useSyncExternalStore(subscribe, getSnapshot);
}
组件中只需调用这个 Hook,并传入从状态中提取所需字段的选择器函数。store 内部的浅比较会过滤掉无关变化,因此组件只在选择器返回值变化时重新渲染。
function Counter() {
const count = useSelector(store, function (state) { return state.count; });
const increment = function () {
store.setState(function (state) {
return { count: state.count + 1 };
});
};
return React.createElement(
'button',
{ onClick: increment },
'count: ' + count
);
}
useSelector 内部使用 useCallback 保证 subscribe 引用稳定,避免每次渲染都重新订阅。getSnapshot 返回选择器计算出的切片值,React 会基于该值决定是否触发组件更新。由于 store 内部已经用浅比较过滤了无关变化,组件层面的更新频率和选择器的返回粒度保持一致。
六、异步状态更新与调试
实际项目中,状态变更经常来自异步请求。只要将请求流程封装为普通函数,在适当的时机调用 setState,订阅方就能自动收到加载、成功或失败等状态变化。关键在于不要让回调函数直接修改内部状态,而应始终通过 setState 派发。
async function loadUser() {
store.setState({ loading: true, error: null });
try {
const response = await fetch('/api/user');
const user = await response.json();
store.setState({ user: user, loading: false });
} catch (error) {
store.setState({ loading: false, error: error.message });
}
}
如果需要在每次状态变化时输出日志或与调试工具通信,可以对 setState 做轻量包装,而不是修改 createStore 本身。
const rawSetState = store.setState.bind(store);
store.setState = function (partial, replace) {
console.log('prev state:', store.getState());
rawSetState(partial, replace);
console.log('next state:', store.getState());
};
这种包装方式保持了原始调用签名的兼容性,同时为所有状态变更增加了一个可观测的切面。对于更复杂的需求,也可以将日志、持久化等功能拆分为独立函数,在执行 setState 前后依次调用,但核心原则仍然是所有状态修改都经过统一入口。
七、总结
发布订阅模式用于前端状态管理时,最核心的价值不是简单的回调通知,而是通过选择器与浅比较形成的精确依赖追踪。订阅方只需要声明关心的数据切片,状态变更时由 store 自动判断是否触发通知,从而将渲染压力控制到最小范围。统一的状态修改入口保证了可变性被约束在可控范围,而取消订阅函数则保证了长期运行时的内存安全。
这套机制虽然轻量,但已经包含了 Redux、Zustand 等成熟方案的基础思想。在实际项目中,完全可以根据需要从这种最小实现出发,逐步加入异步中间层、调试工具和持久化逻辑。理解其内部原理,有助于在遇到性能瓶颈或状态流混乱时迅速定位问题,而不是被动地依赖框架的既有行为。
JavaScriptstate_managementpublish_subscribe修改时间:2026-08-03 21:06:15