一、先聊聊这个卡顿到底咋回事

前阵子做后台管理系统,遇到一个特别常见的场景:一个表单里塞了十几个字段,有的字段还有联动校验。用户输入的时候,只要手指一快,页面就明显掉帧,输入框里的字跟不上了,甚至还会闪一下,光标直接丢了。这其实就是典型的受控组件模式在作祟。

受控组件的意思很简单:输入框的值完全不自己管,每次敲一个字符,都要把值交给 React 的 state,然后 React 再决定渲染成什么样。代码大概长这样:

// 技术栈:React 18 + JavaScript
import { useState } from 'react';

export default function SlowForm() {
  const [values, setValues] = useState({ name: '', email: '', phone: '' });

  const handleChange = (e) => {
    // 每次输入都更新整个 values 对象
    setValues({
      ...values,
      [e.target.name]: e.target.value,
    });
  };

  return (
    <form>
      <input name="name" value={values.name} onChange={handleChange} placeholder="姓名" />
      <input name="email" value={values.email} onChange={handleChange} placeholder="邮箱" />
      <input name="phone" value={values.phone} onChange={handleChange} placeholder="手机号" />
      <p>当前姓名:{values.name}</p>
    </form>
  );
}

问题在哪呢?每次敲一个字,setValues 都会拿到一个全新的对象,然后整个表单组件要重新执行一遍,所有的输入框、标签、提示信息全都要重新 diff。字段少的时候没事,一旦字段多了,或者某个字段下面还挂了列表、表格、自定义组件,这渲染成本就上去了。更要命的是,如果某个字段的校验还很重,比如要发请求查数据库,那卡顿就直接翻倍。

而且这个写法还有一个隐藏的坏处:焦点丢失。如果你在输入框里输入到一半,突然因为校验或者其它业务逻辑导致这个输入框被重新挂载,比如给输入框的 key 加了数据相关的值,或者组件结构变了,那么 React 会销毁原来的 DOM 节点,重新创建一个。你正在输入的焦点就没了,光标停在原地,但那个输入框已经不是原来的输入框了。用户必须再点一下才能继续输入,体验非常糟糕。

二、useReducer 到底能解决啥

想要解决卡顿,核心思路是:不要让整个表单因为一个字段的变化就全部重来。useReducer 是 React 自带的一个 Hook,它做的事情是把“状态更新”和“业务逻辑”分开。你只需要给 reducer 一个动作,它告诉你新状态长什么样。这样有两个好处:

第一,更新路径变得可控。我们可以通过不同的 action.type 来精确表达“这次是哪个字段改了、值是什么、要不要触发校验”,而不是像 setState 那样直接覆盖一个大对象。

第二,reducer 是纯函数,我们可以测试,也方便复用。更重要的是,配合 useReducer 的返回值,我们可以把状态和派发动作的 dispatch 传给子组件,子组件只需要发送一个最小的消息,比如 “name 字段变成了 abc”,而父组件不用把整个表单逻辑都往下传。

但注意,useReducer 本身并不能直接解决渲染性能问题。它只是让状态管理更清晰,让我们有能力去写更精细的更新逻辑。如果还是把所有字段放在一个大 state 里,每次更新还是会导致整个组件重新渲染。所以,真正要做到“不卡顿、不丢焦点、支持细粒度更新”,还需要配合另外几个手段。

三、一个完整的重构示例

我们来看一个具体的例子。技术栈:React 18 + JavaScript(用函数组件和 Hooks)。这个表单包含姓名、邮箱、手机号三个字段,每个字段都有实时校验,校验不通过的时候,输入框下面会出现红字提示,但提示不影响继续打字。

先定义一下 reducer 和初始状态:

// 技术栈:React 18 + JavaScript(使用 useReducer + useContext 实现细粒度更新)
import React, { useReducer, createContext, useContext, useCallback, useRef } from 'react';

// 初始状态:每个字段有自己的值、校验结果、是否被触碰
const initialFormState = {
  fields: {
    name: { value: '', error: null, touched: false },
    email: { value: '', error: null, touched: false },
    phone: { value: '', error: null, touched: false },
  },
  // 这里存表单整体的提交状态,和字段分开,避免互相影响
  status: 'idle', // idle | submitting | success | error
};

// 简单的校验规则(示例中写死,实际项目可以抽成独立模块)
function validateField(name, value) {
  switch (name) {
    case 'name':
      if (!value.trim()) return '姓名不能为空';
      if (value.trim().length < 2) return '姓名至少两个字';
      return null;
    case 'email':
      if (!value.trim()) return '邮箱不能为空';
      const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
      if (!emailRegex.test(value)) return '邮箱格式不正确';
      return null;
    case 'phone':
      if (!value.trim()) return '手机号不能为空';
      const phoneRegex = /^1[3-9]\d{9}$/;
      if (!phoneRegex.test(value)) return '请输入11位有效手机号';
      return null;
    default:
      return null;
  }
}

// reducer:只处理状态如何变化,内部不写 UI 逻辑
function formReducer(state, action) {
  switch (action.type) {
    case 'UPDATE_FIELD': {
      const { name, value } = action.payload;
      // 只更新一个字段的值
      return {
        ...state,
        fields: {
          ...state.fields,
          [name]: {
            ...state.fields[name],
            value: value,
            // 如果字段已经触碰过,则实时校验;否则先不报错,等失焦后再校验
            error: state.fields[name].touched ? validateField(name, value) : null,
          },
        },
      };
    }
    case 'TOUCH_FIELD': {
      const { name } = action.payload;
      // 标记字段被触碰过,并且执行一次校验
      const value = state.fields[name].value;
      return {
        ...state,
        fields: {
          ...state.fields,
          [name]: {
            ...state.fields[name],
            touched: true,
            error: validateField(name, value),
          },
        },
      };
    }
    case 'SUBMIT_START': {
      return { ...state, status: 'submitting' };
    }
    case 'SUBMIT_SUCCESS': {
      return { ...state, status: 'success' };
    }
    case 'SUBMIT_FAIL': {
      return { ...state, status: 'error' };
    }
    case 'RESET':
      return initialFormState;
    default:
      return state;
  }
}

接下来创建 Form 组件,以及一个用于单独渲染输入框的子组件。关键在于:子组件用 useContext 从 context 里取当前字段的值,而不是由父组件把整个 state 传下去。这样,父组件即便重新渲染,由于 React 的机制,只有依赖到具体字段的组件才会重渲染。

但我们还得注意一个细节:如果我们用 useContext 拿整个表单状态,那么任何字段变化都会导致所有使用这个 context 的组件一起重渲染。所以我们需要更精细一点,让每个字段组件只订阅自己那一小块状态。这里有一个常用的技巧:把 useReducer 里返回的 state.fields[name] 单独包装成一个 context value,并且用 useMemo 保证只有该字段的值变化时,引用才会变。

为了演示得完整一点,我们直接把 reducer 和 context 组合起来,写一个自定义 Hook:

// 技术栈:React 18 + JavaScript
// 创建两个 context:一个用于读取字段状态,一个用于读取整表状态和 dispatch
const FieldContext = createContext(null);
const FormContext = createContext(null);

// 一个用于单个字段的输入组件
function Field({ name, label, type = 'text', placeholder }) {
  // 只取当前字段的上下文,不会订阅整个表单
  const field = useContext(FieldContext);
  const form = useContext(FormContext);

  // 如果 context 为空,说明组件不在表单内,这种情况我们抛个友好错误
  if (!field || !form) {
    throw new Error('Field 组件必须在 Form 组件内部使用');
  }

  const { value, error, touched } = field;
  const { dispatch } = form;

  // 处理输入变化:只派发 UPDATE_FIELD 动作
  const handleChange = useCallback((e) => {
    dispatch({
      type: 'UPDATE_FIELD',
      payload: { name, value: e.target.value },
    });
  }, [dispatch, name]);

  // 处理失焦:标记为 touched,并执行校验
  const handleBlur = useCallback(() => {
    dispatch({
      type: 'TOUCH_FIELD',
      payload: { name },
    });
  }, [dispatch, name]);

  return (
    <div style={{ marginBottom: '14px' }}>
      <label htmlFor={name} style={{ display: 'block', marginBottom: '4px' }}>
        {label}
      </label>
      <input
        id={name}
        type={type}
        value={value}
        onChange={handleChange}
        onBlur={handleBlur}
        placeholder={placeholder}
        style={{
          width: '240px',
          padding: '6px 8px',
          border: '1px solid ' + (touched && error ? 'red' : '#ccc'),
          borderRadius: '4px',
        }}
      />
      {/* 只有触碰过并且有错误才显示提示 */}
      {touched && error && (
        <div style={{ color: 'red', fontSize: '13px', marginTop: '4px' }}>{error}</div>
      )}
    </div>
  );
}

现在写主 Form 组件,它负责整体布局,并且通过 context 把 dispatch 和字段状态分发出去。

// 技术栈:React 18 + JavaScript
function Form() {
  const [state, dispatch] = useReducer(formReducer, initialFormState);

  // 通过 useMemo 构造表单上下文,只包含 dispatch 和 status,不包含字段值
  const formContextValue = React.useMemo(
    () => ({ dispatch, status: state.status }),
    [dispatch, state.status]
  );

  // 字段上下文需要根据 state.fields 来生成,我们用一个对象保存三个字段各自的 context
  // 注意:这个对象本身每次渲染都会变,但 Field 组件内部通过 useContext 拿到的只是其中某一个值
  // 为了避免非必要的重渲染,可以把 fields 单独拆开,这里为了方便直接全量放进去
  const fieldContextValue = React.useMemo(() => {
    return {
      name: state.fields.name,
      email: state.fields.email,
      phone: state.fields.phone,
    };
  }, [state.fields]);

  // 处理提交逻辑
  const handleSubmit = (e) => {
    e.preventDefault();
    // 先触碰所有字段,触发全部校验
    ['name', 'email', 'phone'].forEach((name) => {
      dispatch({ type: 'TOUCH_FIELD', payload: { name } });
    });
    // 校验一遍当前状态
    const hasError = ['name', 'email', 'phone'].some((name) =>
      validateField(name, state.fields[name].value)
    );
    if (hasError) {
      dispatch({ type: 'SUBMIT_FAIL' });
      return;
    }
    dispatch({ type: 'SUBMIT_START' });
    // 假装提交异步请求
    setTimeout(() => {
      dispatch({ type: 'SUBMIT_SUCCESS' });
    }, 1000);
  };

  return (
    <FormContext.Provider value={formContextValue}>
      <FieldContext.Provider value={fieldContextValue}>
        <form onSubmit={handleSubmit} style={{ padding: '24px', background: '#f9f9f9' }}>
          <h3>快速表单示例</h3>
          <Field name="name" label="姓名" placeholder="请输入姓名" />
          <Field name="email" label="邮箱" placeholder="请输入邮箱" />
          <Field name="phone" label="手机号" placeholder="请输入手机号" />
          <div style={{ marginTop: '12px' }}>
            <button type="submit" disabled={state.status === 'submitting'}>
              {state.status === 'submitting' ? '提交中...' : '提交'}
            </button>
            <button
              type="button"
              onClick={() => dispatch({ type: 'RESET' })}
              style={{ marginLeft: '8px' }}
            >
              重置
            </button>
          </div>
          {state.status === 'success' && <p style={{ color: 'green' }}>提交成功</p>}
          {state.status === 'error' && <p style={{ color: 'red' }}>请检查表单中的错误</p>}
        </form>
      </FieldContext.Provider>
    </FormContext.Provider>
  );
}

// 最终导出
export default function App() {
  return <Form />;
}

这个示例里,Field 组件只认自己 name 对应的字段上下文。当你在姓名里打字时,state.fields.name.value 变了,fieldContextValue 对象变了,但是因为 React 对 context 的处理方式是引用比较,每个 Field 拿到的都是 fieldContextValue.name 这个值。注意,fieldContextValue 本身每次渲染都是新对象,所以三个 Field 组件都会收到新 context 引用,都会重渲染。哎呀,这样似乎没有优化到?别急,我们马上指出问题并升级。

上面的写法有一个不完美的地方:FieldContext.Provider 的 value 是一个整体对象,任何字段变化都会导致整体对象重建,即便某个 Field 只关注其中的一小块,React 也无法得知。所以三个 Field 其实都会跟着重渲染。要真正做到细粒度更新,我们需要让 context 的粒度更细——每个字段单独一个 Provider。由于字段数量是固定的,我们可以这样拆:

// 技术栈:React 18 + JavaScript
// 用字段名作为 context 的 key,动态创建多个 Provider
function Form() {
  const [state, dispatch] = useReducer(formReducer, initialFormState);

  const formContextValue = React.useMemo(
    () => ({ dispatch, status: state.status }),
    [dispatch, state.status]
  );

  // 这是更优的做法:每个字段单独生成一个 Provider value,并单独用 useMemo 包裹
  const nameFieldMemo = React.useMemo(() => state.fields.name, [state.fields.name]);
  const emailFieldMemo = React.useMemo(() => state.fields.email, [state.fields.email]);
  const phoneFieldMemo = React.useMemo(() => state.fields.phone, [state.fields.phone]);

  return (
    <FormContext.Provider value={formContextValue}>
      <FieldContext.Provider value={nameFieldMemo}>
        {/* 这里由于嵌套不美观,实际开发建议把每个 Field 直接放在对应 Provider 下 */}
        {/* 但为了演示清楚,我们可以在一个 Provider 里嵌套下一个 Provider */}
        <FieldContext.Provider value={emailFieldMemo}>
          <FieldContext.Provider value={phoneFieldMemo}>
            <form>...</form>
          </FieldContext.Provider>
        </FieldContext.Provider>
      </FieldContext.Provider>
    </FormContext.Provider>
  );
}

但是这种嵌套写法很难看,而且如果有动态字段(比如列表),字段数量不固定,就不好处理了。所以更好的方案是:把“下一个输入框组件所需要的字段信息”作为 props 或者 context 的 key 动态绑定。一种常见的做法是使用一个 Map 来存储每个字段自己的 context,或者更简单:写一个 FieldProvider 组件,它接收 namestate.fields[name],然后内部只渲染一个 Provider,它只包裹自己的 Field。

我们重构一下 Field 组件,让它自己带一个 Provider:

// 技术栈:React 18 + JavaScript
function Field({ name, label, type = 'text', placeholder }) {
  // 这个组件既是 input 也负责提供自己的 context
  const { fields, dispatch } = useContext(FormContext);
  const field = fields[name]; // 注意这里 fields 是 state.fields

  // 使用 useMemo 保证只有该字段变化时,value 才变
  const contextValue = React.useMemo(() => field, [field]);

  return (
    <FieldContext.Provider value={contextValue}>
      <FieldInput name={name} label={label} type={type} placeholder={placeholder} dispatch={dispatch} />
    </FieldContext.Provider>
  );
}

但是这样每次 fields 变化,Field 组件都要重新渲染,因为 field 对象本身变了。可是如果我们在 Field 里用 useMemo 处理 contextValue,它实际上还是会重新执行(因为 fields 是新对象)。我们需要让 Field 组件本身不依赖整个 fields,只依赖 fields[name]。这就用到了 useSelector 的思想。

我们可以在自定义 Hook 里,通过 useReducer 返回的 state,以及一个 useCallback 来选择字段。但 React 没有内置的选择器,所以我们得用一点技巧:把 fields 拆开,通过 useSyncExternalStore 或者直接给每个字段一个独立 reducer?其实更简单的一个方案是:我们不必追求极致的细粒度,只要把更新逻辑写清楚、不丢焦点、不至于卡顿,就已经达到目的了。对于大多数表单场景,上面第一个完整示例的性能已经可以了,因为输入框不是几百个。如果真遇到几百个输入框的动态列表,那我们就得用缓存组件或者把输入框转成非受控模式。

不过为了展示“更细粒度的更新路径”,我们完全可以使用一个更灵活的方法:把 useReducer 的返回值拆成两个对象,一个用于整体 status,一个用于每个字段。然后利用 React.memo 包裹 Field 组件,让 Field 只在它的字段 value 变化时才重渲染,而不是随着整个 form state 重渲染。

让我们来做一个更完美的版本。核心思想是:在 Form 组件里,我们通过 useMemo 生成每个字段独立的 context value,并且把嵌套 Provider 用一个自定义组件来管理,避免难看的嵌套。

// 技术栈:React 18 + JavaScript
// 自定义组件:为每个字段提供一个独立的 Provider
function FieldProvider({ name, field, children }) {
  // 只有 field 对象变化时,底层 children 才会感知到新值
  const memoValue = React.useMemo(() => field, [field]);
  return <FieldContext.Provider value={memoValue}>{children}</FieldContext.Provider>;
}

然后在 Form 中,直接使用三个 FieldProvider 包裹三个 Field:

// 技术栈:React 18 + JavaScript
function Form() {
  const [state, dispatch] = useReducer(formReducer, initialFormState);

  const formContextValue = React.useMemo(
    () => ({ dispatch, fields: state.fields }),
    [dispatch, state.fields]
  );

  return (
    <FormContext.Provider value={formContextValue}>
      {/* 每个字段用独立 Provider,这样只有该字段的 value 变化时,对应的 Field 才重新渲染 */}
      <FieldProvider name="name" field={state.fields.name}>
        <FieldInput name="name" label="姓名" />
      </FieldProvider>
      <FieldProvider name="email" field={state.fields.email}>
        <FieldInput name="email" label="邮箱" />
      </FieldProvider>
      <FieldProvider name="phone" field={state.fields.phone}>
        <FieldInput name="phone" label="手机号" />
      </FieldProvider>
      <div className="actions">...</div>
    </FormContext.Provider>
  );
}

注意这里 FieldProvider 作为父组件,同时被 FormContext.Provider 包裹。当状态变化时,FormContext.Provider 的 value 因为 state.fields 变化而变,所以所有消费者都会重渲染。但是,FieldInput 并没有消费 FormContext,它只消费 FieldContext。它从哪拿 dispatch 呢?我们可以让 dispatch 也走 FieldContext?不好,dispatch 是全局稳定的。我们可以把 dispatch 单独放在一个全局 context,或者直接通过 props 传下去。最简单的办法是:让 FieldInput 从 FormContext 拿 dispatch,因为它只是用了 formContextValue.dispatch,而 dispatch 引用是稳定的,所以即便 FormContext.value 每次变,FormContext 对象本身引用变了,但 React 对 context 的 diff 是按整个 value 引用来的。所以 FieldInput 如果消费 FormContext,它还是会因为 value 变化而重渲染。我们需要避免 FieldInput 消费 FormContext。

因此,我们让 FieldInput 从 props 里接收 dispatch,而 Form 组件直接把 dispatch 传给 FieldProvider,再传给 FieldInput。这样就避免了 FieldInput 订阅变化频繁的 FormContext。

最终优雅的写法如下:

// 技术栈:React 18 + JavaScript
function FieldInput({ name, label, type = 'text', placeholder, dispatch }) {
  const field = useContext(FieldContext);
  if (!field) throw new Error('FieldInput 必须由 FieldProvider 包裹');

  const { value, error, touched } = field;
  const handleChange = (e) => {
    dispatch({ type: 'UPDATE_FIELD', payload: { name, value: e.target.value } });
  };
  const handleBlur = () => {
    dispatch({ type: 'TOUCH_FIELD', payload: { name } });
  };

  return (
    <div style={{ marginBottom: '14px' }}>
      <label htmlFor={name} style={{ display: 'block', marginBottom: '4px' }}>{label}</label>
      <input
        id={name}
        type={type}
        value={value}
        onChange={handleChange}
        onBlur={handleBlur}
        placeholder={placeholder}
        style={{ width: '240px', padding: '6px 8px', border: '1px solid ' + (touched && error ? 'red' : '#ccc') }}
      />
      {touched && error && <div style={{ color: 'red', fontSize: '13px' }}>{error}</div>}
    </div>
  );
}

function FieldProvider({ name, field, dispatch, children, ...rest }) {
  const memoValue = React.useMemo(() => field, [field]);
  return (
    <FieldContext.Provider value={memoValue}>
      {children}
    </FieldContext.Provider>
  );
}

function Form() {
  const [state, dispatch] = useReducer(formReducer, initialFormState);

  return (
    <form onSubmit={handleSubmit} style={{ padding: '24px' }}>
      {/* 直接通过 props 传 dispatch,FieldInput 不消费 FormContext,就不会受整体状态变化影响 */}
      <FieldProvider name="name" field={state.fields.name} dispatch={dispatch}>
        <FieldInput name="name" label="姓名" placeholder="请输入姓名" dispatch={dispatch} />
      </FieldProvider>
      <FieldProvider name="email" field={state.fields.email} dispatch={dispatch}>
        <FieldInput name="email" label="邮箱" placeholder="请输入邮箱" dispatch={dispatch} />
      </FieldProvider>
      <FieldProvider name="phone" field={state.fields.phone} dispatch={dispatch}>
        <FieldInput name="phone" label="手机号" placeholder="请输入手机号" dispatch={dispatch} />
      </FieldProvider>
      <button type="submit" disabled={state.status === 'submitting'}>提交</button>
    </form>
  );
}

这样,当 state.fields.name 变化时,FieldProviderfield prop 变了,所以 memoValue 变了,只有包裹在对应 FieldContext.Provider 下的 FieldInput 会重新渲染。其它两个 FieldInput 因为它们的 memoValue 没有变,所以它们不会重新渲染。这就实现了细粒度的更新路径。

当然,我们还可以使用 React.memo 进一步保护 FieldInput,但由于上面已经通过 context 值隔离了,FieldInput 之间已经不会互相影响了。不过,有一个细微的问题:当 state.fields 对象本身变化时,Form 组件要重新执行,从而重新渲染所有 FieldProvider。FieldProvider 本身如果是一个普通函数组件,它也会执行,但它的 memoValue 依赖 field 这个对象,只有当 field 对象引用变化时,memoValue 才变。由于你更新 name 时,state.fields 是新对象,但 state.fields.emailstate.fields.phone 的引用没有变,所以它们对应的 useMemo 返回的是旧的 memoValue,因此 FieldInput 不会重渲染。但是 FieldProvider 这个函数本身还是会走一遍,因为它没有用 memo。如果 FieldProvider 内部逻辑很复杂,也可以包裹 React.memo,让它在 field 不变且 dispatch 不变时不重渲。我们可以把 FieldProvider 用 memo 包一层:

const FieldProvider = React.memo(function FieldProvider({ name, field, dispatch, children }) {
  const memoValue = React.useMemo(() => field, [field]);
  return <FieldContext.Provider value={memoValue}>{children}</FieldContext.Provider>;
});

这样,当 name 字段变化时,对于 email 和 phone 的 FieldProvider,因为 field prop 引用没变(我们保证了),且 dispatch 是稳定的(useReducer 的 dispatch 不变),children 也没变(因为 JSX 引用不变?实际上 children 是 React element,每次渲染产生的 element 对象引用都会变,但 React.memo 会浅比较 props,children 是引用类型,所以它每次都会变,这就导致 memo 失效。所以最好把 children 放到 FieldProvider 外部?其实不用太纠结,因为我们核心是 FieldInput 不重渲。FieldProvider 本身重复执行也就是创建个 context 对象,成本很低。但我们为了严谨,可以这样设计:FieldProvider 只接收 name 和 field,children 通过一个 render prop 或者直接用 children 但让 FieldInput 使用 memo 以避免 re-render。这里不深入下去了,因为已经足够了。

关于焦点丢失:只要输入框没有被 React 销毁重建,焦点就不会丢。在上面例子中,输入框的 key 是固定的,结构也没变,唯一的变动是 value,而 value 变化不会导致 DOM 重建,所以焦点安全。常见丢焦点的原因是:条件渲染时输入框被卸载,或者 key 变了。所以在重构时,要保证输入框在整个生命周期内稳定存在,不要因为校验错误而切换输入框的标签或类型。

四、应用场景:到底什么时候需要这种重构

受控组件 + useState 的大对象管理,在以下场景很容易出问题:

  1. 表单字段超过 15 个,并且每个字段都有校验逻辑。
  2. 字段之间存在联动,比如选 A 会改变 B 的校验规则,或者 B 的选项。
  3. 需要频繁监听表单状态并触发副作用,比如做草稿保存、实时统计。
  4. 表单中嵌入了重组件,比如富文本编辑器、自定义下拉、表格编辑器,这些组件对渲染频率很敏感。
  5. 需要支持表单状态的撤销/重做,或者需要记录操作日志。

用 useReducer 重构后,动作语义化更强,比如 “UPDATE_FIELD”、“TOUCH_FIELD”、“SUBIMT”,这让代码更容易阅读。而且 reducer 是一个纯函数,方便单元测试。你不需要再写一堆 setValues 然后去猜哪个字段变了,reducer 接收 action,就能明确知道要改哪一块。

五、技术优缺点对比

优点:

  • 状态更新逻辑集中,reducer 清晰可测。
  • Action 描述意图,比直接 setState 更可追踪。
  • 配合 context + memo,可以实现字段级的组件重渲染,减少不必要的性能开销。
  • 容易扩展,比如加一个 “填满所有字段” 的动作,只需要在 reducer 里加一个分支。
  • 因为 dispatch 引用稳定,可以安全地传给子组件,避免回调函数频繁变化。

缺点:

  • 代码量增加,需要定义 action type、reducer、初始状态。对于简单两三个字段的表单,useState 反而更简单。
  • 学习成本有点高,不熟悉函数式编程的人可能会觉得 action 和 reducer 绕。
  • 如果 reducer 变得特别大,例如动态表单支持添加/删除字段,那 action 类型会膨胀,维护也需要规划。
  • 性能优化需要额外的技巧(如 memo、Selector),并不是用了 useReducer 就自动高效。

需要注意的坑:

  • 不要把所有字段状态混在一个对象里,否则性能问题依旧。
  • 不要频繁创建 action 对象?其实没问题,dispatch 本身不重,但是要注意如果你在子组件里写 dispatch 放在了依赖数组,可能导致 effect 反复触发。
  • 校验函数要纯,不要在 reducer 里做异步操作。异步校验应该放在 effect 或者提交时处理,reducer 只能做同步计算。
  • 不要用 JSON.parse/stringify 做拷贝,大大浪费性能。
  • 编写 reducer 时,要注意每次返回新对象不可变数据,但不要深拷贝,只更新受影响的部分。

六、关联技术:useContext 和 useMemo 是怎么配合的

既然已经用到了 useContext,那就顺便多说两句。React 的 context 本来就有两个不同的数据通道:一个是 Provider 的 value,一个是消费方的消费方式。默认情况下,一个组件通过 useContext 订阅了某个 context,只要这个 context 的 value 引用变化,组件就会重渲染。所以我们必须谨慎地控制 value 的引用。用 useMemo 可以保证:只有当依赖数组里的值真正变化时,value 才会被重建。

在上面的例子里,我们用 useMemo 将每个字段的值包装成 memoValue,它就是依赖 field 对象的。而 field 对象只有当该字段自己的 value/error/touched 变化时才会重建(因为 reducer 中操作的是 state.fields[name],只有 [name] 这个引用变了,其它字段引用保持不变)。这就是细粒度更新的底层原理。

再看另一个关联技术:useCallback 能稳定函数引用。比如输入框的 handleChange,如果用 useCallback,只要 dispatch 不变且 name 不变,函数引用就稳定。但这里有 memoValue 的 context 存在,即便 handleChange 每次变,只要输入框不重渲染,也不会影响。如果你用优化过的组件,例如 React.memo(FieldInput),那 handleChange 的稳定性就很重要。但在我们的实现中,FieldInput 没有用 memo,所以不需要额外优化。

七、一个更完整的进阶示例:带有异步校验和防抖

有的表单需要异步校验,比如检查用户名是否已被注册。这种校验不能放在 reducer 里,因为 reducer 要求同步。我们可以把校验放到一个自定义 hook 里,在 UPDATE_FIELD 之后,通过 useEffect 监听字段值的变化,延迟去发请求。来看一个扩展的例子,演示如何保持松耦合。

// 技术栈:React 18 + JavaScript(useReducer + useEffect + 防抖)
function Form() {
  const [state, dispatch] = useReducer(formReducer, initialFormState);

  // 异步校验姓名是否可用
  const checkNameAvailable = useCallback(async (name) => {
    // 模拟异步请求
    const res = await fetch('/api/check-name?name=' + encodeURIComponent(name));
    const data = await res.json();
    return data.available; // 返回 boolean
  }, []);

  // 监听 name 字段的变化,做防抖校验
  const nameValue = state.fields.name.value;
  const nameTouched = state.fields.name.touched;

  useEffect(() => {
    if (!nameTouched || nameValue.trim() === '') return;
    const timer = setTimeout(() => {
      checkNameAvailable(nameValue).then((available) => {
        if (!available) {
          // 通过 dispatch 设置一个自定义的错误码
          dispatch({
            type: 'SET_CUSTOM_ERROR',
            payload: { name: 'name', error: '该用户名已被注册' },
          });
        }
      });
    }, 500);
    return () => clearTimeout(timer);
  }, [nameValue, nameTouched, checkNameAvailable, dispatch]);

  // ... 其它代码
}

这里我们新增了一个 action SET_CUSTOM_ERROR,需要在 reducer 中处理:

case 'SET_CUSTOM_ERROR': {
  const { name, error } = action.payload;
  return {
    ...state,
    fields: {
      ...state.fields,
      [name]: { ...state.fields[name], error: error },
    },
  };
}

这样,异步校验和输入更新完全分离,校验不直接修改输入值,而是通过独立的 action 去更新错误提示。输入框始终保持着自身状态,不会因为校验动作而被迫重新渲染。

八、不丢焦点的秘诀:保证输入框的稳定性

前面提到焦点丢失,其实有一个底层的原理:React 需要将虚拟 DOM 与真实 DOM 保持一致。如果一个元素在重渲染之后仍然位于相同的位置、相同的类型、相同的 key,React 就会复用它,从而保留焦点。所以我们要保证:

  • 输入框的 key 不要变。
  • 输入框不能因为条件渲染而卸载。
  • 不要给输入框的父组件增加新的条件分支把它包进去。
  • 校验提示可以动态显示,但别改变输入框本身的结构。

在 useReducer 重构中,我们还要注意一点:不要在校验后把输入框从受控模式切换为非受控模式,比如把 value 改成 defaultValue,这会让 React 认为这是一个新组件,从而卸载重挂载。别踩这个坑。

九、文章总结

受控组件本身是 React 官方的推荐写法,它让数据流变得可预测。但在复杂表单场景下,如果用 useState 去管理一个大对象,确实会遇到渲染性能瓶颈和焦点丢失等问题。使用 useReducer 重构,本质上不是“性能银弹”,而是让状态更新的逻辑变得模块化和可预测,再结合 context 的精确订阅、useMemo 的引用控制,以及稳定组件的 key,就能做到细粒度的更新,让卡顿自然消失。

整个重构过程中,我最大的感受是:性能优化不是一开始就做的,而是在实际遇到卡顿后,通过分析渲染路径,精准定位是哪个环节导致了重复渲染。useReducer 的价值在于它给了你一个清晰的“行动地图”,让你能把大状态拆成一个个小的更新切片,最终实现“牵一发动全身”的效果。而校验与反馈机制的松耦合,则让表单的扩展变得轻松,异步校验、联动逻辑都可以独立抽出去,而不污染输入框。

如果你正在做一个超过十个字段的表单,或者输入卡顿明显,不妨试试用 useReducer 配合 context 和 useMemo,把字段级别的更新路径理清楚。一开始你可能觉得代码变多了,但当你不用再为一个输入框的卡顿焦头烂额时,就会觉得这一切很划算。