受控 / 非受控组件与 Portals
面试向讲清三件事:受控与非受控的本质是"谁持有状态"(React state 还是 DOM)、 只写 value 不写 onChange 为什么会变成只读输入框、受控/非受控这套思路同样适用于 Modal 等通用组件; 以及 createPortal 为什么能突破 overflow/z-index 却仍然按 React 树冒泡事件、共享 context。
一句话概括
受控和非受控,说白了就是问一句:输入框里那个值,到底归谁管? 归 React state 管的叫受控,归 DOM 自己管的叫非受控(你要取值就用 ref 伸手去拿)。
面试官爱问这个,不是想听你背定义,而是想看你知道状态该放哪——这是组件设计的核心问题。所以答题时别只说”表单”,要补一句:Modal、Dropdown 这些通用组件也有受控/非受控两种写法。
Portals 则是另一个方向的题:我想让弹窗渲染到 body 下面,但它还得认我这个爹。createPortal 就是干这个的——DOM 位置换了,React 树上的父子关系一点没变。
先记三句话:
- 受控 =
value+onChange,单一数据源在 React;非受控 =defaultValue+ref,数据源在 DOM。 value写了不给onChange,输入框直接变只读。- Portal 只搬 DOM,不搬 React 树——事件照样按 React 树冒泡。
核心知识点
1. 本质区别:谁是单一数据源
| 受控(Controlled) | 非受控(Uncontrolled) | |
|---|---|---|
| 值存在哪 | React state | DOM 节点内部 |
| 写法 | value={x} + onChange | defaultValue="x" + ref |
| 怎么取值 | 直接读 state | ref.current.value 或 new FormData(form) |
| 每次输入 | 触发 setState → 重渲染 | 不重渲染 |
| 实时校验/格式化 | 天然支持 | 得手动来 |
| 适合 | 需要联动、校验、格式化 | 简单表单、大表单、第三方 DOM 库 |
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// ✅ 受控:React 是唯一数据源
function ControlledInput() {
const [text, setText] = useState('');
return (
<input
value={text}
onChange={(e) => setText(e.target.value.toUpperCase())} // 边打字边转大写
/>
);
}
// ✅ 非受控:DOM 自己记,提交时再拿
function UncontrolledInput() {
const ref = useRef(null);
const handleSubmit = (e) => {
e.preventDefault();
console.log(ref.current.value); // 伸手去 DOM 里拿
};
return <input defaultValue="hello" ref={ref} />;
}
注意 defaultValue 只在首次挂载时生效,之后 React 就不管它了;你改 defaultValue 不会更新输入框。
2. 只写 value 不写 onChange:输入框变只读
这是最高频的追问。答案:React 每次渲染都会把 value 强行写回 DOM,而你没有 onChange 去更新 state,于是 state 永远是初始值——用户敲什么都被下一次渲染覆盖掉。
1
2
3
4
5
6
7
8
9
10
11
// ❌ 输入框锁死,敲不动,控制台还会警告
// "You provided a `value` prop to a form field without an `onChange` handler."
<input value="hello" />
// ❌ 另一个经典:给了 undefined,React 判定为非受控,之后再传值就警告
<input value={someData?.name} /> // someData 为 undefined 时 → 非受控
<input value={someData.name} /> // 有值后又变受控 → "A component is changing an
// uncontrolled input to be controlled"
// ✅ 兜底成空字符串,保证永远是受控
<input value={someData?.name ?? ''} onChange={(e) => setName(e.target.value)} />
面试金句:一个 input 在自己的生命周期里,必须始终是受控或始终是非受控,中途切换 React 会告警。所以异步数据回填表单时,一定要给 ?? '' 兜底。
3. 什么时候该用非受控
别把非受控当”落后写法”,它有明确的适用场景:
<input type="file">:它的value是只读的(浏览器出于安全不允许脚本赋值),所以在 React 里永远是非受控,只能用 ref 拿files。这是必考题。- 超大表单:几十个字段,每次按键都重渲染确实卡,非受控可以做到零重渲染。
- 接第三方 DOM 库:jQuery 日期选择器、地图 SDK 这类自己操作 DOM 的东西,交给它们管最省事。
- 只在提交时关心值的场景。
1
2
3
4
5
6
7
8
9
10
11
// ✅ 提交时一次性拿全部字段,不用为每个字段建 state
function BigForm() {
const formRef = useRef(null);
const handleSubmit = (e) => {
e.preventDefault();
const data = Object.fromEntries(new FormData(formRef.current));
// { username: '...', email: '...' } —— 前提是每个 input 都有 name
submit(data);
};
return <form ref={formRef} onSubmit={handleSubmit}>{/* ... */}</form>;
}
4. 同一套思路:Modal / Dropdown 的受控与非受控
这是拉开差距的一答。受控/非受控不只是表单概念,它说的是”状态归父组件还是归自己”:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// ✅ 受控 Modal:开关由父组件持有(Ant Design / Radix 默认做法)
function ControlledModal({ open, onOpenChange }) {
if (!open) return null;
return <div onClick={() => onOpenChange(false)}>...</div>;
}
// 用法:<Modal open={open} onOpenChange={setOpen} />
// ✅ 非受控 Modal:内部自己管,父组件通过 ref 调命令式方法
const Modal = forwardRef(function Modal(props, ref) {
const [open, setOpen] = useState(false);
useImperativeHandle(ref, () => ({ open: () => setOpen(true), close: () => setOpen(false) }));
if (!open) return null;
return <div>...</div>;
});
// 用法:modalRef.current.open() —— 用起来爽,但开关状态对父组件不可见
顺带一提:上面用了
forwardRef是为了兼容 React 18 及更早版本。React 19 起函数组件可以直接把ref当普通 prop 接收,forwardRef已被官方标注为废弃(还能用,未来会移除)。面试时提一句,说明你跟得上版本。
取舍一句话:需要父组件知道/控制状态(比如”提交成功后关闭弹窗”)就受控;纯展示、父组件不关心就非受控。 真要做组件库,通常是”受控 + 非受控都支持”:内部存一份 state,若父组件传了 open 就以父组件的为准。
5. 非受控组件怎么重置
重置非受控表单有三招,面试常问:
1
2
3
4
5
6
7
8
// ① 改 key:最彻底,直接卸载重挂载,DOM 节点整个换掉(推荐)
<input key={formVersion} defaultValue="" />
// ② 拿 DOM 调原生 reset()
formRef.current.reset();
// ③ React 19 新增:给 <form action={fn}> 传异步函数,
// 成功后 React 会自动重置表单里的非受控字段;想手动重置用 requestFormReset
6. Portals:把 DOM 搬到别处,React 关系不变
createPortal(children, domNode, key?) 解决的问题很实在:父容器有 overflow: hidden 或 z-index 形成层叠上下文时,弹窗会被裁掉、盖不住。 把 DOM 挂到 document.body 就躲开了。
1
2
3
4
5
6
7
8
9
10
11
import { createPortal } from 'react-dom';
function Modal({ children, onClose }) {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []); // SSR 时 document 不存在,必须等挂载后
if (!mounted) return null;
return createPortal(
<div className="mask" onClick={onClose}>{children}</div>,
document.body // DOM 上挂到 body,React 树上仍是 Modal 的父组件的子节点
);
}
7. 两条铁律:事件按 React 树冒泡,context 照常生效
这是 Portal 的送分/送命题。portal 只改变 DOM 节点的物理位置,在 React 树里它还是原来那个孩子,所以:
1
2
3
4
5
6
7
8
9
function Parent() {
return (
<div onClick={() => console.log('父组件也能收到!')}>
{createPortal(<button>点我</button>, document.body)}
</div>
);
}
// button 在 DOM 上是 body 的直接子节点,但点击时 onClick 照样触发
// —— 因为 React 的合成事件是沿着 React 树(fiber 树)冒泡的
同理,portal 内部的组件能读到外层 Provider 的 context,因为 context 也是沿 React 树查找。
如果不想让事件冒泡上去,在 portal 内部的容器上 e.stopPropagation() 即可。
8. Portals 的三个坑
- SSR 直接
document.body会炸:服务端没有document。要么像上面用mounted标志,要么在useEffect里动态创建容器节点。 - 容器节点必须已存在:
createPortal不会帮你创建 DOM 节点,传null会报错。 - 更新时换 container 会重建内容:同一个 portal 前后传了不同的
domNode,React 会销毁重建整个子树,状态丢失。 - 无障碍:弹窗要自己处理焦点陷阱、
aria-modal,Portal 不管这些。
其实你每天都在用
- 登录表单:密码框要实时判断”至少 8 位才让点登录”——必须受控,因为 React 得每次按键都知道值。
- 手机号/银行卡输入自动加空格:
onChange里格式化后再setState,只有受控做得到。 - 上传头像:
<input type="file">天生非受控,只能ref.current.files[0]。 - Ant Design 的 Modal / Drawer:
open+onClose就是受控写法;Modal.confirm()是命令式(非受控 + 内部渲染)。 - Select / DatePicker 组件库:基本都是受控(
value+onChange),所以你清空时得手动setValue(null)而不是setValue('')。 - 消息提示 Message / Toast:全局
message.success()内部就是往 body 上挂 DOM,和 Portal 一个思路。 - 表格里的行内编辑:大表格用受控会每次按键重渲染整行,通常会降级成非受控 + 失焦提交。
- 微前端 / 老页面局部 React 化:用 portal 把 React 组件渲到 jQuery 生成的 DOM 节点里,比开多个
createRoot更省事(状态能共享)。
常见误解(FAQ)
❌ 误区1:”非受控组件是反模式,不应该用” 不对。React 官方文档明确给了非受控的适用场景,
<input type="file">更是只能非受控。只是”默认优先选受控”这个习惯是对的——大部分业务表单需要校验和联动。❌ 误区2:”受控组件性能差,所以大表单一律用非受控” 想多了。一次 setState 触发一次组件重渲染,代价通常远小于一次网络请求。真卡了再优化,别提前把代码写成难维护的非受控。而且非受控做实时校验会更麻烦,得不偿失。
❌ 误区3:”Portal 里的事件不会冒泡到父组件,因为 DOM 上不是父子” 正好相反,会冒泡。React 17 之后合成事件挂在 root container 上,靠 fiber 树模拟冒泡,走的是 React 树而非 DOM 树。这是 Portal 最经典的考点。
❌ 误区4:”用 Portal 渲染的组件拿不到外层的 context” 拿得到。只要它在 React 树里还是那个 Provider 的后代,context 就照常透传——Portal 只换了 DOM 挂载点。
❌ 误区5:”defaultValue 改了,输入框的值就会变” 不会。
defaultValue等价于原生valueattribute,只在挂载时写一次。要动态改非受控输入框的值,得直接操作 DOM(ref.current.value = x)或者用key强制重挂载。❌ 误区6:”Portal 能解决所有 z-index 问题” 只能解决”被父容器层叠上下文困住”的问题。如果你的 mask 和另一个全屏元素都在 body 下,
z-index该抢还是会抢。
一句话总结
受控非受控问的是”状态归谁管”(React 管就受控,DOM 管就非受控,value 配 onChange、defaultValue 配 ref,一个 input 中途不许换阵营);Portal 问的是”DOM 挂哪”(createPortal 只搬 DOM 不搬 React 树,所以事件照样按 React 树冒泡、context 照样透传)——一个管数据归处,一个管渲染位置,两码事。