🔥 面试必杀技:手写 Zustand,彻底搞懂 React 状态管理的“中央银行”模式
导读:在 React 面试中,被问到“如何实现一个轻量级状态管理库”时,90% 的候选人会背诵 Redux 流程或 Context API 的缺点。但如果你能现场手写一个 Zustand 的核心逻辑,并用“中央银行”的比喻清晰阐述其发布订阅与按需更新的原理,你将直接锁定 Offer。本文将以掘金深度解析的风格,带你从零构建一个迷你版 Zustand。
🏦 一、背景:为什么我们需要“中央银行”?
在 React 的组件王国里,状态管理经历了三个时代的演变:
-
Redux 时代(繁琐的 bureaucracy):
- 修改一个状态需要:
Dispatch Action->Reducer->Store Update。 - 痛点:样板代码太多,像去银行办事要填无数张单子。
- 修改一个状态需要:
-
Context API 时代(大喇叭广播):
- 虽然去掉了 Redux 的繁琐,但引入了新问题:上下文穿透。
- 痛点:一旦
Provider的值变化,所有消费该 Context 的子组件,无论是否用到变化的数据,都会无条件重渲染。这就像央行发通知,不管你是关心利率还是汇率,全村人都得跑出来听一遍。
-
Zustand 时代(精准的中央银行):
- 无围墙:不需要
<Provider>包裹,组件随时随地访问。 - 精准广播:基于发布订阅模式 (Pub/Sub)。组件只订阅自己关心的数据切片(Slice)。
- 直接操作:直接在组件内调用
set修改状态,无需 dispatch。
- 无围墙:不需要
🛠️ 二、核心实现:构建“金库” (Vanilla JS Store)
Zustand 的灵魂在于它完全独立于 React。我们先剥离框架,用原生 JS 实现一个支持发布订阅和不可变更新的 Store。
💻 代码实现:createStore
/**
* 核心工厂:createStore
* 模拟 Zustand 底层的 createStoreImpl
*/
export function createStore(createState) {
// 1. 【金库账本】(State)
// 使用闭包变量存储状态,外部无法直接修改,保证安全性
let state;
// 2. 【听众名单】(Listeners)
// 使用 Set 数据结构。
// 面试题点:为什么用 Set 不用 Array?
// 答:Set 自动去重,且删除操作 (delete) 的时间复杂度为 O(1),适合高频增删的订阅场景。
const listeners = new Set();
// 3. 【获取账本】(Get)
const getState = () => state;
// 4. 【核心:修改账本 & 广播】(Set)
const setState = (partial) => {
// A. 计算新状态 (支持函数式更新和对象式更新)
// 对应源码逻辑:如果是函数则执行,否则直接使用
const nextState = typeof partial === 'function' ? partial(state) : partial;
// B. 【不可变性关键】浅合并 (Shallow Merge)
// 确保每次更新都返回一个新的对象引用,这是 React 检测变化的基础
state = typeof nextState === 'object' && nextState !== null
? { ...state, ...nextState }
: nextState;
// C. 【广播时刻!】(Publish)
// 遍历所有监听器,通知它们“数据变了”,并传入最新状态
// 注意:实际生产中这里可能需要处理异步队列,但核心逻辑是 forEach
listeners.forEach((listener) => listener(state));
};
// 5. 【办理订阅】(Subscribe)
const subscribe = (listener) => {
// 将监听函数加入名单
listeners.add(listener);
// 【重要考点】返回取消订阅函数 (Unsubscribe)
// 作用:防止内存泄漏。当组件卸载时,必须调用此函数将 listener 从 Set 中移除。
return () => {
listeners.delete(listener);
};
};
// 6. 【初始化金库】
// 调用用户传入的 createState 函数,注入 set 和 get 工具
// 此时 state 被正式赋值(例如:{ count: 0, increment: fn })
state = createState(setState, getState);
// 7. 【交付接口】
return {
getState,
setState,
subscribe,
// 实际源码还有 destroy 等方法,面试手写这三个核心即可
};
}
📝 掘金式知识点植入
- 闭包的力量:
state和listeners被封闭在createStore的作用域内,形成了真正的私有变量,外界只能通过暴露的方法访问。 - 中间件原理:Zustand 的强大中间件(如
persist,devtools)本质上是高阶函数。它们包裹setState和getState,在数据读写前后插入自定义逻辑(如写入 localStorage)。
⚛️ 三、桥梁搭建:连接 React (Hook 封装)
光有金库不行,React 组件需要在数据变化时重新渲染。我们需要编写一个自定义 Hook useStore。
💻 代码实现:经典 Hooks 版 (原理演示)
为了展示对 React 生命周期的理解,我们先使用 useState + useEffect 实现。
import { useState, useEffect } from 'react';
/**
* 工厂函数:create
* 接收 createState,返回一个可以在组件中使用的 Hook (useStore)
*/
export function create(createStateFn) {
// 1. 实例化唯一的“金库” (Store)
// 注意:这个 store 在模块作用域内是单例的,所有组件共享这一个实例
const store = createStore(createStateFn);
// 2. 返回自定义 Hook
// selector: 选择器函数,用于“按需订阅” (Zustand 的核心优势)
// equalityFn: 可选的相等性判断函数 (用于性能优化)
return function useStore(selector, equalityFn) {
// 默认选择器:返回整个 state
const sel = selector || ((s) => s);
// A. 【本地状态】
// 用于触发当前组件的重渲染。
// 初始化时,立即同步获取一次最新数据
const [slice, setSlice] = useState(() => sel(store.getState()));
// B. 【副作用:订阅与清理】
useEffect(() => {
// 定义回调函数:当 store 变化时执行
const onUpdate = () => {
const nextSlice = sel(store.getState());
// 【性能优化关键点】
// 如果提供了相等性判断函数,且数据没变,则不触发 setState
// 避免无效重渲染 (Zustand 默认行为通常包含浅比较)
if (equalityFn && equalityFn(slice, nextSlice)) {
return;
}
// 数据确实变了,更新本地状态,触发 React 重渲染
setSlice(nextSlice);
};
// 1. 订阅金库
const unsubscribe = store.subscribe(onUpdate);
// 2. 再次检查 (Double Check)
// 防止在 useEffect 执行前,store 已经发生了变化导致的丢失更新
onUpdate();
// 3. 清理函数 (组件卸载时)
// 调用 unsubscribe,从 Set 中移除 listener,防止内存泄漏
return unsubscribe;
}, [store, sel, equalityFn]);
return slice;
};
}
🚀 面试加分项:口述 useSyncExternalStore
写完上述代码后,务必补充以下内容,体现你对 React 18 新特性的掌握:
“面试官,上面的写法是经典的
useState+useEffect模式,能够很好地解释原理。但在 React 18 之后,Zustand 源码已经升级使用useSyncExternalStoreAPI。为什么要用它?
- 防止 Tearing (数据撕裂):在并发渲染 (Concurrent Mode) 下,旧写法可能导致 UI 显示不一致的状态快照。
- SSR 友好:更好地支持服务端渲染的数据注水。
如果用
useSyncExternalStore改写,核心逻辑会变得极其简洁:import { useSyncExternalStore } from 'react'; return function useStore(selector) { return useSyncExternalStore( store.subscribe, // 订阅函数 () => selector(store.getState()), // getSnapshot: 获取当前客户端快照 () => selector(store.getState()) // getServerSnapshot: 获取服务端快照 ); };这样代码更简洁,且由 React 内部保证调度一致性。”
🧩 四、实战场景:为什么它比 Context 快?
让我们用刚才手写的库,模拟一个经典场景,展示 Selector (选择器) 的威力。
场景:用户信息更新
假设我们有一个包含 name 和 age 的状态。
const useUserStore = create((set) => ({
name: 'Alice',
age: 25,
updateName: (name) => set({ name }),
updateAge: (age) => set({ age })
}));
// 组件 A:只关心名字
function NameDisplay() {
// 🔥 关键点:只订阅 name
// 即使 age 改变了,这个组件也不会重渲染!
const name = useUserStore((state) => state.name);
console.log('NameDisplay Rendered');
return <h2>Hello, {name}</h2>;
}
// 组件 B:只关心年龄
function AgeDisplay() {
// 🔥 关键点:只订阅 age
// 即使 name 改变了,这个组件也不会重渲染!
const age = useUserStore((state) => state.age);
console.log('AgeDisplay Rendered');
return <p>Age: {age}</p>;
}
对比 Context API:
如果使用 Context,当 updateName 触发时,<UserContext.Provider> 的值改变,NameDisplay 和 AgeDisplay 都会重渲染,哪怕 AgeDisplay 根本没用到了 name。这就是 Zustand “精准广播”的优势。
🗺️ 五、面试思维导图 (Memory Map)
在白板面试中,你可以按照这个结构进行陈述:
mindmap
root((手写 Zustand))
核心理念
中央银行模式
发布订阅 (Pub/Sub)
无 Provider (单例 Store)
按需订阅 (Selector)
1. 核心 Store (JS)
数据结构
state (闭包变量)
listeners (Set 集合)
关键方法
setState
浅合并 (...spread)
遍历通知 (listeners.forEach)
subscribe
add listener
返回 unsubsribe (delete)
getState
2. React Hook 层
create 工厂
实例化 Store
返回 useStore
useStore 实现
useState (存储切片)
useEffect
注册订阅
回调中 setState 触发渲染
Return 清理函数
Selector (选择器)
提取部分状态
配合 equalityFn 优化
3. 进阶亮点 (口述)
useSyncExternalStore (React 18)
中间件原理 (高阶函数包裹)
Immer 集成 (处理深层嵌套)
💡 六、总结与回答策略
如果在面试中被问到“请手写一个状态管理库”或“讲讲 Zustand 原理”,请按以下步骤输出:
- 开场定调:一句话概括,“Zustand 本质是一个基于发布订阅模式的状态容器,通过闭包维护状态,利用 React Hooks 实现视图联动,解决了 Context 的全量重渲染问题。”
- 手写 Store:重点写出
Set存监听器、setState里的遍历通知、以及subscribe返回取消函数。一定要提到浅合并保证不可变性。 - 手写 Hook:重点写出
useEffect中如何订阅,以及如何通过selector提取状态存入useState来驱动渲染。 - 展示深度:主动提及
useSyncExternalStore解决并发问题,以及中间件是如何通过装饰器模式增强set/get的。 - 结合场景:用上面的
NameDisplay/AgeDisplay例子说明“为什么需要 Selector”,这是 Zustand 优于 Context 的关键点。
掌握这套逻辑,你不仅学会了 Zustand,更深刻理解了响应式系统的设计精髓。