一、问题背景:当Props drilling遇上Context的“伪优化”

先交代下背景。我所在团队同时维护两个老项目:

  • Project A:React 16.8 + Mobx 6(但业务代码大量使用useState + Props)
  • Project B:Vue 3.2 + Options API + Provide/Inject

这两个项目都不是从零开始的,属于“能跑但很难改”的典型。最近三个月,随着业务迭代,我在两个仓库里都遇到了同一个噩梦:

// React 项目里的典型代码,第五层组件需要 userId





        // 中间每一层都不得不透传 userId 和一堆回调,哪怕它们根本不用

Vue那边类似,只是把Props换成了inject: ['userId', 'updateUser'],然后模板里到处是v-if="userId"的防御性判断。

核心痛点
1. 传参噪音:因为新增一个字段,平均需要改动4-6个中间组件的Props定义/Inject声明。
2. 性能劣化:React Context一旦value变化,所有消费组件全量重渲染。我们有个表格页,Context里存了筛选条件,结果每次输入防抖后,整个表格区域(包含100+行组件)全部重算,帧率掉到20fps以下。
3. 调试地狱:Chrome DevTools里看组件树,每层都堆满不相关的Props,根本分不清数据流。

我花了一周时间统计了两个项目的代码量,结论是:超过5层深度的组件通信,有40%的代码是纯透传逻辑。这根本不是状态管理的问题,是架构设计问题。于是决定统一迁移到专门的状态管理库。

二、环境与版本:为什么选Zustand和Pinia而不是Redux/Vuex

当时是2023年6月,我评估了主流方案:

React生态
- Redux Toolkit 1.9:太重,团队需要额外学习immer和thunk。
- MobX 6:项目里已有,但受不了装饰器语法在TS下的报错。
- Zustand 4.3.2:极简API,无需Provider包裹,store定义在组件外部,天然避开Context问题。

Vue生态
- Pinia 2.1.3(Vue 3专用):比Vuex少了Mutation,支持Composition API风格。

最终环境锁定:

React 16.8.6 + TypeScript 4.9.5 + Zustand 4.3.2
Vue 3.2.45 + Pinia 2.1.3 + 

选择理由很务实:不需要引入新的编程范式,Zustand就是“全局useState”,Pinia就是“全局reactive”。

三、方案设计:渐进式替换,不搞“周末大重构”

我把迁移策略定为“由外向内、按功能模块切割”,而不是一次性把所有状态都塞进store。具体分三步:

  1. 识别核心状态:只迁移被3个以上组件共享的、或者更新频率较高的状态(筛选条件、用户信息、主题配置)。
  2. 保留局部状态:组件内部UI状态(弹窗开关、表单临时值)继续用useState/ref。
  3. 接口不变:对外暴露的组件API(Props类型)尽量保持原样,只是内部实现从“透传”改为“读store”。

以React项目为例,我画了一张迁移前后对比图:

迁移前:
App (useState) → Layout → Header → SearchBar → SearchResults
  → 每次SearchBar输入,App重渲染,Header和Layout跟着重渲染

迁移后:
App (无状态) → Layout → Header → SearchBar → SearchResults
  SearchStore (Zustand) ←--- SearchBar直接读写store
  SearchResults 通过selector订阅store,只有结果数据变化时才重渲染

四、核心实现:Zustand和Pinia的实际落地代码

React 16.8 + Zustand 迁移示例

原代码在App组件里用useState保存搜索词和结果,然后一层层传下去。改造后,我在/store/searchStore.ts里新建store:

// store/searchStore.ts
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';

// 定义store状态结构
interface SearchState {
  keyword: string;
  results: Array;
  isLoading: boolean;
  // 不再需要传回调,action直接写在store里
  setKeyword: (kw: string) => void;
  fetchResults: () => Promise;
}

export const useSearchStore = create()(
  devtools(
    (set, get) => ({
      keyword: '',
      results: [],
      isLoading: false,
      setKeyword: (kw) => set({ keyword: kw }),
      fetchResults: async () => {
        set({ isLoading: true });
        // 模拟API请求
        const res = await fetch(`/api/search?q=${get().keyword}`);
        const data = await res.json();
        set({ results: data, isLoading: false });
      },
    }),
    { name: 'SearchStore' } // 方便Redux DevTools调试
  )
);

然后,在SearchBar组件里,不再通过Props拿值,直接消费store:

// components/SearchBar.tsx
import { useSearchStore } from '../store/searchStore';

export const SearchBar = () => {
  // 只订阅keyword,避免不必要渲染
  const keyword = useSearchStore((state) => state.keyword);
  const isLoading = useSearchStore((state) => state.isLoading);
  const setKeyword = useSearchStore((state) => state.setKeyword);

  return (
     setKeyword(e.target.value)}
      placeholder="输入关键词搜索..."
    />
  );
};

而SearchResults组件,等待store里的results变化:

// components/SearchResults.tsx
import { useSearchStore } from '../store/searchStore';

export const SearchResults = () => {
  const results = useSearchStore((state) => state.results);
  const isLoading = useSearchStore((state) => state.isLoading);

  if (isLoading) return 加载中...;

  return (

      {results.map((item) => (
        {item.title}
      ))}

  );
};

注意Zustand 4.3的selector用法:必须返回具体值,不能返回对象字面量(比如state => ({a: state.a, b: state.b})),否则会因为浅比较不一致导致无限重渲染。

Vue 3 + Pinia迁移示例

Vue项目原本用Provide/Inject,现在改为Pinia store。定义在src/stores/user.ts

// stores/user.ts
import { defineStore } from 'pinia';

export const useUserStore = defineStore('user', {
  state: () => ({
    userId: null as string | null,
    userInfo: null as { name: string; avatar: string } | null,
    token: '',
  }),
  getters: {
    isLoggedIn: (state) => !!state.userId,
  },
  actions: {
    async login(username: string, password: string) {
      // 模拟登录
      const resp = await fetch('/api/login', { /* ... */ });
      const data = await resp.json();
      // 直接修改state,不需要commit/mutation
      this.userId = data.userId;
      this.userInfo = data.userInfo;
      this.token = data.token;
    },
    logout() {
      // 也可以直接重置state
      this.$reset();
    },
  },
});

在组件里(比如Header和UserProfile),使用store:

    {{ store.userInfo?.name }}
    退出




import { useUserStore } from '@/stores/user';
// 直接在setup里获取store实例,模板中可直接使用
const store = useUserStore();

Pinia的storeToRefs是给解构用的,但我实际测试下来,直接store.xxx在模板里响应性最好,不需要额外转换。

五、踩坑与优化:四个真实遇到的问题

  1. Zustand的selector导致重渲染风暴:因为我一开始在组件里用了const state = useSearchStore(),这样任何state变化都会触发组件更新。改成了单个字段selector后,问题解决。用useShallow可以解决对象选择器问题,但不如直接拆成多个selector简单。

  2. React17以下的Concurrent特性:我们的React是16.8.6,不支持useSyncExternalStore(React 18才引入)。Zustand 4.x在React 16.8下会自动降级为useEffect监听模式,性能比React 18差一些,但依然比Context快。如果你在用React 17及以下,建议用Zustand 3.x版本,4.x针对React 18做了优化,老版本上反而有点水土不服。

  3. Pinia的持久化插件:项目需要把token存在localStorage。一开始手动写watch,后来发现pinia-plugin-persistedstate非常方便,只需要在store里加一行配置:
    ts persist: { key: 'user-token', storage: localStorage, paths: ['token'] // 只持久化token }
    省了我不少代码。

  4. 迁移顺序:React项目我先迁移了用户信息(低频变化)再迁移搜索状态(高频变化)。实测发现,如果先迁移高频状态,容易在调试期误以为store性能不好。因为高频store一旦设计不当(selector粒度粗),会掩盖低频store的性能提升。

六、效果数据:用数字说话

迁移完成后,我对比了线上监控(Grafana + 自研埋点)和本地Lighthouse测试,主要指标如下:

指标 迁移前 迁移后 变化
列表页交互帧率(FPS) 20-25 55-60 +150%
搜索输入防抖后首帧响应时间 680ms 210ms -69%
组件平均重渲染次数(per interaction) 45次 15次 -67%
代码中Props透传行数(仅计数) 1,283行 210行 -83%
新增功能平均开发耗时(估算) 1.5天 0.5天 -67%

特别说明:首屏渲染时间缩短28%这个数据,可能不完全归功于状态管理库。因为我顺带把原本在App组件里的大量useState动态计算移到了store的getter里,减少了重复计算。

七、总结与建议

什么情况下值得迁移
- 组件树深度 > 5层,且中间层有30%以上的组件只是透传Props。
- Context更新频繁,且消费组件数量 > 10个,导致明显的卡顿。
- 团队无法忍受代码里到处都是props.xxxemit('update:xxx')

什么情况下别折腾
- 项目只有3层组件,别引入store,增加心智负担。
- 状态更新频率极低(比如主题配置),用Context完全够。

最后说点个人意见:Zustand和Pinia不是银弹,它们只是比Context提供了更细粒度的订阅能力。真正重要的是,在设计初期就识别出哪些状态是“全局共享且高频变化”的。如果这个没想清楚,用什么都白搭。

如果你正在迁移路上,建议从小模块开始,第一次迁移控制在2天内完成,验证手感后再逐步铺开。有问题欢迎在评论区交流,尤其是遇到React 16.8 + Zustand的兼容性问题,我可以分享更多细节。