数据主权与渲染防线:从受控组件到性能优化的完整图景

数据主权与渲染防线:从受控组件到性能优化的完整图景

上一节我们把"复用"交给自定义 Hooks、"并行"交给 Web Worker,React 的工程能力又上了一个台阶。但把视线从线程拉回到界面,有一个最朴素、也最躲不开的场景:表单。用户名、密码、评论、搜索框------只要做界面,就逃不开输入框。

而输入框的第一个问题,就是"主权"之争:输入框里的值,到底归谁管?

  • 让 React 的状态来管,输入框只是一个"傀儡"------这是受控组件
  • 让 DOM 自己管,React 用到时再伸手去取------这是非受控组件

同一天,笔记还顺带解锁了性能优化的第一道防线:父组件一重新渲染,子组件全部跟着动,这很浪费。memo 把子组件"记"下来,不该动的时候就不动------而 useCallbackuseMemo,就是这条路上的下一站。

这一天的主题可以并成一句话:先搞清楚"数据归谁管",再搞清楚"什么时候该重渲染"。


一、受控组件:让 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 读值。handleSubmittextareaRef.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>
    </>
  )
}

两个要点:

  1. 每个 input 都写上 name 属性,等于给字段起了一个"键"。
  2. 一个 handleChange 通吃所有字段[e.target.name]: e.target.value------用计算属性名,把"这个字段的名字"和"它的新值"配对写回 form

字段再多,也只要一个状态对象、一个处理函数。这就是受控表单的"标准姿势"。相比给每个字段单独写一个 useState,对象形态有两个明显的优势:提交时直接拿到完整的表单数据,不需要逐个字段拼装;新字段加入时只需在 form 对象里加一个键,再在 JSX 里加一个 inputhandleChange 完全不用改。


五、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 节点内部
谁来更新 onChangesetStatevalue 回流 用户直接改,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 应该和 useCallbackuseMemo 配合使用,形成一套完整的"变化锁定"策略。


九、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 显示某个组件渲染耗时过高),或者确定某个计算复杂度很高(如大数组的排序、过滤),才引入这些优化工具。过度使用 useCallbackuseMemo 反而会增加代码复杂度和内存开销。


十、性能优化的完整决策树

当遇到渲染性能问题时,可以按以下路径排查和优化:

bash 复制代码
观察到渲染卡顿
    ↓
用 React DevTools Profiler 定位
    ↓
找到频繁重渲染的组件
    ↓
该组件的 props 是否真的变了?
    ├─ 没变 → 用 memo 包住
    │       ↓
    │   传给它的函数/对象是否每次新建?
    │       ├─ 是 → 用 useCallback/useMemo 稳定引用
    │       └─ 否 → 优化完成
    │
    └─ 变了 → 检查父组件是否过早更新了这部分 props
              ↓
           优化父组件的状态管理

十一、面试高频问题与答题框架

Q1:受控组件和非受控组件有什么区别?如何选择?

回答框架

区别在于"值归谁管"。受控组件的值存在 React 状态里,通过 value + onChange 完成"输入 → setState → 回流",状态是唯一数据源,适合需要实时校验、联动、格式化等场景。非受控组件的值存在 DOM 里,React 不干预,需要时用 useRef 读取 ref.current.value,适合提交时一次性取值、文件上传或与第三方库集成的场景。大部分业务表单选受控更稳,因为数据流清晰可控。

Q2:受控组件为什么方便做实时校验?

回答框架

因为受控组件的值始终在 React 状态里。每次输入都触发 onChangesetState,校验函数可以同步读到当前值、写入错误消息。isValid 汇总所有条件,提交按钮通过 disabled={!isValid} 自动禁用。非受控组件只在提交时才有值,做不到"输入时实时反馈"。

Q3:父组件重新渲染,子组件一定会重新渲染吗?

回答框架

默认是的。函数组件只要父组件重渲染,子组件也会跟着重渲染,哪怕 props 没有变化。想"不相关的属性变化时拒绝重渲染",就用 memo 把子组件包起来------memo 会对比新旧 props,没变时直接复用上次的渲染结果,跳过本次渲染。

Q4:有了 memo 为什么还需要 useCallback

回答框架

memo 靠浅比较 props 来判断是否重渲染。但函数每次渲染都是新的引用------如果给 memo 子组件传了一个内联函数,即使逻辑没变,memo 也认为 props "变了",照常重渲染,memo 就白用了。useCallback 用依赖数组"记住"一个函数,依赖不变就返回同一个引用,配合 memo 才能真正挡住重渲染。同样地,对象字面量每次渲染也是新引用,可以用 useMemo 来稳定。

Q5:useMemouseCallback 的区别是什么?

回答框架

useCallback 缓存函数引用useMemo 缓存计算结果(值)。两者都靠依赖数组判断是否重新生成:依赖不变就走缓存,依赖变了就重新计算。一个是"记住这个函数",一个是"记住算好的这个数"。

Q6:性能优化是不是该从第一天就全部做上?

回答框架

不必。过早优化会让代码变得复杂且难以维护。正确的做法是:先用 React DevTools 的 Profiler 定位真正的性能瓶颈(哪些组件渲染慢、哪些频繁渲染),然后有针对性地加 memouseCallbackuseMemo。先让代码正确和清晰,再在需要的地方做优化。


结语:先管数据归谁,再管谁该重渲染

这一天的两条线,正好是 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 进阶绕不过的地基。

相关推荐
DFT计算杂谈1 小时前
FeSe超薄膜在CaF2衬底上的电子结构DFT研究
java·服务器·前端
mONESY1 小时前
React 组件通信进化史:从 Props 层层搬运到 Context 跨层级直达
javascript
BreezeJiang1 小时前
父组件一更新,子组件就必须跟着更新吗?从 memo 走到 useCallback
前端·javascript
胡萝卜术1 小时前
复用与并行:从自定义 Hooks 封装状态逻辑,到 Web Worker 的多线程计算
前端·javascript·面试
circuitsosk1 小时前
长文本与高并发下的Token“瘦身”策略:Prompt压缩与上下文窗口优化
java·前端·python·prompt·上下文窗口·token优化
触底反弹1 小时前
🏗️ 写完 Todos 之后,大型 React 项目的 7 个架构真相
前端·react.js·前端框架
huabuyu1 小时前
CLS 总是修不好?因为你只盯着分数,从没拆开看过它
前端·javascript
倾颜1 小时前
会 Vue / React,上手 Electron 真没那么难:前端开发者需要补齐的核心知识
前端
kisshyshy1 小时前
从多页面到SPA:React Router 路由进阶完全指南
前端·javascript·react.js