概览
高级 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)?
- 代码复用更加容易:在类组件时代,想要复用状态逻辑只能通过 HOC(高阶组件)或 Render Props,这会导致可怕的“嵌套地狱”。而 Hooks(如自定义
useFetch)可以把逻辑扁平化地提取出来,极其优雅。- 逻辑聚合(告别碎片化):在类组件中,如果我们要监听一个窗口大小,需要在
componentDidMount里绑定,在componentWillUnmount里解绑,代码被生命周期强行割裂。而在函数组件中,一个useEffect就能把挂载和卸载的逻辑紧密聚合在一起。- 心智模型更简单:没有了晦涩难懂的
this指向问题,也没有了繁琐的模板代码。函数组件的本质就是UI = f(state),每次渲染都是一次快照,更加契合函数式编程的思想。- 对打包工具更友好:Class 编译后的代码体积更大,且内部方法很难被 Webpack 等工具进行有效的 Tree-Shaking 剔除,而函数组件都是普通的 JS 函数,压缩和优化效率更高。
高阶组件 (HOC) 与 Hooks 的对比
- 高阶组件 (HOC):一个接受组件作为参数,并返回新组件的纯函数。
- 优点:逻辑复用,不影响被包裹组件的内部逻辑。
- 缺点:导致“嵌套地狱”(Wrapper Hell);Props 命名冲突且来源不清晰;Ref 传递会被隔断(需要用
React.forwardRef转发)。- Hooks:
- 优点:彻底解决嵌套地狱,逻辑复用更加扁平化;将相关逻辑聚合在一起(而不是分散在生命周期中)。
| - | 类组件 | 函数组件 |
|---|---|---|
| 状态 | this.state | useState/useReducer |
| 生命周期 | 生命周期方法 | useEffect 等 |
| 逻辑复用 | HOC / render props | 自定义 Hook |
| this | 有,需注意绑定 | 无 |
| 心智模型 | 面向对象 | 每次渲染是一次函数快照(闭包) |
生命周期与演进

面试题:为什么在 16.4 版本之后废弃了
componentWillMount/componentWillReceiveProps/componentWillUpdate?
- 根本原因在于 Fiber 架构的引入。在 React 16 引入的 Fiber 架构下,渲染过程(Render 阶段)被设计成了可中断和可恢复的。
- 如果在 Render 阶段的生命周期(即上述三个
will生命周期)里发起了 AJAX 请求或者进行了 DOM 操作,一旦该渲染任务被更高优先级的任务打断并随后重新执行,这些生命周期方法就会被多次调用,从而导致重复发送请求或产生不可预期的 Bug。- 替代方案:引入了静态方法
static getDerivedStateFromProps,强制开发者不能在其中使用this产生副作用。
面试题:函数组件 (Hooks) 能够完全平替类组件的所有生命周期吗?
答案是:不能。 绝大多数生命周期都可以通过
useEffect/useLayoutEffect/useMemo等组合来模拟,但目前(截至 React 18)仍然有三个极少数场景下的生命周期是无法在函数组件中平替的,必须退回使用类组件:
componentDidCatch/getDerivedStateFromError:这是实现错误边界 (Error Boundary) 必须用的两个钩子,目前没有对应的 Hook(如useCatch)。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)中发生的错误,无法捕获以下错误:
- 事件处理函数内部的错误(如
onClick里的报错,应直接使用 try-catch)。 - 异步代码(如
setTimeout、requestAnimationFrame)。 - 服务端渲染时的错误。
- 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 包裹。 - Portal(
createPortal(child, container)):把子节点渲染到父组件 DOM 层级之外的容器(如body),常用于弹窗、Tooltip,避免被父级overflow:hidden/z-index影响。注意:事件冒泡仍沿 React 组件树,而非真实 DOM 树。
Hooks 常被追问
- 为什么 Hooks 不能写在条件/循环里? 依赖调用顺序匹配 Fiber 上的 Hook 链表。
useEffect依赖数组写错会怎样? 漏依赖 → 闭包陷阱/逻辑用旧值;多依赖 → 重复执行。可借助eslint-plugin-react-hooks。- 如何避免
useEffect里的请求竞态? 用 cleanup + 标志位忽略过期响应,或AbortController取消。