文章

受控 / 非受控组件与 Portals

面试向讲清三件事:受控与非受控的本质是"谁持有状态"(React state 还是 DOM)、 只写 value 不写 onChange 为什么会变成只读输入框、受控/非受控这套思路同样适用于 Modal 等通用组件; 以及 createPortal 为什么能突破 overflow/z-index 却仍然按 React 树冒泡事件、共享 context。

受控 / 非受控组件与 Portals

一句话概括

受控和非受控,说白了就是问一句:输入框里那个值,到底归谁管? 归 React state 管的叫受控,归 DOM 自己管的叫非受控(你要取值就用 ref 伸手去拿)。

面试官爱问这个,不是想听你背定义,而是想看你知道状态该放哪——这是组件设计的核心问题。所以答题时别只说”表单”,要补一句:Modal、Dropdown 这些通用组件也有受控/非受控两种写法。

Portals 则是另一个方向的题:我想让弹窗渲染到 body 下面,但它还得认我这个爹。createPortal 就是干这个的——DOM 位置换了,React 树上的父子关系一点没变。

先记三句话:

  1. 受控 = value + onChange,单一数据源在 React;非受控 = defaultValue + ref,数据源在 DOM。
  2. value 写了不给 onChange,输入框直接变只读。
  3. Portal 只搬 DOM,不搬 React 树——事件照样按 React 树冒泡。

核心知识点

1. 本质区别:谁是单一数据源

 受控(Controlled)非受控(Uncontrolled)
值存在哪React stateDOM 节点内部
写法value={x} + onChangedefaultValue="x" + ref
怎么取值直接读 stateref.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 等价于原生 value attribute,只在挂载时写一次。要动态改非受控输入框的值,得直接操作 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 照样透传)——一个管数据归处,一个管渲染位置,两码事。

本文由作者按照 CC BY 4.0 进行授权