一、问题背景:Context 不是状态管理库,但我们都当它是

项目是一个 B 端订单管理系统,前端两套壳:老模块用 React 18.2 + TypeScript 5.3,新模块用 Vue 3.4 + Vite 5。两边共享同一套后端接口和业务模型。

最早的状态方案很"朴素":

  • React 侧:OrderContext + useReducer,一个 Provider 包住整个路由树
  • Vue 侧:provide/injectreactive 对象,顶层 App 注入

小规模时没问题。直到订单列表页上了虚拟滚动 + 多条件筛选 + 实时轮询,问题集中爆发:

  1. 筛选条件一变,OrderContext 的 value 是新对象,所有 useContext 的组件全部重渲染。列表项虽然做了 memo,但父级 ListContainer 一渲染,children 引用变了,memo 直接失效。
  2. 轮询每 5s 更新一次 lastUpdated 字段,这个字段只有顶部状态栏用,却让整个列表跟着抖。
  3. 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 不是坏东西,它们解决的是"依赖传递",不是"状态订阅"。当你的状态开始有性能要求、有跨页面共享、有细粒度更新需求时,就是迁移的信号。

几条经验:

  1. 选型看订阅粒度,不看 API 好不好看。Zustand 的 selector 和 Pinia 的 computed 是核心价值。
  2. 迁移要增量,并存期越长越安全,别一次性重写。
  3. 选择器是命门,迁移后必须检查是否全量订阅,ESLint 规则能救命。
  4. 数据要量化,迁移前后都录 Performance,用数字说话,不然说服不了 leader。

最后一句:如果项目里 Context 只用来传主题、语言这种几乎不变的值,那别迁,真的没必要。工具要用在刀刃上。