前言
在 React.js 中,涉及到处理表单的所有组件,你都不得不先弄清楚一个问题,什么是表单组件的受控和非受控模式!
以下是我的 headless 组件库新增的 Checkbox 组件(这个组件主要参考了字节的 arco-design、阿里的 ant-design 和国外的 shadcn/ui 源码),网站也有详细的组件教程,欢迎点赞交流。
关于受控和非受控的应用具体案例,可以看我的 headless 组件库的 checkbox
其中两个核心概念是 受控组件(Controlled Components) 和 非受控组件(Uncontrolled Components)。它们决定了表单数据在 React 组件中是如何被管理的。简单来说:
-
受控组件 完全依赖 React 的 state 来存储和更新表单数据。
-
非受控组件 则依赖原生 DOM 自身来管理表单数据。
本文将带你理解这两类组件的区别、实现方式,并提供在实际应用中使用它们的最佳实践。
什么是受控组件?
受控组件指由 React state 完全管理 的表单元素(如 input、textarea、select 等)。 也就是说,该表单元素的值完全由 React state 决定,React 成为表单数据的 唯一数据源。
通过使用 state 控制表单,你可以:
-
更精确地控制用户输入行为
-
轻松执行校验逻辑
-
格式化输入等等
我们举个例子:
javascript
import React, { useState } from 'react';
function ControlledComponent() {
const [value, setValue] = useState('');
const handleChange = (event) => {
setValue(event.target.value);
};
const handleSubmit = (event) => {
event.preventDefault();
alert('A name was submitted: ' + value);
};
return (
<form onSubmit={handleSubmit}>
<label>
Name:
<input type="text" value={value} onChange={handleChange} />
</label>
<button type="submit">Submit</button>
</form>
);
}
export default ControlledComponent;
在这个示例中:
-
value 用来保存输入框的当前值。
-
handleChange 在用户输入时更新该 state。
-
handleSubmit 在提交时使用 state 中的值。
我们小小总结一下:受控组件的核心在于:表单数据完全由 React state 管理。
使用受控组件的优势
一般情况下,复杂g场景都会使用受控模式,因为两个很明显的好处:
- 更容易与复杂 UI 库集成
- 更容易实现表单校验
与常见组件库集成
国内主流组件库的表单,基本都是来自于 ant-design 的 Form 组件逻辑, From 组件说白了,就是一个中介者模式,也就是说,Form 组件内部维护了一个收集表单数据的 store,当表单数据修改的时候, From 中的 store 会同步修改,并且刷新视图。
所以这里我们必须要知道什么时候数据被修改了,这就是受控组件的优势,onChange 事件触发,就意味着数据很可能被修改了
更容易实现表单校验
正因为能够显式的收集表单数据的变化(第三方 UI 库),才能做到在提交表单的时候,我们能够有机会来统一校验值是否符合业务要求。
或者在填写表单的时候,就显示错误信息。
举例:
jsx
import React, { useState } from 'react';
function ValidatedForm() {
const [email, setEmail] = useState('');
const [error, setError] = useState('');
const handleChange = (event) => {
const value = event.target.value;
setEmail(value);
if (!value.includes('@')) {
setError('Invalid email address');
} else {
setError('');
}
};
return (
<div>
<input type="email" value={email} onChange={handleChange} />
{error && <p style={{ color: 'red' }}>{error}</p>}
</div>
);
}
上述例子会在用户输入的每一步进行校验,并提供即时反馈。
最后我们可以再来看两个封装一个受控组件的案例,体会其用法:
文本输入框
jsx
import React, { useState } from 'react';
function TextInput() {
const [text, setText] = useState('');
const handleChange = (event) => {
setText(event.target.value);
};
return (
<div>
<label>
Text:
<input type="text" value={text} onChange={handleChange} />
</label>
<p>Entered Text: {text}</p>
</div>
);
}
export default TextInput;
复选框(Checkbox)
javascript
import React, { useState } from 'react';
function Checkbox() {
const [isChecked, setIsChecked] = useState(false);
const handleChange = (event) => {
setIsChecked(event.target.checked);
};
return (
<div>
<label>
Accept Terms:
<input type="checkbox" checked={isChecked} onChange={handleChange} />
</label>
<p>Checked: {isChecked ? 'Yes' : 'No'}</p>
</div>
);
}
export default Checkbox;
什么是非受控组件
非受控组件的状态由 DOM 自己管理,而不是 React state。 这种方式通常依赖 ref 来读取 DOM 中的值。我们来举个例子:
jsx
import React, { Component } from 'react';
class UncontrolledComponent extends Component {
constructor(props) {
super(props);
this.inputRef = React.createRef();
}
handleSubmit = () => {
console.log(this.inputRef.current.value);
}
render() {
return (
<div>
<input
type="text"
ref={this.inputRef}
/>
<button onClick={this.handleSubmit}>Submit</button>
</div>
);
}
}
从上面我们可以看到非受控组件的特点:
- 内部管理状态
表单的状态存储在 DOM 中,而非 React state。
- 直接访问 DOM
通过 ref.current.value 获取值。
- 更简单
适用于无需实时校验的场景
什么时候适合非受控组件
简单单表单
适用情况包括:
-
表单结构简单,字段少
-
不需要复杂校验或联动逻辑
-
不希望 state 频繁更新导致 re-render
注意
这里特别注意一个优点,就是不希望 state 频繁更新导致 re-render,这让非受控组件的性能非常高。
国外流行的 React-hook-form 的性能高的原因,就是主要以非受控为主。
其实 ant-design 内部为了避免 setState 每次全量刷新表单造成性能问题,主要的方式是采用了发布订阅模式,把所有表单都订阅到了 store 里,当一个表单发生变化时,只刷新这个表单的 state,从而减少了性能损耗。
组件库如何合并受控和非受控状态
我们的组件库使用了 useMergeValue hook, 它可以用来合并受控和非受控模式.
这个 hook 大家可以复制下来,在写表单组件的时候,非常好用(在各种 ui 库中广泛存在)。首先我们先看一下简单用法:
假设我们在处理一个 radio 组件,radio 组件默认是不被选中的,所以 useMergeValue 的默认值,也就是第一个参数是 false, 然后 value 表示是否是受控模式,如果是,外界就传了 value,代表外界接管了 radio 的 state 变化。当然非受控模式一般也支持传一个 defaultValue,所以 defaultValue 代表了外界采用的是非受控模式:
javascript
const [checked, setChecked] = useMergeValue(false, {
value: props.checked,
defaultValue: props.defaultChecked,
});
那么我们合并受控和非受控的思路是什么呢?
完整代码如下,先别急,后面会有详细解释:
javascript
'use client';
import React, { useState, useEffect, useRef } from 'react';
import { isUndefined } from '../utils';
import { usePrevious } from './use-previous';
export function useMergeValue<T>(
defaultStateValue: T,
props?: {
defaultValue?: T;
value?: T;
},
): [T, React.Dispatch<React.SetStateAction<T>>, T] {
const { defaultValue, value } = props || {};
const firstRenderRef = useRef(true);
const prevPropsValue = usePrevious(props?.value);
const [stateValue, setStateValue] = useState<T>(
!isUndefined(value) ? value : !isUndefined(defaultValue) ? defaultValue : defaultStateValue,
);
// 受控转为非受控的时候,需要做转换处理
useEffect(() => {
if (firstRenderRef.current) {
firstRenderRef.current = false;
return;
}
if (value === undefined && prevPropsValue !== value) {
setStateValue(value);
}
}, [value]);
const mergedValue = isUndefined(value) ? stateValue : value;
return [mergedValue, setStateValue, stateValue];
}
我们逐条拆解:
1. 初始化:判断当前是受控还是非受控
javascript
const [stateValue, setStateValue] = useState(
value ?? defaultValue ?? defaultStateValue
);
顺序解释:
- 如果用户传了
value→ 是受控组件 - 否则如果传了
defaultValue→ 是非受控组件 - 否则 → 使用组件内部默认值 defaultStateValue
👉 这一步决定了初始模式是 Controlled 还是 Uncontrolled。
2. 运行时:保持跟随受控 value 的变化
javascript
const mergedValue = isUndefined(value) ? stateValue : value;
意思是:
- 如果外面传了 value → 受控,永远以外界 value 为准
- 如果外界没传 value → 非受控,使用内部 state
👉 组件内部不需要关心模式切换,只看 mergedValue 就够了。
3. 特殊处理:当"受控 → 非受控"时,需要同步内部 state
这是最难理解的部分,也是很多 UI 库都踩过的坑:
javascript
useEffect(() => {
if (value === undefined && prevPropsValue !== value) {
setStateValue(value);
}
}, [value]);
说明:
-
如果上一次是受控(有 value)
-
现在变成非受控(value = undefined)
-
那么内部 state 应该更新为 value(undefined)
- 这等价于"清空或重置内部状态"
也就是切换受控和非受控的状态
例如:
javascript
<Checkbox checked={true} /> // 受控,永远选中
// 某个时刻用户把 checked 用 undefined 覆盖掉,比如执行了 reset
<Checkbox checked={undefined} /> // 变成非受控
如果不特殊处理,组件内部 state 还是旧的 true,就会产生 UI 错乱。