React 受控/非受控组件与 React.memo 性能优化------从本质到实战
打开控制台,点击几个按钮,看日志输出,你才能真的理解"React 什么时候渲染、什么时候不渲染"。
开篇:两条知识线,一个共同问题
最近系统性地梳理了 React 表单和性能优化的基础,产出了两组 Demo:
- controlled 文件夹:受控组件 / 非受控组件 / 多字段表单 / 表单校验
- useCallback 文件夹 :
React.memo如何避免不必要的渲染
表面上看是两件事------表单处理和渲染优化。但它们的底层在回答同一个问题:
这轮渲染能不能跳过?或者反过来------React 的数据和 DOM 之间到底谁说了算?
本文按照我自己的学习路径,从源码出发,逐层拆解。
一、受控组件:React 是唯一真相来源
1.1 代码
javascript
// ControlledInput.jsx
// 受控组件 (响应式状态控制input)
// 收集用户输入
import { useState } from 'react';
function ControlledInput() {
const [value, setValue] = useState('');
return (
<>
ControlledInput
<input
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
/>
</>
);
}
1.2 onChange={(e) => setValue(e.target.value)} 到底做了什么?
这行代码是整个受控模式的灵魂。拆开来:
e------React 的合成事件对象(SyntheticEvent)。React 在浏览器原生事件之上包了一层,统一了不同浏览器的事件行为。你拿到的e不是原生的Event,而是 React 构造的。e.target------触发事件的 DOM 元素,就是这个<input>节点。e.target.value------input 此刻的最新文本。这是浏览器 DOM 原生维护的值。setValue(e.target.value)------把最新值写回 React 的valuestate。
关键在这里:setValue 调用后 React 会重新调度渲染,渲染时 <input value={value}> 会用新的 value 再次设置 DOM 的 value 属性。
完整的数据闭环:
perl
用户键入 'a'
→ 浏览器更新 DOM value = 'a'
→ onChange 触发
→ setValue('a') 写入 React state
→ React 调度重渲染
→ <input value='a'> 用 state 的值再次"刷" DOM
→ 屏幕显示 'a'
本质上 React 不信任 DOM 。每轮渲染都要用自己的 state 去覆盖一次 DOM value,确保屏幕上显示的始终等于 state 里的值。这就是"控制"的实质------state 是唯一真相来源(Single Source of Truth) ,DOM 只是它的投影。
1.3 为什么叫"响应式"?
"响应式状态控制 input"------这是我在代码里写的注释。它有两层意思:
- 响应式 :
useState是 React 的响应式原语------状态一变,视图自动跟着变,不需要手动操作 DOM。 - 控制:input 的显示值完全由 state 决定,用户输入只是触发 state 更新的"导火索"。
二、非受控组件:DOM 自己管自己
2.1 UncontrolledInput
javascript
// UncontrolledInput.jsx
// useRef
import { useRef } from 'react';
function UncontrolledInput() {
const inputRef = useRef(null);
const handleClick = () => {
console.log(inputRef.current.value);
};
return (
<>
Uncontrolled Input
<input type="text" ref={inputRef} />
<button onClick={handleClick}>获取输入值</button>
</>
);
}
2.2 useRef 是什么?
useRef(null) 返回一个普通的 JS 对象------{ current: null }。整个组件生命周期里,这个对象的引用不变 ,但 .current 你可以随便改。
把它和 useState 放在一起看,区别非常直观:
useState |
useRef |
|
|---|---|---|
.current 对象引用 |
每次渲染都是全新的 | 始终是同一个 |
| 值变了会重渲染吗 | 会,立刻调度 | 不会,安静地改 |
| 适合存什么 | 驱动 UI 的数据 | DOM 引用、定时器 ID、上一次的值 |
ref={inputRef} 这一步:React 在渲染完成后,把真实的 <input> DOM 节点直接塞进 inputRef.current。此后你读 inputRef.current.value,读的就是浏览器原生维护的当前输入值------React 全程不参与值的更新。
2.3 非受控的数据流
ini
用户键入 'a'
→ 浏览器更新 DOM value = 'a'
→ React 什么也不做
需要取值时(点按钮)
→ inputRef.current.value → 直接从 DOM 上拿
和受控的区别一目了然:受控是 React 主动管理每一帧;非受控是 React 放手不管,需要时才去 DOM 上"取货"。
2.4 CommentBox:非受控的另一个场景
javascript
// CommentBox.jsx
import { useRef } from 'react';
function CommentBox() {
const textareaRef = useRef(null);
const handleSubmit = () => {
const comment = textareaRef.current.value;
if (!comment) return;
console.log(comment);
};
return (
<>
<textarea placeholder="输入评论..." ref={textareaRef}></textarea>
<button onClick={handleSubmit}>提交评论</button>
</>
);
}
和 UncontrolledInput 完全一样的模式------只是 <input> 换成了 <textarea>,useRef 的用法丝毫不变。
这揭示了一个规律:非受控特别适合"提交时才取值"的场景。不需要即时校验、不需要格式化、不需要联动------在最后一刻去 DOM 上捞一把就够。
三、多字段表单:受控 vs 非受控的选择
3.1 RegisterForm------一个 useState 对象搞定所有字段
ini
// RegisterForm.jsx
// 非受控两次useRef
// vue ref/reactive 对象 两种响应式api
import { useState } from 'react';
function RegisterForm() {
const [form, setForm] = useState({
username: "",
password: ""
});
const handleChange = (e) => {
setForm({
...form,
[e.target.name]: e.target.value
});
};
const handleSubmit = (e) => {
e.preventDefault();
console.log('注册', form);
};
return (
<div>
<input
name="username"
value={form.username}
onChange={handleChange}
placeholder="请输入用户名"
type="text"
/>
<button type="submit" onClick={handleSubmit}>提交</button>
</div>
);
}
3.2 "非受控两次 useRef"------为什么没选非受控?
这是我写在代码里的笔记。意思是:如果用非受控方式写这个表单,需要两个 useRef------username 一个、password 一个。
每个字段要一个 ref 去挂 DOM,取值时也要一个一个去读。两个字段还好,但十个字段呢?代码量会线性爆炸。
而受控方式只需要一个 useState 对象 + 一个 handleChange 就全部搞定:
less
setForm({
...form, // 展开旧值,保留不修改的字段
[e.target.name]: e.target.value // 计算属性名:只覆写变化的那个字段
});
[e.target.name] 被 JS 引擎解析为动态键名------当 <input name="username"> 触发时,它就是 { username: e.target.value }。一个 handleChange 通吃所有字段。
3.3 "vue ref/reactive 两种响应式 API"------跨框架类比
这也是我写在注释里的。Vue 3 的响应式有两套 API:
| Vue API | 适用场景 | React 对应物 |
|---|---|---|
ref() |
包裹基本类型,读写要 .value |
useState(单个值) |
reactive() |
包裹对象,直接 .属性 读写 |
useState({...}) |
useState({ username: "", password: "" }) 的思路,就相当于 Vue 的 reactive()------用一个响应式对象打包管理所有表单字段,而不是给每个字段单独起一个 ref。
四、LoginForm------受控模式的完整实战
4.1 代码全貌
ini
// LoginForm/index.jsx
import { useState } from 'react';
import './index.css';
function LoginForm() {
const [form, setForm] = useState({
username: "",
password: ""
});
const [errors, setErrors] = useState({});
const validate = (name, value) => {
let msg = "";
if (name === 'username') {
if (!value) {
msg = '用户名为空';
} else if (value.length < 3) {
msg = '用户名长度不能小于3';
}
}
if (name === 'password') {
if (!value) {
msg = '密码不能为空';
} else if (value.length < 6) {
msg = '密码长度不能小于6位';
}
}
setErrors(prev => ({
...prev,
[name]: msg
}));
};
const handleChange = (e) => {
const { name, value } = e.target;
setForm({
...form,
[name]: value
});
validate(name, value);
};
const isValid = form.username && form.password
&& !errors.username && !errors.password;
const handleSubmit = (e) => {
e.preventDefault();
if (!isValid) return;
console.log(form, '----------');
};
return (
<div className="login-wrapper">
<form className="login-catd" onSubmit={handleSubmit}>
<h2>登录</h2>
<div className="form-item">
<label>用户名</label>
<input type="text" name="username"
value={form.username} onChange={handleChange} />
{errors.username && (
<span className="error">{errors.username}</span>
)}
</div>
<div className="form-item">
<label>密码</label>
<input type="text" name="password"
value={form.password} onChange={handleChange} />
{errors.password && (
<span className="error">{errors.password}</span>
)}
</div>
<button type="submit" disabled={!isValid}>登录</button>
</form>
</div>
);
}
4.2 校验机制:validate(name, value) 的设计
validate 函数吃两个参数------字段名 和当前值,返回该字段的错误信息:
vbscript
validate("username", "") → "用户名为空"
validate("username", "ab") → "用户名长度不能小于3"
validate("username", "dai") → ""
validate("password", "123") → "密码长度不能小于6位"
值得注意的细节是 setErrors 用了函数式更新:
ini
setErrors(prev => ({
...prev,
[name]: msg
}));
prev => ... 拿到的是上一轮的 errors 对象。用函数式写法能保证每次校验基于最新的 error 状态------这在连续快速输入时是安全的做法,避免闭包陷阱。
4.3 isValid------计算属性,不是 state
ini
const isValid = form.username && form.password
&& !errors.username && !errors.password;
这是一个关键的架构选择:isValid 不是 一个独立的 useState,而是每次渲染时从 form 和 errors 实时计算出来的。这避免了状态冗余------如果 isValid 是独立 state,你需要在三个地方(username 变、password 变、errors 变)都去同步它,很容易出现不一致。直接从基础状态派生,永远正确。
4.4 数据流全貌
scss
用户输入 → handleChange
├─ setForm({...form, [name]: value}) // 更新表单值
└─ validate(name, value)
└─ setErrors(prev => ({...prev})) // 更新错误状态
↓
form 和 errors 变化 → App 重渲染
↓
isValid 重新计算(派生值)
↓
button disabled={!isValid} ← 按钮自动响应
{errors.username && <span>} ← 错误信息自动显示/隐藏
受控模式在这里的优势展现得淋漓尽致:一个 handleChange 同时驱动了表单值和校验逻辑 ,而 isValid 和错误提示完全由基础状态自动派生------不需要任何手动同步。
五、React.memo------当渲染需要刹车
从表单跳到性能优化看似跳跃,但逻辑是连贯的。表单部分我们理解了"状态驱动渲染";现在要问的是:每一次状态变化,整棵树都必须重渲染吗?能不能刹车?
5.1 useCallback 项目的 README 笔记
markdown
# useCallback & useMemo
性能优化而生的hooks
## 问题
- 父组件有多个状态,子组件依赖其中的一些状态
- 父组件重新渲染,子组件也会重新渲染
更新
带来性能的浪费
希望不相关的属性发生改变时,拒绝重新渲染
memo memorize 请记住我
属性比对
这段笔记已经把核心矛盾讲清楚了:父组件重渲染时,就算子组件的 props 没变,子组件默认也会跟着重渲染。 这是浪费。
5.2 代码:对比实验
javascript
// App.jsx (callback-demo/src/App.jsx)
import { useState, memo } from 'react';
// 普通子组件------每次父组件渲染它都渲染
function RegularChild({ name }) {
console.log('渲染了RegularChild');
return <h1>{name}</h1>;
}
// memo 包裹的子组件------props 没变就跳过
const MemoChild = memo(({ name }) => {
console.log('MemoizedChild 渲染了');
return <div>Hello, {name}</div>;
});
function App() {
const [count, setCount] = useState(0);
const [name, setName] = useState('少林队');
console.log('App 组件渲染');
return (
<>
<button onClick={() => setCount(count + 1)}>点击计数{count}</button>
<button onClick={() => setName("峨眉队")}>改变名字</button>
<RegularChild name={name} />
<MemoChild name={name} />
</>
);
}
5.3 两个实验,两个结论
实验 1:点击"点击计数"
count变了 →App重渲染 ✅RegularChild→ 控制台打印了 ✅(name 没变,但还是渲染了)MemoChild→ 控制台没打印 ❌(name 没变,跳过了)
实验 2:点击"改变名字"
name变了 →App重渲染 ✅RegularChild→ 打印 ✅MemoChild→ 打印 ✅(name 变了,正常渲染)
5.4 memo 在底层做了什么?
memo 是一个高阶组件(HOC) 。你传给它一个函数组件,它还你一个"带记忆"的新组件。这个新组件在每次渲染前会做一件事:
diff
拿到本次 props → 和上次 props 逐一做浅比较(===)→
├─ 全部相同 → 跳过渲染,直接复用上一轮的虚拟 DOM 结果
└─ 有不同 → 老老实实渲染
浅比较 = ===:
ini
"少林队" === "少林队" // true → 跳过渲染
"少林队" === "峨眉队" // false → 正常渲染
⚠️ 这里藏着一个坑 :因为比较的是引用,如果你传的 prop 是对象、数组或函数,每次父组件渲染都会生成全新的引用,
===永远返回false,memo就形同虚设。这就是为什么下一步需要useCallback(缓存函数引用)和useMemo(缓存计算结果)------这个项目命名为callback-demo的原因正在于此。
六、把两条线串起来
受控/非受控讲的是"数据存在哪 ",React.memo 讲的是"渲染能跳过吗"。但归根结底,它们都在围绕 React 最核心的机制:
状态驱动视图。
useState是 React 的脉搏------它一跳,整棵组件树就开始逐层重渲染。受控组件利用这个脉搏来同步 DOM;React.memo试图阻断不必要的脉搏传导,让没有变化的子树保持静默。
把这个脉搏和阻断机制吃透,后面要学的 useCallback、useMemo、Context、状态提升------都只是在这条主线上添砖加瓦。
组件索引:清单文件的设计
最后提一个工程细节。随着组件增多,我在 components/index.js 里建了一个清单文件(barrel export) :
javascript
// 组件越来越多, index.js 上导出所有组件
// 清单文件
import ControlledInput from './ControlledInput';
import UncontrolledInput from './UncontrolledInput';
import CommentBox from './CommentBox';
import RegisterForm from './RegisterForm';
import LoginForm from './LoginForm';
export {
ControlledInput,
UncontrolledInput,
CommentBox,
RegisterForm,
LoginForm
};
而 App.jsx 只需要一行:
javascript
import { ControlledInput, UncontrolledInput, CommentBox, RegisterForm, LoginForm }
from './components';
把所有组件的导入收敛到一个入口,调用方不需要知道每个组件在哪个文件里------这是 React 项目里的常见实践,也是组件化的最后一环。