一、问题背景:当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。具体分三步:
- 识别核心状态:只迁移被3个以上组件共享的、或者更新频率较高的状态(筛选条件、用户信息、主题配置)。
- 保留局部状态:组件内部UI状态(弹窗开关、表单临时值)继续用useState/ref。
- 接口不变:对外暴露的组件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在模板里响应性最好,不需要额外转换。
五、踩坑与优化:四个真实遇到的问题
-
Zustand的selector导致重渲染风暴:因为我一开始在组件里用了
const state = useSearchStore(),这样任何state变化都会触发组件更新。改成了单个字段selector后,问题解决。用useShallow可以解决对象选择器问题,但不如直接拆成多个selector简单。 -
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做了优化,老版本上反而有点水土不服。 -
Pinia的持久化插件:项目需要把token存在localStorage。一开始手动写watch,后来发现
pinia-plugin-persistedstate非常方便,只需要在store里加一行配置:
ts persist: { key: 'user-token', storage: localStorage, paths: ['token'] // 只持久化token }
省了我不少代码。 -
迁移顺序: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.xxx和emit('update:xxx')。
什么情况下别折腾:
- 项目只有3层组件,别引入store,增加心智负担。
- 状态更新频率极低(比如主题配置),用Context完全够。
最后说点个人意见:Zustand和Pinia不是银弹,它们只是比Context提供了更细粒度的订阅能力。真正重要的是,在设计初期就识别出哪些状态是“全局共享且高频变化”的。如果这个没想清楚,用什么都白搭。
如果你正在迁移路上,建议从小模块开始,第一次迁移控制在2天内完成,验证手感后再逐步铺开。有问题欢迎在评论区交流,尤其是遇到React 16.8 + Zustand的兼容性问题,我可以分享更多细节。