一、问题背景:当Context成为性能瓶颈

我们的React 19项目里有个经典场景:一个用户列表页,包含筛选器、表格、分页器、批量操作栏四个组件。最初用Context管理筛选条件和选中行ID,结果每次点击分页器,整个列表页的41个子组件全部重渲染。用``一测,单次分页操作耗时从18ms飙升到247ms。

Vue 3.5项目则是另一个极端——用props传递一个currentStep到第四层子组件,中途经过3个完全不需要该数据的中间组件。虽然Vue的响应式系统优化了渲染,但每次修改都触发setup()重执行,传递链上7个组件的依赖收集全部失效。

核心问题不在于状态管理本身,而在于状态订阅的粒度太粗。Context的value变化会让所有消费该Context的组件重渲染,而props钻取则让中间层组件被迫参与状态传递。

二、环境与版本:选型前的硬性约束

// React项目 package.json关键依赖
{
  "react": "19.0.0",
  "react-dom": "19.0.0",
  "zustand": "5.0.3",
  "typescript": "5.6.3"
}

// Vue项目
{
  "vue": "3.5.13",
  "pinia": "3.0.1",
  "vite": "6.0.5"
}

团队约定:所有状态管理库不得引入redux-devtools以外的调试插件,且bundle体积增幅不得超过20KB(gzip前)。最终选择Zustand v5是因为它基于useSyncExternalStore,可以精确到selector级别的订阅;Pinia v3则是因为其与Vue 3.5的reactive深度集成,且支持setup store语法。

三、方案设计:按状态类型拆分store

我们按业务域将全局状态拆成4个独立store,避免单一store导致的状态臃肿:

  1. authStore:用户信息、token,全局只读
  2. listStore:列表页的筛选条件、分页、选中行(带undo能力)
  3. uiStore:侧边栏折叠状态、主题、全局loading
  4. documentStore:多标签页的缓存状态

关键设计原则是状态的读写分离。例如listStore中,selectedRows只允许通过toggleRowclearSelection两个action修改,杜绝组件内直接赋值。这在后续性能优化时能快速定位到所有修改入口。

四、核心实现:从Context到Zustand的迁移实录

4.1 React侧:用selector切割订阅粒度

迁移前,我们有一个ListContext

// 迁移前:Context + useReducer
const ListContext = createContext}>(null!);
export const useList = () => useContext(ListContext);

迁移后,使用Zustand的createuseShallow

// 迁移后:Zustand store + 精确selector
import { create } from 'zustand';
import { useShallow } from 'zustand/react/shallow';

interface ListStore {
  filter: Filter;
  currentPage: number;
  selectedRows: Record;
  setFilter: (filter: Filter) => void;
  setPage: (page: number) => void;
  toggleRow: (id: string) => void;
}

export const useListStore = create((set) => ({
  filter: {},
  currentPage: 1,
  selectedRows: {},
  setFilter: (filter) => set({ filter, currentPage: 1 }),
  setPage: (currentPage) => set({ currentPage }),
  toggleRow: (id) =>
    set((state) => {
      const newSelected = { ...state.selectedRows };
      if (newSelected[id]) delete newSelected[id];
      else newSelected[id] = true;
      return { selectedRows: newSelected };
    }),
}));

// 组件内使用:只订阅自己需要的部分
const filter = useListStore((s) => s.filter);
const setPage = useListStore((s) => s.setPage); // action引用稳定,不会触发重渲染

关键点:useListStore((s) => s.filter)返回的是基本类型或独立对象的引用,不依赖useShallow也不会引起额外渲染。但如果有多个状态需要组合,必须用useShallow做浅比较:

const { filter, currentPage } = useListStore(
  useShallow((s) => ({ filter: s.filter, currentPage: s.currentPage }))
);

4.2 Vue侧:Pinia的setup store与响应式解构陷阱

Pinia迁移时最坑的是解构丢失响应性。团队里两个同事都栽在这里——从store里const { filter } = store后,filter变成了普通对象。正确做法:

// 迁移后:Pinia setup store
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';

export const useListStore = defineStore('list', () => {
  const filter = ref({});
  const currentPage = ref(1);
  const selectedRows = ref>({});

  const totalSelected = computed(() => Object.keys(selectedRows.value).length);

  function setFilter(newFilter: Filter) {
    filter.value = newFilter;
    currentPage.value = 1;
  }

  function toggleRow(id: string) {
    if (selectedRows.value[id]) {
      delete selectedRows.value[id];
    } else {
      selectedRows.value[id] = true;
    }
  }

  return { filter, currentPage, selectedRows, totalSelected, setFilter, toggleRow };
});

// 组件内必须使用storeToRefs保持响应性
import { storeToRefs } from 'pinia';
const store = useListStore();
const { filter, currentPage } = storeToRefs(store);
const { toggleRow } = store; // action直接解构没问题

五、踩坑与优化:三个意想不到的性能杀手

5.1 React 19的use()与Zustand的兼容性

React 19新增的use()API可以直接消费Promise或Context,但Zustand v5的selector函数如果返回新创建的对象(比如(s) => ({ a: s.a, b: s.b })),会导致无限循环。因为useSyncExternalStore每次都会比较快照,新对象引用不同导致重渲染。必须配合useShallow或返回基本类型。

5.2 Vue 3.5的reactive解构陷阱

在Pinia的setup store中,如果直接const { filter } = store,filter是普通对象,修改它不会触发更新。但若返回时用了toRefs,则在组件内又需要.value访问,两难。最终我们统一约定:组件内一律使用storeToRefs

5.3 列表页的批量操作性能优化

批量选择2000行时,原来的实现是循环调用toggleRow,导致2000次set操作。优化方案是增加一个toggleAll action,在store内部用一次set完成:

// 优化前:逐个调用
rows.forEach(row => toggleRow(row.id));

// 优化后:批处理
function toggleAll(ids: string[]) {
  set((state) => {
    const newSelected = { ...state.selectedRows };
    ids.forEach(id => {
      if (newSelected[id]) delete newSelected[id];
      else newSelected[id] = true;
    });
    return { selectedRows: newSelected };
  });
}

实测:2000行批量选择从12.8s降到0.3s,因为减少了1999次状态提交和对应的组件渲染调度。

六、效果数据:性能与体积的权衡

迁移完成后的数据(均使用Chrome 130的Performance面板,10次取中位数):

指标 迁移前 迁移后 变化
React列表页交互耗时 247ms 43ms -82.6%
React无效重渲染组件数 31 4 -87.1%
Vue详情页首次加载内存 38.2MB 22.1MB -42.1%
Vue bundle体积 (gzip) 89KB 98KB +10.1%
React bundle体积 (gzip) 112KB 94KB -16.1%

Vue项目体积增加是因为Pinia本身比手写props传递多了一些运行时(约9KB),但内存和渲染性能收益远超这个代价。React项目因为移除了大量Context的Provider嵌套和useMemo缓存,bundle反而瘦身。

七、总结:什么时候该迁移,什么时候别动

如果你的项目满足以下条件,强烈建议迁移:
- 状态传递超过3层,且中间层不需要该数据
- 多个组件需要共享同一份状态,且更新频率高
- 已经出现“点击一次,重渲染半个页面”的性能瓶颈

但如果只是两三个组件共享一个简单状态,用useState + props反而更清晰。状态管理库不是银弹,它带来的是订阅粒度的精细控制,代价是学习成本和架构约束

最后说一句:迁移时不要一次性全改。我们花了三周时间,按业务模块逐个替换,每个模块独立验证性能。先治标(修性能问题),再治本(重构架构),这才是团队能接受的节奏。