状态管理的本质是什么?其实就是解决“数据在一处修改,视图在多处响应”这个问题。Redux、MobX、Pinia这些库的核心机制都离不开发布订阅模式(Publish/Subscribe Pattern)。与其停留在会用的层面,不如动手用TypeScript实现一个类型安全的全局状态管理器,这样再去读主流库的源码会顺畅很多。本文会从模式原理讲到完整实现,再到工程化拆分,一步步展开。
发布订阅模式的核心思想与类型设计
发布订阅模式的核心是三个角色:事件中心(EventBus)、发布者(Publisher)和订阅者(Subscriber)。订阅者向事件中心注册回调,发布者只负责在状态变化时通知事件中心,由事件中心统一分发。这种设计让状态的所有者和状态的消费者完全解耦,消费者不需要知道状态在哪里被修改。
映射到状态管理场景中,store就是事件中心,setState就是发布动作,subscribe就是订阅动作。用TypeScript实现时,最关键的是通过泛型让整个流程保持类型安全。先定义核心的类型结构:
type Listener<S> = (state: S, prevState: S) => void;
type Unsubscribe = () => void;
interface Store<S> {
getState(): S;
setState(partial: Partial<S>): void;
subscribe(listener: Listener<S>): Unsubscribe;
}这里有几个设计细节值得注意。第一,Listener接收当前状态和前一份状态,方便订阅者做差异比较;第二,subscribe返回一个取消订阅的函数,这是防止内存泄漏的关键,后面会详细说;第三,setState接收Partial<S>,允许局部更新而不是每次都要传入完整状态,这和React的setState语义一致,使用体验更好。
手写一个类型安全的Store实现
有了类型定义,实现本身就非常直观了。内部用一个数组保存所有监听器,setState时先浅合并生成新状态对象,再遍历通知所有监听器:
function createStore<S extends object>(initialState: S): Store<S> {
let state = initialState;
const listeners = new Set<Listener<S>>();
return {
getState() {
return state;
},
setState(partial) {
const prevState = state;
state = { ...state, ...partial };
listeners.forEach((listener) => listener(state, prevState));
},
subscribe(listener) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
};
}
// 使用示例
interface AppState {
user: { name: string; loggedIn: boolean };
cartCount: number;
}
const store = createStore<AppState>({
user: { name: '张三', loggedIn: false },
cartCount: 0,
});
store.subscribe((state, prev) => {
if (state.cartCount !== prev.cartCount) {
console.log(`购物车数量变化: ${prev.cartCount} -> ${state.cartCount}`);
}
});
store.setState({ cartCount: 1 }); // 输出: 购物车数量变化: 0 -> 1使用Set而不是数组来存监听器有两个好处:天然去重,避免同一个回调被重复注册;删除操作是O(1)复杂度。另外注意状态更新必须生成新对象({ ...state, ...partial }),绝不能直接修改原对象。因为监听器拿到的是同一个引用的话,state === prevState永远成立,订阅者就无法感知变化,这正是React等框架依赖不可变数据做引用比较的基础。
还有一个容易被忽略的边界情况:如果在某个listener回调执行过程中,又触发了setState,或者某个listener在回调里调用了unsubscribe,直接遍历会导致意外行为。更健壮的做法是在通知前拷贝一份监听器列表:
setState(partial) {
const prevState = state;
state = { ...state, ...partial };
// 拷贝一份再遍历,避免回调中订阅或取消订阅影响本轮通知
[...listeners].forEach((listener) => listener(state, prevState));
}工程化拆分:模块化的状态与更新逻辑
单一大对象在项目变大后会变得难以维护,所有模块的状态挤在一起,命名冲突和误改他人状态的风险都会上升。更工程化的做法是按模块拆分,每个模块维护自己的状态和更新逻辑,最后通过组合函数合并成一个根store:
// modules/cart.ts
export interface CartState {
items: string[];
}
export const cartModule = {
name: 'cart' as const,
initialState: { items: [] } as CartState,
// 模块级的更新方法,语义清晰且类型安全
addItem(get: () => CartState, item: string): Partial<CartState> {
return { items: [...get().items, item] };
},
};
// index.ts 组合根store
const rootStore = createStore({
[cartModule.name]: cartModule.initialState,
// 其他模块...
});这种拆分方式和Redux的combineReducers思路一致:视图层只依赖自己关心的模块,类型系统会保证你只能访问和修改声明的字段。TypeScript在这里的价值体现得淋漓尽致——如果拼错了模块名或者传入了非法的partial对象,编译期就会直接报错,而不是等到运行时才发现状态被污染。
性能优化与常见坑点
当前实现的.notify是广播式的:任何一次setState都会通知所有监听器,即使它们关心的字段根本没变。当订阅者数量增多后,这里有明显优化空间。第一种方案是selector订阅,让订阅者声明自己关心的切片,通知前先比较该切片是否变化:
function subscribeSelector<T>(
selector: (state: S) => T,
listener: (slice: T, prevSlice: T) => void
): Unsubscribe {
let prevSlice = selector(state);
return this.subscribe((nextState) => {
const nextSlice = selector(nextState);
// 浅比较,相同则跳过通知
if (!Object.is(prevSlice, nextSlice)) {
const p = prevSlice;
prevSlice = nextSlice;
listener(nextSlice, p);
}
});
}这基本就是Redux中useSelector、Zustand中selector用法的基本原理。需要注意selector必须保持纯函数,且返回新对象时要小心引用比较失效的问题,必要时提供自定义的相等函数。
第二个大坑是内存泄漏。SPA中组件反复挂载卸载,如果订阅时没有保存并调用unsubscribe返回的清理函数,卸载后的组件回调仍然持有引用,不仅造成内存泄漏,还会对已卸载组件执行setState导致报错。在React中的正确做法是配合useEffect的清理函数:
useEffect(() => {
const unsubscribe = store.subscribe((state) => {
setCartCount(state.cart.count);
});
return unsubscribe; // 组件卸载时自动取消订阅
}, []);第三个坑是泛型推导。如果createStore的泛型约束写成<S>而不加extends object,传入原始类型也能编译通过,后续做展开运算就会报错。加上extends object约束后,类型收窄更严格,配合as const使用模块名,可以让组合后的根状态类型保持字面量精确类型而不是宽泛的string,编辑器提示和类型检查都会准确得多。
总结一下,一个健壮的状态管理器核心就三件事:不可变更新保证变更可感知,订阅返回清理函数防止泄漏,selector机制减少无效通知。理解了这些,再去看Redux Toolkit、Zustand、Pinia的源码,你会发现它们都是在这些基础机制上叠加工程化封装而已。动手实现一遍,远比背API有效。
TypeScript发布订阅模式状态管理修改时间:2026-08-31 07:01:00