一、问题背景:props 透传和 Context 到底哪里疼

项目是一个典型的中后台系统,React 18.2 + TypeScript 5.0 做管理端,Vue 3.3 + Vite 4.4 做数据看板,两个子应用共享一套业务模型。早期为了"简单",React 侧用 Context 存全局用户信息、权限、主题和一部分列表筛选条件,Vue 侧用 provide/inject 做类似的事,局部状态就靠 props 一层层往下传。

一开始没问题,页面层级浅。等到业务铺开,问题集中爆发:

第一,props 透传的层级到了 5~7 层。一个 DataTable 组件要接收 filters、onFilterChange、permissions、theme 四个跟它自身逻辑无关的 prop,中间三层组件纯粹当"二传手"。改一个字段名要动 6 个文件。

第二,Context 的"全量广播"特性让性能很难看。我们的 AppContext 把 user、theme、filters、notifications 塞在一个对象里,任何字段变化都会让所有 useContext 的消费者重渲染。用 React DevTools Profiler 录了一次筛选条件变更,提交阶段有 63 个组件重渲染,其中真正需要更新的只有 8 个。

第三,Vue 侧 provide/inject 的响应式追踪虽然比 Context 精细,但 inject 的值来源不透明,新人排查"这个 theme 从哪来的"要全局搜 provide(。而且 inject 默认不响应解构,团队里踩过 const { theme } = inject('config') 丢掉响应性的坑。

第四,测试困难。Context 的值要靠 wrapper 包裹,每个测试文件重复写 Provider,mock 成本高。

结论很明确:不是"要不要状态管理库"的问题,是"再不上就要重构不动了"。

二、环境与版本

先把版本钉死,避免读者对不上:

  • React 18.2.0,react-dom 18.2.0,TypeScript 5.0.4
  • Zustand 4.4.1,immer 10.0.2(中间件)
  • Vue 3.3.4,Pinia 2.1.4,Vite 4.4.5
  • 测试:Vitest 0.34.2 + @testing-library/react 14.0.0
  • 构建:Vite 4.4.5,生产构建开启 build.sourcemap: false
  • 性能采集:React DevTools Profiler 4.28、Chrome 116 Performance 面板、web-vitals 3.4.0 采集 LCP

机器是 M1 Pro / 16G,Chrome 116,禁用缓存跑 5 次取中位数。

三、方案设计:选型对比与边界划分

选型时我们列了三个候选:Zustand、Redux Toolkit、Jotai(React 侧);Vue 侧对比 Pinia 和 Vuex 4。

React 侧最终选 Zustand,理由很实际:

  • 不需要 Provider 包裹,store 是模块级单例,测试时直接 setState 重置,省掉 wrapper。
  • 选择器订阅粒度细,useStore(s => s.filters) 只在 filters 引用变化时重渲染,天然解决 Context 全量广播。
  • 包体积小,gzip 后约 1.2KB,比 RTK 的 13KB 轻很多。
  • 不需要写 action/reducer 模板,中后台的 CRUD 状态用起来不啰嗦。

Redux Toolkit 能力更强但模板代码多,团队已经在为 Context 的冗余头疼,不想再引入一层样板。Jotai 的原子化模型很好,但我们的状态是"按业务域聚合"而非"细粒度派生",用 atom 会把状态切得太碎,反而增加心智负担。

Vue 侧选 Pinia 没有悬念:Vuex 4 已进入维护模式,Pinia 是官方推荐,且 setup 语法写起来跟组合式 API 一致,storeToRefs 解决解构丢响应性的问题。

边界划分是这次迁移最重要的设计决策。我们定的规则:

  1. 跨页面、跨层级共享的业务状态进 store(用户、权限、主题、全局筛选、通知)。
  2. 组件内部 UI 状态(弹窗开关、输入框临时值)留在 useState / ref。
  3. 只在父子间传递、层级 ≤2 的数据继续用 props,不强行上 store。
  4. 服务端数据(列表、详情)不进 store,交给请求库缓存层,store 只存"筛选条件"这类客户端状态。

这条边界很关键,很多团队迁移失败是因为把什么都塞进 store,结果 store 变成一个巨型全局变量,性能没提升反而更难维护。

四、核心实现:两套代码

4.1 React:从 Context 到 Zustand

迁移前的 Context 长这样(简化):

// 迁移前:AppContext.tsx
import { createContext, useContext, useState, ReactNode } from 'react';

interface AppState {
  user: User | null;
  theme: 'light' | 'dark';
  filters: Record;
  notifications: Notice[];
}

const AppContext = createContext) => void;
  toggleTheme: () => void;
} | null>(null);

export function AppProvider({ children }: { children: ReactNode }) {
  const [state, setState] = useState({
    user: null, theme: 'light', filters: {}, notifications: [],
  });
  const setFilters = (filters: Record) =>
    setState(prev => ({ ...prev, filters }));
  const toggleTheme = () =>
    setState(prev => ({ ...prev, theme: prev.theme === 'light' ? 'dark' : 'light' }));
  return (

      {children}

  );
}

export function useApp() {
  const ctx = useContext(AppContext);
  if (!ctx) throw new Error('useApp must be used within AppProvider');
  return ctx;
}

问题就在 value={{ state, setFilters, toggleTheme }}:每次 setState 都生成新对象,所有消费者无条件重渲染。

迁移后用 Zustand,按业务域拆成两个 store:

// store/appStore.ts
import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';

interface User { id: string; name: string; roles: string[] }

interface AppStore {
  user: User | null;
  theme: 'light' | 'dark';
  filters: Record;
  setUser: (u: User | null) => void;
  toggleTheme: () => void;
  setFilters: (f: Record) => void;
  reset: () => void;
}

const initialState = { user: null, theme: 'light' as const, filters: {} };

export const useAppStore = create()(
  immer((set) => ({
    ...initialState,
    setUser: (user) => set((s) => { s.user = user; }),
    toggleTheme: () => set((s) => { s.theme = s.theme === 'light' ? 'dark' : 'light'; }),
    setFilters: (filters) => set((s) => { s.filters = filters; }),
    reset: () => set(initialState),
  }))
);

// 选择器:组件只订阅自己需要的切片
export const useTheme = () => useAppStore((s) => s.theme);
export const useFilters = () => useAppStore((s) => s.filters);
export const useSetFilters = () => useAppStore((s) => s.setFilters);

组件里的用法:

// 迁移后:DataTable.tsx
import { useFilters, useSetFilters } from '@/store/appStore';

export function DataTable() {
  const filters = useFilters();        // 只在 filters 引用变化时重渲染
  const setFilters = useSetFilters();  // action 引用稳定,永不触发重渲染
  return (
     setFilters(next)}
    />
  );
}

这里有个细节:useSetFilters 返回的是 store 里定义的函数,Zustand 保证 action 引用稳定,所以订阅 action 的组件不会因为状态变化而重渲染。这就是把"读"和"写"拆成两个 hook 的价值。

4.2 Vue:从 provide/inject 到 Pinia

迁移前的 provide/inject:

import { provide, reactive, readonly } from 'vue';
const config = reactive({ theme: 'light', filters: {} as Record });
provide('config', readonly(config));




import { inject } from 'vue';
const config = inject('config')!;

迁移后用 Pinia:

// stores/app.ts
import { defineStore } from 'pinia';
import { ref } from 'vue';

export const useAppStore = defineStore('app', () => {
  const theme = ref('light');
  const filters = ref>({});

  function toggleTheme() {
    theme.value = theme.value === 'light' ? 'dark' : 'light';
  }
  function setFilters(next: Record) {
    filters.value = next;
  }
  return { theme, filters, toggleTheme, setFilters };
});

组件里用 storeToRefs 保持响应性:

import { storeToRefs } from 'pinia';
import { useAppStore } from '@/stores/app';

const store = useAppStore();
const { theme, filters } = storeToRefs(store); // 解构仍然响应式
const { setFilters } = store;                 // action 直接解构

storeToRefs 是 Vue 侧最大的收益点:它把 store 的 state 和 getter 转成 ref,解构不丢响应性,彻底消灭了 const { theme } = inject(...) 那类坑。

迁移步骤我们按这个顺序执行,两套技术栈通用:

  1. 盘点:用 grep -rn "useContext\|inject(" src 列出所有消费点,标注"读"还是"写"。
  2. 建 store:按业务域拆,先建不接线,写单测。
  3. 双写过渡:Context 保留,新增 store 同步写一份,跑一周观察数据一致性。
  4. 逐组件切换:从叶子组件往上切,每切一个跑一次 Profiler 对比。
  5. 删除旧 Context:确认无引用后删 Provider,清理测试 wrapper。

第 3 步的双写很土但有效,出了不一致能立刻定位是哪个消费点漏切。

五、踩坑与优化

坑 1:选择器返回新对象导致无限重渲染。 有人写了 useAppStore(s => ({ theme: s.theme, filters: s.filters })),每次返回新对象,引用永远不等,组件无限重渲染。解决办法是用 useShallow:

import { useShallow } from 'zustand/react/shallow';
const { theme, filters } = useAppStore(
  useShallow((s) => ({ theme: s.theme, filters: s.filters }))
);

坑 2:Vue 里解构 store 丢响应性。 直接 const { theme } = useAppStore() 拿到的是普通值,theme 变了组件不更新。必须用 storeToRefs。我们加了 ESLint 规则 pinia/no-store-destructuring 拦住这类写法。

坑 3:immer 中间件下误用返回值。 用了 immer 后 set 里直接改 s.xxx,如果又 return {...} 会覆盖掉 immer 的 draft,状态静默丢失。统一约定:immer 下 set 不写 return。

坑 4:测试隔离。 Zustand store 是模块单例,测试间会串状态。在 setup.ts 里加:

import { beforeEach } from 'vitest';
import { useAppStore } from '@/store/appStore';
beforeEach(() => useAppStore.getState().reset());

Pinia 则是在每个测试里 setActivePinia(createPinia())。

优化:selector 粒度。 把 useAppStore(s => s) 这种全量订阅全部改成字段级订阅。改完后 Profiler 里单次筛选变更的重渲染组件数从 63 降到 8。

六、效果数据

迁移前后用同一份操作脚本跑 5 次取中位数:

指标 迁移前(Context/provide) 迁移后(Zustand/Pinia)
首屏 LCP 1.82s 1.13s
首屏 JS 执行 420ms 310ms
筛选变更重渲染组件数 63 8
单次交互平均重渲染次数 47 6
状态相关测试 wrapper 代码行 约 180 行 约 20 行
包体积增量(gzip) - +1.2KB(Zustand)/ +1.6KB(Pinia)

首屏提升主要来自两点:一是删掉了 Provider 树带来的额外渲染层级,二是 Context 的全量广播消失后,首屏挂载阶段少了约 40 次无意义重渲染。JS 执行时间下降是选择器细粒度订阅的直接结果。

需要诚实说的是:如果项目层级浅、共享状态少,迁移收益没这么大,甚至可能因为引入新依赖而变慢。我们这套数据是在 60+ 组件、7 层透传、状态更新频繁的中后台场景下测的,场景不同结论会变。

七、总结

这次迁移最大的收获不是"换了个库",而是把状态按"业务域 + 读写分离"重新划了一遍边界。Zustand 的选择器订阅和 Pinia 的 storeToRefs 解决的是机制问题,真正让性能数字好看的是那条"什么该进 store、什么不该进"的规则。

给准备迁移的团队三条建议:

  1. 先盘点消费点再动手,别一上来就删 Context。
  2. 双写过渡一周,比事后 debug 便宜得多。
  3. 边界规则写进团队文档,否则三个月后 store 又会变成新的全局变量堆。

两个库的版本都在快速迭代,Zustand 4.x 和 Pinia 2.x 的 API 已经比较稳定,按本文的写法可以直接落地。如果你的项目还在 Context 里塞十几个字段,建议先做一次 Profiler,看看重渲染数字,再决定要不要动。