一、先聊聊这个卡顿到底咋回事
前阵子做后台管理系统,遇到一个特别常见的场景:一个表单里塞了十几个字段,有的字段还有联动校验。用户输入的时候,只要手指一快,页面就明显掉帧,输入框里的字跟不上了,甚至还会闪一下,光标直接丢了。这其实就是典型的受控组件模式在作祟。
受控组件的意思很简单:输入框的值完全不自己管,每次敲一个字符,都要把值交给 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 组件,它接收 name 和 state.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 变化时,FieldProvider 的 field 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.email 和 state.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 的大对象管理,在以下场景很容易出问题:
- 表单字段超过 15 个,并且每个字段都有校验逻辑。
- 字段之间存在联动,比如选 A 会改变 B 的校验规则,或者 B 的选项。
- 需要频繁监听表单状态并触发副作用,比如做草稿保存、实时统计。
- 表单中嵌入了重组件,比如富文本编辑器、自定义下拉、表格编辑器,这些组件对渲染频率很敏感。
- 需要支持表单状态的撤销/重做,或者需要记录操作日志。
用 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,把字段级别的更新路径理清楚。一开始你可能觉得代码变多了,但当你不用再为一个输入框的卡顿焦头烂额时,就会觉得这一切很划算。
评论
围绕“受控组件模式遭遇高频输入卡顿,用useReducer重构复杂表单状态管理,校验与反馈机制如何做到松耦合、不丢焦点且支持更细粒度的更新路径”参与讨论