文章

手写简易状态管理深度解析

手写一个极简状态管理库(类似 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:三者的权衡

 ReduxZustandPinia
更新方式dispatch → reducerset(fn)直接赋值
订阅粒度整个 storeselector 粒度属性级(Proxy)
不可变要求强制推荐无需关心
TypeScript需 RTK 辅助原生友好一等支持
适用场景大型团队、需中间件中型 React 项目Vue3 生态

其实你每天都在用

  1. Git 的 staging 区就是一个 Redux store——git add = dispatch action,.git/index 是 state,每次 commit 本质上是一个不可变更的新快照。Git 的 reflog 就是 Redux DevTools 的时间旅行。
  2. 浏览器地址栏 history.pushState + popstate 就是发布-订阅模式的实际应用——SPA 路由库(react-router、vue-router)底层都在做类似的事:维护 URL state,变化时通知所有注册的回调。
  3. 事件总线(EventBus)——Vue2 中 $on/$emit、Node.js 的 EventEmitter,都是没有 reducer 约束的发布-订阅。Redux 加了一层”只能通过 dispatch action 修改”的纪律。
  4. React 的 useReducer 就是内建在组件里的迷你 Redux——dispatch(action) → reducer(state, action) → 新 state → 触发重渲染,完全一样的模式。
  5. 任何带”撤销/重做”功能的应用都在使用不可变数据模式——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 自动追踪来换取开发体验,选型的关键不是谁更好,而是你的团队愿意用纪律换效率,还是用效率换纪律。

本文由作者按照 CC BY 4.0 进行授权