一、问题背景:当Props drilling遇上Context的“伪响应式”

我们团队维护的中后台项目在迭代到第8个月时,出现了一个致命问题:状态更新导致无关组件重渲染。当时采用React 18.2 + Vue 3.4双技术栈(历史原因),状态管理分别依赖Context+useReducer和Props+provide/inject。

以React侧为例,一个用户列表页需要传递filterpaginationselectedRows等7个状态字段,通过4层嵌套组件。每次点击翻页,整个列表区域重渲染耗时约120ms,且Context value变化时,所有消费组件(包括未使用该字段的组件)都会触发render。

Vue侧更糟:我们用了reactive对象通过provide注入,但子组件修改父组件状态时,由于没有明确的单向数据流约束,经常出现“改了一处,三处报错”的情况。项目代码量20000+行,其中约30%的代码是在处理状态传递和同步。

核心痛点
- 无法定位状态来源(props? context? 全局?)
- 状态变更无迹可寻(没有时间旅行调试)
- 组件重渲染次数不可控

二、环境与版本:为什么选Zustand和Pinia

经过一周的调研(对比Redux Toolkit、MobX、Jotai),我们最终确定:

框架 方案 版本 包体积(gzip) 学习成本
React Zustand 4.5.2 1.2KB
Vue Pinia 2.1.7 1.6KB 极低

选择理由:
- Zustand:无需Provider包裹,直接在组件外创建store,配合useShallow可以精准控制订阅粒度。对比Redux Toolkit(11KB)体积优势明显,且中间件机制支持持久化、日志。
- Pinia:Vue官方推荐,完全继承Vue 3的setup语法,告别Vuex的mutations样板代码。且Pinia DevTools支持时间旅行,这对我们排错至关重要。

迁移目标:4周内完成双技术栈的状态层重构,保持业务逻辑不变。

三、方案设计:先画边界,再动手拆

我们采用“三层剥离法”:

  1. 第一层:识别全局状态 vs 局部状态
  2. 全局:用户信息、权限、主题、通知
  3. 局部:表单输入、弹窗显隐、列表筛选
  4. 统计:全局状态仅占18%,剩下82%完全可以用组件内useStateref解决。

  5. 第二层:设计Store结构

  6. React侧:拆成useUserStoreusePermissionStoreuseNotificationStore三个独立store,避免“大而全”的单store导致订阅粒度失控。
  7. Vue侧:同理拆分useUserStoreuseAppStore

  8. 第三层:定义迁移接口

  9. 所有store必须暴露stateactionsgetters(不直接操作state)
  10. 异步操作统一用async/await封装在action内

四、核心实现:React侧Zustand迁移实战

4.1 原Context代码(改造前)

// 旧代码:Context + useReducer,每次setState都会导致所有消费者重渲染
const AppContext = createContext}>(null!);

export const AppProvider = ({children}: {children: ReactNode}) => {
  const [state, dispatch] = useReducer(reducer, initialState);
  const value = useMemo(() => ({state, dispatch}), [state]);
  return {children};
};

// 消费组件(每次翻页,即使只用pagination,filter组件也会重渲染)
function ListPage() {
  const { state, dispatch } = useContext(AppContext);
  // 页面所有state变化都触发这里
}

4.2 Zustand重构后(核心代码)

// 新代码:Zustand + 选择性订阅
import { create } from 'zustand';
import { subscribeWithSelector } from 'zustand/middleware';

interface ListState {
  filter: Filter;
  pagination: { page: number; pageSize: number };
  selectedRows: string[];
  // actions
  setFilter: (filter: Filter) => void;
  fetchList: () => Promise;
}

export const useListStore = create()(
  subscribeWithSelector((set, get) => ({
    filter: { keyword: '', status: 'all' },
    pagination: { page: 1, pageSize: 20 },
    selectedRows: [],
    setFilter: (filter) => set({ filter }),
    fetchList: async () => {
      const { pagination, filter } = get();
      const data = await api.fetchList({ ...pagination, ...filter });
      set({ list: data });
    },
  }))
);

// 消费组件:只订阅pagination,filter变化不会触发重渲染
function PaginationBar() {
  const page = useListStore((s) => s.pagination.page);
  const pageSize = useListStore((s) => s.pagination.pageSize);
  const fetchList = useListStore((s) => s.fetchList);
  // 只有page/pageSize变化才重渲染
}

关键优化:使用useShallow封装对象订阅,避免返回新引用导致无限渲染:

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

// 同时订阅多个字段时,用useShallow避免每次render都产生新对象
const { filter, pagination } = useListStore(
  useShallow((s) => ({ filter: s.filter, pagination: s.pagination }))
);

五、核心实现:Vue侧Pinia迁移实战

5.1 旧provide/inject代码(改造前)

const state = reactive({
  user: null,
  permissions: [],
  theme: 'light'
});
provide('appState', state);
// 子组件通过 inject('appState') 修改,无人约束

5.2 Pinia setup store重构后

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

export const useAppStore = defineStore('app', () => {
  // state(用ref定义)
  const user = ref(null);
  const permissions = ref([]);
  const theme = ref('light');

  // getters(用computed)
  const isAdmin = computed(() => permissions.value.includes('admin'));

  // actions(异步方法)
  async function login(username: string, password: string) {
    const { user: u, permissions: p } = await api.login({ username, password });
    user.value = u;
    permissions.value = p;
  }

  function toggleTheme() {
    theme.value = theme.value === 'light' ? 'dark' : 'light';
  }

  return { user, permissions, theme, isAdmin, login, toggleTheme };
});

组件中使用

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

const appStore = useAppStore();
// 关键:用storeToRefs解构才能保持响应性,且不会丢失引用
const { user, isAdmin } = storeToRefs(appStore);
// actions直接解构,无需storeToRefs
const { login, toggleTheme } = appStore;

注意:Pinia中不能直接解构state,否则会失去响应性。我们团队曾踩坑——有人写const { user } = appStore,结果user的值永远不更新。

六、踩坑与优化:三个必须知道的细节

6.1 Zustand的createcreateJSONStorage持久化

我们在迁移过程中,需要把用户token持久化到localStorage。一开始直接persist中间件,但遇到一个问题:存储的version不匹配导致水合错误。解决方案:

import { persist, createJSONStorage } from 'zustand/middleware';

export const useAuthStore = create(
  persist(
    (set) => ({ token: '', setToken: (t) => set({ token: t }) }),
    {
      name: 'auth-storage',
      version: 2, // 必须显式版本,之前默认0,升级后报错
      storage: createJSONStorage(() => localStorage),
      partialize: (state) => ({ token: state.token }), // 只持久化token
    }
  )
);

6.2 Pinia的$reset方法在setup store中失效

在Options API的Pinia store中,自带$reset方法。但setup store没有!我们写了一个通用插件:

// plugins/reset.ts
export function resetPlugin({ store }) {
  const initialState = JSON.parse(JSON.stringify(store.$state));
  store.$reset = () => {
    store.$patch(initialState);
  };
}
// main.ts
pinia.use(resetPlugin);

6.3 性能验证:用React Profiler和Vue Performance DevTools

迁移后,我们做了对比测试:

指标 迁移前 迁移后 提升幅度
首屏渲染时间 2.3s 1.1s 52%
列表翻页重渲染次数 47次 18次 62%
无关组件重渲染次数 23次 0次 100%
代码量(状态相关) 6800行 4500行 34%
新增bug率 2.1个/周 0.3个/周 86%

具体优化点
- React侧:用React.memo包裹列表项组件,配合Zustand selector,确保只有数据变化的项重渲染
- Vue侧:使用v-memo指令缓存静态列表(Vue 3.4+),配合Pinia的storeToRefs避免额外proxy开销

七、总结:迁移后的思考

经过这次实践,我的核心感受是:

  1. 状态管理库不是银弹:82%的局部状态仍然应该留在组件内,全局状态才有必要引入store。我们刚开始想“所有状态都进store”,结果store越来越臃肿,性能反而下降。
  2. 订阅粒度决定性能:Zustand的selector和Pinia的storeToRefs是核心优势,使用不当(比如订阅整个store)反而比Context更慢。
  3. DevTools是救命稻草:Pinia和Zustand的DevTools都能时间旅行调试,排查“状态被谁改了”的效率提升了数倍。

最后给团队的建议:不要为了用库而用库。如果项目组件层级小于3层、状态字段少于20个,Context/Props完全够用。但当你的团队遇到“定位状态困难”或“重渲染失控”时,果断迁移,成本远低于后期维护。

下一步计划:我们正在尝试将Zustand的middleware与Pinia的plugins做一层统一封装,实现跨框架的store层复用。目前已在实验性项目中验证可行性,后续会单独写一篇分享。