数据主权与渲染防线:从受控组件到性能优化的完整图景
上一节我们把"复用"交给自定义 Hooks、"并行"交给 Web Worker,React 的工程能力又上了一个台阶。但把视线从线程拉回到界面,有一个最朴素、也最躲不开的场景:表单。用户名、密码、评论、搜索框------只要做界面,就逃不开输入框。
而输入框的第一个问题,就是"主权"之争:输入框里的值,到底归谁管?
- 让 React 的状态来管,输入框只是一个"傀儡"------这是受控组件。
- 让 DOM 自己管,React 用到时再伸手去取------这是非受控组件。
同一天,笔记还顺带解锁了性能优化的第一道防线:父组件一重新渲染,子组件全部跟着动,这很浪费。memo 把子组件"记"下来,不该动的时候就不动------而 useCallback、useMemo,就是这条路上的下一站。
这一天的主题可以并成一句话:先搞清楚"数据归谁管",再搞清楚"什么时候该重渲染"。
一、受控组件:让 React 说了算
先看最简单的 ControlledInput.jsx:
tsx
import { useState } from 'react'
// 受控组件(响应式状态控制 input)
function ControlledInput() {
const [value, setValue] = useState('')
return (
<>
Controlled Input
<input
type="text"
value={value}
onChange={e => setValue(e.target.value)}
/>
</>
)
}
关键在三个地方:
value={value}:输入框显示什么,完全由 React 状态决定。onChange={e => setValue(e.target.value)}:用户每敲一个字符,先把事件里的新值写进状态,再让状态回流到输入框。- 于是循环永远是:输入 → onChange → setState → value → 输入框,一条单向的数据流。
受控组件 = 状态是唯一数据源,输入框只是状态的"投影"。
用户敲了什么、输入框里该显示什么,都由 React 拍板。这种"单向数据流"让数据变化的源头清晰可追踪------你不会疑惑"这个值到底是被谁改的",因为只有一个地方能改:setValue。
二、非受控组件:让 DOM 自己说了算
再看 UncontrolledInput.jsx:
tsx
import { useRef } from 'react'
// useRef(创建一个 Ref 对象)
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>
</>
)
}
对照着看就清楚了:
| 受控组件 | 非受控组件 | |
|---|---|---|
| 值存在哪 | 存在 state,每次敲键都 setState,React 全程追踪 |
没写 value,值存在 DOM 节点内部,React 不管它 |
| 怎么取值 | 直接用 value 变量 |
需要取值时,useRef 拿到 DOM 节点,inputRef.current.value 直接读出来 |
这里用到的正是前几天的 useRef------非受控组件把"值"的存储权交给浏览器,React 需要时再用 ref 去取。这种方式的优点在于简单直接 ------你不需要为每个输入框维护一个状态,也不需要写 onChange 处理函数。但代价是失去了 React 对数据的实时掌控。
三、CommentBox:提交时才取值的 textarea
CommentBox.jsx 把同样的思路用在了多行文本上:
tsx
import { useRef } from 'react'
function CommentBox() {
const textareaRef = useRef(null)
const handleSubmit = () => {
const comment = textareaRef.current.value
if (!comment) return
console.log(comment)
}
return (
<>
<div>
<textarea
ref={textareaRef}
placeholder="请输入评论"
/>
<button onClick={handleSubmit}>提交评论</button>
</div>
</>
)
}
textarea 一样可以挂 ref 读值。handleSubmit 里 textareaRef.current.value 拿到当前内容,空评论直接 return 掉。这种"用到再取"的模式,适合那些不需要实时响应、只在提交那一刻才关心的字段。比如评论框、备注字段、搜索框的初始值------用户输入时不依赖这些值做任何事,只在提交时读取一次就够了。
四、RegisterForm:受控表单的标准姿势
一个表单往往不止一个字段。受控表单的标准做法,是把所有字段收进一个状态对象。RegisterForm.jsx:
tsx
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
type="text"
name="username"
value={form.username}
onChange={handleChange}
placeholder="请输入用户名"
/>
<input
type="password"
name="password"
value={form.password}
onChange={handleChange}
placeholder="请输入密码"
/>
<button type="submit" onClick={handleSubmit}>提交</button>
</div>
</>
)
}
两个要点:
- 每个
input都写上name属性,等于给字段起了一个"键"。 - 一个
handleChange通吃所有字段 :[e.target.name]: e.target.value------用计算属性名,把"这个字段的名字"和"它的新值"配对写回form。
字段再多,也只要一个状态对象、一个处理函数。这就是受控表单的"标准姿势"。相比给每个字段单独写一个 useState,对象形态有两个明显的优势:提交时直接拿到完整的表单数据,不需要逐个字段拼装;新字段加入时只需在 form 对象里加一个键,再在 JSX 里加一个 input,handleChange 完全不用改。
五、LoginForm:受控 + 实时校验
受控真正的威力在校验 。因为值都走 React 状态,提交之前就能实时检查。LoginForm.jsx:
tsx
import { useState } from "react";
const LoginForm = () => {
const [form, setForm] = useState({
username: "",
password: ""
})
const [error, setError] = 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 < 8) {
msg = "密码长度不能小于8"
}
}
setError(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 &&
!error.username && !error.password
const handleSubmit = e => {
e.preventDefault();
if (!isValid) return
console.log(form, "-----------------")
}
return (
<div className="login-wrapper">
<form>
<h2>登录</h2>
<div className="form-item">
<label>用户名</label>
<input type="text" name="username"
value={form.username}
placeholder="请输入用户名"
onChange={handleChange}
/>{error.username && <span className="error">{error.username}</span>}
</div>
<div className="form-item">
<label>密码</label>
<input type="text" name="password"
value={form.password}
placeholder="请输入密码"
onChange={handleChange}
/>{error.password && <span className="error">{error.password}</span>}
</div>
<button type="submit" onClick={handleSubmit} disabled={!isValid}>提交</button>
</form>
</div>
)
}
export default LoginForm;
校验逻辑一层层很清晰:
validate(name, value):按字段名判断------用户名不能空、不能少于 3 位,密码不能空、不能少于 8 位,把错误消息写进error状态。handleChange:一边更新form,一边触发校验。isValid:用户名、密码都不为空,且都没有错误消息,才算"可提交"。- 提交按钮
disabled={!isValid}:不满足条件,按钮直接禁用。
界面上,每条输入下面紧跟一行错误提示:
tsx
{error.username && <span className="error">{error.username}</span>}
{error.password && <span className="error">{error.password}</span>}
值走状态,校验就能跟上每一次输入;校验跟上输入,按钮就能自己判断能不能点。 这就是受控组件在真实业务里的样子。
这个模式值得记住:
arduino
用户输入 → onChange → 1. 更新 form 状态 2. 触发 validate
↓ ↓
最新数据 最新错误消息
↓ ↓
汇合成 isValid
↓
按钮 disabled={!isValid}
这是一条完整的数据流闭环------从用户手指敲下的那一刻起,每一个字符都在 React 的掌控之中。
六、受控 vs 非受控:一张表看懂
| 维度 | 受控组件 | 非受控组件 |
|---|---|---|
| 值存在哪 | React 状态(state) |
DOM 节点内部 |
| 谁来更新 | onChange → setState → value 回流 |
用户直接改,React 不干预 |
| 怎么取值 | 用 state |
ref.current.value |
| 实时校验 | ✅ 天然支持 | ❌ 做不到,只能提交时校验 |
| 联动逻辑 | ✅ 一个字段变化自动触发另一个变化 | ❌ 需要手动读取再处理 |
| 典型场景 | 需要实时校验、联动、默认值管理的表单 | 提交时一次性读取、第三方组件、简单场景 |
选择原则很简单:需要跟着输入做事的用受控,只用一次的用非受控。
大多数业务表单,选受控更稳。非受控更合适的情况是:文件上传(<input type="file">)、与第三方非 React 库集成、极简单的搜索框(提交时才关心输入了什么)、性能极度敏感的大规模表单(但实际上受控的性能在现代浏览器中已经足够好)。
七、性能问题:父组件一渲染,子组件全跟着动
表单搞定,回到渲染本身。笔记把问题点得很直白:
父组件重新渲染,子组件也会重新渲染,带来性能的浪费;希望不相关的属性发生改变时,拒绝重新渲染。
看 App.jsx:
tsx
function App() {
const [count, setCount] = useState(0);
console.log('App 组件渲染');
const [name, setName] = useState('少林队');
return (
<>
<button onClick={()=>setCount(count+1)}>点击计数{count}</button>
<button onClick={()=>setName("峨眉队")}>改变名字</button>
<RegularChild name={name}/>
<MemoChild name={name}/>
</>
)
}
点"点击计数",count 变了,App 重新渲染。只要父组件重渲染,函数组件里的子组件默认也跟着重渲染------哪怕它们的 props 一个字节都没变。 这种"无辜陪跑"就是浪费。
如果你在 RegularChild 里放一个 console.log('渲染了'),点 count 按钮时你会看到它每次都打印------即使 name 完全没有变化。
八、memo:给子组件装上"记忆"
解决思路是"记忆"。笔记里的关键词是 memo → memorize 缓存。React 提供 memo,一个高阶函数,把组件包一层,让它记住上一次的 props:
tsx
function RegularChild({name}) {
console.log('渲染了RegularChild');
return (
<>
<h1>{name}</h1>
</>
)
}
// memo 高阶函数
const MemoChild = memo(({name}) => {
console.log('渲染了MemoChild')
return (
<>
<h1>Hello {name}</h1>
</>
)
})
同一个 App 里,两个子组件都接收 name:
RegularChild:没包memo,父组件一渲染,它无条件跟着渲染。MemoChild:包了memo,只有当name变了才会重新渲染。
于是点"点击计数"时,count 变了但 name 没变:RegularChild 照常陪跑(打印"渲染了RegularChild"),MemoChild 原地不动(不打印)。
memo就像给组件装了记忆:props没变,就跳过这次渲染,直接用上次的结果。
但要注意 memo 比较的是 props 的引用。 如果父组件传入一个每次渲染都是新引用的函数或对象,memo 会认为"变了"而照常渲染------这正是 useCallback 要解决的问题。
memo 的使用原则: 不是所有组件都该包 memo。它适合那些"接收的 props 稳定、但父组件频繁重渲染"的子组件。如果一个组件的 props 每次都变(比如传入了一个内联对象或函数),memo 的缓存机制就形同虚设,还白白增加了比对成本。所以 memo 应该和 useCallback、useMemo 配合使用,形成一套完整的"变化锁定"策略。
九、useCallback 与 useMemo:把函数和值也缓存起来
memo 挡的是"props 值没变"的场景,但函数和对象有个特性:每次渲染都是一个新的引用。
tsx
const handleClick = () => { /* ... */ } // 每次渲染都生成一个新函数
<MemoChild onClick={handleClick} />
哪怕函数体一模一样,handleClick 每次都是新引用 → memo 比较 props 时认为"变了" → 子组件还是重渲染。于是需要 useCallback:把一个函数"记住",依赖不变就返回同一个引用。
tsx
import { useCallback } from 'react'
const handleClick = useCallback(() => { /* ... */ }, [deps])
useMemo 同理,缓存的是"计算结果"(值):
tsx
import { useMemo } from 'react'
const result = useMemo(() => expensive(), [deps])
三者是一套组合拳:
| 工具 | 缓存什么 | 解决什么问题 |
|---|---|---|
memo |
组件本身 | props 没变时,整棵子树的重复渲染 |
useCallback |
函数引用 | 传给 memo 子组件的函数"每次都变" |
useMemo |
计算结果 | 昂贵的重复计算 |
useCallback 的底层机制: 它接受一个函数和一个依赖数组。在首次渲染时,它返回传入的函数并缓存起来。后续渲染时,它比对依赖数组中的每一项是否变化------如果都没变,就返回上次缓存的同一个函数引用;如果有任何一项变了,就重新生成一个新函数并缓存。这就是为什么依赖数组必须写对:漏写依赖,会导致函数引用一直不变,但内部逻辑却使用了过期的数据(闭包陷阱);多写依赖,会导致函数频繁重建,缓存失去意义。
useMemo 同理,只是它缓存的是值而不是函数。 在依赖不变时,expensive() 不会重新执行,直接返回上一次的结果。
一个实用的开发原则: 不要过早优化。只有当你确实观察到性能问题(比如 React DevTools 的 Profiler 显示某个组件渲染耗时过高),或者确定某个计算复杂度很高(如大数组的排序、过滤),才引入这些优化工具。过度使用 useCallback 和 useMemo 反而会增加代码复杂度和内存开销。
十、性能优化的完整决策树
当遇到渲染性能问题时,可以按以下路径排查和优化:
bash
观察到渲染卡顿
↓
用 React DevTools Profiler 定位
↓
找到频繁重渲染的组件
↓
该组件的 props 是否真的变了?
├─ 没变 → 用 memo 包住
│ ↓
│ 传给它的函数/对象是否每次新建?
│ ├─ 是 → 用 useCallback/useMemo 稳定引用
│ └─ 否 → 优化完成
│
└─ 变了 → 检查父组件是否过早更新了这部分 props
↓
优化父组件的状态管理
十一、面试高频问题与答题框架
Q1:受控组件和非受控组件有什么区别?如何选择?
回答框架:
区别在于"值归谁管"。受控组件的值存在 React 状态里,通过 value + onChange 完成"输入 → setState → 回流",状态是唯一数据源,适合需要实时校验、联动、格式化等场景。非受控组件的值存在 DOM 里,React 不干预,需要时用 useRef 读取 ref.current.value,适合提交时一次性取值、文件上传或与第三方库集成的场景。大部分业务表单选受控更稳,因为数据流清晰可控。
Q2:受控组件为什么方便做实时校验?
回答框架:
因为受控组件的值始终在 React 状态里。每次输入都触发 onChange → setState,校验函数可以同步读到当前值、写入错误消息。isValid 汇总所有条件,提交按钮通过 disabled={!isValid} 自动禁用。非受控组件只在提交时才有值,做不到"输入时实时反馈"。
Q3:父组件重新渲染,子组件一定会重新渲染吗?
回答框架:
默认是的。函数组件只要父组件重渲染,子组件也会跟着重渲染,哪怕 props 没有变化。想"不相关的属性变化时拒绝重渲染",就用 memo 把子组件包起来------memo 会对比新旧 props,没变时直接复用上次的渲染结果,跳过本次渲染。
Q4:有了 memo 为什么还需要 useCallback?
回答框架:
memo 靠浅比较 props 来判断是否重渲染。但函数每次渲染都是新的引用------如果给 memo 子组件传了一个内联函数,即使逻辑没变,memo 也认为 props "变了",照常重渲染,memo 就白用了。useCallback 用依赖数组"记住"一个函数,依赖不变就返回同一个引用,配合 memo 才能真正挡住重渲染。同样地,对象字面量每次渲染也是新引用,可以用 useMemo 来稳定。
Q5:useMemo 和 useCallback 的区别是什么?
回答框架:
useCallback 缓存函数引用 ,useMemo 缓存计算结果(值)。两者都靠依赖数组判断是否重新生成:依赖不变就走缓存,依赖变了就重新计算。一个是"记住这个函数",一个是"记住算好的这个数"。
Q6:性能优化是不是该从第一天就全部做上?
回答框架:
不必。过早优化会让代码变得复杂且难以维护。正确的做法是:先用 React DevTools 的 Profiler 定位真正的性能瓶颈(哪些组件渲染慢、哪些频繁渲染),然后有针对性地加 memo、useCallback、useMemo。先让代码正确和清晰,再在需要的地方做优化。
结语:先管数据归谁,再管谁该重渲染
这一天的两条线,正好是 React 里最日常、也最容易忽略的两个问题:
| 方向 | 工具 | 核心问题 |
|---|---|---|
| 数据主权 | 受控 / 非受控组件 | 值归 React 状态管,还是 DOM 自己管 |
| 渲染防线 | memo / useCallback / useMemo |
该渲染的渲染,不该渲染的拒绝 |
受控组件把"值"握在状态手里,换来了实时校验和可预测的数据流;非受控组件把"值"留给 DOM,换来了简单直接。性能这条线同理:memo 记住组件,useCallback 记住函数,useMemo 记住结果------把变化精确地限制在真正需要变化的地方。
动手前,可以拿这张清单自检:
- 输入框的值需要跟着输入实时处理(校验、联动、格式化)时,用的是受控组件(
value+onChange),而不是让值散在 DOM 里? - 只在提交那一刻才需要读的字段(如评论框),用
useRef读取ref.current.value,而不是硬塞一个状态去追踪? - 一个表单多个字段时,收进一个
form状态对象,用name+ 计算属性名统一handleChange? - 校验逻辑写在输入时触发、提交按钮
disabled={!isValid},而不是等到提交才想起来校验? - 父组件会频繁重渲染、而子组件
props很少变时,用memo把子组件包起来? - 给
memo子组件传函数时,用useCallback保持引用稳定? - 昂贵的重复计算用
useMemo缓存,而不是每次渲染都重新算一遍? - 性能优化是否先用 Profiler 定位了问题,而不是盲目地到处加缓存?
表单是界面和用户的握手,性能是界面和设备的握手。先想清楚"数据归谁管",再想清楚"什么时候该重渲染"------这一天的两件事,都从最朴素的场景里长出来,却是 React 进阶绕不过的地基。