一、问题背景:Props 和 Context 在什么规模下会崩

项目是一个运营后台,React 侧负责数据看板与配置中心,Vue 侧负责内容审核与日志查询。两边最初都遵循“简单优先”:

  • React:跨层级用 createContext + useContext,相邻层级用 Props。
  • Vue:用 provide/inject,局部状态用 props + emit。

页面数量到 40+ 之后,问题集中爆发:

  1. Context 的“全局刷新”问题:一个 Context 里放了 userInfo、permission、theme、globalFilter。任何一项变化,所有 useContext 的组件都会重渲染。我们实测修改一次 globalFilter,触发 230 次组件渲染,其中 190 次是无意义渲染。
  2. Props 链过深:Vue 侧一个审核详情页,props 从 ListPage 传到 DetailPanel 再到 ActionBar,最深 7 层。中间层组件根本不使用这些 props,只做“搬运”。
  3. 逻辑复用困难:多个页面需要“当前选中项”“批量操作状态”,用 Context 要新建 Provider,用 provide/inject 要写重复的 symbol key,维护成本高。
  4. 调试困难:React DevTools 里 Context 值变化看不到“谁改的”;Vue DevTools 里 inject 的值没有时间线。

我们决定迁移到专门的状态管理库:React 用 Zustand 4.4.1,Vue 用 Pinia 2.1.6。

二、环境与版本

  • React:18.2.0,TypeScript 5.2.2,Vite 4.4.5
  • Vue:3.3.4,TypeScript 5.2.2,Vite 4.4.5
  • 原状态方案:React Context + useReducer;Vue provide/inject + reactive
  • 新状态库:Zustand 4.4.1;Pinia 2.1.6
  • 性能测量:React Profiler API + Vue onRenderTracked,Chrome Performance 面板
  • 测试页面:数据看板(React,约 120 个组件)、审核列表(Vue,约 90 个组件)

三、方案设计:为什么选 Zustand 和 Pinia

我们对比了 4 个候选:

方案 React 支持 Vue 支持 包体积 学习成本 细粒度更新
Context + useReducer 原生 不适用 0 低 差
Redux Toolkit 好 需适配 13KB 中 中(需 reselect)
Zustand 好 不适用 1.2KB 低 好(selector)
Pinia 不适用 好 1.5KB 低 好(setup store)

最终选择理由:

  • Zustand:不需要 Provider,store 是外部对象,组件通过 selector 订阅。只有 selector 返回值变化时才重渲染。API 极简:create + useStore。
  • Pinia:Vue 官方推荐,setup 语法和组合式 API 一致,支持 storeToRefs 解构保持响应性,DevTools 时间线完整。

迁移原则:不一次性重写。先在新页面用新库,再逐步替换旧页面。每个模块迁移后跑一次性能对比。

四、核心实现与迁移步骤

4.1 React:从 Context 到 Zustand

原 Context 写法(问题版):

// 旧代码:GlobalContext.tsx
const GlobalContext = createContext(null);

export function GlobalProvider({ children }) {
  const [userInfo, setUserInfo] = useState({});
  const [globalFilter, setGlobalFilter] = useState({ status: 'all' });
  const [theme, setTheme] = useState('light');

  const value = { userInfo, setUserInfo, globalFilter, setGlobalFilter, theme, setTheme };
  return {children};
}

// 任意子组件
const { globalFilter, theme } = useContext(GlobalContext); // 任一值变化都重渲染

迁移步骤:

  1. 按领域拆分 store:useUserStore、useFilterStore、useThemeStore。
  2. 每个 store 用 create 定义,状态和 action 放一起。
  3. 组件用 selector 订阅,避免解构整个 store。

Zustand 实现:

// stores/filterStore.ts
import { create } from 'zustand';

interface FilterState {
  status: 'all' | 'pending' | 'done';
  keyword: string;
  setStatus: (s: FilterState['status']) => void;
  setKeyword: (k: string) => void;
  reset: () => void;
}

export const useFilterStore = create((set) => ({
  status: 'all',
  keyword: '',
  setStatus: (status) => set({ status }),
  setKeyword: (keyword) => set({ keyword }),
  reset: () => set({ status: 'all', keyword: '' }),
}));

// 组件中使用:只订阅 status
function StatusSelect() {
  const status = useFilterStore((s) => s.status);
  const setStatus = useFilterStore((s) => s.setStatus);
  console.log('StatusSelect render'); // 只有 status 变化才打印
  return (
     setStatus(e.target.value as any)}>
      全部
      待审核
      已完成

  );
}

关键点:useFilterStore((s) => s.status) 返回原始值,Zustand 用 Object.is 比较。如果返回对象,需要 shallow 比较:

import { shallow } from 'zustand/shallow';

const { status, keyword } = useFilterStore(
  (s) => ({ status: s.status, keyword: s.keyword }),
  shallow
);

4.2 Vue:从 provide/inject 到 Pinia

原 provide/inject 写法:

import { provide, reactive } from 'vue';
const filter = reactive({ status: 'all', keyword: '' });
provide('filter', filter);




import { inject } from 'vue';
const filter = inject('filter'); // 类型丢失,key 硬编码

迁移步骤:

  1. 创建 stores/filter.ts,用 defineStore + setup 语法。
  2. 组件中 useFilterStore() 获取 store,用 storeToRefs 解构状态。
  3. 移除所有 provide/inject 和对应的 symbol key。

Pinia 实现:

// stores/filter.ts
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';

export const useFilterStore = defineStore('filter', () => {
  const status = ref('all');
  const keyword = ref('');

  const hasFilter = computed(() => status.value !== 'all' || keyword.value !== '');

  function setStatus(s: typeof status.value) {
    status.value = s;
  }
  function setKeyword(k: string) {
    keyword.value = k;
  }
  function reset() {
    status.value = 'all';
    keyword.value = '';
  }

  return { status, keyword, hasFilter, setStatus, setKeyword, reset };
});
import { storeToRefs } from 'pinia';
import { useFilterStore } from '@/stores/filter';

const filterStore = useFilterStore();
const { status, keyword } = storeToRefs(filterStore); // 保持响应性
const { setStatus } = filterStore; // action 直接解构

console.log('StatusSelect setup'); // 只有 status 变化才触发组件更新




    全部
    待审核
    已完成

注意:storeToRefs 只对 state 和 getter 有效,action 直接解构即可。

五、踩坑与优化

坑 1:Zustand selector 返回新对象导致无限重渲染

// 错误:每次返回新对象,Object.is 比较失败
const { status, keyword } = useFilterStore((s) => ({
  status: s.status,
  keyword: s.keyword,
})); // 每次渲染都触发更新

// 正确:用 shallow 比较
import { shallow } from 'zustand/shallow';
const { status, keyword } = useFilterStore(
  (s) => ({ status: s.status, keyword: s.keyword }),
  shallow
);

坑 2:Pinia 解构丢失响应性

// 错误:直接解构,status 变成普通值
const { status } = useFilterStore();

// 正确:用 storeToRefs
const { status } = storeToRefs(useFilterStore());

坑 3:迁移期间两套状态并存导致数据不同步

我们采用“双写”过渡:旧 Context 的 setter 内部调用新 store 的 action,新组件只读新 store。持续 2 周后删除旧代码。不要试图一次性替换所有组件,否则回归测试量太大。

优化:Zustand 持久化与 Pinia 插件

// Zustand 持久化
import { persist } from 'zustand/middleware';

export const useThemeStore = create(
  persist(
    (set) => ({
      theme: 'light',
      setTheme: (theme: string) => set({ theme }),
    }),
    { name: 'theme-storage' }
  )
);
// Pinia 持久化插件
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate';

const pinia = createPinia();
pinia.use(piniaPluginPersistedstate);

// store 中
defineStore('theme', () => { ... }, {
  persist: { key: 'theme-storage', storage: localStorage },
});

六、效果数据

迁移后我们用 React Profiler 和 Vue onRenderTracked 统计同一操作(修改筛选条件)的渲染次数:

指标 迁移前(Context/provide) 迁移后(Zustand/Pinia) 变化
组件渲染次数 230 18 -92%
首屏可交互时间 1.82s 1.48s -340ms
内存占用(堆快照) 42.3MB 38.7MB -3.6MB
代码行数(状态相关) 1,240 860 -31%
新增页面平均开发时间 2.5 天 1.5 天 -40%

具体到页面:

  • React 数据看板:修改 globalFilter 从触发 230 次渲染降到 18 次,其中 12 次是真正需要更新的图表组件。
  • Vue 审核列表:切换分页从触发 90 次渲染降到 9 次,列表滚动 FPS 从 48 提升到 58。

七、总结

从 Context/Props 迁移到 Zustand/Pinia 不是“为了用而用”,而是在特定规模下的必然选择。我们的经验:

  1. Context 适合低频、全局、不常变的值(如用户信息、主题)。一旦出现高频更新或大对象,就该换。
  2. Props 透传超过 3 层就考虑状态库或组合式函数,不要硬传。
  3. 迁移要渐进:双写过渡、按模块替换、每步测性能。
  4. Zustand 的 selector 和 Pinia 的 storeToRefs 是性能关键,用错等于没换。
  5. 版本选择:Zustand 4.4.x 稳定,Pinia 2.1.x 配合 Vue 3.3 无坑。

如果你的项目也出现“改一个值触发上百次渲染”,建议先量一下渲染次数,再决定是否迁移。数据不会骗人。