一、问题背景:Context 不是状态管理库,但我们都当它是
项目是一个 B 端订单管理系统,前端两套壳:老模块用 React 18.2 + TypeScript 5.3,新模块用 Vue 3.4 + Vite 5。两边共享同一套后端接口和业务模型。
最早的状态方案很"朴素":
- React 侧:
OrderContext+useReducer,一个 Provider 包住整个路由树 - Vue 侧:
provide/inject加reactive对象,顶层 App 注入
小规模时没问题。直到订单列表页上了虚拟滚动 + 多条件筛选 + 实时轮询,问题集中爆发:
- 筛选条件一变,
OrderContext的 value 是新对象,所有useContext的组件全部重渲染。列表项虽然做了memo,但父级ListContainer一渲染,children引用变了,memo 直接失效。 - 轮询每 5s 更新一次
lastUpdated字段,这个字段只有顶部状态栏用,却让整个列表跟着抖。 - Vue 侧
provide/inject的响应式对象更隐蔽——只要读了reactive里的任意属性,整个对象变更都会触发依赖更新,v-memo都救不回来。
Performance 面板录了一次筛选操作:
| 指标 | Context 方案 | 目标 |
|---|---|---|
| 筛选响应(点击到列表稳定) | 640ms | ) => void |
| fetchList: () => Promise | ||
| } |
export const useOrderStore = create()(
devtools(
subscribeWithSelector(
immer((set, get) => ({
list: [],
filters: { status: 'all', page: 1, size: 20 },
loading: false,
lastUpdated: 0,
setFilters: (f) =>
set((s) => { Object.assign(s.filters, f) }, false, 'order/setFilters'),
fetchList: async () => {
set((s) => { s.loading = true })
const data = await api.getOrders(get().filters)
set((s) => {
s.list = data.items
s.loading = false
s.lastUpdated = Date.now()
})
},
}))
),
{ name: 'orderStore' }
)
)
关键点:
- `subscribeWithSelector` 必须加,否则 `subscribe` 拿不到 selector
- `immer` 中间件让 `set` 里可以直接改,省掉展开运算符
- 第三个参数是 action name,DevTools 里能看到调用链
组件里只订阅需要的字段:
```tsx
// components/OrderList.tsx
import { useShallow } from 'zustand/react/shallow'
// ❌ 错误:返回新对象,每次渲染都触发
// const { list, loading } = useOrderStore()
// ✅ 正确:浅比较 + 选择器
const { list, loading } = useOrderStore(
useShallow((s) => ({ list: s.list, loading: s.loading }))
)
// 只关心 lastUpdated 的状态栏组件
const lastUpdated = useOrderStore((s) => s.lastUpdated)
Vue 侧 Pinia 写法,注意 storeToRefs 解构保持响应性:
// stores/order.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useOrderStore = defineStore('order', () => {
const list = ref([])
const filters = ref({ status: 'all', page: 1, size: 20 })
const loading = ref(false)
const lastUpdated = ref(0)
const total = computed(() => list.value.length)
async function fetchList() {
loading.value = true
try {
const data = await api.getOrders(filters.value)
list.value = data.items
lastUpdated.value = Date.now()
} finally {
loading.value = false
}
}
return { list, filters, loading, lastUpdated, total, fetchList }
})
import { storeToRefs } from 'pinia'
import { useOrderStore } from '@/stores/order'
const store = useOrderStore()
// ✅ storeToRefs 保持响应式,直接解构会丢
const { list, loading } = storeToRefs(store)
// action 不需要 storeToRefs
const { fetchList } = store
四、迁移步骤:增量替换,别一次性重写
我的迁移顺序,四步走,每步都能独立上线:
Step 1:并存期。新建 store 文件,Context 保留,组件逐个切换。用 useOrderStore 替代 useContext(OrderContext),一次改一个组件,PR 小、review 快。
Step 2:订阅精细化。切换后立刻检查选择器。经验:凡是 useContext 整个 value 的,迁移后大概率会写成 useOrderStore() 不带选择器,等于没迁。用 ESLint 规则兜底:
// .eslintrc.json
{
"rules": {
"no-restricted-syntax": [
"error",
{
"selector": "CallExpression[callee.name='useOrderStore'][arguments.length=0]",
"message": "必须传 selector,避免全量订阅"
}
]
}
}
Step 3:删 Context。确认没有 useContext 引用后,删除 Provider 和 reducer。React DevTools 里确认组件树少了一层 Provider。
Step 4:数据流收口。轮询、筛选、分页原本散在多个 useEffect,统一收进 store 的 action,组件只负责触发。
Vue 侧同理,先 provide/inject 和 Pinia 并存,再逐步删 inject。Pinia 的好处是 store.$subscribe 可以监听变化,方便调试。
五、踩坑与优化:三个真实的坑
坑 1:Zustand 选择器返回新对象,重渲染反而更多
// ❌ 每次渲染都返回新对象,引用比较永远不等
const { list, loading } = useOrderStore((s) => ({ list: s.list, loading: s.loading }))
第一次迁移后重渲染数不降反升,从 1247 涨到 1400+。原因是对象字面量每次都是新引用。解法:useShallow(zustand 4.4+ 内置,之前要自己写比较函数)。
坑 2:Pinia 解构丢响应性
// ❌ 解构后 list 是普通数组,不再响应
const { list } = useOrderStore()
这个坑在 Vue 侧老生常谈,但迁移时还是踩了。storeToRefs 解决,但注意它只对 state/getters 有效,action 直接用。
坑 3:immer 和 Map/Set 类型
订单里有个 selectedIds: Set,immer 10 对 Set 支持要开 enableMapSet():
import { enableMapSet } from 'immer'
enableMapSet() // 必须在任何 produce 之前调用
没开的话运行时报错,且错误信息很隐晦,查了半小时。
优化:轮询改订阅
原来轮询写在 useEffect 里,每次 lastUpdated 更新触发全组件重渲染。改成 store 订阅:
useOrderStore.subscribe(
(s) => s.lastUpdated,
(updated) => {
// 只有状态栏组件需要,直接操作 DOM 或单独 store
document.title = `更新于 ${new Date(updated).toLocaleTimeString()}`
}
)
轮询引发的多余渲染从每秒 30+ 次降到 0。
六、效果数据
迁移后同样用 Performance 面板录筛选操作,Chrome 121,MacBook Pro M1,数据取 5 次平均:
| 指标 | Context 方案 | Zustand/Pinia | 变化 |
|---|---|---|---|
| 筛选响应 | 640ms | 88ms | ↓ 86% |
| 单次重渲染组件数 | 1247 | 62 | ↓ 95% |
| 首屏 TTI | 2.4s | 1.1s | ↓ 54% |
| 轮询多余渲染/秒 | 30+ | 0 | — |
| 包体积(gzip) | — | +1.2KB(React) / +1.5KB(Vue) | 可忽略 |
| 内存占用(列表页) | 48MB | 41MB | ↓ 15% |
React DevTools Profiler 里,一次筛选的火焰图从"整棵树"变成"两个叶子节点"。Vue DevTools 的组件更新高亮也只剩列表容器和状态栏。
代码量上,删掉 OrderContext.tsx(约 180 行)和 provide/inject 封装(约 90 行),store 文件约 120 行,净减少约 150 行。
七、总结
Context 和 provide/inject 不是坏东西,它们解决的是"依赖传递",不是"状态订阅"。当你的状态开始有性能要求、有跨页面共享、有细粒度更新需求时,就是迁移的信号。
几条经验:
- 选型看订阅粒度,不看 API 好不好看。Zustand 的 selector 和 Pinia 的 computed 是核心价值。
- 迁移要增量,并存期越长越安全,别一次性重写。
- 选择器是命门,迁移后必须检查是否全量订阅,ESLint 规则能救命。
- 数据要量化,迁移前后都录 Performance,用数字说话,不然说服不了 leader。
最后一句:如果项目里 Context 只用来传主题、语言这种几乎不变的值,那别迁,真的没必要。工具要用在刀刃上。