Skip to main content

概览

高级 React 面试的核心不再是“怎么用”,而是“底层怎么跑的”。重点掌握:Fiber 架构与时间切片Hooks 闭包陷阱与原理setState 批处理机制 以及 React 18 并发模式带来的变革

执行 setState 到底发生了什么?

面试题:当你调用 setState (或 useState 的 set 函数) 后,React 内部经历了哪些过程?

这是一个能够全面考察你对 React 渲染机制理解深度的问题。完整的流程分为以下几个阶段:

① 状态更新入队 (Enqueue)

当你调用 setState 时,React 并不会立即更新 DOM。它会将这次更新包装成一个 Update 对象,并将其挂载到当前组件 Fiber 节点的更新队列(UpdateQueue)中。

  • React 18 批处理:如果在同一个事件循环(如点击事件)中多次调用 setState,React 会自动将它们合并为一次渲染(Automatic Batching),极大提升了性能。

② 调度阶段 (Schedule)

React 内部的调度器(Scheduler)接管任务。它会根据本次更新的优先级(Priority),决定什么时候开始执行更新。

  • 比如:用户的交互事件(点击/输入)优先级最高,而网络请求返回的数据渲染优先级较低。
  • 在 React 18 中,如果遇到更高优先级的任务插入,当前的低优先级更新甚至会被打断。

③ 调和阶段 (Reconcile / Render Phase)

调度器决定开始执行后,进入 Render 阶段(可中断)

  • React 会从根节点开始向下遍历 Fiber 树。
  • 对于状态发生改变的组件,React 会重新调用其 render 方法(类组件)或重新执行整个函数(函数组件),生成新的子节点。
  • 通过 Diff 算法(同层比较、Key 比对),对比新旧 Fiber 节点,找出需要更新的地方。
  • 最终,为需要真实操作 DOM 的 Fiber 节点打上“增删改”的副作用标记(Effect Tag)。
  • Fiber 的魔法:这个遍历和 Diff 的过程被拆分成了微小的任务单元(时间切片 Time Slicing)。每执行完一个小任务,React 都会把控制权交还给浏览器,看看有没有更高优先级的任务(如动画、用户点击)。如果有,就暂停 Render;如果没有,继续干活。这彻底解决了 React 15 树太大导致的页面卡顿问题。

④ 提交阶段 (Commit Phase)

当整棵 Fiber 树的 Diff 完毕后,进入 Commit 阶段(不可中断)

  • React 会遍历带有 Effect Tag 的 Fiber 节点,将所有的变更一次性、同步地应用到真实的 DOM 树上。
  • DOM 更新完成后,React 会同步调用生命周期钩子(如 componentDidMount/Update)或执行 Hooks 的副作用(useLayoutEffect 同步执行,useEffect 异步执行)。

React 的三种组件逻辑复用方式演进

  • 类组件 (Class Component)
    • 底层只需实例化一次,实例中保存了组件的 state 等状态。
    • 对于每一次更新,只需调用 render 方法以及对应的生命周期,具有面向对象的特点。
  • 函数组件 (Function Component)
    • 每一次更新都是一次全新的函数执行
    • 函数组件的更新意味着内部的变量会重新声明,这就引出了闭包陷阱问题。借助 Hooks 实现了状态的保存。

为什么 React 官方全面拥抱并推荐使用函数组件(Hooks)?

  1. 代码复用更加容易:在类组件时代,想要复用状态逻辑只能通过 HOC(高阶组件)或 Render Props,这会导致可怕的“嵌套地狱”。而 Hooks(如自定义 useFetch)可以把逻辑扁平化地提取出来,极其优雅。
  2. 逻辑聚合(告别碎片化):在类组件中,如果我们要监听一个窗口大小,需要在 componentDidMount 里绑定,在 componentWillUnmount 里解绑,代码被生命周期强行割裂。而在函数组件中,一个 useEffect 就能把挂载和卸载的逻辑紧密聚合在一起。
  3. 心智模型更简单:没有了晦涩难懂的 this 指向问题,也没有了繁琐的模板代码。函数组件的本质就是 UI = f(state),每次渲染都是一次快照,更加契合函数式编程的思想。
  4. 对打包工具更友好:Class 编译后的代码体积更大,且内部方法很难被 Webpack 等工具进行有效的 Tree-Shaking 剔除,而函数组件都是普通的 JS 函数,压缩和优化效率更高。

高阶组件 (HOC) 与 Hooks 的对比

  • 高阶组件 (HOC):一个接受组件作为参数,并返回新组件的纯函数。
    • 优点:逻辑复用,不影响被包裹组件的内部逻辑。
    • 缺点:导致“嵌套地狱”(Wrapper Hell);Props 命名冲突且来源不清晰;Ref 传递会被隔断(需要用 React.forwardRef 转发)。
  • Hooks
    • 优点:彻底解决嵌套地狱,逻辑复用更加扁平化;将相关逻辑聚合在一起(而不是分散在生命周期中)。
-类组件函数组件
状态this.stateuseState/useReducer
生命周期生命周期方法useEffect
逻辑复用HOC / render props自定义 Hook
this有,需注意绑定
心智模型面向对象每次渲染是一次函数快照(闭包)

生命周期与演进

react生命周期>=16.4

面试题:为什么在 16.4 版本之后废弃了 componentWillMount / componentWillReceiveProps / componentWillUpdate

  • 根本原因在于 Fiber 架构的引入。在 React 16 引入的 Fiber 架构下,渲染过程(Render 阶段)被设计成了可中断和可恢复的。
  • 如果在 Render 阶段的生命周期(即上述三个 will 生命周期)里发起了 AJAX 请求或者进行了 DOM 操作,一旦该渲染任务被更高优先级的任务打断并随后重新执行,这些生命周期方法就会被多次调用,从而导致重复发送请求或产生不可预期的 Bug。
  • 替代方案:引入了静态方法 static getDerivedStateFromProps,强制开发者不能在其中使用 this 产生副作用。

面试题:函数组件 (Hooks) 能够完全平替类组件的所有生命周期吗?

答案是:不能。 绝大多数生命周期都可以通过 useEffect / useLayoutEffect / useMemo 等组合来模拟,但目前(截至 React 18)仍然有三个极少数场景下的生命周期是无法在函数组件中平替的,必须退回使用类组件:

  1. componentDidCatch / getDerivedStateFromError:这是实现错误边界 (Error Boundary) 必须用的两个钩子,目前没有对应的 Hook(如 useCatch)。
  2. getSnapshotBeforeUpdate:在 React 完成 DOM 计算但还未提交到屏幕前触发,常用于获取滚动条位置,目前没有官方的 Hook 替代品。

异常处理 (Error Boundaries)

React 中可以定义 ErrorBoundary 组件来帮助我们捕获子组件树中的异常,防止整个应用白屏。

  • static getDerivedStateFromError核心职责是“拯救 UI”。当子组件抛出错误时触发,它必须返回一个新的 state。React 会根据这个新 state 重新 render,从而显示出“备用 UI”(降级页面)。如果没有它,React 依然会崩溃白屏。
  • componentDidCatch核心职责是“打小报告”。它允许你拿到详细的错误栈(errorInfo),然后发送给后端的监控系统(如 Sentry)。

面试题:这两个错误处理钩子能不能只写其中一个?

  • 只写 componentDidCatch 不写 getDerivedStateFromError 在 React 16 早期勉强可以(在 catch 里执行 setState),但在现代 React 的并发模式下,严禁在 componentDidCatch 中修改状态。如果只写 catch,你只能把错误上报,但用户的屏幕依然会白屏崩溃
  • 只写 getDerivedStateFromError 不写 componentDidCatch 完全可以!如果你的业务不需要将错误上报给监控系统,只需要让页面不白屏,仅实现这个静态方法就足够了。

追问:那我能不能干脆偷懒,直接在 getDerivedStateFromError 里面调用接口上报错误呢? 绝对不行! 因为 getDerivedStateFromError 是在 React 的 Render 阶段执行的。在 Fiber 架构和 Concurrent Mode(并发模式)下,Render 阶段的任务是可被中断、可被废弃、且可能被多次重复执行的。如果在里面发网络请求,可能会导致同一个错误被上报多次。而 componentDidCatch 发生在 Commit 阶段,它是同步且只执行一次的,是执行副作用(上报日志)的唯一安全场所。

面试考点:ErrorBoundary 只能捕获其子组件在渲染期间(Render phase)、生命周期方法中、以及构造函数(constructor)中发生的错误,无法捕获以下错误:

  1. 事件处理函数内部的错误(如 onClick 里的报错,应直接使用 try-catch)。
  2. 异步代码(如 setTimeoutrequestAnimationFrame)。
  3. 服务端渲染时的错误。
  4. ErrorBoundary 组件自身的错误。
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error) {
return { hasError: true }; // 更新 state 使下一次渲染显示降级 UI
}
componentDidCatch(error, errorInfo) {
// 通常在这里把错误上报给监控系统 (如 Sentry)
logErrorToMyService(error, errorInfo);
}
render() {
if (this.state.hasError) return <h1>Something went wrong.</h1>;
return this.props.children;
}
}

React 18+ 推荐用社区库 react-error-boundary,配合 onReset 提供重试能力。

性能优化

减少组件重渲染

  • React.memo / shouldComponentUpdate:对 props 做浅比较,跳过无变化的渲染。
  • useMemo / useCallback:缓存计算结果或函数引用,保持子组件 props 引用稳定(配合 memo 才有意义)。
  • 状态下沉、内容上提:把频繁变化的 state 下移到最小子组件;把不依赖 state 的内容通过 children 作为 prop 传入,避免父组件更新带动整棵子树。
  • 批量更新 ReactDOM.unstable_batchedUpdates(React 18 之前适用,React 18 已自动批量更新)。

减少节点 / 减少计算量

  • 惰性渲染(懒加载):React.lazy + Suspense 做组件级、路由级代码分割,减小首屏体积。
  • 长列表虚拟化react-window / react-virtuoso 只渲染可视区域(原理见 虚拟列表)。
  • useMemo 缓存昂贵计算;CPU 密集任务放到 Web Worker
  • 不可变数据:配合 immer,让浅比较高效可靠。

精细化渲染

  • 参考 mobx / vue 的依赖收集思路,做细粒度更新。
  • 不滥用 Context:大 Context 任一值变化会让所有消费组件重渲染,应按更新频率拆分 Context,或用 useSyncExternalStore/状态库。

时间分片与并发

  • 时间分片:将渲染任务拆分到多个时间片,利用浏览器空闲时间执行(React Fiber 的 Scheduler 机制),避免长任务阻塞主线程。详见 Fiber 架构
  • 并发特性useTransition / useDeferredValue 把非紧急更新降级,优先响应用户交互。

定位手段

React DevTools Profiler(开启 "Highlight updates")定位重渲染热点,再针对性优化,避免凭感觉过早优化。


其他高频考点

受控组件 vs 非受控组件

  • 受控组件:表单值由 React state 驱动,value + onChange 双向绑定。React 是「唯一数据源」,便于即时校验、联动。
  • 非受控组件:表单值由 DOM 自己管理,用 ref 在需要时读取(defaultValue 设初值)。代码更简洁,适合简单表单、文件上传(<input type="file"> 只能非受控)。
// 受控
const [val, setVal] = useState("");
<input value={val} onChange={(e) => setVal(e.target.value)} />;

// 非受控
const ref = useRef(null);
<input defaultValue="hi" ref={ref} />; // 提交时读 ref.current.value

key 的作用

面试题:key 有什么用?为什么不能用 index 作为 key?

  • key 帮助 Diff 算法识别哪些节点是「同一个」,从而复用而非销毁重建。
  • index 作 key:当列表发生插入/删除/排序时,元素与 key 的对应关系错位,会导致复用错误,引发状态错乱(如输入框内容串位)、性能下降。
  • 应使用稳定、唯一的业务 id。

为什么 setState 表现为「异步」

setState 把更新放入队列,在批处理结束、组件重新执行前拿到的都是旧值。React 18 起,几乎所有场景都自动批处理。需要拿更新后的值时用函数式更新 setCount(c => c + 1) 或在 useEffect 中监听

JSX 与虚拟 DOM

  • JSX 本质<div id="a">hi</div> 经 Babel 编译为 React.createElement("div", { id: "a" }, "hi")(React 17+ 新 JSX 转换为 _jsx(...),无需手动 import React)。
  • 虚拟 DOM 的意义:用 JS 对象描述 UI,通过 Diff 批量、最小化操作真实 DOM;并带来跨平台与声明式开发能力。它不一定比直接操作 DOM 快,价值在于「可维护性 + 可接受的性能」

Fragment / Portal

  • Fragment<>...</>):返回多个根节点而不产生额外 DOM 包裹。
  • PortalcreatePortal(child, container)):把子节点渲染到父组件 DOM 层级之外的容器(如 body),常用于弹窗、Tooltip,避免被父级 overflow:hidden/z-index 影响。注意:事件冒泡仍沿 React 组件树,而非真实 DOM 树。

Hooks 常被追问

  • 为什么 Hooks 不能写在条件/循环里? 依赖调用顺序匹配 Fiber 上的 Hook 链表。
  • useEffect 依赖数组写错会怎样? 漏依赖 → 闭包陷阱/逻辑用旧值;多依赖 → 重复执行。可借助 eslint-plugin-react-hooks
  • 如何避免 useEffect 里的请求竞态? 用 cleanup + 标志位忽略过期响应,或 AbortController 取消。