一、问题背景:当Props drilling遇上Context的“伪响应式”
我们团队维护的中后台项目在迭代到第8个月时,出现了一个致命问题:状态更新导致无关组件重渲染。当时采用React 18.2 + Vue 3.4双技术栈(历史原因),状态管理分别依赖Context+useReducer和Props+provide/inject。
以React侧为例,一个用户列表页需要传递filter、pagination、selectedRows等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周内完成双技术栈的状态层重构,保持业务逻辑不变。
三、方案设计:先画边界,再动手拆
我们采用“三层剥离法”:
- 第一层:识别全局状态 vs 局部状态
- 全局:用户信息、权限、主题、通知
- 局部:表单输入、弹窗显隐、列表筛选
-
统计:全局状态仅占18%,剩下82%完全可以用组件内
useState或ref解决。 -
第二层:设计Store结构
- React侧:拆成
useUserStore、usePermissionStore、useNotificationStore三个独立store,避免“大而全”的单store导致订阅粒度失控。 -
Vue侧:同理拆分
useUserStore、useAppStore。 -
第三层:定义迁移接口
- 所有store必须暴露
state、actions、getters(不直接操作state) - 异步操作统一用
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的create与createJSONStorage持久化
我们在迁移过程中,需要把用户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开销
七、总结:迁移后的思考
经过这次实践,我的核心感受是:
- 状态管理库不是银弹:82%的局部状态仍然应该留在组件内,全局状态才有必要引入store。我们刚开始想“所有状态都进store”,结果store越来越臃肿,性能反而下降。
- 订阅粒度决定性能:Zustand的selector和Pinia的storeToRefs是核心优势,使用不当(比如订阅整个store)反而比Context更慢。
- DevTools是救命稻草:Pinia和Zustand的DevTools都能时间旅行调试,排查“状态被谁改了”的效率提升了数倍。
最后给团队的建议:不要为了用库而用库。如果项目组件层级小于3层、状态字段少于20个,Context/Props完全够用。但当你的团队遇到“定位状态困难”或“重渲染失控”时,果断迁移,成本远低于后期维护。
下一步计划:我们正在尝试将Zustand的middleware与Pinia的plugins做一层统一封装,实现跨框架的store层复用。目前已在实验性项目中验证可行性,后续会单独写一篇分享。