前言
这篇文章围绕两个实战项目,覆盖 React 中最核心的几个 Hook:
| 项目 | 文件数 | 涉及 Hook |
|---|---|---|
| uncontrolled-demo | 9 个文件 | useState、useRef |
| callback-demo | 2 个文件 | memo、useCallback、useMemo |
分为上下两篇:上篇讲表单状态管理(受控 vs 非受控 ),下篇讲渲染性能优化(memo + useCallback + useMemo)。
上篇:受控组件 vs 非受控组件
React 里处理表单输入,有两条完全不同的路:
| 模式 | 数据存在哪 | 关键词 |
|---|---|---|
| 受控组件 | React state | useState、value、onChange |
| 非受控组件 | DOM 自己管 | useRef、ref、需要时再读 |
项目全景
bash
src/
├── main.jsx # 入口
├── App.jsx # 主组件,一次性展示 5 个示例
└── components/
├── index.js # 统一导出(清单文件)
├── ControlledInput.jsx # ① 最简受控组件
├── UncontrolledInput.jsx # ② 最简非受控组件
├── CommentBox.jsx # ③ 非受控 textarea
├── RegisterForm.jsx # ④ 受控表单(对象 state)
└── LoginForm/
├── index.jsx # ⑤ 受控表单 + 完整验证
└── index.css # ⑥ 样式文件
一、受控组件:React 是唯一数据源
1.1 最简版本 ------ ControlledInput.jsx
jsx
function ControlledInput() {
const [value, setValue] = useState('');
return (
<input
type="text"
value={value} // value 永远等于 state
onChange={(e) => setValue(e.target.value)} // 每次输入都更新 state
/>
);
}
受控的完整闭环:
markdown
用户输入 → onChange 触发 → setValue 更新 state → value 变化 → 屏幕刷新
↑
没有这一步,输入框就不会显示新内容
value={value} 意味着 input 的显示值永远由 React state 决定 。如果你只写 value={value} 但不写 onChange,输入框会完全无法输入 ------ 因为 state 不变,value 就不变。
1.2 对象 state 的受控表单 ------ RegisterForm.jsx
单个 input 用 useState(''),多个 input 呢?
jsx
function RegisterForm() {
const [form, setForm] = useState({
username: "",
password: ""
});
const handleChange = (e) => {
setForm({
...form, // 保留其他字段
[e.target.name]: e.target.value // 用计算属性名更新当前字段
});
};
return (
<>
<input name="username" value={form.username} onChange={handleChange} />
<input name="password" value={form.password} onChange={handleChange} />
<button type="submit">提交</button>
</>
);
}
三个要点:
- 用一个
useState管全部字段 ------ 跟 Vue 的reactive思路一致(对象级别的响应式) [e.target.name]------ 计算属性名,一个handleChange通吃所有 input...form------ 展开旧对象,保留未修改字段。忘了这行 → 改 username 时 password 就丢了
1.3 带完整验证的受控表单 ------ LoginForm/index.jsx
这是项目里最完整的组件,加入了验证逻辑和错误提示:
jsx
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;
return (
<form onSubmit={handleSubmit}>
<input name="username" value={form.username} onChange={handleChange} />
{errors.username && <span className="error">{errors.username}</span>}
<input name="password" value={form.password} onChange={handleChange} />
{errors.password && <span className="error">{errors.password}</span>}
<button type="submit" disabled={!isValid}>登录</button>
</form>
);
}
受控组件的优势在这里体现得淋漓尽致:
因为数据全在 state 里,验证、提交、禁用按钮、错误提示 ------ 所有 UI 都可以根据 state 实时决定。
二、非受控组件:需要时才问 DOM
2.1 最简版本 ------ UncontrolledInput.jsx
jsx
function UncontrolledInput() {
const inputRef = useRef(null);
const handleClick = () => {
console.log(inputRef.current.value); // 用到的时候再读 DOM
};
return (
<>
<input type="text" ref={inputRef} />
<button onClick={handleClick}>获取输入值</button>
</>
);
}
非受控的逻辑:
csharp
用户输入 → DOM 自己存着值 → 需要时 ref.current.value 取出来
↑
React 不追踪每次输入,state 不改,不触发重渲染
没有 value 绑定,没有 onChange 处理,输入框完全靠浏览器原生行为工作。
2.2 textarea 同理 ------ CommentBox.jsx
jsx
function CommentBox() {
const textareaRef = useRef(null);
const handleSubmit = () => {
const comment = textareaRef.current.value;
if (!comment) return;
console.log(comment);
};
return (
<>
<textarea ref={textareaRef} placeholder="输入评论..." />
<button onClick={handleSubmit}>提交评论</button>
</>
);
}
逻辑和 UncontrolledInput 一模一样。React 里 <textarea> 的用法跟 <input> 完全一致 ------ 不像原生 HTML 需要把内容放在标签中间。
三、清单文件 ------ components/index.js
js
// 组件越来越多,index.js 上导出所有组件 ------ 清单文件
export { ControlledInput, UncontrolledInput, CommentBox, RegisterForm, LoginForm };
这是barrel export(桶文件),一行导入所有组件:
jsx
import { ControlledInput, UncontrolledInput, CommentBox, RegisterForm, LoginForm } from './components';
四、受控 vs 非受控 对比
| 维度 | 受控组件 | 非受控组件 |
|---|---|---|
| 数据存放 | React state | DOM 内部 |
| 实时追踪 | ✅ 每次输入 state 都更新 | ❌ 只在需要时读取 |
| 重渲染 | 每次输入触发渲染 | 不触发 |
| 即时验证 | ✅ 天然支持 | ❌ 需要自己读 DOM |
| 按钮禁用/实时反馈 | ✅ 读 state 就行 | ❌ 不读 ref 不知道值 |
| 适用场景 | 需要实时响应的表单 | 简单场景、文件上传、第三方 DOM 库集成 |
核心判断标准:需要"边输入边反馈"就用受控,只需要"提交时取值"就用非受控。
下篇:memo + useCallback + useMemo 性能优化
五、问题根源:父组件渲染的"连坐"机制
先看这段代码:
jsx
function App() {
const [count, setCount] = useState(0);
const [name, setName] = useState('少林队');
return (
<>
<button onClick={() => setCount(count + 1)}>点击计数 {count}</button>
<button onClick={() => setName("峨眉队")}>改变名字</button>
<RegularChild name={name} />
<MemoChild name={name} />
</>
);
}
问:点击"点击计数"时,RegularChild 会重新渲染吗?
直觉告诉你"不会吧,count 又没传给子组件"。但答案是 ------ 会。
React 的默认行为:父组件重新渲染,它所有的子组件也跟着渲染。
markdown
点击计数(只改 count)
→ App 重渲染
→ RegularChild 重渲染 ❌ 浪费!name 没变
→ MemoChild 重渲染 ❌ 浪费!name 没变
父组件有多个状态,子组件只依赖其中的一些状态。不想关的属性改变时,希望子组件拒绝重新渲染。
六、memo ------ 让组件"记住"自己
6.1 对比实验
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>;
});
| 操作 | App | RegularChild | MemoChild |
|---|---|---|---|
| 点击计数(改 count) | ✅ | ✅ 没必要! | ❌ 被 memo 拦住 |
| 改变名字(改 name) | ✅ | ✅ 应该的 | ✅ 应该的 |
6.2 memo 的致命弱点:引用类型
memo 默认用浅比较 (Object.is),所以:
jsx
// ✅ 基本类型:没问题
<MemoChild name="少林队" /> // "少林队" === "少林队" → true → 不渲染
// ❌ 对象:每次都是新引用!
<MemoChild config={{ team: '少林队' }} /> // 新对象 ≠ 旧对象 → memo 白干
// ❌ 函数:每次都是新引用!
<MemoChild onClick={() => handleClick()} /> // 新函数 ≠ 旧函数 → memo 白干
每次父组件渲染,对象字面量和箭头函数都会创建全新的引用。 这就是 useCallback 和 useMemo 存在的意义。
七、useCallback ------ 稳定函数引用
jsx
function App() {
const [count, setCount] = useState(0);
const [name, setName] = useState('少林队');
// ❌ 每次渲染都是新函数 → memo 失效
// const handleClick = () => { setName('峨眉队'); };
// ✅ useCallback 缓存函数引用,依赖不变就返回同一个函数
const handleClick = useCallback(() => {
setName('峨眉队');
}, []); // 空依赖 → 函数引用永远不变
return (
<>
<button onClick={() => setCount(count + 1)}>点击计数 {count}</button>
<MemoChild name={name} onClick={handleClick} />
</>
);
}
useCallback(fn, [依赖]) 的逻辑:
rust
首次渲染:创建函数 fn,缓存起来,返回 fn
后续渲染:
→ 依赖没变?直接返回缓存的 fn(同一个引用!)
→ 依赖变了?创建新函数,替换缓存,返回新 fn
依赖的正确写法
jsx
// ❌ 经典错误:函数里用了 count,但依赖没写 → 永远打印旧值!
const handleClick = useCallback(() => { console.log(count); }, []);
// ✅ 诚实写依赖
const handleClick = useCallback(() => { console.log(count); }, [count]);
八、useMemo ------ 稳定计算结果
useCallback 和 useMemo 本质是同一个东西:
js
useCallback(fn, [a, b]); // 缓存函数
useMemo(() => fn, [a, b]); // 缓存计算结果(上面那行的底层实现)
两个典型场景
① 昂贵的计算,避免每次渲染重算
jsx
// ❌ 每次渲染都重新过滤 + 排序
const filteredTodos = todos.filter(t => t.status === filter).sort(...);
// ✅ 只在依赖变了才重新算
const filteredTodos = useMemo(() =>
todos.filter(t => t.status === filter).sort(...),
[todos, filter]
);
② 传给 memo 子组件的对象 prop
jsx
// ❌ 每次都是新对象 → memo 失效
<MemoChild config={{ team: '少林队', color: 'red' }} />
// ✅ useMemo 稳定对象引用
const config = useMemo(() => ({ team: '少林队', color: 'red' }), []);
<MemoChild config={config} />
九、三剑合璧:完整优化链路
arduino
父组件状态变化
│
├── 要传函数给 memo 子组件?
│ → useCallback 稳定函数引用
│
├── 要传对象/数组给 memo 子组件?或要做昂贵计算?
│ → useMemo 稳定值引用 / 缓存计算结果
│
└── 子组件本身是"纯展示"、props 不常变?
→ React.memo 包裹子组件
jsx
function App() {
const [count, setCount] = useState(0);
const [items, setItems] = useState([...]);
const handleDelete = useCallback((id) => {
setItems(prev => prev.filter(i => i.id !== id));
}, []);
const config = useMemo(() => ({ theme: 'dark', fontSize: 14 }), []);
const sortedItems = useMemo(() =>
[...items].sort((a, b) => b.score - a.score),
[items]
);
return (
<>
<button onClick={() => setCount(count + 1)}>计数 {count}</button>
<MemoList items={sortedItems} config={config} onDelete={handleDelete} />
</>
);
}
const MemoList = memo(({ items, config, onDelete }) => {
console.log('MemoList 渲染');
return items.map(item => (
<MemoItem key={item.id} item={item} onDelete={onDelete} />
));
});
点击计数 → count 变 → sortedItems/config/handleDelete 依赖都没变 → 引用全部稳定 → MemoList 浅比较 props → 全都没变 → 不渲染。
十、全篇对比总结
Hooks 一览
| Hook | 分类 | 一句话 | 所在项目 |
|---|---|---|---|
useState |
表单 | 受控组件数据源,输入即同步 | uncontrolled-demo |
useRef |
表单 | 非受控组件数据源,需要时读 DOM | uncontrolled-demo |
memo |
优化 | props 没变就别渲染 | callback-demo |
useCallback |
优化 | 依赖没变,函数还是上次那个 | callback-demo |
useMemo |
优化 | 依赖没变,值还是上次那个 | callback-demo |
使用原则
受控 vs 非受控:
→ 边输入边反馈 → 受控(useState)
→ 提交时才取值 → 非受控(useRef)
性能优化三剑客:
→ memo 包组件
→ useCallback 包函数
→ useMemo 包对象 / 昂贵计算
→ 先写正常代码,有瓶颈再优化,不要无脑包
写于 2026-08-10