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

我们团队维护的是一个中后台数据平台,前端同时存在React 18.2和Vue 3.3两个技术栈(历史原因,部分老模块是React,新模块用Vue 3)。项目初期页面简单,状态管理就是用最朴素的方式:

  • React侧:createContext + useContext,顶层包一个AppContext.Provider,所有全局状态(用户信息、权限、主题、字典数据)都塞进去。
  • Vue侧:props逐层透传,或者用provide/inject

一开始没问题。但当组件层级超过4层、全局状态字段超过20个之后,问题集中爆发:

  1. React Context的“全量更新”:只要Context value里任何一个字段变化,所有useContext的组件都会重渲染。我们用一个Profile页面测试,修改一个输入框的值,触发了47次组件渲染(React DevTools Profiler数据)。
  2. Vue Props透传的“中间商”:一个dictData从App传到第5层组件,中间3层组件根本不使用它,却被迫声明props,代码噪音极大。
  3. 逻辑复用困难:多个页面需要“获取用户权限→判断按钮显隐→记录操作日志”这一套逻辑,用Context写就是复制粘贴。

于是我们决定迁移到专门的状态管理库。选型上,React侧选Zustand 4.4.7,Vue侧选Pinia 2.1.7。

二、环境与版本

先交代清楚环境,避免版本差异导致行为不一致:

  • React:18.2.0,react-dom 18.2.0,构建工具Vite 4.4.5
  • Vue:3.3.4,构建工具Vite 4.4.5
  • 状态管理:zustand 4.4.7,pinia 2.1.7
  • 开发调试:React DevTools 4.28.5,Vue DevTools 6.6.1
  • 测试设备:MacBook Pro M1,Chrome 118,Performance面板采样

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

我们对比了三个方案,用表格说清楚:

维度 Context/Props Redux Toolkit Zustand / Pinia
学习成本 中高
模板代码 多(slice、reducer) 极少
选择性订阅 不支持 需reselect 原生支持
异步处理 自己写 createAsyncThunk 直接async函数
DevTools 一般 优秀 良好
包体积 0 ~11KB zustand ~1.2KB / pinia ~1.5KB

选Zustand的核心原因是它的选择性订阅useStore(state => state.foo)只在foo变化时重渲染,这正好解决Context的全量更新问题。选Pinia则是因为它是Vue官方推荐,且和Vue 3的响应式系统无缝集成,storeToRefs解构后仍保持响应性。

四、核心实现:从迁移到落地

4.1 React侧:Context迁移到Zustand

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

// 旧代码:AppContext.jsx
import { createContext, useContext, useState } from 'react';

const AppContext = createContext(null);

export function AppProvider({ children }) {
  const [user, setUser] = useState(null);
  const [theme, setTheme] = useState('light');
  const [dict, setDict] = useState({});

  return (

      {children}

  );
}

export const useApp = () => useContext(AppContext);

问题很明显:任何setState都会让value对象变化,所有消费者重渲染。

迁移后的Zustand store:

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

export const useAppStore = create(
  devtools(
    persist(
      (set, get) => ({
        user: null,
        theme: 'light',
        dict: {},

        setUser: (user) => set({ user }, false, 'setUser'),
        setTheme: (theme) => set({ theme }, false, 'setTheme'),

        // 异步action直接写
        fetchDict: async () => {
          const res = await fetch('/api/dict');
          const dict = await res.json();
          set({ dict }, false, 'fetchDict');
        },

        // 派生状态用selector,不放进store
        hasPermission: (code) => {
          const { user } = get();
          return user?.permissions?.includes(code) ?? false;
        },
      }),
      { name: 'app-storage', partialize: (s) => ({ theme: s.theme }) }
    )
  )
);

组件里按需订阅:

// 只订阅theme,user变化不会触发本组件重渲染
function ThemeToggle() {
  const theme = useAppStore((s) => s.theme);
  const setTheme = useAppStore((s) => s.setTheme);
  return  setTheme(theme === 'light' ? 'dark' : 'light')}>{theme};
}

注意partialize配置:只把theme持久化到localStorage,user和dict不落盘,避免敏感信息和过期字典被缓存。

4.2 Vue侧:Props透传迁移到Pinia

迁移前,dictData从App透传到第5层:

const props = defineProps({ dictData: Object });

迁移后,用Pinia store:

// stores/dict.js
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';

export const useDictStore = defineStore('dict', () => {
  const dict = ref({});
  const loading = ref(false);

  const statusOptions = computed(() => dict.value.status ?? []);

  async function fetchDict() {
    loading.value = true;
    try {
      const res = await fetch('/api/dict');
      dict.value = await res.json();
    } finally {
      loading.value = false;
    }
  }

  return { dict, loading, statusOptions, fetchDict };
});

组件里直接用,中间层不再需要声明props:

import { storeToRefs } from 'pinia';
import { useDictStore } from '@/stores/dict';

const dictStore = useDictStore();
// 解构后仍保持响应性,这是Pinia的关键API
const { statusOptions, loading } = storeToRefs(dictStore);





      {{ item.label }}

4.3 迁移步骤(我们实际执行的顺序)

  1. 冻结新功能:迁移期间只修bug,不加需求,避免边迁边改。
  2. 盘点状态:把Context/Props里的状态分三类——全局(用户、主题)、模块级(字典、权限)、组件级(表单输入)。只有前两类进store,组件级留在本地useState/ref
  3. 先建store,再改组件:store写好后,逐个组件替换useContext/props,每改一个提交一次,方便回滚。
  4. 保留旧Context一层:用useAppStore包装一个useApp兼容函数,老组件不改也能跑,新组件用新API。这一步让迁移可以分批进行。
  5. 全量回归后删除旧代码:确认无引用后删除Context文件和props声明。

五、踩坑与优化

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

// 错误写法:每次返回新对象,引用变化,触发重渲染
const { user, theme } = useAppStore((s) => ({ user: s.user, theme: s.theme }));

解决:用useShallow(zustand 4.4+提供),或者分开写多个selector。

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

坑2:Pinia解构丢失响应性。 直接const { dict } = useDictStore()会变成普通值,必须用storeToRefs。这个坑我们团队新人踩了三次,后来直接在ESLint规则里加了检测。

坑3:DevTools里action名字不明显。 Zustand的set第三个参数可以传action名,我们在devtools中间件里统一加了名字,调试时能直接看到setThemefetchDict,而不是一堆匿名setState

优化:字典数据只请求一次。 迁移前每个页面各自请求字典,迁移后store里加了个inited标志,fetchDict先判断是否已初始化,避免重复请求。接口调用从每天约2000次降到约300次。

六、效果数据

迁移前后用Chrome Performance面板和React DevTools Profiler采样,数据如下:

指标 迁移前 迁移后 变化
Profile页修改输入框触发渲染次数 47 6 -87%
首屏TTI(3G模拟) 2.8s 1.9s -32%
字典接口日调用量 ~2000 ~300 -85%
中间层组件props声明行数 平均4.2行/组件 0 -100%
包体积增量 0 +1.2KB(gzip后) 可接受

需要说明的是,TTI的下降主要来自减少重渲染和字典请求,而不是状态管理库本身“更快”。Zustand和Pinia的体积都很小,性能收益来自更细粒度的订阅,这点在选型时容易被忽略。

七、总结

从Context/Props迁移到Zustand和Pinia,本质上不是“换个库”,而是把状态按作用域重新划分。我们的经验是:

  • 全局状态、跨模块共享状态进store,组件内部状态继续留在本地,不要什么都往store塞。
  • React侧优先用useShallow处理多字段订阅,Vue侧牢记storeToRefs
  • 迁移要分批,保留兼容层,别想着一个PR改完。
  • 性能收益要看具体场景,如果项目只有两三个全局状态,Context其实够用,不必为了用库而用库。

对于中大型项目,状态管理库带来的可维护性提升是实打实的;对于小项目,先想清楚痛点再决定,别被“最佳实践”绑架。