金三银四面试官最想听的 React 答案:虚拟 DOM、Hooks 陷阱与大型列表优化

基础篇

React 的核心特点是什么?

markdown 复制代码
*考察点*:组件化、声明式编程、虚拟 DOM (Virtual DOM)、单向数据流。

1. 声明式编程 (Declarative)

  • 概念:你只需要描述界面"应该是什么样子"(基于当前的状态),而不需要关心"如何一步步改变它"。
  • 对比 :在传统命令式编程(如 jQuery)中,你需要手动操作 DOM(例如:document.getElementById('app').innerHTML = ...)。而在 React 中,你只需更新 state,React 会自动高效地更新 DOM 以匹配最新的状态。
  • 优势:代码更易于理解、调试和维护,因为数据流是单向且可预测的。

2. 组件化 (Component-Based)

  • 概念:将 UI 拆分为独立、可复用的小代码块,称为"组件"(Components)。

  • 结构 :组件像乐高积木一样,可以嵌套组合。大组件由小组件构成(例如:Page -> Header + Sidebar + Feed -> Post + Comment)。

  • 优势

    • 高复用性:写一次,到处使用。
    • 高内聚低耦合:每个组件管理自己的逻辑和样式,便于团队协作和独立测试。

3. 虚拟 DOM (Virtual DOM) 与 高效更新

  • 概念:React 在内存中维护了一个轻量级的 JavaScript 对象树(即虚拟 DOM),它是真实 DOM 的映射。

  • 工作原理 (Diffing Algorithm)

    1. 当状态改变时,React 会生成一个新的虚拟 DOM 树。
    2. React 将新树与旧树进行对比(Diff 算法),找出最小化的变更集合。
    3. React 只将这些必要的变更批量应用到真实的浏览器 DOM 上。
  • 优势:直接操作真实 DOM 是非常昂贵的性能消耗。通过虚拟 DOM,React 极大地减少了重绘(Repaint)和重排(Reflow)的次数,提升了应用性能。

4. 单向数据流 (Unidirectional Data Flow)

  • 概念 :数据在 React 应用中总是沿着一个方向流动:从父组件流向子组件

  • 机制

    • 父组件通过 Props(属性)将数据传递给子组件。
    • 子组件不能直接修改 Props,只能通过回调函数通知父组件去修改状态。
  • 优势:使得数据流向清晰透明,避免了双向绑定(如 AngularJS 早期版本)可能导致的数据状态混乱,极大地降低了调试难度。


💡 补充:现代 React 的额外特点 (Hooks & Concurrent)

随着 React 16.8+ 和 React 18 的发布,还有两个重要的现代特点:

  • Hooks (钩子) :允许在函数组件中使用状态(State)和其他 React 特性,彻底改变了逻辑复用的方式(替代了高阶组件 HOC 和渲染属性 Render Props)。
  • 并发渲染 (Concurrent Rendering) :React 18 引入的新能力,允许 React 中断渲染任务,优先处理高优先级的用户交互(如点击、输入),从而保持界面的流畅响应。

总结一句话

React 是一个声明式 的、组件化 的库,它利用虚拟 DOM 技术高效更新界面,并遵循单向数据流原则,让构建复杂用户界面变得简单且可预测。

什么是 Virtual DOM?它是如何工作的?为什么它比直接操作真实 DOM 快?

markdown 复制代码
*考察点*:内存中的 JS 对象树、Diff 算法(协调过程)、批量更新、减少重绘重排。

Virtual DOM (虚拟 DOM) 是 React 的核心概念之一,它是一种编程概念,也是 React 实现高效渲染的关键机制。

简单来说,Virtual DOM 是真实 DOM 在内存中的轻量级 JavaScript 对象表示。它不是浏览器中实际的节点,而是一个普通的 JS 对象树,描述了界面应该长什么样。


1. Virtual DOM 是如何工作的?

React 的更新过程通常被称为 "协调" (Reconciliation) ,主要包含以下三个步骤:

第一步:渲染 (Render) -> 生成新的 Virtual DOM

当组件的 stateprops 发生变化时,React 会重新执行组件函数(或 render 方法),生成一棵新的 Virtual DOM 树

  • 例子:原本列表有 3 项,现在 state 变了,React 会在内存中构建一个包含 4 项的新 JS 对象树。
第二步:Diffing (差异对比) -> 找出变化

React 将新的 Virtual DOM 树旧的 Virtual DOM 树进行对比。

  • React 使用高效的 Diff 算法,逐层比较两个树的差异。
  • 它会精准地定位到哪些节点发生了变化、哪些被添加了、哪些被删除了。
  • 结果 :生成一个最小的变更列表 (Patch)
第三步:Commit (提交) -> 更新真实 DOM

React 拿着这个"变更列表",一次性批量地将这些变化应用到浏览器的真实 DOM上。

  • 只有真正发生变化的部分才会被操作,其他部分保持不动。

流程图解:

State 变化 ➡️ 生成新 Virtual DOM ➡️ Diff 算法对比 (新 vs 旧) ➡️ 生成最小变更集 ➡️ 批量更新真实 DOM


2. 为什么它比直接操作真实 DOM 快?

很多人有一个误区,认为"Virtual DOM 本身比真实 DOM 快"。其实,创建 Virtual DOM 对象本身也是有成本的,甚至单纯创建一个 JS 对象可能比创建一个简单的 DOM 节点还要慢一点点。

Virtual DOM 的真正优势在于"减少了不必要的真实 DOM 操作" ,具体原因如下:

A. 真实 DOM 操作非常昂贵

浏览器的真实 DOM 节点不仅包含你看到的 HTML 结构,还关联了大量的元数据、事件监听器、样式计算信息等。

  • 重排 (Reflow) :修改 DOM 结构(如添加/删除节点)会触发浏览器重新计算布局,这非常消耗 CPU。
  • 重绘 (Repaint) :修改样式会触发浏览器重新绘制像素。
  • 如果你频繁地、逐个地操作真实 DOM(例如在循环中 appendChild),浏览器可能会被迫多次进行重排和重绘,导致页面卡顿。
B. 批量更新 (Batching)

React 通过 Virtual DOM 机制,将多次状态变更合并为一次 DOM 更新。

  • 传统方式:如果你要修改 100 个列表项,直接操作 DOM 可能触发 100 次重排。
  • React 方式:React 先在内存中算好这 100 个项该怎么变,最后只进行一次真实的 DOM 操作(或者尽可能少的操作),从而将 100 次重排合并为 1 次。
C. 智能的 Diff 算法

React 的 Diff 算法做了很多优化假设(启发式规则),使得对比速度极快(复杂度接近 O(n)):

  1. 不同元素类型直接替换 :如果 <div> 变成了 <span>,React 不会尝试去修改它,而是直接销毁旧树,重建新树。
  2. Key 的作用 :对于列表,通过 key 属性,React 能迅速识别出哪个元素是移动的、哪个是新增的,而不是傻傻地从头比对。
  3. 层级对比:React 只对比同一层级的节点,不会跨层级比较,大大减少了计算量。

总结

特性 直接操作真实 DOM React Virtual DOM
操作对象 浏览器沉重的 DOM 节点 轻量的 JS 对象
更新频率 每次数据变都直接操作,易频繁触发重排 先在内存计算,批量更新真实 DOM
性能瓶颈 频繁的 Reflow/Repaint 导致卡顿 将大量 DOM 操作合并为少量操作
开发体验 需要手动管理状态和 DOM 同步,易出错 声明式,只需关注数据,React 自动处理

一句话结论

Virtual DOM 并不是让"创建节点"变快了,而是通过在内存中进行快速的 JS 运算 ,计算出最小化的 DOM 操作方案,从而避免了浏览器昂贵的重排和重绘,最终提升了整体渲染性能。

JSX 是什么?浏览器能直接读取 JSX 吗?

markdown 复制代码
*考察点*:JSX 是语法糖,最终会被 Babel 编译成 `React.createElement()` 调用。

1. JSX 是什么?

  • JSX (JavaScript XML) 是 JavaScript 的一种语法扩展(Syntax Extension),主要用于 React 中描述 UI 的结构。它看起来非常像 HTML,但实际上它是 JavaScript。

  • 本质 :JSX 并不是字符串,也不是真正的 HTML。它是 React.createElement() 函数的语法糖(Syntactic Sugar)。

  • 作用:它允许你在 JavaScript 代码中直接编写类似 HTML 的标记,使组件的结构更加直观、易读,并且可以将逻辑(JS)和视图(HTML-like)紧密结合在同一个文件中。

代码对比

使用 JSX 写法:

ini 复制代码
const element = <h1 className="greeting">Hello, world!</h1>;

编译后的真实 JavaScript 写法(Babel 转换后):

php 复制代码
const element = React.createElement(
  'h1',
  { className: 'greeting' },
  'Hello, world!'
);

可以看到,JSX 让代码更简洁,而底层依然是标准的 JavaScript 函数调用。


2. 浏览器能直接读取 JSX 吗?

答案是:不能。

浏览器原生只理解标准的 HTMLCSSJavaScript (ECMAScript) 。JSX 既不是有效的 JavaScript(因为里面有 <tag> 这种语法),也不是 HTML。如果直接将包含 JSX 的代码交给浏览器执行,浏览器会抛出语法错误(SyntaxError)。

如何让浏览器运行 JSX?

为了让浏览器能运行 JSX 代码,必须经过一个编译(Transpilation) 过程,将 JSX 转换为浏览器能理解的普通 JavaScript。

通常有以下几种方式:

  1. 构建工具编译(生产环境标准做法) ⭐️推荐

    在开发阶段,使用工具如 BabelTypeScriptViteWebpack

    • 当你运行 npm run build 或启动开发服务器时,这些工具会自动扫描代码,将所有 .jsx.tsx 文件中的 JSX 语法转换成标准的 React.createElement 调用。
    • 最终部署到服务器的文件是纯 JavaScript,浏览器可以直接执行。
  2. 浏览器端即时编译(仅用于学习/演示,不推荐生产使用)

    你可以在 HTML 中引入 Babel 的 standalone 版本,并在 <script> 标签中指定 type="text/babel"

    xml 复制代码
    <!-- 不推荐在生产环境使用,性能较差 -->
    <script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
    <script type="text/babel">
      const element = <h1>Hello JSX</h1>;
      // Babel 会在浏览器中实时将其转换为 JS 并执行
    </script>
    • 缺点:这会增加页面的加载时间,因为浏览器需要下载 Babel 库并在运行时进行编译,严重影响性能。

总结

特性 说明
定义 JavaScript 的语法扩展,用于描述 UI 结构。
本质 React.createElement(component, props, ...children) 的语法糖。
浏览器支持 不支持。浏览器无法直接解析 JSX 语法。
解决方案 必须通过 Babel 等工具在构建阶段(或运行时)将其转译为标准 JavaScript。
关键区别 JSX 中用 className 代替 HTML 的 class,用 htmlFor 代替 for,因为 classfor 是 JavaScript 的保留字。

State 和 Props 的区别是什么?

markdown 复制代码
*考察点*:Props 是只读的(父传子),State 是可变的(组件内部管理)。
**State(状态)**  和 **Props(属性)**  是 React 组件中管理数据的两种核心机制。虽然它们都存储信息并影响渲染结果,但它们的**来源**、**可变性**和**用途**有着本质的区别。

1. 核心区别总结表

特性 Props (Properties) State (状态)
数据来源 外部传入 (由父组件传递) 内部维护 (由组件自己管理)
可变性 只读 (Read-only) ,子组件不能修改 可变 (Mutable) ,组件可通过 setState 更新
控制权 父组件控制数据的变化 组件自身控制数据的变化
默认值 可以设置 defaultProps 可以在初始化时设置初始状态
类比 函数的参数 组件内部的局部变量
主要用途 配置组件、传递数据、回调函数 响应用户交互、管理动态数据、定时器

2. 详细解析

🟢 Props (属性)
  • 定义 :Props 是组件接收的输入参数。就像你调用一个函数 fn(a, b) 时传递的 ab

  • 单向数据流 :数据从父组件流向子组件。子组件绝对不能直接修改自己的 props。如果子组件需要改变某个值,它必须通知父组件(通常通过父组件传递下来的回调函数),由父组件来修改状态,从而触发新的 props 下发。

  • 示例

    javascript 复制代码
    // 父组件
    function Parent() {
      const userName = "Alice";
      return <Child name={userName} />; // 传递 props
    }
    
    // 子组件
    function Child(props) {
      // ❌ 错误:不能修改 props
      // props.name = "Bob"; 
      
      // ✅ 正确:只能读取
      return <h1>Hello, {props.name}</h1>;
    }
🔵 State (状态)
  • 定义:State 是组件内部管理的私有数据。它决定了组件在某一时刻的渲染内容。

  • 响应式更新 :当 State 发生变化时(通过 setStateuseState 的 setter 函数),React 会自动重新渲染该组件及其子组件,以反映最新的数据。

  • 交互性:State 通常用于处理用户输入、表单数据、切换开关、加载状态等动态场景。

  • 示例 (使用 Hooks):

    javascript 复制代码
    import { useState } from 'react';
    
    function Counter() {
      // count 是 state,setCount 是更新函数
      const [count, setCount] = useState(0); 
    
      return (
        <div>
          <p>当前计数: {count}</p>
          {/* ✅ 正确:通过 setter 函数更新 state */}
          <button onClick={() => setCount(count + 1)}>
            点击加 1
          </button>
        </div>
      );
    }

3. 形象类比

为了更好地理解,我们可以把 React 组件想象成一个自动售货机

  • Props 就像是售货机的型号设置(由工厂/父组件决定):

    • 比如"这台机器卖可乐"、"这台机器卖水"。
    • 售货机自己不能决定把自己变成卖咖啡的,必须由外部(工厂)来设定或更换。
    • 特点:外部给定,内部只读。
  • State 就像是售货机内部的库存和当前显示(由机器自己管理):

    • 比如"还剩 5 瓶可乐"、"当前屏幕显示'请投币'"。
    • 当用户投币或取货时,机器内部的状态会发生变化(库存减 1,屏幕变化)。
    • 特点:内部维护,随交互动态变化。

4. 常见面试追问

Q: 既然 Props 不能改,那子组件如何与父组件通信?

  • A : 父组件在传递 Props 时,同时传递一个回调函数 (例如 onItemClick)。子组件在需要改变数据时,调用这个函数并传递新数据。父组件接收到调用后,更新自己的 State,从而导致传递给子组件的 Props 发生变化。这就是单向数据流的闭环。

Q: 所有的数据都应该放在 State 里吗?

  • A : 不是。原则是:能算出来的就不要存 State能由父组件管理的就尽量提升状态。只有那些真正随时间变化、且需要触发 UI 重新渲染的独立数据,才适合放在 State 中。

Q: Class 组件和 Function 组件在 State 处理上有什么区别?

  • A:

    • Class 组件:使用 this.statethis.setState(),更新是合并对象。
    • Function 组件:使用 useState Hook,返回 [state, setState],更新是替换值(如果是对象需手动合并)。

理解 State 和 Props 的区别是掌握 React 单向数据流思想的关键。

为什么在 React 中列表渲染需要 key?可以用 index 作为 key 吗?

markdown 复制代码
*考察点*:帮助 React 识别哪些元素改变了、添加了或移除了。使用 index 可能导致状态错乱或性能问题(特别是在列表顺序变化时)。

1. 为什么需要 key

在 React 中,key 是列表元素的一个特殊字符串属性 。它的核心作用是:帮助 React 识别哪些元素发生了变化、被添加或被删除。

当组件的 State 或 Props 变化导致列表重新渲染时,React 需要对比"旧列表"和"新列表"的 Virtual DOM 树(Diff 算法)。如果没有 key,React 只能默认按照索引顺序进行对比,这会导致以下问题:

  • 无法精准定位:React 不知道某个元素是"移动了位置"还是"内容变了"。
  • 低效更新:React 可能会销毁并重建大量本可以复用的 DOM 节点,而不是仅仅移动它们。
  • 状态错乱:如果列表项内部包含输入框(Input)或局部状态,错误的复用会导致状态对应到错误的数据上。

有了 key,React 就能建立旧节点和新节点之间的映射关系:

  • 如果 key 相同但位置变了 -> 移动节点(性能高)。
  • 如果 key 相同且位置没变 -> 复用节点。
  • 如果 key 不存在于新列表 -> 删除节点。
  • 如果 key 不存在于旧列表 -> 创建新节点。

2. 可以用 index(索引)作为 key 吗?

简短回答

  • 不推荐在大多数动态场景下使用 index 作为 key。
  • 仅在 列表是静态的 (不会排序、过滤、增删)且没有内部状态时,才可以勉强使用。
❌ 为什么通常不能用 index 作为 key?

如果列表会发生排序、筛选、插入或删除操作,使用 index 作为 key 会引发严重问题:

问题一:组件状态错乱 (Stale State)

这是最致命的 Bug。如果列表项包含输入框、开关等状态,使用 index 会导致状态"粘"在索引位置上,而不是跟随数据移动。

场景演示

假设有一个待办列表,每个项都有一个输入框。

  1. 初始列表:['A', 'B']

    • Index 0: 输入框值为 "A"
    • Index 1: 输入框值为 "B"
  2. 用户在第一项输入框输入了 "Modified A"。

  3. 操作 :在列表头部插入一个新项 'C'。列表变为 ['C', 'A', 'B']

  4. React 的行为 (如果 key=index)

    • React 看到 Index 0 还在,认为它是同一个组件,只是内容从 'A' 变成了 'C'。但是,它保留了 Index 0 的输入框状态 ("Modified A")。
    • React 看到 Index 1 还在,认为它是同一个组件,内容从 'B' 变成了 'A'。它保留了 Index 1 的状态(空或之前的值)。
    • 结果 :界面上显示的第一项是 'C',但输入框里却显示着 "Modified A"(原本属于 'A' 的状态)。数据和 UI 状态不一致!
问题二:性能浪费

如果使用 index,当列表中间插入一项时,后续所有项的 index 都变了。React 会认为后续所有组件都变了,从而销毁并重新创建它们,而不是简单地移动 DOM 节点。这会触发不必要的生命周期钩子(如 useEffect 重新执行),导致性能下降。


✅ 什么时候可以用 index?

只有在同时满足以下两个条件时,使用 index 才是安全的:

  1. 列表是静态的:永远不会发生排序、过滤、插入或删除操作(例如:渲染一个固定的导航菜单、颜色列表)。
  2. 列表项无内部状态:列表项只是纯展示数据,不包含 Input、Checkbox 或任何由组件自己管理的 State。

即使如此,为了代码的健壮性和未来扩展性,最佳实践依然是使用唯一 ID


3. 最佳实践:应该用什么做 Key?

✅ 推荐方案:使用数据中唯一的 ID

通常后端返回的数据都会带有一个唯一标识符(如 id, uuid)。

ini 复制代码
// ✅ 正确做法
{todos.map(todo => (
  <TodoItem
    key={todo.id} // 使用稳定的唯一 ID
    todo={todo}
  />
))}

💡 如果没有 ID 怎么办?

如果数据本身没有唯一 ID,且列表是动态的:

  1. 生成唯一 ID:在数据进入 React 之前(或在 reducer 中),为每一项生成一个 UUID 或基于内容的哈希值。
  2. 避免使用 Math.random() :不要在 render 函数中使用 Math.random() 生成 key,这会导致每次渲染 key 都不同,强制组件完全重置,性能极差且状态丢失。

总结

Key 的选择 适用场景 风险
唯一 ID (item.id) 所有场景 (推荐) 无。性能最优,状态稳定。
索引 (index) 仅静态列表、无内部状态 高风险:列表变动时导致状态错乱、性能降低。
随机数 (Math.random) 绝对禁止 每次渲染都重建组件,完全破坏性能及状态。

面试金句

"key 的作用是让 React 在 Diff 过程中精准识别节点的身份。使用 index 作为 key 在列表动态变化时会导致组件状态与数据不对应(状态错乱),并引起不必要的重渲染,因此应始终优先使用数据中稳定的唯一 ID。"

受控组件 (Controlled Component) 和非受控组件 (Uncontrolled Component) 的区别?

markdown 复制代码
*考察点*:表单数据是由 React State 控制还是由 DOM 本身控制(使用 `ref` 获取)。

受控组件 (Controlled Component)非受控组件 (Uncontrolled Component) 是 React 中处理表单数据的两种主要模式。它们的核心区别在于:表单数据是由 React 状态(State)管理,还是由 DOM 自身管理。


1. 核心概念对比

特性 受控组件 (Controlled) 非受控组件 (Uncontrolled)
数据来源 React State (单一数据源) DOM 节点本身 (内部状态)
数据流向 单向:State ➡️ Input Value 双向:Input 用户输入 ➡️ DOM 内部
获取值方式 通过 State 变量直接读取 需要使用 ref 从 DOM 中查询
即时验证 ✅ 支持 (每次输入都触发验证) ❌ 困难 (通常在提交时验证)
强制格式化 ✅ 支持 (如限制只能输入数字) ❌ 困难 (需手动操作 DOM)
代码量 较多 (需写 state, onChange, value) 较少 (只需 ref)
推荐场景 大多数表单、复杂交互、实时验证 简单表单、集成非 React 库、文件上传

2. 代码示例

🟢 受控组件 (Controlled Component)

数据由 React 的 state 控制。Input 的 value 属性绑定到 state,用户的输入通过 onChange 事件更新 state。

javascript 复制代码
import { useState } from 'react';

function ControlledForm() {
  const [text, setText] = useState('');

  const handleChange = (e) => {
    setText(e.target.value); // 1. 用户输入 -> 更新 State
  };

  const handleSubmit = (e) => {
    e.preventDefault();
    alert(`提交的内容: ${text}`); // 2. 直接从 State 读取
  };

  return (
    <form onSubmit={handleSubmit}>
      <label>
        姓名:
        {/* 3. State -> 驱动 Input 显示 */}
        <input 
          type="text" 
          value={text} 
          onChange={handleChange} 
        />
      </label>
      <button type="submit">提交</button>
      {/* 实时显示输入内容 */}
      <p>当前输入: {text}</p>
    </form>
  );
}
  • 特点 :Input 是一个"受控"的终端,它的值完全取决于 React State。如果删除 onChangesetState,输入框将无法输入(变成只读)。
🔵 非受控组件 (Uncontrolled Component)

数据由 DOM 节点自己管理。React 不监听每次输入变化,只在需要时(如提交时)通过 ref 去 DOM 里取值。

ini 复制代码
import { useRef } from 'react';

function UncontrolledForm() {
  const inputRef = useRef(null);

  const handleSubmit = (e) => {
    e.preventDefault();
    // 1. 需要时,通过 ref 直接从 DOM 获取值
    const text = inputRef.current.value;
    alert(`提交的内容: ${text}`);
  };

  return (
    <form onSubmit={handleSubmit}>
      <label>
        姓名:
        {/* 2. 不绑定 value 和 onChange,让浏览器自己管理 */}
        <input 
          type="text" 
          defaultValue="默认名字" 
          ref={inputRef} 
        />
      </label>
      <button type="submit">提交</button>
    </form>
  );
}
  • 特点 :Input 的行为和原生 HTML 表单一样。React 只是"旁观者",直到提交那一刻才介入。注意使用 defaultValue 而不是 value 来设置初始值。

3. 深度解析:为什么 React 推荐受控组件?

虽然非受控组件写起来代码更少,但 React 官方文档强烈推荐使用受控组件,原因如下:

  1. 即时验证与反馈

    • 受控组件可以在用户输入的每一个字符时进行验证(例如:密码强度检测、禁止输入特殊字符),并立即给用户反馈。
    • 非控组件很难做到这一点,因为 React 不知道用户输入了什么,直到提交。
  2. 强制格式化

    • 如果你想让用户只能输入大写字母,或者自动格式化电话号码 (123) 456-7890,受控组件可以通过 onChange 轻松拦截并修改 value
    • 非控组件需要手动操作 DOM (ref.current.value = ...),这不仅繁琐,还破坏了 React 的声明式模型。
  3. 条件渲染与动态禁用

    • 可以根据一个输入框的值,动态禁用另一个按钮或显示另一个输入框。这在受控模式下非常自然(基于 State 判断),而在非控模式下非常麻烦。
  4. 单一数据源 (Single Source of Truth)

    • 受控组件遵循 React 的核心哲学:UI 是 State 的函数。所有数据都在 JS 中,调试、测试和序列化(转为 JSON 提交)都非常容易。

4. 什么时候使用非受控组件?

尽管受控组件是首选,但在以下场景中,非受控组件更有优势:

  • 集成非 React 库:当你使用 jQuery 插件或其他操作 DOM 的第三方库时,让它们自己管理状态更方便。
  • 文件输入 (<input type="file" />) :在 React 中,文件输入的值是只读的,无法通过 State 设置,因此必须是非受控的(配合 ref 使用)。
  • 性能优化(极端情况) :如果一个表单有成千上万个输入框,且用户很少交互,为每个输入框都创建 State 和 onChange 监听器可能会导致渲染性能下降。此时非控组件可以避免频繁的 Re-render。(注:现代 React 性能通常足以应付,此场景较少见)。
  • 快速原型开发:写 Demo 时为了省事。

总结

  • 受控组件 = React 驱动 。数据在 State 中,实时同步,功能强大,是标准做法
  • 非受控组件 = DOM 驱动 。数据在 DOM 中,按需读取,类似传统 HTML 表单,适用于特定场景(如 file input)。

面试建议

如果在面试中被问到,应明确表示:"在绝大多数业务场景下,我会优先选择受控组件,因为它能提供更好的用户体验(实时验证、格式化)并符合 React 的数据流理念。只有在处理文件上传或集成旧代码库等特殊情况下,才会考虑使用非受控组件。"

Hooks 篇

为什么要引入 Hooks?解决了 Class 组件的什么问题?

markdown 复制代码
 *考察点*:逻辑复用(替代 HOC 和 Render Props)、状态逻辑关注点分离、简化组件写法。
  • 引入 Hooks 主要解决了以下三个核心问题:

1. 状态逻辑复用困难 (Logic Reuse)

在 Hooks 出现之前,要在组件之间复用状态逻辑(例如:获取数据、订阅事件、表单验证),主要有两种模式:高阶组件 (HOC)渲染属性 (Render Props)

  • Class 组件的痛点

    • 嵌套地狱 (Wrapper Hell) :如果一个组件需要复用多个逻辑(如 withRouter + withAuth + withTheme),代码会被层层包裹,导致结构深不可读,调试困难。
    • 命名冲突:HOC 注入的 props 可能会发生命名冲突。
    • 静态树限制:Render Props 虽然灵活,但会导致 JSX 树结构变得复杂,难以维护。
  • Hooks 的解决方案

    • 允许你将状态逻辑提取到独立的自定义 Hook 函数中(如 useUser, useFetch)。

    • 可以在多个组件中直接调用这些函数,无需改变组件层级结构。

    • 代码对比

      javascript 复制代码
      // ❌ Class/HOC 模式:嵌套严重
      <WithAuth>
        <WithTheme>
          <UserProfile />
        </WithTheme>
      </WithAuth>
      
      // ✅ Hooks 模式:扁平清晰
      function UserProfile() {
        const { user } = useAuth(); // 逻辑复用
        const { theme } = useTheme(); // 逻辑复用
        return <div>{user.name} - {theme}</div>;
      }

2. 复杂组件难以理解与维护 (Complex Components become Hard to Understand)

Class 组件强制要求将生命周期方法(componentDidMount, componentDidUpdate, componentWillUnmount)作为代码组织的单元。

  • Class 组件的痛点

    • 相关逻辑被拆分 :一个功能(例如"订阅聊天室")通常需要在 mount 时订阅,在 update 时可能重新订阅,在 unmount 时取消订阅。在 Class 组件中,这三段代码分散在三个不同的生命周期方法里。
    • 副作用混乱 :随着组件变复杂,componentDidUpdate 里往往堆积了大量互不相关的 if-else 判断,用来处理不同状态的更新,极易产生 Bug(例如忘记清理定时器或事件监听器)。
    • 心智负担:开发者必须在脑海中拼凑分散在各处的代码才能理解一个完整功能的逻辑。
  • Hooks 的解决方案

    • 关注点分离 :Hooks 允许你将相关逻辑组织在一起 。使用 useEffect,你可以把订阅、更新订阅、取消订阅的代码全部写在一个 Hook 调用中。

    • 代码对比

      scss 复制代码
      // ❌ Class 组件:逻辑分散
      class ChatRoom extends Component {
        componentDidMount() { this.subscribe(); }
        componentDidUpdate(prevProps) { 
          if (prevProps.roomId !== this.props.roomId) { 
            this.unsubscribe(); 
            this.subscribe(); 
          } 
        }
        componentWillUnmount() { this.unsubscribe(); }
        // ... 其他不相关的逻辑也混在这里
      }
      
      // ✅ Hooks 组件:逻辑内聚
      function ChatRoom({ roomId }) {
        useEffect(() => {
          // 订阅、更新、取消订阅的逻辑都在这一处
          const connection = createConnection(serverUrl, roomId);
          connection.connect();
          return () => connection.disconnect(); // 清理函数
        }, [roomId]); // 依赖项明确
      }

3. Class 组件的困惑与学习门槛 (Confusing Classes)

对于很多前端开发者(尤其是习惯函数式编程或刚入门的开发者)来说,JavaScript 的 class 语法本身就是一个障碍。

  • Class 组件的痛点

    • this 指向问题 :这是新手最容易踩的坑。方法需要手动绑定 this(在构造函数中或使用箭头函数),否则 this.setState 会报错。
    • 语法繁琐 :需要定义构造函数、继承 React.Component、区分实例方法和静态方法等。
    • 压缩优化差:Class 的方法名在压缩时难以被混淆(因为需要通过字符串引用或继承链),而函数式组件更容易被 Tree-shaking 和压缩。
  • Hooks 的解决方案

    • 纯函数 :组件只是简单的 JavaScript 函数,不需要 class,不需要 constructor,不需要 this
    • 更简洁:代码量显著减少,逻辑更直观。
    • 易于优化:函数式写法更符合现代编译器的优化策略。

补充:Hooks 带来的额外优势

除了上述解决 Class 痛点的原因外,Hooks 还带来了新的可能性:

  1. 更细粒度的更新控制 :配合 React.memo 和自定义 Hook,可以更容易地避免不必要的子组件重渲染。
  2. 更好的类型推导 (TypeScript) :泛型函数在 TypeScript 中的类型推导通常比复杂的 Class 泛型更友好、更精准。
  3. 社区生态统一:现在新的 React 库和教程几乎都首选 Hooks,统一技术栈降低了团队协作成本。

总结:为什么要引入 Hooks?

问题领域 Class 组件的表现 Hooks 的改进
逻辑复用 依赖 HOC/Render Props,导致嵌套地狱 自定义 Hook,逻辑扁平化,随意组合
代码组织 生命周期拆分,相关逻辑分散 功能逻辑聚合,代码内聚易读
语法难度 需处理 this 绑定,语法繁琐 纯函数 ,无 this,简单直观
未来趋势 官方不再推荐新功能开发使用 Class React 未来的发展方向(如 Concurrent Features)主要基于 Hooks

一句话总结

Hooks 让 React 组件从"基于生命周期的类"转变为"基于功能组合的函数",彻底解决了逻辑复用难复杂组件逻辑分散两大历史难题,同时降低了学习门槛。

useEffect 的依赖数组 (deps) 是如何工作的?空数组 [] 代表什么?

markdown 复制代码
*考察点*:依赖变化触发副作用。`[]` 表示只在挂载和卸载时执行(类似 `componentDidMount` 和 `componentWillUnmount`)。

1. 依赖数组的工作原理

React 会在每次组件渲染(Render)后,对比当前渲染的依赖数组上一次渲染的依赖数组 。对比使用的是 浅比较(Shallow Comparison) ,即 Object.is() 算法。

  • 浅比较规则

    • 对于基本类型(string, number, boolean, null, undefined):比较值是否相等。
    • 对于引用类型 (object, array, function):比较内存地址(引用)是否相同,而不是比较内容。
执行流程逻辑:
  1. 首次渲染 :无论依赖数组是什么,useEffect 都会在浏览器绘制屏幕后执行一次。

  2. 后续渲染

    • 如果没有提供依赖数组:每次渲染后都执行。

    • 如果提供了依赖数组:

      • 若数组中任意一个 依赖项发生变化(浅比较不等) -> 执行 Effect。
      • 若数组中所有 依赖项都没变 -> 跳过 Effect。
  3. 清理函数(Cleanup)

    • 如果 Effect 返回了一个函数,该函数会在下一次 Effect 执行前组件卸载时运行。
    • 只有当依赖变化导致 Effect 需要重新运行时,才会先执行上一次的清理函数。

2. 空数组 [] 代表什么?

[] 代表:仅在组件挂载(Mount)时执行一次,相当于 Class 组件的 componentDidMount

  • 含义:你告诉 React,"这个 Effect 不依赖任何 props 或 state 的变化"。

  • 行为

    1. 组件第一次渲染后 -> 执行
    2. 组件后续更新(Props/State 变化) -> 不执行 (因为 [][] 浅比较永远相等)。
    3. 组件卸载(Unmount) -> 执行清理函数(如果有)。

典型场景

  • 发起初始数据请求(API Fetch)。
  • 订阅全局事件(如 window.resize),并在卸载时取消订阅。
  • 初始化第三方库(如图表库、地图库)。
javascript 复制代码
useEffect(() => {
  console.log('只在挂载时运行一次');
  
  return () => {
    console.log('只在卸载时运行一次');
  };
}, []); // <--- 空数组

3. 不同依赖数组配置的对比

依赖数组配置 代码示例 执行时机 对应 Class 生命周期 常见用途
无数组 useEffect(() => { ... }) 每次渲染后都执行 componentDidMount + componentDidUpdate 需要同步 DOM 操作、无优化需求的简单日志
空数组 useEffect(() => { ... }, []) 仅挂载时执行一次 componentDidMount 初始化数据、订阅全局事件
有依赖项 useEffect(() => { ... }, [a, b]) 挂载时 + 依赖项变化时 componentDidMount + componentDidUpdate (带条件) 依赖特定 Props/State 的数据请求、动态订阅

4. ⚠️ 常见陷阱与注意事项

陷阱一:引用类型导致的无限循环

由于依赖数组进行的是浅比较,如果在依赖中放入一个在每次渲染时都会新建的对象、数组或函数,Effect 会认为依赖变了,从而无限触发执行。

scss 复制代码
// ❌ 错误示范:无限循环
function Component({ userId }) {
  const config = { id: userId }; // 每次渲染都创建新对象引用
  
  useEffect(() => {
    fetchData(config);
  }, [config]); // config 引用每次都变 -> 无限触发
}

// ✅ 正确做法 1:只依赖原始值
useEffect(() => {
  const config = { id: userId };
  fetchData(config);
}, [userId]); 

// ✅ 正确做法 2:使用 useMemo 稳定引用 (如果对象必须在外部定义)
const config = useMemo(() => ({ id: userId }), [userId]);
useEffect(() => {
  fetchData(config);
}, [config]);
陷阱二:遗漏依赖项 (Stale Closure)

如果在 Effect 内部使用了某个 state 或 prop,但没有把它加入依赖数组,ESLint 会报警告。这会导致 Effect 捕获到旧的值(闭包陷阱)。

scss 复制代码
// ❌ 错误:count 永远是初始值 0
const [count, setCount] = useState(0);
useEffect(() => {
  const timer = setInterval(() => {
    console.log(count); // 这里打印的永远是 0
    setCount(count + 1); // 这里也是基于 0 增加
  }, 1000);
  return () => clearInterval(timer);
}, []); // 漏掉了 count

// ✅ 正确:加入依赖,或使用函数式更新
useEffect(() => {
  const timer = setInterval(() => {
    setCount(c => c + 1); // 函数式更新不需要依赖 count
  }, 1000);
  return () => clearInterval(timer);
}, []); 

注意:如果逻辑强依赖 count 的最新值且不能用函数式更新,必须将 count 加入 deps,但这会导致定时器反复重置。此时通常需要用 ref 来存储最新值,或者重构逻辑。

陷阱三:不要"欺骗"依赖数组

有时为了只运行一次,开发者会故意省略依赖项。这是危险的。

  • 原则Effect 内部用到的所有响应式变量(props, state, functions),原则上都应该出现在依赖数组中。
  • 如果不想因为某个变量变化而重跑 Effect,应该检查该变量是否真的需要在 Effect 内部使用,或者使用 useRef / useCallback / useMemo 来稳定它,而不是简单地将其从数组中移除。

总结

  • 依赖数组是 React 决定"是否需要重新运行副作用"的开关。
  • [] = 生命周期中的 Mount,只跑一次,常用于初始化。
  • [a, b] = Mount + a 或 b 变化时
  • 核心规则:诚实填写依赖项,警惕引用类型带来的意外更新,利用浅比较特性优化性能。

useMemouseCallback 的区别是什么?什么时候使用它们?

markdown 复制代码
*考察点*:`useMemo` 缓存**计算结果**(值),`useCallback` 缓存**函数引用**。主要用于性能优化,避免不必要的子组件渲染或作为 `useEffect` 的依赖。
`useMemo` 和 `useCallback` 都是 React 中用于**性能优化**的 Hooks,它们的核心目的都是**避免不必要的计算或重新创建**,并利用**缓存(Memoization)** 机制。

它们的根本区别在于:缓存的内容不同

1. 核心区别一句话总结

  • useMemo :缓存计算结果(值)

    • 返回的是一个(变量、对象、数组等)。
    • 用于避免昂贵的计算逻辑在每次渲染时重复执行。
  • useCallback :缓存函数定义(引用)

    • 返回的是一个函数
    • 用于避免函数本身在每次渲染时被重新创建,从而防止子组件因 props 变化而无效重渲染。

本质关系useCallback(fn, deps) 其实等价于 useMemo(() => fn, deps)useCallback 只是 useMemo 的一个语法糖,专门用于缓存函数。


2. 详细对比与代码示例

🟢 useMemo:缓存"值"

场景:你有一个耗时的计算(如遍历大数组、复杂数学运算),或者你需要创建一个对象/数组作为 props 传递给子组件,但不希望每次父组件渲染都生成一个新的引用。

javascript 复制代码
import { useMemo, useState } from 'react';

function ProductList({ products, filterText }) {
  // ❌ 问题:每次渲染都会执行过滤,即使 filterText 没变
  // 如果 products 有 10000 条,性能会很差
  const filteredProducts = products.filter(p => p.name.includes(filterText));

  // ✅ 解决:只有当 products 或 filterText 变化时,才重新计算
  const filteredProductsMemo = useMemo(() => {
    console.log('执行了昂贵的过滤计算...');
    return products.filter(p => p.name.includes(filterText));
  }, [products, filterText]);

  return (
    <ul>
      {filteredProductsMemo.map(p => (
        <li key={p.id}>{p.name}</li>
      ))}
    </ul>
  );
}

关键点

  • 第一个参数是产生值的函数 () => value
  • 返回的是函数的执行结果 value
🔵 useCallback:缓存"函数"

场景 :你将一个函数作为 prop 传递给一个使用了 React.memo 的子组件。如果父组件每次渲染都创建一个新的函数实例,子组件的 props 引用就会变化,导致 React.memo 失效,子组件被迫重渲染。

javascript 复制代码
import { useCallback, useState } from 'react';

// 子组件:使用 React.memo 优化,只有 props 变化才重渲染
const SubmitButton = React.memo(({ onSubmit, label }) => {
  console.log(`${label} 按钮渲染了`);
  return <button onClick={onSubmit}>{label}</button>;
});

function Form() {
  const [count, setCount] = useState(0);
  const [input, setInput] = useState('');

  // ❌ 问题:每次 Form 渲染(比如 input 变化),handleClick 都会变成新函数
  // 导致 SubmitButton 的 props 变化,触发重渲染
  const handleClick = () => {
    console.log('提交:', input);
  };

  // ✅ 解决:只有当 input 变化时,才创建新的函数实例
  // 如果 input 没变,handleClick 保持之前的引用
  const handleClickMemo = useCallback(() => {
    console.log('提交:', input);
  }, [input]);

  return (
    <div>
      <input value={input} onChange={e => setInput(e.target.value)} />
      <p>计数: {count}</p>
      <button onClick={() => setCount(c => c + 1)}>增加计数</button>
      
      {/* 传入缓存后的函数,避免子组件无效更新 */}
      <SubmitButton onSubmit={handleClickMemo} label="提交" />
    </div>
  );
}

关键点

  • 第一个参数是函数本身 fn
  • 返回的是这个函数的引用

3. 什么时候使用它们?(决策指南)

不要过早优化! 滥用这两个 Hooks 反而会增加内存开销和代码复杂度。请遵循以下原则:

✅ 应该使用的情况:
  1. 昂贵的计算 (useMemo):

    • 数据处理、排序、过滤大型列表。
    • 复杂的数学推导。
    • 判断标准:如果不缓存,用户能感觉到卡顿或 CPU 占用高。
  2. 保持引用稳定性 (useCallback & useMemo):

    • 当你把函数对象/数组 作为 props 传递给经过 React.memo 优化的子组件时。
    • 当你把这些函数或对象作为依赖项传给 useEffect 或其他 Hooks 时(避免 Effect 频繁触发)。
  3. 作为依赖项

    • 如果一个对象或函数被用在 useEffect 的依赖数组中,必须用 Hooks 包裹,否则会导致死循环或频繁执行。
❌ 不应该使用的情况:
  1. 简单的计算

    • 例如 a + b 或简单的字符串拼接。Hooks 本身的调用开销可能比直接计算还大。
  2. 未优化的子组件

    • 如果子组件没有包 React.memo,传递新函数引用给它也没关系,它反正都会重渲染。此时 useCallback 无意义。
  3. 盲目包裹所有东西

    • 不要为了"看起来专业"而给每个变量和函数都加上 Memo。这会使得代码难以阅读,且调试困难。

4. 常见误区与陷阱

陷阱一:立即执行
  • useMemo 接收的是工厂函数 () => result
  • 错误写法useMemo(expensiveCalculation(a, b), [a, b]) ------ 这会在每次渲染时立即执行计算,完全失去了意义。
  • 正确写法useMemo(() => expensiveCalculation(a, b), [a, b])
陷阱二:依赖项缺失

useEffect 一样,如果依赖数组不完整,可能会缓存旧的值或旧的函数闭包,导致数据不一致。

  • 务必使用 ESLint 插件 eslint-plugin-react-hooks 来自动检查依赖项。
陷阱三:认为它们能"阻止"父组件渲染
  • 纠正useMemouseCallback 不能阻止当前组件的渲染。它们只是在渲染发生后,决定是"重用旧值/旧函数"还是"计算新值/创建新函数"。
  • 它们的主要作用是保护子组件 (配合 React.memo)或保护 Effect (配合 useEffect)不被触发。

总结对照表

特性 useMemo useCallback
缓存内容 (Value / Result) 函数 (Function Reference)
返回类型 计算后的结果 (任何类型) 函数
主要用途 1. 优化昂贵计算 2. 稳定对象/数组引用 1. 稳定函数引用 2. 防止子组件无效重渲染
语法形式 useMemo(() => compute(), [deps]) useCallback(() => { ... }, [deps])
等价转换 useMemo(() => fn, deps) useCallback(fn, deps)
典型搭配 复杂数据处理、useEffect 依赖对象 React.memo 子组件、useEffect 依赖函数

useRef 的作用是什么?修改 ref.current 会触发重新渲染吗?

markdown 复制代码
*考察点*:访问 DOM 节点、存储可变变量(不触发渲染)。修改它**不会**触发重渲染。
`useRef` 是 React 中一个非常独特且强大的 Hook,它的核心作用是**在组件的多次渲染之间持久化存储数据,且不会触发重新渲染**。

1. useRef 的两大主要作用

🅰️ 作用一:访问 DOM 节点(最常用)

这是 useRef 最直观的功能。通过给元素添加 ref 属性,你可以直接在组件中获取该 DOM 节点的实例,从而进行命令式操作(如聚焦、滚动、测量尺寸、播放视频等)。

javascript 复制代码
import { useRef, useEffect } from 'react';

function InputForm() {
  const inputRef = useRef(null); // 1. 创建 ref

  useEffect(() => {
    // 2. 组件挂载后,直接操作 DOM
    inputRef.current.focus(); 
  }, []);

  return <input ref={inputRef} type="text" />;
}
🅱️ 作用二:存储可变变量(Mutable Variable)

useRef 返回的对象 { current: ... } 就像一个"容器",它的值在组件的整个生命周期内保持不变(除非你手动修改它)。

  • 持久性 :即使组件重新渲染,ref.current 的值也会保留上一次修改后的结果。
  • 无副作用 :修改它不会触发组件重新渲染。

典型场景

  • 保存定时器 ID (setTimeout / setInterval)。
  • 保存上一次的状态值(用于对比)。
  • 存储不需要驱动 UI 变化的复杂对象或实例。
  • 解决闭包陷阱(在 useEffect 中获取最新的 State 值而不依赖它)。
javascript 复制代码
function Timer() {
  const [count, setCount] = useState(0);
  const intervalId = useRef(null); // 用来存定时器 ID

  const startTimer = () => {
    // 存储 ID,下次点击停止时可以清除
    intervalId.current = setInterval(() => {
      setCount(c => c + 1);
    }, 1000);
  };

  const stopTimer = () => {
    clearInterval(intervalId.current); // 直接读取之前存的 ID
    intervalId.current = null;
  };

  return (
    <div>
      <p>{count}</p>
      <button onClick={startTimer}>开始</button>
      <button onClick={stopTimer}>停止</button>
    </div>
  );
}

2. 核心问题:修改 ref.current 会触发重新渲染吗?

答案:不会。

这是 useRefuseState 最大的区别:

表格

特性 useState useRef
修改方式 setState(newValue) ref.current = newValue
触发重渲染? 不会
数据持久性 ✅ 渲染间保留 ✅ 渲染间保留
主要用途 驱动 UI 变化的数据 DOM 引用 / 不驱动 UI 的临时数据
为什么不会触发渲染?

React 的渲染机制是监听 State 的变化 。当你调用 setState 时,React 会标记该组件为"脏"(Dirty),并安排一次重新渲染。

ref 只是一个普通的 JavaScript 对象 { current: value }。修改它的属性就像修改普通对象的属性一样,React 并不知晓,也不会将其视为状态变更,因此不会触发 Re-render。


3. 进阶:如何利用 useRef 不触发渲染的特性?

既然修改 ref 不触发渲染,那如果我想基于 ref 的值更新 UI 怎么办?
答案是:你需要结合 State 使用,或者在特定的生命周期中手动触发。

场景:获取最新值但不希望 Effect 频繁重置

useEffect 中,我们经常遇到"闭包陷阱"(拿不到最新的 state),如果把 state 加入依赖数组又会导致 Effect 频繁执行。这时可以用 ref 来"同步"最新值。

javascript 复制代码
function ChatRoom({ roomId }) {
  const [message, setMessage] = useState('');
  const messageRef = useRef(message);

  // 1. 每次渲染后,同步最新 message 到 ref (不触发渲染)
  useEffect(() => {
    messageRef.current = message;
  });

  useEffect(() => {
    const interval = setInterval(() => {
      // 2. 这里可以直接读到最新的 message,即使它不在依赖数组里
      console.log('发送消息:', messageRef.current); 
      
      // 注意:这里如果直接改 messageRef.current 是不会更新 UI 的
      // 如果要更新 UI,必须调用 setMessage
    }, 1000);

    return () => clearInterval(interval);
  }, [roomId]); // 依赖只有 roomId,Effect 不会因 message 变化而重置

  return <input value={message} onChange={e => setMessage(e.target.value)} />;
}
场景:强制更新(不推荐,但可行)

如果你真的需要通过修改 ref 来触发渲染(极少见),你可以配合 useState 做一个假的触发器:

ini 复制代码
const [, forceUpdate] = useState(0);
const myRef = useRef(0);

const handleChange = () => {
  myRef.current += 1;
  forceUpdate(n => n + 1); // 手动触发一次渲染,让 UI 读取新的 ref.current
};

通常更好的做法是直接用 useState


4. 总结与最佳实践

  1. DOM 操作首选 :需要直接操作 DOM(聚焦、动画、第三方库集成)时,必须用 useRef

  2. "静默"数据仓库 :当你需要存储一些数据(如定时器ID、上一个值、请求取消令牌),且这些数据的变化不应该 导致界面刷新时,用 useRef

  3. 不要滥用

    • 如果数据的变化需要 体现在界面上 -> 请用 useState
    • 如果数据的变化不需要 体现在界面上 -> 请用 useRef
  4. 注意事项

    • ref.current 是可变的(Mutable),修改它时要小心,因为它不会触发渲染,可能导致 UI 与数据不同步。
    • 不要在渲染阶段(Render Phase)直接读取或修改 ref.current 来决定渲染内容(这会导致不可预测的行为),应该在 useEffect 或事件处理函数中操作。

一句话记忆

useState 是为了让 UI 随数据变;useRef 是为了让数据随时间变,但 UI 不动(或者为了摸到 DOM)。

自定义 Hook 是什么?如何编写一个自定义 Hook?

markdown 复制代码
*考察点*:提取组件逻辑以便复用。命名必须以 `use` 开头,内部可以调用其他 Hooks。

1. 什么是自定义 Hook (Custom Hook)?

自定义 Hook 是一个 JavaScript 函数,其名称以 use 开头,内部可以调用其他的 React Hooks(如 useState, useEffect, useContext 等)。

它的核心目的是:逻辑复用

  • 不是新特性:它不是 React 提供的内置 API,而是你利用 React Hooks 机制自己编写的函数。
  • 共享状态逻辑:它将组件中通用的状态逻辑(如数据获取、表单处理、订阅监听、动画控制等)提取出来,让多个组件可以共享这段逻辑,而无需改变组件层级结构(避免了高阶组件 HOC 或 Render Props 的嵌套地狱)。
  • 纯逻辑:自定义 Hook 本身不渲染任何 UI,它只返回数据(state)和行为(functions),由调用它的组件决定如何渲染。

2. 如何编写一个自定义 Hook?

编写自定义 Hook 遵循以下三个核心步骤:

✅ 步骤 1:命名规范

函数名必须use 开头(例如 useFetch)。

  • 原因 :React 的 ESLint 插件依靠这个前缀来检查 Hooks 规则(如只能在顶层调用)。如果不以 use 开头,React 不会将其视为 Hook,也就无法享受 Hooks 的生命周期管理。
✅ 步骤 2:内部调用其他 Hooks

在函数内部,你可以自由组合使用 useState, useEffect, useReducer, useContext 等内置 Hook,也可以调用其他自定义 Hook。

✅ 步骤 3:返回值

通常返回一个数组 (类似 useState)或一个对象(包含 state 和 handler 函数),供调用者解构使用。


3. 实战案例:编写一个 useFetch Hook

假设我们在多个组件中都需要从 API 获取数据,并且需要处理 loading(加载中)、data(数据)和 error(错误)状态。

❌ 没有自定义 Hook 时(代码重复)

每个组件都要写一遍 useState, useEffect, try-catch 逻辑。

✅ 使用自定义 Hook 后

第一步:创建 useFetch.js

scss 复制代码
import { useState, useEffect } from 'react';

// 1. 命名必须以 use 开头
function useFetch(url) {
  // 2. 内部使用标准 Hooks
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    // 定义一个异步函数来获取数据
    const fetchData = async () => {
      try {
        setLoading(true);
        const response = await fetch(url);
        
        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`);
        }
        
        const result = await response.json();
        setData(result);
        setError(null);
      } catch (err) {
        setError(err);
        setData(null);
      } finally {
        setLoading(false);
      }
    };

    fetchData();

    // 可选:添加清理逻辑或依赖项控制
    // 如果 url 变化,会重新请求
  }, [url]); 

  // 3. 返回需要的数据和函数
  return { data, loading, error };
}

export default useFetch;

第二步:在组件中使用

javascript 复制代码
import React from 'react';
import useFetch from './useFetch';

function UserProfile({ userId }) {
  // 调用自定义 Hook,就像调用内置 Hook 一样简单
  const { data: user, loading, error } = useFetch(`https://api.example.com/users/${userId}`);

  if (loading) return <div>加载中...</div>;
  if (error) return <div>出错了: {error.message}</div>;

  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
    </div>
  );
}

4. 编写自定义 Hook 的关键规则

  1. 必须在顶层调用

    和其他 Hooks 一样,自定义 Hook 内部的 Hooks 调用必须在函数的最顶层,不能在循环、条件判断或嵌套函数中调用。

    • ✅ 正确:const [val, setVal] = useState(0);
    • ❌ 错误:if (condition) { const [val, setVal] = useState(0); }
  2. 只能在 React 函数组件或其他 Hook 中调用

    你不能在普通的 JavaScript 函数或非 React 环境中调用自定义 Hook。

  3. 参数灵活

    自定义 Hook 可以接收任意参数(如上面的 url),这使得逻辑更加通用。

  4. 返回值灵活

    • 返回数组 :适合类似 useState 的场景,调用者可以自定义变量名 const [data, setData] = useCustom()
    • 返回对象 :适合返回多个相关值,调用更清晰 const { data, loading } = useCustom()推荐做法,因为扩展性更好(增加返回值不会破坏现有代码的顺序)。

5. 什么时候应该提取自定义 Hook?

当你发现以下情况时,就是提取自定义 Hook 的最佳时机:

  • 复制粘贴 :你在两个或多个组件中写了几乎相同的 useState + useEffect 逻辑。
  • 逻辑复杂 :某个组件内的 useEffect 逻辑太长,导致组件难以阅读(例如复杂的表单验证、WebSocket 连接管理)。
  • 测试需求:你想单独测试某段逻辑(如数据获取逻辑),而不想渲染整个 UI 组件。

6. 自定义 Hook 的优势总结

特性 说明
逻辑解耦 将"业务逻辑"与"UI 渲染"分离,组件只负责展示。
代码复用 一次编写,到处调用,消除重复代码。
易于测试 可以在不渲染组件的情况下,单独测试 Hook 的逻辑(配合 renderHook 等工具)。
社区生态 许多优秀的库(如 react-use, ahooks, TanStack Query)本质上就是一堆高质量的自定义 Hook 集合。

总结

自定义 Hook 是 React 逻辑复用的终极方案。
编写口诀

  1. 名字 use 开头。
  2. 里面调其他 Hook。
  3. 返回数据和方法。
  4. 组件解构直接用。

通过自定义 Hook,你可以像搭积木一样构建复杂的 React 应用,保持代码的整洁和高效。

useLayoutEffectuseEffect 的区别?

markdown 复制代码
*考察点*:执行时机。`useLayoutEffect` 在 DOM 变更后同步执行(阻塞渲染),用于测量布局;`useEffect` 异步执行(不阻塞)。

useLayoutEffectuseEffect 的签名(参数和用法)完全相同,它们的核心区别在于执行的时机 以及是否阻塞浏览器的绘制(Paint)

1. 核心区别:执行时机与渲染流程

React 的渲染流程大致如下:

  1. Render (渲染) : React 计算 JSX,生成虚拟 DOM。
  2. Commit (提交) : React 将变更应用到真实 DOM。
  3. Browser Paint (浏览器绘制) : 浏览器根据最新的 DOM 计算布局并绘制到屏幕上(用户看到变化)。
  4. Effects 执行: 运行副作用代码。
🟢 useEffect (异步,不阻塞)
  • 执行时机 :在 DOM 更新后浏览器绘制完成后 异步执行。
  • 用户体验:用户会先看到 DOM 更新后的界面,然后 Effect 才运行。
  • 特点不会阻塞浏览器的绘制。如果 Effect 中有耗时操作,用户界面依然保持流畅,不会卡顿。
  • 适用场景:绝大多数副作用(数据请求、订阅、日志、非关键的 DOM 操作)。
🔵 useLayoutEffect (同步,阻塞)
  • 执行时机 :在 DOM 更新后浏览器绘制之前 同步执行。
  • 用户体验 :浏览器会暂停绘制,等待 useLayoutEffect 执行完毕(包括其中的 DOM 测量和修改),然后再进行绘制。用户看到的是经过 Layout Effect 修改后的最终界面。
  • 特点会阻塞浏览器的绘制。如果代码耗时过长,会导致页面出现"卡顿"或白屏闪烁。
  • 适用场景 :需要测量 DOM 布局 (如获取宽高、位置)或同步修改 DOM以避免视觉闪烁的场景。

2. 直观对比图

rust 复制代码
【useEffect 流程】
React Render -> DOM 更新 -> [浏览器绘制 (用户看到旧/中间状态)] -> useEffect 执行 -> (可能触发二次渲染)

【useLayoutEffect 流程】
React Render -> DOM 更新 -> useLayoutEffect 执行 (测量/修改 DOM) -> [浏览器绘制 (用户直接看到最终状态)]

3. 什么时候使用 useLayoutEffect

只有在以下两种情况时,才应该使用 useLayoutEffect

场景一:避免视觉闪烁 (Visual Glitch)

当你需要根据 DOM 的内容动态调整样式(例如:根据文本长度调整气泡位置、根据内容高度调整容器高度、实现模态框居中),如果使用 useEffect,用户可能会先看到元素在默认位置,然后瞬间跳到正确位置(闪烁)。

使用 useLayoutEffect 可以确保在用户看到之前,位置已经修正好了。

示例:测量元素宽度并调整

javascript 复制代码
import { useRef, useLayoutEffect, useState } from 'react';

function Tooltip({ text }) {
  const ref = useRef(null);
  const [width, setWidth] = useState(0);

  // ✅ 必须用 useLayoutEffect
  // 如果用 useEffect,用户会先看到 width=0 的状态,然后瞬间跳变
  useLayoutEffect(() => {
    if (ref.current) {
      // 1. 测量 DOM
      const measuredWidth = ref.current.offsetWidth;
      // 2. 同步更新 state
      setWidth(measuredWidth);
    }
  }, [text]);

  return (
    <div ref={ref} style={{ width: width ? width : 'auto' }}>
      {text}
    </div>
  );
}
场景二:第三方库集成

某些第三方库(如 D3.js, Mapbox, 复杂的动画库)需要在 DOM 挂载后立即进行初始化或计算布局,如果等到浏览器绘制后再执行,可能会导致初始渲染不正确。


4. 什么时候使用 useEffect

95% 的情况都应该使用 useEffect

  • 数据获取 (fetch, axios)。
  • 设置订阅/事件监听(window.addEventListener)。
  • 修改 document.title
  • 发送分析日志。
  • 任何不需要立即在首屏绘制前完成的 DOM 操作。

原则 :优先使用 useEffect,只有当发现界面有闪烁或布局计算必须在绘制前完成时,才降级切换到 useLayoutEffect


5. 服务端渲染 (SSR) 的注意事项

这是一个非常重要的陷阱:

  • useEffect :在服务端(Node.js)不会执行。它只在客户端浏览器中运行。这是安全的。

  • useLayoutEffect :在服务端也会尝试执行,但因为没有 DOM 和窗口对象,通常会报错或产生警告。

    • React 会在 SSR 时警告:Warning: useLayoutEffect does nothing on the server...
    • 解决方案 :如果在 SSR 项目中需要使用同步 DOM 逻辑,通常建议封装一个兼容 Hook,或者在 SSR 环境下降级为 useEffect
javascript 复制代码
// 兼容 SSR 的写法
import { useEffect, useLayoutEffect } from 'react';

const useIsomorphicLayoutEffect = 
  typeof window !== 'undefined' ? useLayoutEffect : useEffect;

function MyComponent() {
  useIsomorphicLayoutEffect(() => {
    // 安全地在客户端执行布局逻辑
  }, []);
  
  return <div>...</div>;
}

6. 总结对比表

特性 useEffect useLayoutEffect
执行时机 浏览器绘制 (异步) 浏览器绘制 (同步)
阻塞渲染 ❌ 否 (非阻塞) ✅ 是 (阻塞)
视觉闪烁 可能会有 (如果修改了 DOM) 无 (修改在绘制前完成)
主要用途 数据请求、订阅、日志、普通副作用 DOM 测量、同步布局修正、避免闪烁
SSR 兼容性 ✅ 安全 (服务端不执行) ⚠️ 会报警告 (需特殊处理)
推荐优先级 ⭐⭐⭐⭐⭐ (首选) ⭐⭐ (仅在必要时使用)

最佳实践建议

  1. 默认使用 useEffect
  2. 如果你发现页面加载时有"跳动"或"闪烁",或者你需要读取 getBoundingClientRect 等布局信息来立即更新 State,请切换到 useLayoutEffect
  3. 在编写通用库或 SSR 应用时,注意 useLayoutEffect 的服务端警告问题。

进阶与原理篇

React 的生命周期(Class 组件)是怎样的?对应的 Hooks 是什么?

markdown 复制代码
*考察点*:Mounting, Updating, Unmounting。对应 `useEffect` 的不同依赖配置。

React 的 Class 组件生命周期和函数组件(Hooks)的对应关系是 React 开发中的核心概念。由于 Hooks 的出现,官方推荐在新项目中优先使用函数组件,但理解两者的映射关系对于维护旧代码和深入理解 React 机制非常重要。

以下是详细的对照解析:

1. 核心生命周期与 Hooks 对照表

Class 组件生命周期阶段 Class 方法 对应的 Hooks (函数组件) 说明
挂载 (Mounting) constructor - 在函数组件中,状态直接在 useState 初始化时定义,无需构造函数。
static getDerivedStateFromProps - 没有直接对应的 Hook 。通常在渲染时直接计算派生状态,或使用 useMemo
render 函数体本身 函数组件的函数体就是 render 方法。
componentDidMount useEffect(() => { ... }, []) 依赖数组为空 [] 时,仅在组件首次渲染后执行一次。
更新 (Updating) static getDerivedStateFromProps - 同上。
shouldComponentUpdate React.memo / useMemo / useCallback 函数组件通常通过包裹 React.memo 或优化 Hook 依赖来避免不必要的重渲染。
render 函数体本身 每次状态或 props 变化都会重新执行函数体。
getSnapshotBeforeUpdate - 没有直接对应的 Hook 。极少使用,通常可在 useLayoutEffect 中处理 DOM 读取。
componentDidUpdate useEffect(() => { ... }, [deps]) 依赖数组包含特定变量时,当这些变量变化后执行。
卸载 (Unmounting) componentWillUnmount useEffect清理函数 useEffect 返回的函数会在组件卸载或下次 effect 运行前执行。
错误处理 componentDidCatch / getDerivedStateFromError 无直接对应 目前 Hooks 没有 等价物。仍需使用 Class 组件作为"错误边界 (Error Boundary)"。

2. 详细场景映射与代码示例

A. componentDidMount / componentDidUpdate / componentWillUnmount

这三个最常用的生命周期在 Hooks 中统一由 useEffect 处理。

  • Class 写法:

    javascript 复制代码
    class Example extends React.Component {
      componentDidMount() {
        console.log('挂载了');
      }
      componentDidUpdate(prevProps) {
        if (prevProps.id !== this.props.id) {
          console.log('ID 更新了');
        }
      }
      componentWillUnmount() {
        console.log('卸载了,清理定时器');
        clearInterval(this.timer);
      }
      
      render() { return <div>{this.props.id}</div>; }
    }
  • Hooks 写法 (useEffect):

    javascript 复制代码
    function Example({ id }) {
      useEffect(() => {
        // 相当于 componentDidMount
        console.log('挂载了 (或 ID 变了)');
    
        // 可选:处理更新逻辑 (如果需要对比 prevProps,需使用 ref 存储旧值)
        // 但通常我们只关心 "当 id 变化时" 做什么
        
        // 返回的清理函数相当于 componentWillUnmount
        return () => {
          console.log('卸载了,清理定时器');
          // clearInterval(timer); 
        };
      }, [id]); // 依赖数组决定何时触发
                // [] -> 仅挂载时 (componentDidMount)
                // [id] -> 挂载及 id 变化时 (componentDidMount + componentDidUpdate)
                // 无数组 -> 每次渲染后 (componentDidMount + componentDidUpdate)
    }
B. shouldComponentUpdate

在 Class 组件中用于性能优化,防止不必要的渲染。

  • Class 写法:

    javascript 复制代码
    shouldComponentUpdate(nextProps, nextState) {
      return nextProps.id !== this.props.id;
    }
  • Hooks 写法:

    函数组件默认每次父组件渲染都会重渲染。优化方式主要有两种:

    1. React.memo: 包裹组件,浅比较 props。

      javascript 复制代码
      const Example = React.memo(({ id }) => {
         // ...
      });
    2. useMemo / useCallback : 缓存计算结果或函数引用,配合 React.memo 使用。

C. getDerivedStateFromProps

这是一个静态方法,用于根据 props 更新 state。在 Hooks 时代,官方不推荐这种模式。

  • 最佳实践 : 直接在渲染时计算,或者使用 useEffect 同步状态(仅在极少数复杂场景)。

    javascript 复制代码
    // 推荐:渲染时直接计算
    function Example({ firstName, lastName }) {
      const fullName = `${firstName} ${lastName}`; // 每次渲染都重新计算,成本极低
      return <div>{fullName}</div>;
    }
D. 错误边界 (Error Boundaries)

这是目前 唯一必须使用 Class 组件 的场景。

  • componentDidCatchgetDerivedStateFromError 没有 Hook 版本。
  • 如果你需要捕获子组件的渲染错误,必须保留一个 Class 组件包装器。

3. 特殊 Hook:useLayoutEffect

还有一个 Hook 叫 useLayoutEffect,它的签名和 useEffect 一样,但执行时机不同:

  • 执行时机: 在所有 DOM 变更之后,但在浏览器绘制屏幕之前(同步执行)。
  • 对应 Class : 类似于 componentDidMount / componentDidUpdate,但更接近 getSnapshotBeforeUpdate + componentDidUpdate 的组合,用于测量 DOM 布局(如获取元素宽高)并同步触发重绘,避免闪烁。
  • 建议 : 优先使用 useEffect,只有在涉及 DOM 测量且需要避免视觉闪烁时才使用 useLayoutEffect

总结

  • 副作用处理 (mount, update, unmount) -> useEffect
  • 状态记忆 -> useState , useReducer
  • 性能优化 -> React.memo , useMemo , useCallback
  • DOM 测量/同步布局 -> useLayoutEffect
  • 错误捕获 -> 仍需 Class 组件

React 18 引入了哪些新特性?

markdown 复制代码
*考察点*:并发渲染 (Concurrent Rendering)、自动批处理 (Automatic Batching)、`useTransition`、`useDeferredValue`、新的 Root API (`createRoot`)。

React 18 是 React 发展史上的一个重要里程碑,于 2022 年 3 月正式发布。它的核心目标是提升应用性能改善用户体验 ,主要通过引入并发渲染(Concurrent Rendering) 机制来实现。

以下是 React 18 的主要新特性详解:

1. 并发渲染 (Concurrent Rendering)

这是 React 18 最核心的底层特性。

  • 什么是并发? 它允许 React 中断、暂停或恢复渲染工作。React 可以将渲染任务拆分成多个小单元,并根据任务的优先级进行调度。
  • 好处: 浏览器主线程不会被长时间阻塞。高优先级的更新(如用户输入)可以打断低优先级的更新(如大数据列表渲染),从而避免页面卡顿,保持 UI 的响应性。
  • 注意: 并发模式是"可选"的,只有在使用新的 Root API 时才会启用。

2. 自动批处理 (Automatic Batching)

在 React 18 之前,只有 React 事件处理函数(如 onClick)中的多个 setState 会被合并为一次重渲染。而在 setTimeout、原生事件或 Promise 回调中,每个状态更新都会触发一次重渲染。

  • React 18 改进: 所有场景下的状态更新默认都会自动批处理。

  • 效果: 减少了不必要的重渲染次数,提升了性能。

  • 代码对比:

    scss 复制代码
    // React 17 及之前 (在 setTimeout 中会渲染两次)
    setTimeout(() => {
      setCount(c => c + 1); // 渲染 1
      setFlag(f => !f);     // 渲染 2
    }, 1000);
    
    // React 18 (自动合并,只渲染一次)
    setTimeout(() => {
      setCount(c => c + 1);
      setFlag(f => !f);
    }, 1000);

    如果确实需要跳过批处理,可以使用 flushSync(不推荐常规使用)。

3. 新的根 API (New Root API)

要启用 React 18 的新功能,必须使用新的挂载方式。

  • 旧写法 (React 17):

    javascript 复制代码
    import ReactDOM from 'react-dom';
    ReactDOM.render(<App />, document.getElementById('root'));
  • 新写法 (React 18):

    javascript 复制代码
    import ReactDOM from 'react-dom/client';
    const root = ReactDOM.createRoot(document.getElementById('root'));
    root.render(<App />);

    如果不迁移到这个新 API,应用将以 React 17 的模式运行,无法享受并发特性。

4. 过渡更新 (Transitions)

React 18 引入了概念上的更新分类:紧急更新 (如打字、点击)和过渡更新(如切换标签页、加载新列表)。

  • startTransition API: 允许开发者标记某些状态更新为"非紧急"的过渡更新。React 会优先处理紧急更新,而将过渡更新推迟执行,甚至在过渡更新过程中保持旧 UI 的交互性。

    scss 复制代码
    import { startTransition } from 'react';
    
    // 紧急更新:立即执行
    setInputValue(text);
    
    // 过渡更新:可被中断
    startTransition(() => {
      setSearchQuery(text);
    });
  • useTransition Hook: 返回一个 isPending 标志和一个 startTransition 函数,用于在 UI 中显示加载状态(如 Spinner),告诉用户内容正在加载中,但界面仍可交互。

5. Suspense 的新能力

虽然 Suspense 在 React 16 就已引入,但在 React 18 中功能更加完善:

  • 服务端渲染 (SSR) 支持: React 18 的 SSR 架构完全重构,支持流式 HTML 渲染 (Streaming HTML) 。服务器可以分块发送 HTML,客户端可以在数据完全加载前就开始 hydration(水合),显著降低 TTFB (Time to First Byte) 和整体加载时间。
  • 组件级加载状态: 可以更细粒度地控制加载 fallback UI,配合并发渲染实现更平滑的加载体验。

6. 新增 Hooks

React 18 引入了几个专门的新 Hook:

  • useId : 生成唯一的 ID 字符串,主要用于无障碍属性(如 aria-labelledby)。关键优势是它在服务端和客户端渲染时生成的 ID 是一致的,避免了 SSR 水合不匹配的问题。
  • useSyncExternalStore: 专为外部数据存储(如 Redux、Zustand 或原生 DOM 事件)设计。它能确保在并发渲染模式下,外部存储的数据读取是同步且一致的,防止"撕裂"(Tearing)现象(即 UI 不同部分显示了不同时间的状态)。
  • useInsertionEffect: 专为 CSS-in-JS 库设计。它在 DOM 突变之前但在布局计算之前执行,用于动态插入样式标签。普通应用开发极少用到。

7. 严格模式 (Strict Mode) 的增强

在 React 18 的开发模式下,Strict Mode 会自动模拟组件的挂载 -> 卸载 -> 重新挂载过程。

  • 目的: 帮助开发者发现副作用(Side Effects)处理不当的问题(例如:未清理的定时器、重复的事件监听器、竞态条件)。
  • 影响: 如果你的组件在 Strict Mode 下表现异常,说明它可能没有正确处理清理逻辑,这在未来的并发渲染中会导致 Bug。

总结:为什么要升级?

特性 收益
并发渲染 保持高优先级交互(如输入)流畅,不被大计算量阻塞。
自动批处理 默认性能优化,减少渲染次数。
Transitions 区分任务优先级,提供丝滑的页面切换体验。
SSR 流式渲染 大幅提升首屏加载速度。
新 Hooks 解决 SSR ID 不匹配、外部状态撕裂等深层问题。

React 18 是一个渐进式升级 版本,大多数现有代码无需修改即可运行(除了根 API 的变化),但只有主动使用新 API(如 startTransition, Suspense)才能解锁其全部潜力。

React 中的数据流是如何管理的?如何解决"Prop Drilling"(属性层层传递)问题?

scss 复制代码
*考察点*:Context API、状态管理库 (Redux, Zustand, Recoil)、组件组合 (Composition)。

在 React 中,数据流管理是架构设计的核心。理解默认的数据流向以及如何打破它的限制(解决 Prop Drilling),是掌握 React 的关键。

1. React 的默认数据流:单向数据流 (Unidirectional Data Flow)

React 遵循 "自顶向下" 的单向数据流原则:

  • 方向 :数据(Props)只能从父组件 传递给子组件
  • 通信 :子组件不能直接修改父组件的数据。如果子组件需要通知父组件更新数据,必须通过父组件传递下来的回调函数(Callback Props)来触发。
  • 优点:数据流向清晰,易于调试和追踪 Bug(因为数据源单一且明确)。
  • 缺点 :当深层嵌套的组件需要访问顶层状态时,中间层的组件即使不需要这些数据,也必须充当"透传"角色,这就是 Prop Drilling

2. 什么是 Prop Drilling(属性层层传递)?

场景 :假设有一个组件树:App -> Layout -> Header -> UserAvatar

如果 UserAvatar 需要显示用户名字,而用户名存储在 App 的状态中:

  1. Appuser 传给 Layout
  2. Layout 不需要 user,但必须把它传给 Header
  3. Header 不需要 user,但必须把它传给 UserAvatar
  4. UserAvatar finally 使用 user.name

问题

  • 代码耦合:中间组件(Layout, Header)被迫依赖它们并不需要的数据结构。
  • 维护困难:如果将来需要在更深层的组件使用该数据,或者数据结构发生变化,所有中间组件都需要修改。
  • 可读性差:难以一眼看出哪些组件真正使用了该数据。

3. 解决 Prop Drilling 的方案

根据应用场景的复杂度和规模,主要有以下几种解决方案:

方案 A:组件组合 (Component Composition) ------ 最轻量级,推荐优先使用

利用 React 的 children 属性或 props 传递组件,让父组件直接"包裹"需要数据的子组件,从而跳过中间层。

  • 适用场景:简单的 UI 布局传递,不需要全局状态。
  • 原理 :将需要数据的组件作为 children 或直接作为 prop 传入,中间层只负责渲染 children,无需关心其内容。
javascript 复制代码
// ❌ Prop Drilling
function App() {
  const user = { name: 'Alice' };
  return <Layout user={user} />;
}
function Layout({ user }) {
  return <Header user={user} />; // Layout 不需要 user,但必须透传
}

// ✅ 组件组合 (Composition)
function App() {
  const user = { name: 'Alice' };
  // 直接将需要数据的组件放在这里,并注入 props
  return (
    <Layout>
      <Header>
        <UserAvatar user={user} /> 
      </Header>
    </Layout>
  );
}

// 中间组件完全不需要知道 user 的存在
function Layout({ children }) {
  return <div className="layout">{children}</div>;
}
function Header({ children }) {
  return <header>{children}</header>;
}
方案 B:Context API (React 内置) ------ 中等复杂度,官方推荐

React 提供了一种机制,允许数据"跨越"组件树直接传递给深处的组件,无需手动逐层传递。

  • 适用场景 :主题(Theme)、用户信息、语言设置、全局 UI 状态等变化频率不高的全局数据。

  • 核心概念

    • createContext:创建上下文。
    • Provider:在树的上层提供数据。
    • useContext:在任意下层组件消费数据。
javascript 复制代码
// 1. 创建 Context
const UserContext = React.createContext(null);

// 2. 在顶层提供数据
function App() {
  const user = { name: 'Alice' };
  return (
    <UserContext.Provider value={user}>
      <Layout /> {/* Layout 及其子代都可以访问 user */}
    </UserContext.Provider>
  );
}

// 3. 在深层组件直接消费 (跳过中间层)
function UserAvatar() {
  const user = useContext(UserContext); // 直接获取,无需 props
  return <div>{user.name}</div>;
}
  • 注意 :Context 的值发生变化时,所有消费该 Context 的组件都会重渲染。对于高频更新的数据,需配合 memo 或拆分 Context 优化性能。
方案 C:状态管理库 (State Management Libraries) ------ 复杂应用,企业级

当应用状态非常复杂(包含大量异步逻辑、复杂依赖、需要时间旅行调试等)时,Context 可能显得力不从心。此时引入第三方库。

  • 主流库

    • Redux Toolkit (RTK) :基于 Flux 架构,单一数据源,可预测性强,生态最丰富。适合超大型应用。
    • Zustand:轻量级,API 简单,无样板代码,基于 Hook。目前非常流行。
    • Recoil / Jotai:原子化(Atomic)状态管理,适合细粒度状态更新,能更好地避免不必要的重渲染。
    • TanStack Query (React Query)专门用于服务端状态管理 (缓存、同步、去重服务器请求)。注意:它通常与上述客户端状态库配合使用,而不是替代它们。
  • 解决方式 :组件直接从全局 Store 中 selectuseStore 所需的数据切片,完全解耦组件树结构。

javascript 复制代码
// 使用 Zustand 示例
const useUserStore = create((set) => ({
  user: null,
  setUser: (newUser) => set({ user: newUser }),
}));

function UserAvatar() {
  // 直接从全局 store 获取,与组件树位置无关
  const user = useUserStore((state) => state.user);
  return <div>{user.name}</div>;
}
方案 D:自定义 Hooks (逻辑复用)

虽然 Custom Hooks 主要目的是复用逻辑而非解决深层 Prop Drilling,但通过封装数据获取逻辑,可以减少传递回调函数的层级。

scss 复制代码
// 封装获取用户的逻辑
function useUser() {
  const [user, setUser] = useState(null);
  useEffect(() => { /* fetch logic */ }, []);
  return user;
}

// 任何组件都可以直接调用,无需从顶层传下来
function UserAvatar() {
  const user = useUser(); 
  // ...
}

局限:这会导致数据在不同组件实例间不同步(每个组件都有自己的 state),不适合真正的"全局单源"状态。


4. 方案选型指南

场景 推荐方案 理由
仅为了传递 UI 结构/插槽 组件组合 (children) 最符合 React 设计哲学,零性能开销,代码最清晰。
低频更新的全局配置 (主题、语言、用户信息) Context API 内置支持,无需额外依赖,够用且简单。
高频更新的全局状态 (复杂表单、即时通讯、游戏状态) Zustand / Recoil / Redux 提供细粒度订阅,避免 Context 导致的整体重渲染,具备更强的调试工具。
服务端数据 (API 列表、详情) TanStack Query 处理缓存、加载状态、重试逻辑的最佳实践,不属于纯前端状态。
逻辑复用 (表单验证、数据格式化) Custom Hooks 提取业务逻辑,保持组件纯净。

总结

  1. 首选 :尝试通过组件组合重构代码,往往能解决大部分看似需要 Prop Drilling 的问题。
  2. 次选 :如果是真正的全局数据且更新不频繁,使用 Context
  3. 进阶 :如果状态复杂、更新频繁或需要强大的调试能力,引入 Zustand/Redux 等专业状态管理库。
  4. 切记:不要为了"防止 Prop Drilling"而过早引入复杂的全球状态管理,这可能会导致状态分散、难以追踪新的问题(Global Drilling)。

性能优化篇

如何避免 React 组件的不必要渲染?

markdown 复制代码
*考察点*:`React.memo`、`PureComponent`、合理使用 `key`、优化 `useEffect` 依赖、使用 `useMemo`/`useCallback`。

在 React 中, "不必要渲染" 指的是当组件的 propsstate 实际上没有发生有效变化时,组件仍然执行了重渲染函数(Re-render)。虽然 React 的渲染很快,但频繁的无效渲染会导致:

  1. 性能浪费:消耗 CPU 计算资源。
  2. 子组件连锁反应:父组件重渲染会导致所有子组件默认也重渲染。
  3. 副作用重复执行 :如果 useEffect 依赖项处理不当,可能导致网络请求重复发送或定时器重复创建。

以下是避免不必要渲染的核心策略具体手段,按重要性排序:


1. 理解渲染机制:为什么会有不必要渲染?

React 组件在以下情况会重渲染:

  • 自身的 state 发生变化。
  • 父组件重渲染(导致子组件接收到了新的 props 引用,即使值没变)。
  • Context 的值发生变化。

关键点 :React 默认使用浅比较(Shallow Comparison) 。对于对象、数组或函数,只要引用地址变了,React 就认为它变了,从而触发重渲染。


2. 核心优化手段

A. 使用 React.memo (针对组件)

这是防止子组件因父组件渲染而无效重渲染的第一道防线。

  • 作用 :包裹函数组件,仅当 props 发生浅比较变化时才重渲染。
  • 适用:纯展示型组件、渲染开销大的组件。
javascript 复制代码
// 未优化:父组件每次渲染,Child 都会重渲染
function Child({ user }) {
  console.log('Child rendered');
  return <div>{user.name}</div>;
}

// 优化后:只有 user 对象引用变化时,Child 才重渲染
const Child = React.memo(({ user }) => {
  console.log('Child rendered');
  return <div>{user.name}</div>;
});

注意:如果传入的是新创建的对象/数组/函数,memo 会失效(见下文 B 和 C)。

B. 稳定函数引用:useCallback

如果在父组件中定义了一个函数传给子组件,每次父组件渲染时,这个函数都会重新创建(新引用),导致使用了 React.memo 的子组件依然重渲染。

  • 作用:缓存函数实例,直到依赖项变化。
  • 场景 :传递给 React.memo 子组件的回调函数,或作为 useEffect 的依赖项。
ini 复制代码
function Parent() {
  const [count, setCount] = useState(0);
  
  // ❌ 每次渲染都创建新函数,导致 Child 重渲染
  // const handleClick = () => { console.log('click'); };

  // ✅ 只有当 dependency 变化时,函数引用才变
  const handleClick = useCallback(() => {
    console.log('click');
  }, []); 

  return <Child onClick={handleClick} />;
}
C. 稳定对象/数组引用:useMemo

同理,如果在 JSX 中直接创建对象或数组传给子组件,引用也会每次变化。

  • 作用:缓存计算结果(对象、数组、复杂计算值)。
  • 场景:传递给子组件的配置对象、样式对象、过滤后的列表。
ini 复制代码
function Parent() {
  const [theme, setTheme] = useState('dark');
  
  // ❌ 每次渲染 styles 都是新对象 { color: 'red' }
  // const styles = { color: 'red' }; 

  // ✅ 缓存对象引用
  const styles = useMemo(() => ({ color: 'red' }), []);

  return <Child style={styles} />;
}
D. 合理的 State 结构 (State Colocation)

将状态保存在最接近需要它的组件中,而不是全部提升到顶层。

  • 原理:如果状态在顶层,顶层组件更新会带动整个树更新。如果状态在局部,只有该分支更新。

  • 操作

    • 如果一个 state 只被某个子组件使用,把它移到该子组件内部。
    • 如果多个子组件需要不同状态,考虑拆分父组件,或将状态拆分为多个独立的 state。
javascript 复制代码
// ❌ 糟糕的结构:input 的变化导致整个 List 重渲染
function App() {
  const [text, setText] = useState('');
  const [items, setItems] = useState([...]);
  return (
    <>
      <input value={text} onChange={e => setText(e.target.value)} />
      <List items={items} /> {/* 即使 items 没变,也会重渲染 */}
    </>
  );
}

// ✅ 优化结构:拆分组件,隔离状态
function App() {
  return (
    <>
      <SearchBar /> {/* 内部维护 text 状态 */}
      <List />      {/* 内部维护 items 状态或通过全局状态管理 */}
    </>
  );
}
E. 使用 Key 正确列表渲染

在渲染列表时,务必使用稳定的 key(如 ID),严禁使用索引(index) 作为 key(除非列表是静态且不可排序/删除的)。

  • 原因:使用 index 作为 key,当列表顺序变化或删除元素时,React 可能会复用错误的 DOM 节点和组件状态,导致不必要的更新或 UI 错误。

3. 进阶优化与陷阱

F. 避免 Context 导致的"全量重渲染"

Context 的值变化时,所有消费该 Context 的组件都会重渲染,即使它们只使用了值的一部分。

  • 解决方案

    1. 拆分 Context :将大对象拆分为多个小 Context(如 UserContext, ThemeContext)。
    2. 记忆化 Context 值 :确保传给 Provider value 的对象引用是稳定的(配合 useMemo)。
    3. 组件分割 :将需要 Context 的部分提取为小组件,只在小组件上应用 React.memo(虽然 Context 穿透 memo,但结合选择器模式的库如 Zustand/Recoil 更好)。
G. 不要过早优化 (Premature Optimization)
  • 原则React.memo, useMemo, useCallback 本身也有开销(内存存储、依赖比较)。

  • 建议

    1. 默认不写这些优化。
    2. 只有在遇到明显性能卡顿 ,或通过 React DevTools Profiler 发现特定组件渲染耗时过长/频率过高时,再添加。
    3. 对于简单的纯展示组件(如 <div>{name}</div>),优化的收益通常小于开销。
H. 使用并发特性 (React 18+)

利用 startTransition 将非紧急更新(如列表过滤、大数据渲染)标记为过渡更新,让 React 优先响应交互,避免阻塞主线程导致的渲染卡顿感。


4. 调试工具:如何发现不必要渲染?

在优化之前,必须先确认问题存在。

  1. React Developer Tools (Profiler) :

    • 安装浏览器插件。
    • 点击 "Profiler" 标签,开始录制。
    • 操作应用,停止录制。
    • 查看哪些组件渲染了(Flame graph),以及渲染的原因("Props changed", "State changed", "Parent rendered")。
  2. 控制台日志:

    • 在组件函数体顶部加 console.log('Rendered: ComponentName'),观察控制台输出频率。
  3. Strict Mode:

    • 在开发模式下,Strict Mode 会故意双重渲染组件,帮助你发现副作用是否纯净。

总结清单

问题现象 推荐解决方案
父组件更新导致子组件无脑重渲染 React.memo 包裹子组件
传给子组件的函数导致 memo 失效 useCallback 缓存函数
传给子组件的对象/数组导致 memo 失效 useMemo 缓存对象/数组
修改一个状态导致无关的大组件树重渲染 状态下移 (Colocation)拆分组件
Context 更新导致所有消费者重渲染 拆分 Context使用原子化状态库 (Zustand/Recoil)
列表增删改导致错乱或重渲染 使用稳定的 key (ID)
不确定哪里慢了 使用 React DevTools Profiler

核心心法:保持数据流单向清晰,尽量让组件成为"纯函数"(输入确定,输出确定,无副作用),仅在瓶颈处使用记忆化优化。

大型列表中如何优化性能?

markdown 复制代码
*考察点*:虚拟滚动 (Virtualization / Windowing),如 `react-window` 或 `react-virtualized`,只渲染可视区域内的元素。

在 React 中渲染大型列表(例如成千上万条数据)是导致性能瓶颈的最常见场景之一。如果直接渲染所有 DOM 节点,会导致:

  1. 初始加载慢:创建大量 DOM 节点耗时。
  2. 内存占用高:浏览器需要维护庞大的 DOM 树。
  3. 交互卡顿:滚动、搜索或更新数据时,主线程被阻塞,帧率(FPS)下降。

以下是优化大型列表性能的核心方案最佳实践


1. 核心方案:虚拟滚动 (Virtualization / Windowing)

这是解决大型列表性能问题的终极方案,也是行业标准做法。

  • 原理只渲染用户当前可视区域(Viewport)内的元素,加上少量的缓冲区(Buffer)。当用户滚动时,动态回收离开视口的 DOM 节点,并创建新进入视口的节点。

    • 无论列表有 100 行还是 100,000 行,DOM 节点数量始终保持在几十个左右。
  • 实现库:不要自己造轮子,使用成熟的库。

    • react-window (推荐): 由 React 核心团队成员开发,轻量、API 简单、性能好。适合固定高度或简单可变高度的列表。
    • react-virtuoso (推荐): 功能更强大,原生支持可变高度、分组、无限滚动,API 更人性化(类似原生 <ul>)。
    • @tanstack/virtual: 框架无关的核心逻辑,适合需要高度自定义的场景。
代码示例 (react-window)
javascript 复制代码
import { FixedSizeList as List } from 'react-window';

function Row({ index, style }) {
  return (
    <div style={style} className="row">
      行 {index + 1}
    </div>
  );
}

function LargeList({ items }) {
  return (
    <List
      height={600} // 容器高度
      itemCount={items.length} // 总条目数
      itemSize={50} // 每行高度 (固定)
      width="100%"
    >
      {Row}
    </List>
  );
}

注意:如果是可变高度,使用 VariableSizeList,但需要配合 estimateItemSize 和重置逻辑,稍微复杂一些。此时 react-virtuoso 可能更简单。


2. 基础优化:组件记忆化 (Memoization)

即使使用了虚拟滚动,如果列表项组件(Row Item)本身渲染开销大,或者频繁重渲染,依然会卡顿。

  • React.memo: 包裹列表项组件。确保只有当该行的数据(props)真正变化时才重渲染。

    javascript 复制代码
    const ListItem = React.memo(({ data }) => {
      // 复杂的渲染逻辑
      return <div>{data.title}</div>;
    });
  • 稳定 Props : 确保传递给 ListItem 的对象、函数引用是稳定的(使用 useCallbackuseMemo),否则 React.memo 会失效。


3. CSS 与布局优化

DOM 操作不仅仅是 JS 的问题,浏览器的重排(Reflow)和重绘(Repaint)也是性能杀手。

  • 避免复杂布局: 列表项内部尽量使用简单的 Flexbox 或 Grid,避免深层嵌套。

  • will-change: 对于频繁滚动的容器或项,可以提示浏览器进行图层提升(Layer Promotion),利用 GPU 加速。

    css 复制代码
    .list-item {
      will-change: transform; /* 告诉浏览器这个元素即将变化 */
      contain: strict; /* 告诉浏览器该元素内部的变化不影响外部布局 */
    }

    注意:will-change 滥用会增加内存消耗,仅在性能分析确认需要时使用。

  • 固定高度: 如果可能,尽量让列表项高度固定。可变高度会导致虚拟滚动库需要反复计算位置,增加 JS 开销。


4. 数据处理与分页策略

A. 分页 (Pagination) vs 无限滚动 (Infinite Scroll)
  • 分页: 传统方式,用户点击"下一页"。性能最好,因为内存中只保留当前页数据。缺点是用户体验不连续。

  • 无限滚动: 用户体验好,但随着滚动距离增加,累积的 DOM 节点(如果没有虚拟化)或内存中的数据对象会越来越多。

    • 优化 : 结合虚拟滚动 使用无限滚动。甚至可以在滚动到非常远的位置时,卸载最早的数据(Recycling),保持内存占用恒定。
B. 后端过滤与排序

不要在客户端对超大数据集进行全量排序或过滤。

  • 策略 : 将排序、筛选逻辑交给后端 API。前端只请求当前需要展示的那部分数据(配合虚拟滚动的 onItemsRendered 事件动态请求数据块)。

5. 高级技巧:Web Workers

如果列表数据需要在客户端进行极其复杂的计算(如格式化、统计、过滤百万级数据),这会阻塞主线程导致 UI 冻结。

  • 方案 : 将数据处理逻辑放入 Web Worker
  • 流程: 主线程发送原始数据 -> Worker 线程处理 -> 返回结果 -> 主线程渲染。
  • 适用: 仅在数据处理本身成为瓶颈时使用,通常配合虚拟化一起使用。

6. 方案对比与选型指南

场景 推荐方案 理由
数据量 > 1000 条 虚拟滚动 (react-window / react-virtuoso) 必须使用。这是唯一能保持流畅滚动的方案。
数据量 < 500 条 React.memo + 合理 Key 现代浏览器通常能轻松处理几百个 DOM 节点,无需过度工程化。
列表项高度不固定 react-virtuosoVariableSizeList 自动处理高度测量和位置计算,体验更好。
需要复杂筛选/排序 后端处理 + 虚拟滚动 避免主线程计算阻塞,减少传输数据量。
移动端长列表 虚拟滚动 + contain: strict 移动设备 CPU/GPU 较弱,更需严格限制 DOM 数量。

7. 常见陷阱

  1. Key 的使用 : 务必使用唯一的 ID 作为 key严禁在虚拟列表中使用索引(index)作为 key,否则会导致状态错乱和严重的重渲染。
  2. 样式冲突 : 虚拟滚动通常通过 transform: translate3d(...) 来移动内容。确保列表项内部的 position: sticky 或绝对定位元素不会因父容器的 transform 而失效(Sticky 在 transform 父级下通常失效)。
  3. 过度优化: 如果只有 50 条数据,不要上虚拟滚动。引入库的体积和复杂度可能得不偿失。

总结

对于大型列表,虚拟滚动(Virtualization)是银弹

  1. 首选 react-virtuoso (易用性强) 或 react-window (轻量)。
  2. 配合 React.memo 优化单个列表项。
  3. 确保 Key 的唯一性和稳定性。
  4. 尽可能将繁重的数据处理移至后端Web Worker

通过这套组合拳,即使是渲染 10 万条数据,也能保持 60FPS 的流畅滚动体验。

什么是"闭包陷阱" (Stale Closure) 在 Hooks 中?如何解决?

markdown 复制代码
*考察点*:在 `useEffect` 或 `useCallback` 中引用了过期的 State。解决方法:正确设置依赖数组,或使用函数式更新 `setState(prev => ...)`,或使用 `ref`。

实战与场景题

scss 复制代码
*考察点*:Portal (将内容渲染到 body 下)、点击遮罩关闭、ESC 键关闭、滚动锁定、无障碍访问 (A11y)、动画。

如何处理 React 中的错误边界 (Error Boundaries)?

markdown 复制代码
*考察点*:目前只有 Class 组件支持 `getDerivedStateFromError` 和 `componentDidCatch`。Hooks 暂无等效 API(需借助第三方库或包裹 Class 组件)。

在 React 中发起请求的最佳实践是什么?放在哪里?

markdown 复制代码
*考察点*:通常在 `useEffect` 中发起。考虑取消请求(AbortController)、加载状态、错误处理、防抖/节流。

你用过哪些状态管理库?Redux 的工作流程是怎样的?

rust 复制代码
*考察点*:Action -> Dispatch -> Reducer -> Store -> View。Redux Toolkit 的用法,Selector 的作用。
相关推荐
HelloReader1 小时前
深入理解 Tauri 架构与应用体积优化实战指南
前端
lemon_yyds1 小时前
vue 2 升级vue3 : ref 和 v-model 命名为同名
前端·vue.js
codingWhat1 小时前
小程序里「嵌」H5:一套完整可落地的 WebView 集成方案
前端·uni-app·webview
重庆穿山甲1 小时前
Java开发者的大模型入门:Spring AI Alibaba组件全攻略(二)
前端·后端
光影少年1 小时前
在 React 中,什么情况下需要用 useCallback 和 useMemo?它们的区别是什么?
前端·react.js·掘金·金石计划
合天网安实验室1 小时前
H2O-3反序列化漏洞分析(CVE-2025-6507&CVE-2025-6544)
前端·黑客
袋鱼不重1 小时前
Typescript 核心概念
前端·typescript
重庆穿山甲2 小时前
Java开发者的大模型入门:Spring AI Alibaba组件全攻略(一)
前端·后端
ssshooter2 小时前
Tauri 踩坑 appLink 修改后闪退
前端·ios·rust