React 受控/非受控组件与 React.memo 性能优化——从本质到实战

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 的 value state。

关键在这里: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"------这是我在代码里写的注释。它有两层意思:

  1. 响应式useState 是 React 的响应式原语------状态一变,视图自动跟着变,不需要手动操作 DOM。
  2. 控制: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,而是每次渲染时从 formerrors 实时计算出来的。这避免了状态冗余------如果 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 是对象、数组或函数,每次父组件渲染都会生成全新的引用,=== 永远返回 falsememo 就形同虚设。这就是为什么下一步需要 useCallback(缓存函数引用)和 useMemo(缓存计算结果)------这个项目命名为 callback-demo 的原因正在于此。


六、把两条线串起来

受控/非受控讲的是"数据存在哪 ",React.memo 讲的是"渲染能跳过吗"。但归根结底,它们都在围绕 React 最核心的机制:

状态驱动视图。 useState 是 React 的脉搏------它一跳,整棵组件树就开始逐层重渲染。受控组件利用这个脉搏来同步 DOM;React.memo 试图阻断不必要的脉搏传导,让没有变化的子树保持静默。

把这个脉搏和阻断机制吃透,后面要学的 useCallbackuseMemo、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 项目里的常见实践,也是组件化的最后一环。

相关推荐
IT_陈寒1 小时前
Java并行流把我坑惨了:原来不是线程安全的!
前端·人工智能·后端
计科土狗2 小时前
GESP六级专题之类与对象
java·前端·数据库
anOnion2 小时前
构建无障碍组件之Listbox Pattern
前端·html·交互设计
凌涘2 小时前
前端路由(三):鉴权、拦截与重定向
前端
其美杰布-富贵-李2 小时前
第 8 篇:Three.js 材质系统
javascript·three.js·js
To_OC2 小时前
LC 3 无重复字符的最长子串:从入门滑动窗口到优化写法,再也不怕面试官追问
javascript·算法·leetcode
半个落月2 小时前
从零梳理 React Router:路由、懒加载、嵌套页面与登录鉴权
前端·react.js
tedcloud1233 小时前
Impeccable 部署指南:开源前端设计工具 Linux 环境搭建实践
linux·运维·服务器·前端·人工智能·开源
默_笙3 小时前
🚩 React + TypeScript 的 Props 通信,我从"把事件对象传给父组件"进化到了"只传值"
前端·javascript