一、问题背景:Context 不是状态管理库,Props 更不是

先说结论:Context 是依赖注入工具,不是状态管理方案。这句话我在迁移前就看过,但真正被教育是在一次线上事故之后。

我们的后台系统有两个入口:React 18.2 写的运营控制台,Vue 3.3 写的商家工作台。两边共享一套业务模型,但状态管理是各写各的:

  • React 侧:AppContext + useReducer,所有全局状态塞在一个 reducer 里,Provider 包在最顶层。
  • Vue 侧:props + emits 一路往下传,跨层级用 provide/inject 兜底。

问题在 Q2 集中爆发:

  1. React 侧:一个"切换租户"的操作会触发 AppContext 更新,而 Context value 是一个新对象,导致所有 useContext(AppContext) 的组件全部重渲染。我们在 React DevTools Profiler 里看到一次切换租户触发了 312 次组件渲染,其中真正需要更新的只有侧边栏和列表区大约 40 个组件。
  2. Vue 侧provide/inject 的响应式对象被某个子组件直接 mutate,父组件不知情,出现了一个"改了数据但视图不更新"的 bug,排查了两天才定位到。

指标上:Lighthouse 首屏 TTI 3.4s,React Profiler 单次交互平均渲染耗时 180ms,Vue 侧一次表单联动触发 60+ 组件更新。

这不是性能优化问题,是架构问题

二、环境与版本

先交代清楚环境,避免版本差异导致结论不可复现。

React: 18.2.0
ReactDOM: 18.2.0
Zustand: 4.5.2
Immer: 10.1.1
Vue: 3.3.4
Pinia: 2.1.7
Vite: 5.0.10
TypeScript: 5.3.3
Node: 20.11.0

浏览器基准:Chrome 120,MacBook Pro M1 16G,测试数据取 5 次运行中位数。

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

候选方案我们列了这几个:

React 侧:

方案 包体积(gzip) 学习成本 重渲染控制 结论
Context + useReducer 0 差(全量订阅) 现状,淘汰
Redux Toolkit ~13KB 中(需 useSelector) 样板代码多
Zustand ~1.2KB 好(selector 粒度) 采用
Jotai ~3KB 好(原子化) 适合细粒度,但迁移成本高

Vue 侧:

方案 包体积(gzip) DevTools SSR 结论
props/emits 0 支持 现状,淘汰
provide/inject 0 支持 隐式依赖,淘汰
Vuex 4 ~4KB 支持 官方已推荐 Pinia
Pinia ~1.5KB 支持 采用

选 Zustand 的核心理由:它不在 React 树里,订阅是外部的。这意味着 Context 那种"Provider value 变了整棵树重渲染"的问题从根上不存在。Pinia 则是 Vue 官方推荐,TypeScript 推断好,且天然支持组合式 API。

四、核心实现

4.1 React:从 Context 到 Zustand

迁移前(简化版):

// 迁移前:Context + useReducer
const AppContext = createContext(null);

function AppProvider({ children }) {
  const [state, dispatch] = useReducer(reducer, initialState);
  // value 每次都是新对象,全量订阅者重渲染
  const value = { state, dispatch };
  return {children};
}

// 任意子组件
function TenantSwitcher() {
  const { state, dispatch } = useContext(AppContext);
  // 只用到 state.tenant,但 state.user / state.list 变化也会重渲染
  return  dispatch({ type: 'SWITCH_TENANT' })}>{state.tenant};
}

迁移后

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

interface AppState {
  tenant: string;
  user: { id: string; name: string } | null;
  list: Array;
  switchTenant: (t: string) => void;
}

export const useAppStore = create()(
  immer((set) => ({
    tenant: 'default',
    user: null,
    list: [],
    switchTenant: (t) =>
      set((draft) => {
        draft.tenant = t;
      }),
  }))
);
// 使用:selector 精确订阅
function TenantSwitcher() {
  // 只订阅 tenant,其他字段变化不触发本组件重渲染
  const tenant = useAppStore((s) => s.tenant);
  const switchTenant = useAppStore((s) => s.switchTenant);
  return  switchTenant('new-tenant')}>{tenant};
}

关键点:

  1. selector 必须返回原始值或稳定引用。如果写 useAppStore((s) => ({ tenant: s.tenant })),每次返回新对象,会触发无限重渲染。要配合 useShallow
import { useShallow } from 'zustand/react/shallow';
const { tenant, user } = useAppStore(useShallow((s) => ({ tenant: s.tenant, user: s.user })));
  1. action 直接写在 store 里,不要单独 dispatch,减少一层间接。

4.2 Vue:从 props/emits 到 Pinia

迁移前


一路透传,中间组件被迫声明自己根本不用的 props。

迁移后

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

export const useAppStore = defineStore('app', () => {
  const tenant = ref('default');
  const user = ref(null);
  const tenantLabel = computed(() => `租户:${tenant.value}`);

  function switchTenant(t: string) {
    tenant.value = t;
  }

  return { tenant, user, tenantLabel, switchTenant };
});
import { storeToRefs } from 'pinia';
import { useAppStore } from '@/stores/app';

const store = useAppStore();
// 用 storeToRefs 保持解构后的响应性
const { tenant, user } = storeToRefs(store);
// action 直接解构,不需要 storeToRefs
const { switchTenant } = store;



  {{ tenant }}

踩坑点:直接解构 store 会丢响应性,必须用 storeToRefs。这是 Pinia 新手最容易犯的错,我们 code review 时抓到过 3 次。

五、踩坑与优化

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

前面提过。表现是 React 报 "Maximum update depth exceeded"。解决:useShallow 或拆成多个原子 selector。

坑 2:Pinia 在 setup 外使用

在路由守卫、axios 拦截器里用 useAppStore() 会报 "no active Pinia"。解决:在 main.tsapp.use(pinia) 之后,把 pinia 实例导出,或者在这些地方传入实例:

// main.ts
export const pinia = createPinia();
app.use(pinia);

// 拦截器里
import { pinia } from '@/main';
const store = useAppStore(pinia);

坑 3:Zustand 的 set 默认浅合并,嵌套对象要小心

set({ user: { name: 'x' } }) 会整个替换 user,丢掉 id。我们用 immer 中间件解决,写法更直观。

坑 4:迁移期间的混合状态

迁移不是一蹴而就的,我们花了 3 周,期间新旧共存。做法是:先迁移叶子组件依赖的全局状态,再逐层往上。Context 和 Zustand 可以并存,但要注意不要两处都存同一份数据,否则同步噩梦。

优化:selector 粒度

迁移后我们发现还有优化空间。原来 useAppStore((s) => s.list) 在 list 数组任何一项变化时都会重渲染,即使用了 React.memo。后来改成:

// 列表组件只订阅长度和 id 列表
const ids = useAppStore(useShallow((s) => s.list.map((i) => i.id)));

渲染次数又降了约 15%。

六、效果数据

迁移前后对比(同一套测试用例,5 次中位数):

React 侧:

指标 Context Zustand 变化
切换租户触发渲染次数 312 87 -72%
单次交互平均渲染耗时 180ms 52ms -71%
首屏 TTI 3.4s 1.9s -44%
包体积(gzip) 0 +1.2KB +1.2KB
Lighthouse Performance 68 89 +21

Vue 侧:

指标 props/emits Pinia 变化
表单联动触发更新组件数 60+ 12 -80%
表单输入响应延迟 120ms 28ms -77%
代码行数(状态相关) ~2400 ~1600 -33%

包体积上 Zustand + Pinia 一共增加约 2.7KB gzip,换来 TTI 缩短 1.5s,非常划算。

七、总结

几点真实体会:

  1. Context 和 props 不是不能用,是不该用在频繁更新的全局状态上。静态配置、主题、i18n 用 Context 完全没问题。判断标准:这个状态更新频率高不高?订阅者多不多?两个都高,就该上状态管理库。
  2. Zustand 的核心优势是"在 React 树外"。它不是更快的 Context,它是完全不同的订阅模型。理解这一点,很多设计决策就顺了。
  3. Pinia 的 storeToRefs 是必踩的坑,团队里要有约定,code review 要盯。
  4. 迁移要分阶段,不要想着一次全换。新旧共存期间,关键是"同一份数据只有一个 source of truth"。
  5. 性能数据要自己测。网上说的"Zustand 比 Redux 快"、"Pinia 比 Vuex 轻"都是参考,你的项目里真正瓶颈在哪,只有 Profiler 知道。

如果你们也在做类似迁移,欢迎交流。下一步我们打算试试 Jotai 做细粒度表单状态,有经验的朋友可以留言。