阅读前先想
- 一个
<input>输入框里显示的文字,到底存在哪里------React 的 state 里,还是浏览器 DOM 里? - 下面两种写法,哪种会在每次输入时触发重渲染,哪种不会?
- 如果一个组件根本没有
useState,它还能重渲染吗?
本文主线
从两段最简代码出发,回答同一个问题:表单数据由谁来管? 然后追踪用户每次按键后的完整执行链路------事件如何触发、数据流向哪里、页面是否重渲染------最终形成选择两种模式的判断标准。
表单数据归谁管
知识点
受控组件 把表单值存在 React state 里,输入框的显示内容由 state 驱动。非受控组件 把表单值留在浏览器 DOM 里,React 只在需要时通过 ref 读取。
理解这一点之前,先明确 React 的核心原则:UI 是 state 的投影。只要你管理好 state,界面就会自动跟随。受控组件完全遵守这一原则;非受控组件则在 state 体系之外开了一条"暗渠",数据直接放在 DOM 上,React 感知不到。
关键代码
两种模式的最简写法:
javascript
// 非受控组件 ------ 值留在 DOM
import { useRef } from 'react';
function UncontrolledInput() {
const inputRef = useRef(null);
const handleClick = () => {
console.log(inputRef.current.value);
};
return (
<>
<input type="text" ref={inputRef} />
<button onClick={handleClick}>获取输入值</button>
</>
);
}
javascript
// 受控组件 ------ 值存在 state
import { useState } from 'react';
function ControlledInput() {
const [value, setValue] = useState('');
return (
<>
<input
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
/>
</>
);
}
运行过程
非受控组件 :用户输入 "hello" → DOM 的 value 从空变为 "h" 再变为 "he" 再到 "hello",整个过程 React 不感知,没有 setState,没有重渲染。用户点击按钮 → inputRef.current 指向真实的 DOM 节点 → 读取 .value 得到 "hello" → 打印到控制台。按钮点击后仍然没有重渲染,因为整个组件里根本没有 useState。
受控组件 :用户按下 "h" → onChange 触发 → setValue("h") → state 从 "" 变为 "h" → React 调度重渲染 → 组件函数重新执行 → <input value="h"> 让 DOM 与 state 同步。用户再按 "e" → 上述流程再跑一遍。敲了 5 个字符,组件重渲染了 5 次。
为什么这样设计
React 选择把"受控"作为默认推荐,源于它的设计哲学:数据只在一个方向流动 。state 是唯一的真相来源,UI 只是 state 的投影------当输入框的 value 绑定到 state,你就永远不会遇到"state 说值是 A,但输入框显示的是 B"这种不一致。
非受控组件的存在则是务实的妥协:不是所有场景都需要 React 管表单。一个搜索框的下拉提示需要实时读取输入值,用受控;一个"联系我们"的单行留言表单,用户填完点发送就完事,用非受控可以减少不必要的渲染。
容易混淆
误区一:"受控组件必须用 onChange,非受控组件不能用 onChange。"
非受控组件也可以加 onChange------比如你想在用户输入时做点别的事(打日志、更新外部状态),只要不把 value 绑到 state 上,它仍然是非受控的。区分两者的标准不是有没有 onChange,而是 value 是否由 React state 控制。
误区二:"非受控组件用 useRef,受控组件用 useState。"
Hook 只是手段,不是定义。一个组件即使用了 useState,如果没有把 state 传给 <input value={...}>,它就不是受控组件。判断标准只有一个:输入框的 value 属性是否由 React state 驱动。
非受控组件:让 DOM 自己管
知识点
useRef 返回一个 { current: null } 形状的普通 JavaScript 对象。这个对象在组件的整个生命周期中引用不变 ------每次渲染拿到的是同一个对象。挂载时 React 把 DOM 节点赋值到 ref.current,卸载时置为 null。
关键在于:修改 ref.current 不会触发重渲染。它只是一个普通对象属性的赋值,React 不知道也不关心它的变化。
关键代码
javascript
const inputRef = useRef(null);
// 此时 inputRef 是 { current: null }
const handleClick = () => {
console.log(inputRef.current.value);
// 读取 DOM 节点的 value 属性,没有触发任何 React 更新
};
// JSX 里
<input type="text" ref={inputRef} />
运行过程
用户输入文字
→ DOM 的 value 更新
→ React 不感知,无重渲染
用户点击按钮
→ handleClick 执行
→ inputRef.current.value 读取当前 DOM 值
→ 打印结果
→ 仍然无重渲染(没有 setState)
每一步都是因果链。关键点在于:React 只在挂载时把 DOM 节点绑到 inputRef.current,之后用户怎么输入,React 一概不管------需要时再去"瞄一眼" DOM。
为什么这样设计
useRef 的核心价值在于:在多次渲染之间保持一个不触发重新渲染的"储物箱" 。对于非受控表单来说,这个储物箱里放的就是 DOM 节点的引用。因为 DOM 自身的值会随用户输入而更新,不需要 React 来同步,所以也就没必要让每次读值都触发渲染。
但这同时也意味着一个限制:你只能在事件回调 (如 onClick、onSubmit)中读取 ref.current.value,因为那时用户操作已经发生,值一定是最新的。如果你试图在渲染期间直接读 ref.current.value 并渲染到页面上,React 不会在值变化时自动更新------因为 ref 的变化不触发渲染。
容易混淆
"ref 的值是响应式的吗?"
不是。如果你写 <p>{inputRef.current?.value}</p>,用户输入时这个 <p> 不会自动更新。因为 ref 的变化不是 state 变化,React 不知道需要重渲染。如果想让页面实时显示输入值,必须用 state------也就是受控组件。
受控组件:React 全权接管
知识点
受控组件的核心是由三个部分组成的闭环:
perl
state 值 → value={state} 传给 input
→ 用户输入触发 onChange
→ setState 更新 state
→ state 变化触发重渲染
→ 回到起点
这个闭环意味着任何时候,输入框里显示的内容都精确等于 state 的值------不多不少。
关键代码
ini
const [value, setValue] = useState('');
return (
<input
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
/>
);
三行代码各自扮演明确角色:
value={value}--- state 驱动显示内容onChange--- 用户操作的事件入口setValue(e.target.value)--- 把用户输入写回 state,闭合循环
运行过程
perl
用户按下 'a'
→ 浏览器更新 input 的 value(瞬态)
→ React 合成 onChange 事件
→ setValue('a') 被调用
→ React 将 state 从 '' 更新为 'a'
→ React 调度重渲染
→ 组件函数重新执行
→ value state 读为 'a'
→ <input value='a'> 让 DOM 与 state 同步
用户再按 'b' → 回到起点,循环一遍
一个微妙的事实:浏览器在触发 input 事件时已经暂时展示了新字符。但 React 随后用 value={state} 覆盖了它------如果 state 没更新,输入框会"弹回"旧值。这就是受控:React 有最终控制权,浏览器的瞬时更新不算数。
为什么这样设计
闭环设计带来两个关键收益:
第一,你可以随时操作值。 在 onChange 里做任何事情------格式化手机号、限制只能输入数字、根据当前值动态调整 UI------因为这些逻辑都在 state 更新之前执行,更新后的 state 会自动反映到输入框。
第二,输入框和 state 永远不会不一致。 如果有多个来源想修改输入框内容(点击清空按钮、下拉选项自动填充),你只需要 setState,React 保证 DOM 跟上。不需要手动操作 DOM 和 JS 变量两边。
渲染行为的深层差异
知识点
React 在以下情况重渲染组件:state 变化、父组件重渲染、Context 值变化。两个组件的渲染差异正是源于它们对 setState 的依赖程度不同。
对比
| 对比项 | 受控组件 | 非受控组件 |
|---|---|---|
| 每次 keystroke 是否重渲染 | 是,setState 驱动 |
否,值在 DOM 里 |
| 点击按钮读值是否重渲染 | 读取 state,不额外渲染 | 读取 ref,不额外渲染 |
| 能否实时拿到最新值 | 随时,value state |
只在事件回调中读 ref |
| 值变化时 UI 是否自动更新 | 是 | 否,除非手动触发渲染 |
为什么这样设计
受控组件"每敲一下渲染一次"听起来像性能问题,但实际上对于单个输入框来说,一次 setState 触发的重渲染开销极小------React 的 reconciliation 算法只更新变化的部分。输入框本身在 diff 后发现 value 和上一次一致(state 已更新到新值),不会有额外的 DOM 操作。
这不是性能缺陷,而是设计选择------用每次 keystroke 的一次渲染,换取"state 始终是最新的"这个保证。
使用边界
受控组件的"每个 keystroke 都渲染"在以下场景不可接受时,应考虑非受控或优化方案:高频输入(如实时协作编辑器的光标同步)、大量受控字段(几百个输入框同时渲染)、或需要在输入期间保持动画帧率的场景。这些在实际项目中极少遇到,不应该成为默认避开受控组件的理由。
什么时候用哪个
| 场景 | 推荐 | 原因 |
|---|---|---|
| 需要实时校验 | 受控 | onChange 里拿到最新值即可校验 |
| 需要格式化输入 | 受控 | 在 setState 前处理值再写入 |
| 输入影响其他 UI | 受控 | state 变化自动驱动重渲染 |
| 仅表单提交时取值 | 两者皆可 | 非受控更轻量,受控更可控 |
| 集成第三方 DOM 库 | 非受控 | 让第三方直接操作 DOM,React 只读结果 |
React 官方文档的立场:大多数表单场景推荐受控组件。非受控组件是有用的逃生舱,但不是默认选择。
代码串起来
css
flowchart TD
subgraph 非受控组件
A1["用户输入"] --> A2["DOM 更新 value"]
A2 --> A3["React 不感知"]
A3 --> A4["用户点击按钮"]
A4 --> A5["ref.current.value 读取 DOM"]
A5 --> A6["打印,无重渲染"]
end
subgraph 受控组件
B1["用户输入"] --> B2["onChange 触发"]
B2 --> B3["setState 更新 value"]
B3 --> B4["React 调度重渲染"]
B4 --> B5["组件重执行"]
B5 --> B6["input value 同步 DOM"]
B6 --> B7["循环:回到 B1"]
end
左侧非受控:数据在 DOM 和 React 之间只有单向读取 ,路径短但 React 感知力弱。右侧受控:数据形成了闭合环,每次都经过 React,路径长但每一步都可控。
最后回顾
核心概念只有一句话:受控组件把表单值放在 state 里,非受控组件把表单值留在 DOM 里。
推导出的设计原则:
- 受控 = 每次输入都重渲染 = 数据实时可控 = 适合有交互逻辑的表单
- 非受控 = 不触发重渲染 = 需要时才读值 = 适合简单取值的场景
useRef和useState只是手段,判断标准是value是否由 React state 驱动- 单个输入框的 keystroke 渲染开销在 React 中可忽略,不必因此回避受控组件