一、问题背景:Context 不是不能用,是不该这么用
先说清楚,我不是来黑 Context 的。Context 解决的是"跨层级传值"的问题,它本身设计得很好。问题在于我们把它当成了状态管理库来用。
这个项目是一个运营中台,2022 年立项,技术栈 React 18.2 + TypeScript 4.9 + Vite 4.1。最初团队只有 3 个人,为了"不引入额外依赖",全局状态用了最朴素的方式:
- 用户信息、权限、主题:
UserContext - 全局配置、字典数据:
ConfigContext - 通知、弹窗队列:
NoticeContext - 当前工作区、选中的租户:
WorkspaceContext
局部状态就靠 Props 一层层传。最深的组件树传了 6 层,中间层组件被迫接收一堆自己根本用不到的 props,我们内部管这叫"props 隧道"。
到了 2023 年底,问题集中爆发:
- 重渲染失控。
WorkspaceContext的 value 是一个对象,每次 Provider 重新渲染都会创建新引用,所有useContext的消费者全部重渲染。我们做过一次 Profile,在一个订单筛选页面输入关键字时,单次按键触发了 213 次组件渲染。 - Props 隧道难以维护。改一个叶子组件的入参,要顺着链路改 4 个中间组件,TypeScript 报错能帮你找出来,但改起来很烦。
- 逻辑分散。同一个业务状态(比如"当前选中的仓库")在 3 个地方有各自的 useState,同步靠 useEffect,偶尔出现不一致的 bug。
真正让我下决心的是有一次用户反馈:在 SKU 搜索框里打字,光标会卡顿。我用 React DevTools Profiler 录了一段,输入 "SKU12345" 这 8 个字符,总渲染耗时 1.4s,平均每个字符 175ms。这不能忍。
二、环境与版本
先把环境交代清楚,方便对照:
react: 18.2.0
react-dom: 18.2.0
typescript: 4.9.5
vite: 4.1.1
zustand: 4.4.1
选型阶段我对比了四个方案,都是当时(2023 年 12 月)的稳定版本:
| 方案 | 版本 | 包体积(gzip) | 学习成本 | 是否需 Provider | 选择器粒度 |
|---|---|---|---|---|---|
| 继续用 Context + useMemo | - | 0 | 低 | 是 | 组件级 |
| Redux Toolkit | 1.9.7 | ~13KB | 中高 | 是 | 选择器 |
| Jotai | 2.6.0 | ~4KB | 中 | 是(可选) | 原子级 |
| Zustand | 4.4.1 | ~1.2KB | 低 | 否 | 选择器 |
最终选 Zustand,理由很实际:
- 不需要包 Provider,可以直接在组件外调用
store.getState(),这对我们一些工具函数和 axios 拦截器很友好; - 选择器机制天然解决重渲染问题,
useStore(s => s.xxx)只在选中值变化时重渲染; - 迁移成本低,API 就
create一个函数,团队 3 个人半天就能上手; - 包体积小,对中台这种本来就不在乎几 KB 的项目其实是次要因素,但没坏处。
Jotai 其实也不错,原子化模型更细,但我们现有状态大多是"按业务域聚合"的,用 Zustand 的 slice 模式更贴合。Redux Toolkit 太重,样板代码多,团队之前用过,评价一般。
三、方案设计:按业务域拆分 store
迁移不是把 Context 原样搬过去,得先重新设计状态边界。我们按业务域拆了 5 个 store:
src/stores/
userStore.ts // 用户信息、权限、登录态
configStore.ts // 全局配置、字典
workspaceStore.ts // 工作区、租户、仓库选择
noticeStore.ts // 通知、弹窗队列
orderFilterStore.ts // 订单筛选页局部状态(从 useState 提升)
原则有三条:
- 全局状态进 store,页面局部状态仍用 useState。不要把所有状态都塞进 store,那只是把 Context 的问题换个地方。
- 每个 store 只暴露最小粒度的选择器。避免
useStore()不带选择器直接拿整个 state。 - 异步逻辑写在 store 的 action 里,替代原来散落在组件里的 useEffect + fetch。
四、核心实现
先看一个典型的迁移前后对比。原来 WorkspaceContext 大概是这样:
// 迁移前:WorkspaceContext.tsx
const WorkspaceContext = createContext(null);
export function WorkspaceProvider({ children }: { children: ReactNode }) {
const [workspace, setWorkspace] = useState(null);
const [tenantId, setTenantId] = useState('');
const [warehouseId, setWarehouseId] = useState('');
// 每次渲染都创建新对象,所有消费者重渲染
const value = { workspace, setWorkspace, tenantId, setTenantId, warehouseId, setWarehouseId };
return {children};
}
export function useWorkspace() {
const ctx = useContext(WorkspaceContext);
if (!ctx) throw new Error('useWorkspace must be used within WorkspaceProvider');
return ctx;
}
迁移后,workspaceStore.ts:
// 迁移后:src/stores/workspaceStore.ts
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
import { workspaceApi } from '@/api/workspace';
interface WorkspaceState {
workspace: Workspace | null;
tenantId: string;
warehouseId: string;
loading: boolean;
setTenant: (id: string) => void;
setWarehouse: (id: string) => void;
fetchWorkspace: (id: string) => Promise;
}
export const useWorkspaceStore = create()(
devtools(
persist(
(set, get) => ({
workspace: null,
tenantId: '',
warehouseId: '',
loading: false,
setTenant: (id) => {
// 切换租户时清空仓库选择,避免脏数据
set({ tenantId: id, warehouseId: '' }, false, 'workspace/setTenant');
},
setWarehouse: (id) => set({ warehouseId: id }, false, 'workspace/setWarehouse'),
fetchWorkspace: async (id) => {
set({ loading: true }, false, 'workspace/fetchStart');
try {
const data = await workspaceApi.get(id);
set({ workspace: data, loading: false }, false, 'workspace/fetchSuccess');
} catch (e) {
set({ loading: false }, false, 'workspace/fetchError');
throw e;
}
},
}),
{
name: 'workspace-storage',
// 只持久化必要的字段,workspace 对象太大不存
partialize: (state) => ({ tenantId: state.tenantId, warehouseId: state.warehouseId }),
}
),
{ name: 'WorkspaceStore', enabled: import.meta.env.DEV }
)
);
组件里这样用:
// 迁移后:组件内使用
import { useWorkspaceStore } from '@/stores/workspaceStore';
import { useShallow } from 'zustand/react/shallow';
function WarehouseSelector() {
// 只订阅需要的字段,tenantId 变化不会触发这个组件重渲染
const warehouseId = useWorkspaceStore((s) => s.warehouseId);
const setWarehouse = useWorkspaceStore((s) => s.setWarehouse);
return (
);
}
// 需要多个字段时用 useShallow,避免对象引用每次都变
function WorkspaceHeader() {
const { workspace, loading } = useWorkspaceStore(
useShallow((s) => ({ workspace: s.workspace, loading: s.loading }))
);
// ...
}
这里有个关键点:如果要选多个字段,必须用 useShallow。直接 useStore(s => ({ a: s.a, b: s.b })) 每次都会返回新对象,等于没优化,甚至比 Context 更糟。这是迁移时最容易踩的坑,后面会细说。
再说异步 action 替代 useEffect 的例子。订单筛选页原来是这样:
// 迁移前:组件内散落的异步逻辑
function OrderFilter() {
const { tenantId } = useWorkspace();
const [dict, setDict] = useState([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!tenantId) return;
setLoading(true);
fetchDict(tenantId).then(setDict).finally(() => setLoading(false));
}, [tenantId]);
// ...
}
迁移后把字典拉取收进 configStore,组件只订阅结果:
// src/stores/configStore.ts
export const useConfigStore = create()((set, get) => ({
dictMap: {} as Record,
dictLoading: false,
fetchDict: async (tenantId: string) => {
if (get().dictMap[tenantId]) return; // 缓存命中直接返回
set({ dictLoading: true });
const data = await dictApi.list(tenantId);
set((s) => ({ dictMap: { ...s.dictMap, [tenantId]: data }, dictLoading: false }));
},
}));
组件里 useEffect(() => { fetchDict(tenantId) }, [tenantId, fetchDict]),注意 action 引用是稳定的,可以直接进依赖数组,不会死循环。
五、踩坑与优化
迁移过程大概用了两周,其中踩坑占了 3 天,说几个典型的:
坑 1:选择器返回新对象导致无限重渲染。 一开始有同事写 useStore(s => ({ list: s.list, total: s.total })),结果组件疯狂重渲染,控制台刷屏。原因是每次渲染选择器都返回新对象,Zustand 用 Object.is 比较,永远不相等。解决办法就是用 useShallow,或者拆成两个 useStore 调用。我们在 ESLint 里加了一条自定义规则,检测 useStore 内返回对象字面量且外层没包 useShallow 的情况,CI 直接拦。
坑 2:persist 中间件把大对象写进 localStorage。 一开始没配 partialize,workspace 对象(含大量嵌套配置)被整个序列化进去,localStorage 单个 key 超过 2MB,某些浏览器直接抛 QuotaExceededError。加上 partialize 只存两个 id 字段后,体积降到 200 字节以内。
坑 3:devtools 在 production 下仍然打包。 devtools 中间件默认会引入一些调试代码,虽然不重,但我们还是用 enabled: import.meta.env.DEV 关掉了。另外提醒一句,zustand/middleware 的 devtools 需要配合 Redux DevTools 扩展,团队里用 Safari 的同事装不了,调试时得换 Chrome。
坑 4:SSR/测试环境里 store 单例问题。 我们有个内部工具用 Vitest 跑单测,多个测试文件共享同一个 store 实例,状态互相污染。解决办法是在 beforeEach 里调用 useWorkspaceStore.setState(initialState, true) 重置,或者用 createStore 工厂函数给每个测试建独立实例。
性能优化上还做了两件事:
- 用
subscribeWithSelector中间件做跨 store 联动。比如租户切换时要清空订单筛选条件,原来靠 useEffect 监听,现在直接:
useWorkspaceStore.subscribe(
(s) => s.tenantId,
(tenantId, prevTenantId) => {
if (tenantId !== prevTenantId) {
useOrderFilterStore.getState().reset();
}
}
);
- 对高频更新的状态用 transient update。通知队列的倒计时每秒更新,如果走 React 渲染会触发很多次。我们用
useNoticeStore.subscribe直接在回调里操作 DOM 的textContent,绕过 React 渲染,这个场景下够用了。
六、效果数据
迁移上线后,我用 React DevTools Profiler 和 Lighthouse 各测了一轮,数据如下(同一台机器、同一份数据、Chrome 120):
| 指标 | 迁移前 | 迁移后 | 变化 |
|---|---|---|---|
| SKU 搜索输入 8 字符总渲染次数 | 213 次 | 12 次 | -94% |
| 单次按键平均渲染耗时 | 175ms | 41ms | -77% |
| 订单筛选页首次交互延迟(TTI) | 2.8s | 2.1s | -25% |
| 首屏 JS 体积(gzip) | 186KB | 187.2KB | +1.2KB |
| Context Provider 嵌套层数 | 4 层 | 0 层 | - |
渲染次数从 213 降到 12 是我最满意的。原因很直接:原来输入框的 value 存在 Context 里,每次输入整个 Provider 子树重渲染;现在输入框用本地 useState,只有提交时才写 store,其他订阅 warehouseId 的组件完全不受影响。
包体积增加了 1.2KB(Zustand 本体),基本可以忽略。代码行数上,删掉了 4 个 Context 文件约 380 行,新增 5 个 store 约 420 行,净增 40 行,但逻辑集中度明显提高——原来找一个状态的修改点要全局搜 setTenantId,现在直接看 store 文件。
还有个小收益:新同事上手快了。之前要理解 Context + Props 混合的数据流,得画半天图;现在打开 stores/ 目录,5 个文件就是全部全局状态,一目了然。
七、总结
这次迁移整体是值得的,但我想说几句实话:
不是所有项目都需要迁。 如果你的项目 Context 用得规范(value 用 useMemo 包、按领域拆分、消费者少),其实没必要折腾。我们迁是因为 Context 被滥用成了全局状态容器,且出现了可测量的性能问题。
迁移的核心不是换库,是重新划分状态边界。 我们花在设计 store 拆分上的时间比写代码多。如果只是把 Context 的 value 原样搬进 store,性能问题可能解决一半,但维护性问题还在。
Zustand 不是银弹。 选择器写不好照样重渲染,useShallow 该用还得用。它的优势在于 API 简单、没有 Provider、能在组件外访问,适合中小型项目和团队。
如果让我重来一次,我会在项目立项时就引入 Zustand,而不是等到 Context 撑不住了再迁。但话说回来,不经历一次 Context 的性能坑,团队也不会真正理解"状态管理"和"跨层传值"的区别。这大概就是所谓的技术债的利息吧。
本文代码基于 zustand@4.4.1,不同版本 API 可能有差异,迁移前请对照官方 changelog。