一、问题背景:Props drilling 和 Context 的边界在哪里

先说结论:Context 和 Props 不是不能用,而是当你的应用满足下面任意两条时,就该考虑状态管理库了。

  • 全局状态跨越 3 层以上组件树
  • 单次状态更新触发超过 20 个组件重渲染
  • 多个不相关模块需要读写同一份数据
  • 需要中间件能力(持久化、日志、时间旅行)

我们有两个项目同时撞上了这堵墙。

项目 A:React 18.2 + TypeScript 4.9 + Vite 4.1,后台管理系统,约 120 个组件,用 Context + useReducer 管理用户信息、权限、主题、通知、侧边栏折叠等 5 类全局状态。

项目 B:Vue 3.3 + TypeScript 5.0 + Vite 4.4,数据看板,约 80 个组件,用 Props + provide/inject 传递筛选条件和用户配置。

问题表现很典型:项目 A 里切换侧边栏折叠状态,整个页面卡顿约 300ms,React DevTools Profiler 显示 47 个组件重渲染,其中 41 个跟侧边栏毫无关系。项目 B 里修改一个筛选条件,触发了 3 层 provide/inject 的链式更新,图表组件重绘了 4 次。

二、环境与版本

项目 框架 语言 构建 原方案 新方案
A React 18.2.0 TS 4.9.5 Vite 4.1.0 Context + useReducer Zustand 4.3.9
B Vue 3.3.4 TS 5.0.4 Vite 4.4.5 Props + provide/inject Pinia 2.1.6

选 Zustand 而不是 Redux Toolkit,是因为项目 A 的状态逻辑不复杂,Redux 的 action/reducer 样板代码收益比太低。Zustand 的 selector 天然支持细粒度订阅,正好解决重渲染问题。Pinia 是 Vue 官方推荐,跟 Vue 3 的响应式系统结合最自然,没有理由选别的。

三、方案设计:Context vs Zustand vs Pinia 的取舍

先做一张对比表,这是当时团队评审时用的:

维度 React Context Zustand Pinia
重渲染控制 全量订阅,靠 memo 手动优化 selector 细粒度订阅 响应式依赖收集
样板代码 中(Provider + reducer)
中间件 persist/devtools/immer 插件系统
跨组件读写 需要 Provider 嵌套 直接 import store 直接 import store
调试 DevTools 一般 Redux DevTools 兼容 Vue DevTools
学习成本

关键差异在订阅粒度。Context 的 value 一变,所有 useContext 的组件全部重渲染,你只能靠 React.memo + useMemo 手动挡。Zustand 的 selector 是订阅级别的,只有 selector 返回值变化的组件才重渲染。

迁移策略上,我们没有一次性替换,而是采用并行迁移

  1. 新建 store 目录,先迁移一个独立模块(比如主题)
  2. 保持 Context 存在,新旧并存
  3. 逐个模块迁移,每个模块迁移后跑一次性能对比
  4. 全部迁移完,删除 Context Provider

这样风险可控,任何一步出问题都能回滚。

四、核心实现

4.1 React:从 Context 迁移到 Zustand

迁移前的 Context 写法(简化):

// 旧代码:ThemeContext.tsx
import { createContext, useContext, useReducer } from 'react';

type Theme = 'light' | 'dark';
type State = { theme: Theme; sidebarCollapsed: boolean };

const ThemeContext = createContext(null);

export function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [state, dispatch] = useReducer(
    (s: State, a: { type: string; payload?: any }) => {
      switch (a.type) {
        case 'SET_THEME': return { ...s, theme: a.payload };
        case 'TOGGLE_SIDEBAR': return { ...s, sidebarCollapsed: !s.sidebarCollapsed };
        default: return s;
      }
    },
    { theme: 'light', sidebarCollapsed: false }
  );
  return {children};
}

export function useTheme() {
  const ctx = useContext(ThemeContext);
  if (!ctx) throw new Error('useTheme must be used within ThemeProvider');
  return ctx;
}

迁移后的 Zustand store:

// 新代码:stores/useAppStore.ts
import { create } from 'zustand';
import { persist, devtools } from 'zustand/middleware';

type Theme = 'light' | 'dark';

interface AppState {
  theme: Theme;
  sidebarCollapsed: boolean;
  setTheme: (t: Theme) => void;
  toggleSidebar: () => void;
}

export const useAppStore = create()(
  devtools(
    persist(
      (set) => ({
        theme: 'light',
        sidebarCollapsed: false,
        setTheme: (theme) => set({ theme }),
        toggleSidebar: () =>
          set((s) => ({ sidebarCollapsed: !s.sidebarCollapsed })),
      }),
      { name: 'app-store', partialize: (s) => ({ theme: s.theme }) }
    ),
    { name: 'AppStore' }
  )
);

组件里的用法对比:

// 旧:任何 state 变化都重渲染
const { theme, sidebarCollapsed } = useTheme();

// 新:只订阅需要的字段
const theme = useAppStore((s) => s.theme);
const toggleSidebar = useAppStore((s) => s.toggleSidebar);

注意 toggleSidebar 这种 action 引用是稳定的,不会引起重渲染。这里有个坑:如果你写 useAppStore((s) => ({ theme: s.theme, collapsed: s.sidebarCollapsed })),每次返回新对象,会无限重渲染。正确做法是用 useShallow

import { useShallow } from 'zustand/react/shallow';

const { theme, sidebarCollapsed } = useAppStore(
  useShallow((s) => ({ theme: s.theme, collapsed: s.sidebarCollapsed }))
);

4.2 Vue:从 Props/provide-inject 迁移到 Pinia

迁移前的 provide/inject:

import { provide, ref, readonly } from 'vue';

const filters = ref({ keyword: '', dateRange: [null, null], status: 'all' });
const updateKeyword = (v: string) => { filters.value.keyword = v; };

provide('filters', readonly(filters));
provide('updateKeyword', updateKeyword);

迁移后的 Pinia store:

// 新代码:stores/filterStore.ts
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';

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

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

  function reset() {
    keyword.value = '';
    dateRange.value = [null, null];
    status.value = 'all';
  }

  return { keyword, dateRange, status, hasActiveFilter, reset };
}, {
  persist: { key: 'filter-store', paths: ['status'] }
});

组件中使用:

import { storeToRefs } from 'pinia';
import { useFilterStore } from '@/stores/filterStore';

const filterStore = useFilterStore();
const { keyword, status, hasActiveFilter } = storeToRefs(filterStore);

storeToRefs 是必须的,直接解构会丢失响应式。

五、踩坑与优化

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

前面提过,返回对象必须用 useShallow。这个坑我们踩了两次,第二次是因为一个同事在 selector 里写了 .map()

坑 2:persist 中间件和 SSR 冲突

项目 A 后来接了 Next.js,persist 在服务端渲染时报 localStorage is not defined。解决方案是用 createJSONStorage 加环境判断:

storage: createJSONStorage(() =>
  typeof window !== 'undefined' ? localStorage : ({} as Storage)
)

坑 3:Pinia 的 store 在 setup 外调用

在路由守卫、axios 拦截器里调用 store,必须在函数内部调用,不能在外层 import 后就调用,否则 Pinia 实例还没挂载:

// 错误
const store = useFilterStore();
router.beforeEach(() => { store.reset(); });

// 正确
router.beforeEach(() => {
  const store = useFilterStore();
  store.reset();
});

优化 1:Zustand 状态分片

一开始把所有状态塞一个 store,后来拆成 4 个:appStore、userStore、notificationStore、permissionStore。分片后单个 store 更新影响面更小。

优化 2:Pinia 使用 $subscribe 做日志

开发环境用 $subscribe 监听所有变更,打到 console,排查问题效率提升明显。

六、效果数据

用 React DevTools Profiler 和 Vue DevTools Performance 采集,每种场景跑 10 次取平均。

项目 A(React)

指标 迁移前 迁移后 变化
切换侧边栏重渲染组件数 47 6 -87%
切换主题重渲染组件数 52 4 -92%
交互平均延迟 312ms 42ms -86%
首屏 JS 体积 218KB 226KB +8KB
首次加载时间 1.42s 1.46s +0.04s

项目 B(Vue)

指标 迁移前 迁移后 变化
筛选更新触发重渲染 18 5 -72%
图表重绘次数 4 1 -75%
交互平均延迟 186ms 38ms -80%
首屏 JS 体积 164KB 172KB +8KB

体积增加了约 8KB(gzip 后约 3KB),换来的是交互延迟降低 80%+,这笔账很划算。

七、总结

回头看,从 Context/Props 迁移到状态管理库,核心收益不是"代码更好写",而是订阅粒度可控。Context 和 provide/inject 的设计目标是依赖注入,不是状态管理,用它做全局状态,重渲染是必然代价。

几点建议:

  1. 不要一上来就上 Redux,Zustand/Pinia 的收益比更高
  2. 迁移要并行,不要一次性替换,保留回滚能力
  3. selector/storeToRefs 是性能关键,必须理解订阅机制
  4. persist 中间件注意 SSR 和存储配额
  5. 状态分片,一个 store 不要超过 200 行

如果你的项目也是 100+ 组件、Context 嵌套 3 层以上,建议做个 Profiler 采样,数据会告诉你答案。