手写简易状态管理深度解析
手写一个极简状态管理库(类似 Redux/Pinia 的 createStore),理解订阅发布、dispatch、reducer 与不可变更新。 面试能复述单向数据流与中间件思想即可体现深度。
一句话概括
手写 Redux(60 行)和 Pinia(80 行)的核心,本质上是实现一个具备订阅-通知机制的全局状态容器:Redux 靠纯函数 + 不可变数据保证可预测性,Pinia 靠 Proxy 响应式系统让修改状态像改普通对象一样自然。
核心知识点
1. 发布-订阅:状态管理的骨架
无论 Redux 还是 Pinia,底层都是”状态变更 → 通知订阅者”。Redux 里叫 subscribe/dispatch,Pinia 里是 Vue 的 effect 系统自动完成。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 25 行核心:Redux 的 createStore 骨架
function createStore(reducer, initialState) {
let state = initialState;
const listeners = new Set(); // 用 Set 而非数组:O(1) 去重 + 删除
const getState = () => state;
const dispatch = (action) => {
state = reducer(state, action); // 纯函数计算新状态
listeners.forEach(fn => fn()); // 通知所有订阅者
return action;
};
const subscribe = (listener) => {
listeners.add(listener);
return () => listeners.delete(listener); // 返回退订函数,方便 useEffect cleanup
};
dispatch({ type: '@@INIT' }); // 初始化:用虚拟 action 让 reducer 填充默认值
return { getState, dispatch, subscribe };
}
面试追问:为什么
subscribe返回退订函数?—— React 组件卸载时需要清理订阅,否则会造成内存泄漏。useEffect(() => { const unsub = store.subscribe(fn); return unsub; }, [])就是经典模式。
2. combineReducers:拆分与合并
1
2
3
4
5
6
7
8
9
10
11
12
13
function combineReducers(reducers) {
return (state = {}, action) => {
let changed = false;
const next = {};
for (const key of Object.keys(reducers)) {
const prev = state[key];
next[key] = reducers[key](prev, action);
if (next[key] !== prev) changed = true; // 引用比较,跳过无变化的分片
}
return changed ? next : state;
};
}
// 用法:const rootReducer = combineReducers({ user: userReducer, cart: cartReducer });
要点:只替换变化了的分片,没变的分片保持原引用。这是 React-Redux useSelector 能做浅比较优化的基础。
3. 中间件:洋葱模型
中间件的本质是”增强 dispatch”,通过函数组合形成洋葱圈:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
const logger = store => next => action => {
console.log('before', store.getState());
const result = next(action);
console.log('after', store.getState());
return result;
};
const thunk = ({ dispatch, getState }) => next => action =>
typeof action === 'function'
? action(dispatch, getState) // thunk:拦截函数类型的 action
: next(action);
// compose(f, g, h)(x) === f(g(h(x)))
function compose(...fns) {
return fns.reduce((a, b) => (...args) => a(b(...args)));
}
function applyMiddleware(...middlewares) {
return createStore => (reducer, init) => {
const store = createStore(reducer, init);
const chain = middlewares.map(m => m({ getState: store.getState, dispatch: store.dispatch }));
store.dispatch = compose(...chain)(store.dispatch);
return store;
};
}
4. Pinia:基于 Proxy 的响应式状态
Pinia 不需要 dispatch + reducer,直接改属性即可。核心是 reactive():
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 极简版 reactive(约 20 行)
const depsMap = new WeakMap();
let activeEffect = null;
function reactive(obj) {
return new Proxy(obj, {
get(target, key, receiver) {
if (activeEffect) {
let deps = depsMap.get(target);
if (!deps) depsMap.set(target, deps = new Map());
if (!deps.has(key)) deps.set(key, new Set());
deps.get(key).add(activeEffect); // 收集依赖
}
return Reflect.get(target, key, receiver);
},
set(target, key, value, receiver) {
const old = target[key];
const ok = Reflect.set(target, key, value, receiver);
if (old !== value) {
depsMap.get(target)?.get(key)?.forEach(fn => fn()); // 触发更新
}
return ok;
}
});
}
Pinia 的 defineStore 做的事:执行 setup 函数 → 把返回的 ref/reactive 挂到全局 state 上 → 返回带自动解包 ref 的 Proxy wrapper。这样使用者写 store.count 而非 store.count.value。
5. Redux vs Zustand vs Pinia:三者的权衡
| Redux | Zustand | Pinia | |
|---|---|---|---|
| 更新方式 | dispatch → reducer | set(fn) | 直接赋值 |
| 订阅粒度 | 整个 store | selector 粒度 | 属性级(Proxy) |
| 不可变要求 | 强制 | 推荐 | 无需关心 |
| TypeScript | 需 RTK 辅助 | 原生友好 | 一等支持 |
| 适用场景 | 大型团队、需中间件 | 中型 React 项目 | Vue3 生态 |
其实你每天都在用
- Git 的 staging 区就是一个 Redux store——
git add= dispatch action,.git/index是 state,每次 commit 本质上是一个不可变更的新快照。Git 的 reflog 就是 Redux DevTools 的时间旅行。 - 浏览器地址栏
history.pushState+popstate就是发布-订阅模式的实际应用——SPA 路由库(react-router、vue-router)底层都在做类似的事:维护 URL state,变化时通知所有注册的回调。 - 事件总线(EventBus)——Vue2 中
$on/$emit、Node.js 的EventEmitter,都是没有 reducer 约束的发布-订阅。Redux 加了一层”只能通过 dispatch action 修改”的纪律。 - React 的
useReducer就是内建在组件里的迷你 Redux——dispatch(action)→reducer(state, action)→ 新 state → 触发重渲染,完全一样的模式。 - 任何带”撤销/重做”功能的应用都在使用不可变数据模式——Photoshop 的历史记录面板、Notion 的版本历史、VS Code 的 Local History,底层逻辑和 Redux DevTools 的时间旅行一模一样。
常见误解(FAQ)
❌ 误区:「Redux 已经过时了,新项目不该用」 真相:Redux Toolkit(RTK)在 2024-2025 仍是大厂标配。RTK Query 内置缓存/去重/乐观更新,对于需要精细控制数据流的复杂 B 端应用,它比 Zustand + React Query 组合更内聚。过时的是”裸写 Redux 样板代码”,不是 Redux 本身。
❌ 误区:「Pinia 不需要 action,直接改 state 就行」 真相:可以直接改,但不推荐在组件里直接
store.count = 5绕过 action。action 的价值在于:① 语义化(login()vs 手动token=xxx;user=yyy;loading=false),② DevTools 自动记录操作名,③ 异步和批量更新的统一入口。❌ 误区:「reducer 里用 push/splice 然后 return state 没问题,因为引用没变」 真相:引用确实没变,所以 React-Redux 的浅比较检测不到变化,UI 不会更新。这不是语法错误,而是”静默不更新”的 bug,调试极其痛苦。
❌ 误区:「手写 Redux 只是面试造火箭,工作用不上」 真相:理解 “listener 快照”(
nextListeners = new Set(currentListeners))的设计,能帮你避免在生产代码中写出”dispatch 中途 unsubscribe 导致 forEach 跳元素”的坑。这不是八股文,是真实的运行时安全设计。
一句话总结
状态管理的本质就是一句话:数据变了,让关心它的人知道——Redux 选择了显式的 dispatch 链条和不可变约束来换取可预测性,Pinia 选择了 Proxy 自动追踪来换取开发体验,选型的关键不是谁更好,而是你的团队愿意用纪律换效率,还是用效率换纪律。