原生html元素受控和非受控模式
在 React 中,表单组件(如 <input>
、<textarea>
、<select>
等)有两种处理用户输入的方式:受控组件(Controlled Components) 和 非受控组件(Uncontrolled Components)。
受控组件 (Controlled Components)
定义:表单元素的值完全由React组件状态驱动,形成单向数据流
核心特征:
- 数据源唯一性:
value
属性绑定组件state - 严格同步机制:必须通过
onChange
事件更新state - 完全状态控制:每次输入都触发重新渲染
js
。
function ControlledInput() {
const [inputValue, setInputValue] = useState("");
return (
<div>
<input
type="text"
value={inputValue} // 受控
onChange={(e) => setInputValue(e.target.value)} // 更新 state
/>
<p>输入值: {inputValue}</p>
</div>
);
}
export default ControlledInput;
非受控组件 (Uncontrolled Components)
定义:非受控组件是指表单元素的值由 DOM 自己管理,而不是由 React state 控制。通常使用 ref 直接访问和操作 DOM 元素。
特点:
- 数据存储在 DOM 中,而不是 React 组件 state 中。
- 文件上传等不需要即时验证的输入
- 适用于与非 React 代码(如第三方库)集成的场景。
js
import { useRef } from "react";
function UncontrolledInput() {
const inputRef = useRef(null);
const handleSubmit = () => {
alert(`输入值: ${inputRef.current.value}`);
};
return (
<div>
<input type="text" ref={inputRef} /> {/* 非受控 */}
<button onClick={handleSubmit}>提交</button>
</div>
);
}
export default UncontrolledInput;
在实际开发中,很多第三方组件库(如 antd)的表单组件都支持受控和非受控两种模式
<Input value={value} onChange={setValue} />
(受控模式)<Input defaultValue="初始值" ref={inputRef} />
或者<Input defaultValue="初始值" onChange={读取数据}>
(非受控模式)
组件级别的受控和非受控模式
当从原生html元素的受控/非受控模式切换到组件级别的受控/非受控模式时,具体的控制逻辑也是相似的。
受控组件模式
父组件通过props完全控制子组件状态
特征:
- 父组件传入
value
和onChange
- 子组件成为无状态(pure)组件
- 数据流完全可预测
非受控组件模式
子组件自主管理状态,父组件通过defaultValue
初始化
特征:
- 父组件传入
defaultValue
- 子组件内部维护状态
- 通过
onChange
通知父组件状态变更
实现一个同时支持受控模式和非受控模式的子组件Calendar
:
tsx
function Calendar(props: CalendarProps) {
// 受控模式:父组件传入value和onChange
// 非受控模式:父组件传入defaultValue和onChange
const { value: propsValue, defaultValue, onChange, dates } = props;
// 初始化内部状态(非受控模式使用)
const [internalValue, setInternalValue] = useState<Date | undefined>(propsValue ?? defaultValue);
// 处理日期选择
const changeValue = (date: Date) => {
if (propsValue === undefined) {
setInternalValue(date); // 仅非受控模式更新内部状态
}
onChange?.(date);
};
// 最终显示值
const mergedValue = propsValue ?? internalValue;
return (
<div>
<div>当前选中:{mergedValue?.toLocaleDateString() || "未选择"}</div>
{dates.map((date) => (
<div key={date.toISOString()} onClick={() => changeValue(date)} style={{ cursor: "pointer", margin: "8px 0" }}>
{date.toLocaleDateString()}
</div>
))}
</div>
);
}
tsx
// 受控模式调用子组件
export default function App() {
const dates = [new Date(2025, 4, 1), new Date(2025, 4, 2), new Date(2025, 4, 3)];
const [date, setDate] = useState(new Date(2025, 4, 1));
return (
<Calendar dates={dates} value={date} onChange={(date) => {setDate(date); }}
/>
);
}
// 非受控模式调用子组件
export default function App() {
const dates = [new Date(2025, 4, 1), new Date(2025, 4, 2), new Date(2025, 4, 3)];
return (
<Calendar dates={dates} onChange={(date) => {console.log(date);}}
defaultValue={new Date(2025, 4, 1)}
/>
);
}
考虑edge case:
- 非受控转受控:如果组件开始时是非受控的(没有传入value),后来父组件传入了value,此时组件会切换为受控模式。此时internalValue会被保留,但由于mergedValue优先使用propsValue,所以UI会正确显示父组件传入的value。
- 受控转非受控 :当组件从受控模式切换到非受控模式时,比如父组件之前传了value,后来不传了,这时候
internalValue
却不会更新,因为此时propsValue
变为undefined,mergedValue
会取internalValue
,而internalValue
可能还是之前的value,这可能导致错误展示。
所以需要增加useEffect监听propsValue的变化,同步受控模式的外部值变化。
tsx
function Calendar(props: CalendarProps) {
const { value: propsValue, defaultValue, onChange, dates } = props;
// 初始化内部状态(非受控模式使用)
const [internalValue, setInternalValue] = useState<Date | undefined>(propsValue ?? defaultValue);
// ===同步受控模式的外部值变化===
useEffect(() => {
if (propsValue !== undefined) {
setInternalValue(propsValue);
}
}, [propsValue]);
// =============================
// 处理日期选择
const changeValue = (date: Date) => {
if (propsValue === undefined) {
setInternalValue(date); // 仅非受控模式更新内部状态
}
onChange?.(date);
};
// 最终显示值
const mergedValue = propsValue ?? internalValue;
return (
......
);
}
但是这可能导致性能问题。
当 propsValue
变化时,父组件触发子组件渲染 → useEffect
内 setInternalValue
再次触发子组件渲染,导致 单次外部变化引发两次渲染 。所以将useEffect改成只有新旧值不同时才更新。
tsx
import { isEqual } from "lodash";
useEffect(() => {
if (propsValue === undefined) return;
setInternalValue((prev) => { // 值未变化时返回 prev 以跳过渲染
return isEqual(prev, propsValue) ? prev : propsValue;
});
}, [propsValue]);
知乎大佬提出用 Ref + forceUpdate() 来解决这个问题,可以参考:zhuanlan.zhihu.com/p/536322574
封装hook
但简单起见,直接用useEffect这种好理解的方案进行hook封装。
tsx
import { isEqual } from "lodash";
type Options<T> = {
value?: T;
defaultValue?: T;
onChange?: (data: T) => void;
};
export function useMergeState<T>(options: Options<T>) {
const { defaultValue, value: propValue, onChange } = options || {};
const [internalValue, setInternalValue] = useState<T | undefined>(propValue ?? defaultValue);
// 同步受控模式的外部值变化
useEffect(() => {
if (propValue === undefined) return;
setInternalValue((prev) => {
// 值未变化时返回 prev 以跳过渲染
return isEqual(prev, propValue) ? prev : propValue;
});
}, [propValue]);
const updateValue = (newValue: T) => {
if (propValue === undefined) {
setInternalValue(newValue);
}
onChange?.(newValue);
};
const mergedValue = propValue ?? internalValue;
return [mergedValue, updateValue] as const;
}
仅当值变化时触发onChange
但是现在的封装还存在一个问题,只要用户触发了点击,无论值是否存在变化,都会调用onChange。
需要做一下判断,只有新旧值不同时,才触发 onChange
和状态更新。在 updateValue
中,通过 mergedValueRef.current
获取最新值,使用 isEqual
进行深比较。
diff
import { isEqual } from "lodash";
type Options<T> = {
value?: T;
defaultValue?: T;
onChange?: (data: T) => void;
};
export function useMergeState<T>(options: Options<T>) {
const { defaultValue, value: propValue, onChange } = options || {};
const [internalValue, setInternalValue] = useState<T | undefined>(propValue ?? defaultValue);
const mergedValue = propValue ?? internalValue;
+ const mergedValueRef = useRef<T | undefined>(mergedValue);
+ mergedValueRef.current = mergedValue;
// 同步受控模式的外部值变化
useEffect(() => {
if (propValue === undefined) return;
setInternalValue((prev) => {
// 值未变化时返回 prev 以跳过渲染
return isEqual(prev, propValue) ? prev : propValue;
});
}, [propValue]);
const updateValue = (newValue: T) => {
+ if (isEqual(mergedValueRef.current, newValue)) return;
if (propValue === undefined) {
setInternalValue(newValue);
}
onChange?.(newValue);
};
return [mergedValue, updateValue] as const;
}
最后在子组件调用useMergeState
:
tsx
function Calendar(props: CalendarProps) {
const { value, defaultValue, onChange, dates } = props;
const [date, updateValue] = useMergeState({ value, defaultValue, onChange });
return (
<div>
<div>当前选中:{date?.toLocaleDateString() || "未选择"}</div>
{dates.map((d) => (
<div key={d.toISOString()} onClick={() => updateValue(d)} >
{d.toLocaleDateString()}
</div>
))}
</div>
);
}
总结
在 React 开发中,受控与非受控模式 是管理组件状态的核心设计思想,其本质区别在于数据控制权的归属:
- 受控模式 通过
value
和onChange
将数据流完全交给父组件,适合需要精准控制状态(如实时表单验证、动态交互)的场景,确保数据流的可预测性。 - 非受控模式 通过
defaultValue
和ref
让组件自主管理状态,适用于性能敏感场景(如表单初始化后无需频繁更新)。
参考: