🔥 React Hooks 进阶实战:受控组件 + 性能优化一站式掌握

前言

这篇文章围绕两个实战项目,覆盖 React 中最核心的几个 Hook:

项目 文件数 涉及 Hook
uncontrolled-demo 9 个文件 useStateuseRef
callback-demo 2 个文件 memouseCallbackuseMemo

分为上下两篇:上篇讲表单状态管理(受控 vs 非受控 ),下篇讲渲染性能优化(memo + useCallback + useMemo)。


上篇:受控组件 vs 非受控组件

React 里处理表单输入,有两条完全不同的路:

模式 数据存在哪 关键词
受控组件 React state useStatevalueonChange
非受控组件 DOM 自己管 useRefref、需要时再读

项目全景

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>
    </>
  );
}

三个要点:

  1. 用一个 useState 管全部字段 ------ 跟 Vue 的 reactive 思路一致(对象级别的响应式)
  2. [e.target.name] ------ 计算属性名,一个 handleChange 通吃所有 input
  3. ...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 白干

每次父组件渲染,对象字面量和箭头函数都会创建全新的引用。 这就是 useCallbackuseMemo 存在的意义。


七、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 ------ 稳定计算结果

useCallbackuseMemo 本质是同一个东西:

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

相关推荐
Synmbrf3 小时前
flv播放设置hasAudio为true黑屏
javascript
看到我请叫我铁锤3 小时前
vue编写web端在线预览文档
前端·javascript·vue.js
ssshooter4 小时前
为什么明明只有一个 12px 的小元素,父容器却有 63px 高?
前端·javascript·面试
breeze jiang4 小时前
React + WebGPU 在浏览器运行 DeepSeek:从 Worker 通信到流式生成
前端·javascript·react.js
sunly_5 小时前
React 三个重要概念:Ref、Props、State 详解
前端·javascript·react.js
Mh5 小时前
如何使用GSAP实现一个 `pinned` 滚动楼层叙事?
前端·javascript·css
Larcher11 小时前
React Router 不只是页面跳转:从 SPA 路由到权限守卫的完整实践
javascript·后端
Larcher11 小时前
大模型为什么每次回答都不一样?一文搞懂 Temperature、Top K 与 Top P
javascript·后端
eric-sjq14 小时前
10 分钟实战:用 0.6B 本地模型生成中文网页(WanlyFrontend + 编译器全流程)
javascript·机器学习·自然语言处理·html