一个表单表单状态把 Context 撑爆的真实案例,以及为什么换成 useReducer 就稳了

经常听到“Context 性能不好”的说法,但问题往往不在 Context 本身,而在于你往里面塞了什么东西。我最近就碰上一个典型案例:一个复杂表单页面,随着用户操作越来越卡,React DevTools Profiler 一查,好家伙,整个表单树都在随着每次按键重新渲染。而罪魁祸首,就是一个被撑爆的 Context。

当你把几十个字段的状态、校验规则、联动逻辑全塞进一个 Context value 对象里,每一次 setState 都会让这颗原子弹原地爆炸。把 useState 换成 useReducer,不是简单的 API 替换,而是改变了状态更新的粒度——从“替换整个对象”变成“派发一个动作”。

原子弹式的状态更新是怎么拖垮表单的

先看看出问题的代码结构。我们有一个复杂的投保表单,包含被保人信息、险种配置、受益人指定等 7 个区块,总计 40 多个字段。最初的设计很直接——一个顶层 Context 管所有:

// ❌ 问题代码
interface FormState {
  applicant: { name: string; idNumber: string; phone: string };
  insuredList: InsuredItem[];
  plans: PlanConfig[];
  beneficiaries: Beneficiary[];
  currentStep: number;
  validationErrors: Record<string, string>;
}

const FormContext = createContext<{
  state: FormState;
  setState: Dispatch<SetStateAction<FormState>>;
} | null>(null);

function FormProvider({ children }: { children: ReactNode }) {
  const [state, setState] = useState<FormState>(initialState);
  return (
    <FormContext.Provider value={{ state, setState }}>
      {children}
    </FormContext.Provider>
  );
}

在某个子组件里,用户修改被保人姓名:

function ApplicantNameInput() {
  const { state, setState } = useContext(FormContext)!;
  
  return (
    <input
      value={state.applicant.name}
      onChange={(e) => {
        setState(prev => ({
          ...prev,
          applicant: { ...prev.applicant, name: e.target.value }
        }));
      }}
    />
  );
}

这段代码看起来没毛病,React 18 的自动批处理也会保证只触发一次渲染。但问题在于:setState 每次返回的是一个全新的 FormState 对象,这个新对象通过 Context 的 value 传给所有消费者。React 用 Object.is 比较新旧 value,发现引用变了,于是每一个 useContext(FormContext) 的组件全部重新渲染。

表单有 40 个字段,意味着至少 40 个输入组件。用户每敲一个字符,40 个组件全部走一遍 diff。更糟的是,validationErrors 这类高频更新的字段也在同一个对象里——输入过程中实时校验的结果更新,又会引发新一轮的全树渲染。

实测数据:在 M1 MacBook Air 上,连续快速输入时,React DevTools 记录的渲染耗时从单次 3ms 飙升至 85ms,帧率掉到 20fps 以下,输入明显感受到延迟。这就是“Context 性能差”的真相——不是 Context 的锅,是你让一颗原子弹在整个组件树里反复引爆。

useReducer 如何把爆炸半径缩小到一个 action

问题的本质是:状态更新粒度太粗useState 逼迫你用一个整体的新对象去替换旧对象,即使你只改了 applicant.name,整个 FormState 引用都变了。useReducer 的思路不同——你 dispatch 的是一个描述“发生了什么”的 action,而不是一个新的状态快照。

改造成 reducer:

type FormAction =
  | { type: 'UPDATE_APPLICANT'; field: string; value: string }
  | { type: 'ADD_INSURED'; insured: InsuredItem }
  | { type: 'REMOVE_INSURED'; index: number }
  | { type: 'UPDATE_PLAN'; index: number; config: Partial<PlanConfig> }
  | { type: 'SET_VALIDATION_ERRORS'; errors: Record<string, string> }
  | { type: 'SET_STEP'; step: number };

function formReducer(state: FormState, action: FormAction): FormState {
  switch (action.type) {
    case 'UPDATE_APPLICANT':
      return {
        ...state,
        applicant: { ...state.applicant, [action.field]: action.value }
      };
    case 'ADD_INSURED':
      return { ...state, insuredList: [...state.insuredList, action.insured] };
    // ... 其他 case
    default:
      return state;
  }
}

function FormProvider({ children }: { children: ReactNode }) {
  const [state, dispatch] = useReducer(formReducer, initialState);
  
  // 关键:value 对象引用稳定
  const contextValue = useMemo(() => ({ state, dispatch }), [state]);
  
  return (
    <FormContext.Provider value={contextValue}>
      {children}
    </FormContext.Provider>
  );
}

等等——这里 contextValue 依赖了 state,所以 state 变化时 value 引用还是会变,这不还是全树渲染吗?

你说得对。useReducer 本身不会自动解决 Context 的全树渲染问题。它真正的价值在于让拆分 Context 成为可能。因为 dispatch 函数的引用是稳定的(React 保证 useReducer 返回的 dispatch 在重渲染时不变),我们可以把 state 和 dispatch 拆成两个独立的 Context:

const FormStateContext = createContext<FormState | null>(null);
const FormDispatchContext = createContext<Dispatch<FormAction> | null>(null);

function FormProvider({ children }: { children: ReactNode }) {
  const [state, dispatch] = useReducer(formReducer, initialState);
  
  return (
    <FormStateContext.Provider value={state}>
      <FormDispatchContext.Provider value={dispatch}>
        {children}
      </FormDispatchContext.Provider>
    </FormStateContext.Provider>
  );
}

现在,一个只负责派发事件的按钮组件:

function AddInsuredButton() {
  const dispatch = useContext(FormDispatchContext)!;
  return <button onClick={() => dispatch({ type: 'ADD_INSURED', insured: newItem })}>添加</button>;
}

这个组件只消费 FormDispatchContext,而 dispatch 引用永远不变,所以这个组件永远不会因为表单数据变化而重渲染。它只在自身 state 或 props 变化时才更新。

真正需要读取表单数据的输入组件,消费 FormStateContext。但即便如此,40 个输入组件仍然会在任何一个字段变化时全部重渲染,因为 FormStateContext 的 value 是整个 state 对象。

解决这个问题的最后一步,是继续拆分 state Context。比如把被保人信息、险种配置、校验错误分别放进独立的 Context:

const ApplicantContext = createContext<ApplicantInfo | null>(null);
const PlansContext = createContext<PlanConfig[]>([]);
const ValidationContext = createContext<Record<string, string>>({});

function FormProvider({ children }: { children: ReactNode }) {
  const [state, dispatch] = useReducer(formReducer, initialState);
  
  // 用 useMemo 让每个子 Context 的 value 只在对应部分变化时更新
  const applicantValue = useMemo(() => state.applicant, [state.applicant]);
  const plansValue = useMemo(() => state.plans, [state.plans]);
  const validationValue = useMemo(() => state.validationErrors, [state.validationErrors]);
  
  return (
    <ApplicantContext.Provider value={applicantValue}>
      <PlansContext.Provider value={plansValue}>
        <ValidationContext.Provider value={validationValue}>
          <FormDispatchContext.Provider value={dispatch}>
            {children}
          </FormDispatchContext.Provider>
        </ValidationContext.Provider>
      </PlansContext.Provider>
    </ApplicantContext.Provider>
  );
}

现在,用户修改被保人姓名时,只有 applicantValue 的引用变化,消费 ApplicantContext 的组件重渲染。消费 PlansContext 的险种配置区域纹丝不动——plansValue 的引用没变,React 直接跳过这些组件的渲染。

改造后的实测数据:同样的快速输入场景,单次渲染耗时稳定在 1.5ms 以内,因为每次只有 5 个申请人相关的输入组件在渲染,其余 35 个组件完全跳过。帧率稳定 60fps。

为什么不用第三方状态库

这个表单的业务逻辑已经足够复杂——字段联动有十几条规则,比如险种保额不能超过被保人年收入的 20 倍,受益人指定方式影响校验逻辑。你可能会问,都拆成这样了,干嘛不直接上 Zustand 或者 Jotai?

如果项目已经在用这类库,用它们完全没问题。但如果你在一个对依赖敏感的团队里(比如我们这边,新增一个 npm 依赖需要架构组评审),或者这个表单只是一个大项目中的一个小模块,为它引入一个全局状态库性价比不高。useReducer + 拆分 Context 的方案零依赖,TypeScript 类型推导完整,调试时 Redux DevTools 还能通过 useReducer 的 action 日志看到所有状态变更(React DevTools 原生支持)。

更关键的是,这个方案的思路和那些原子化状态库本质相同——都是把状态拆细,让组件只订阅自己关心的那部分。Jotai 的 atom 就是细粒度状态单元,Zustand 的选择器也是同样的道理。你用手动拆分 Context 实现了相同的效果,代价是多写一些模板代码,收益是不引入外部依赖、团队成员零学习成本。

常见问题

dispatch 的 type string 写错了不会报错,怎么保证类型安全?

别用裸 string,用枚举或联合类型。上面代码里的 FormAction 已经是联合类型了,TypeScript 会在你写 dispatch({ type: 'UPDATA_APPLICANT' }) (注意拼写错误)时直接报红线。如果确实需要更严格的约束,可以把 action type 抽成常量枚举,配合类型工具保证 reducer 的 case 穷举。

拆分太多 Context 会让组件嵌套地狱吗?

不会,因为 Provider 的嵌套只在 FormProvider 内部一次写好,业务组件完全无感。业务组件只通过 useContext(ApplicantContext) 消费数据,它的父组件不需要层层传递任何东西。如果你觉得写多个 Context 文件太繁琐,可以像上面那样在一个文件里集中管理,只暴露自定义 hook(如 useApplicant()usePlans())给外部。

useMemo 依赖 state 的某个属性,但那个属性如果是对象,每次 reducer 返回新引用,useMemo 不还是会触发更新吗?

是的,这正是你要的效果。useMemo(() => state.applicant, [state.applicant]) 只在 state.applicant 引用变化时更新 applicantValue。如果这次 dispatch 只改了一个 plan 的保额,reducer 返回的新 state 里 applicant 字段的引用和旧 state 是同一个对象(因为你没有修改它),useMemo 直接返回缓存的旧值,消费 ApplicantContext 的组件不重渲染。这里的关键是 reducer 里正确使用不可变更新——只浅拷贝你真正修改的那一层。

这个方案能处理跨 Context 的联动逻辑吗?比如险种变化要触发被保人信息校验。

联动逻辑放在 reducer 里处理。Reducer 能拿到完整的 state,一个 action 可以同时修改多个字段。比如 UPDATE_PLAN 的 case 里,除了更新 plans,还可以顺便更新 validationErrors 中与被保人相关的部分。如果联动逻辑特别复杂,可以把纯计算逻辑抽成独立函数,reducer 只负责调用和组装返回的新 state。