Skip to content

第 12 章 · React 进阶 面试题集

Q1:什么是自定义 Hook?为什么名字必须以 use 开头?

考察点:Hooks 复用、ESLint 约定

标准答案: 自定义 Hook 是一个普通函数,内部调用了其他 Hook(useState 等),用于抽离和复用组件之间的状态逻辑。约定以 use 开头有两个作用:

  1. React/ESLint 的 react-hooks/rules-of-hooks 通过函数名识别它是 Hook,强制遵循 Hooks 调用规则
  2. 让代码读者一眼看出"这是一个 Hook",需注意 useState 等只能在顶层调用

通俗理解: 自定义 Hook 像把"日常组合动作"打包成一个工具盒。命名 useXxx 就像在工具盒上贴标签——告诉同事和工具检查员:这盒子里有"魔法"(内部用了 React 状态),别乱动顺序。

代码示例

jsx
function useToggle(initial = false) {
  const [on, setOn] = useState(initial);
  return [on, () => setOn(o => !o)];
}

加分回答

  • 自定义 Hook 不共享状态,每次调用都是独立实例
  • 实战常用:useFetchuseLocalStorageuseDebounceusePrevioususeEventListener
  • 配合 react-use 之类的库,几十个常用 Hook 开箱即用

追问

  • 自定义 Hook 能在普通 JS 函数里调用吗?为什么?
  • useToggle 返回数组 vs 返回对象有什么取舍?

Q2:useMemo 和 useCallback 有什么区别?怎么用?

考察点:缓存、引用稳定、性能优化

标准答案

  • useMemo(fn, deps) 缓存 计算结果:依赖不变就返回上次的值
  • useCallback(fn, deps) 缓存 函数引用:等价于 useMemo(() => fn, deps)

二者本质都是依赖未变化时返回缓存值,避免重复计算或重新创建。

通俗理解useMemo 像"算账员"——你给他一堆账单,结果不变就别重算。useCallback 像"门牌号"——同一个函数地址不变,外面的人才认得"还是同一个家"。

代码示例

jsx
const sortedList = useMemo(() => list.sort(), [list]);
const handleClick = useCallback(() => setX(x+1), [x]);

加分回答

  • 单独使用 useCallback 没收益——必须配合 React.memo 子组件 / 作为其它 Hook 依赖时才有意义
  • 它们本身有缓存开销:依赖比较 + 闭包持有,不要无脑加
  • React 19 起的编译器(React Compiler)会自动加这层缓存

追问

  • 为什么 useCallback 经常和 React.memo 一起用?
  • 依赖数组里放对象会引发什么问题?

Q3:useReducer 比 useState 好在哪?什么时候用?

考察点:状态机思想、纯函数

标准答案: 当满足以下任一条件时优先用 useReducer

  • 状态结构复杂(多字段联动)
  • 状态更新逻辑复杂(多种 action 类型)
  • 下一个状态依赖前一个状态
  • 需要把状态更新逻辑放在组件外部测试

useReducer 把"状态变化"集中到一个纯函数 reducer 里,可单独单元测试,且 action 是数据描述,便于打日志、回放。

通俗理解useState 像"自助 ATM"——自己存自己取。useReducer 像"银行柜台"——你提交申请单(action),柜员(reducer)按规则处理,所有规则集中在一个窗口管理。

代码示例

jsx
const [state, dispatch] = useReducer(reducer, initialState);
dispatch({ type: 'add', payload: item });

加分回答

  • 配合 useContext 可实现"轻量 Redux",无需引入第三方
  • reducer 必须是纯函数:相同输入 → 相同输出,无副作用
  • 第三个参数 initializer 函数可懒初始化复杂初始状态

追问

  • 一个组件里 useReducer 能 dispatch 异步 action 吗?怎么做?
  • useReducer 跟 Redux 的核心区别?

Q4:useContext 的工作原理?什么场景下不适合用?

考察点:Context 性能、订阅机制

标准答案Context.Providervalue 注入到内部组件子树。任何调用 useContext(Ctx) 的子组件订阅该 Context,一旦 value 引用变化(Object.is 比较),所有消费者全部重渲染,无论它们用到的字段是否变化。

不适合的场景

  • 高频变化的状态(如鼠标位置)
  • value 是个大对象但消费者只用其中几个字段
  • 需要选择器(selector)订阅(Context 没有内置)

通俗理解: Context 像办公室的广播喇叭:广播一变,全办公室都听到(重渲染),就算你只关心今天的午餐菜单。

加分回答

  • 优化方法:拆 Context(高频/低频分开)、用 useMemo 稳定 value、外加状态库(Zustand 有 selector)
  • React 18 之后 useSyncExternalStore 提供更细粒度订阅
  • Context 不能替代 Redux:无中央 store、无中间件、无 devtools

追问

  • 同时多个 Provider 嵌套时,怎么取最近一层?
  • useContext 拿到的值什么时候是 default value?

Q5:React.memo、useMemo、useCallback 三个怎么配合?

考察点:性能优化体系

标准答案

工具缓存对象解决问题
React.memo(Comp)整个子组件props 浅比较相等就跳过子组件渲染
useMemo(fn, deps)任意值(计算结果)避免重复昂贵计算、稳定对象/数组引用作为 props
useCallback(fn, deps)函数引用给 memo 子组件传稳定的回调

三者协作典型链路:父组件用 useCallback 让传给 React.memo(Child) 的回调引用稳定 → Child 浅比较通过 → 跳过渲染。

代码示例

jsx
const Child = React.memo(({ onClick, list }) => ...);

function Parent() {
  const list = useMemo(() => raw.filter(...), [raw]);
  const handle = useCallback(() => doX(), []);
  return <Child onClick={handle} list={list} />;
}

加分回答

  • React.memo 默认浅比较,自定义比较函数:React.memo(Comp, (prev, next) => ...)
  • 三件套都是"以空间换时间",要看场景是否值得
  • 不要为了 memo 而 memo,性能优化先 measure 再 optimize

追问

  • React.memo 失效的常见原因有哪些?
  • 子组件还是 children prop,memo 怎么处理?

Q6:什么是虚拟 DOM?React Diff 算法的复杂度是多少?

考察点:虚拟 DOM、Diff 启发式

标准答案: 虚拟 DOM 是用 JS 对象描述 UI 结构的轻量数据结构。React 在每次状态变化时生成新的虚拟 DOM 树,与上一次对比(Diff),算出最小变更集,再批量提交到真实 DOM。

React 的 Diff 复杂度是 O(n),依赖三个启发式假设:

  1. 同层比较:跨层级移动一律销毁重建
  2. type 不同直接替换:组件类型变了不复用
  3. 列表用 key 标识:相同 key 视为同一元素,不同 key 视为新增/删除

传统树 Diff 是 O(n³)。

通俗理解: 虚拟 DOM 像"装修效果图"——先在 PS 里改图(内存中算 Diff),定稿后让工人按图施工(操作真实 DOM),不用每次改个像素就开干。

加分回答

  • 虚拟 DOM 不一定比直接操作 DOM 快——它的价值在于"声明式 + 跨平台 + 可控上限"
  • 列表 Diff 用双端比较或最小编辑距离的近似算法
  • Svelte / Solid 没有虚拟 DOM,编译时静态分析后生成精确的 DOM 操作代码

追问

  • 为什么不用编辑距离这种精确算法?
  • 虚拟 DOM 真的提升性能吗?

Q7:Fiber 架构解决了什么问题?

考察点:调度、可中断渲染、时间切片

标准答案: React 16 之前的 Reconciler 是递归同步的,一旦开始无法暂停。组件树过大时,渲染会长时间占用主线程,导致用户输入、动画卡顿。

Fiber 把虚拟 DOM 树重构成链表结构,每个组件对应一个 Fiber 节点(带 child / sibling / return 指针)。这让 React 能够:

  1. 可中断渲染:每处理一个 Fiber 节点就检查是否还有时间,没时间就让出主线程
  2. 优先级调度:用户输入/动画 > 数据加载 > 离屏内容
  3. 双缓存:current 树(已显示) vs workInProgress 树(构建中),失败可丢弃 wip
  4. 副作用收集 + 一次性提交:避免中间态展示

渲染分两阶段

  • Render Phase(可中断、可丢弃):构建 Fiber、Diff、收集 effects
  • Commit Phase(同步、不可中断):操作 DOM、调用生命周期/effect

通俗理解: 老 React 像"必须一鼓作气炒完一桌菜的老厨师",中途停下就会出错。Fiber 像"现代厨房工单系统",每做完一个菜就看下有没有 VIP 加急单,灵活穿插。

加分回答

  • 时间切片基于 MessageChannel 实现(不是 requestIdleCallback,因为后者帧率太低)
  • React 18 的并发模式(Concurrent Rendering)正是 Fiber 调度能力的对外开放
  • useTransition / useDeferredValue 把更新标记为低优先级

追问

  • Render Phase 可中断会有什么副作用风险?
  • 为什么 Commit Phase 必须同步?

Q8:受控组件和非受控组件的区别?什么时候用哪个?

考察点:表单设计、单向数据流

标准答案

  • 受控组件:表单值由 React state 控制,每次输入触发 onChange 更新 state,"value 永远等于 state"
  • 非受控组件:表单值由 DOM 自身管理,通过 ref 在需要时读取,使用 defaultValue 设初始值

选择

  • 多数场景用受控(实时校验、联动、状态可序列化)
  • 集成第三方 DOM 库、文件上传(<input type=file> 必须)、性能关键的大表单(万级字段)用非受控

代码示例

jsx
<input value={x} onChange={e => setX(e.target.value)} />
<input defaultValue="" ref={inputRef} />

加分回答

  • 受控大表单可借助 react-hook-form 内部用非受控 + 订阅机制提升性能
  • 受控组件一旦从 controlled → uncontrolled(如 value 变成 undefined)会报警告

追问

  • 大型表单受控为什么会卡?怎么优化?
  • 为什么 <input type=file> 必须非受控?

Q9:什么是 forwardRef?什么时候需要 useImperativeHandle?

考察点:组件封装、ref 传递

标准答案: 函数组件默认无法接收 refforwardRef 把父组件传入的 ref 转发给子组件内部的某个 DOM 节点或子组件。useImperativeHandle 用于自定义"暴露给父组件的 ref 方法集",封装内部细节,只暴露 API。

代码示例

jsx
const FancyInput = forwardRef((props, ref) => {
  const inputRef = useRef();
  useImperativeHandle(ref, () => ({
    focus: () => inputRef.current.focus(),
    clear: () => { inputRef.current.value = ''; }
  }));
  return <input ref={inputRef} />;
});

通俗理解forwardRef 像"快递员把包裹(ref)转交"。useImperativeHandle 像快递箱外贴的"使用说明"——只允许打开一面,其它面焊死。

加分回答

  • React 19 起函数组件可以直接接 ref prop,无需 forwardRef
  • 不要滥用 imperative handle,命令式 API 违反 React 声明式哲学,用纯 props 优先

追问

  • 为什么默认情况下函数组件不能接 ref?
  • ref 在什么时机被赋值?

Q10:React Portal 是什么?事件冒泡按 DOM 还是按 React 树?

考察点:渲染脱离 + 合成事件机制

标准答案createPortal(child, container) 把 child 渲染到 container 节点(通常是 document.body),但事件冒泡仍按 React 组件树冒泡,跟它在 JSX 里的位置一致。

典型用法:弹窗、Toast、Tooltip——逃出父级 overflow: hidden / z-index / position 的限制。

代码示例

jsx
function Modal({ children }) {
  return createPortal(
    <div className="mask">{children}</div>,
    document.body
  );
}

通俗理解: Portal 像"DOM 上的传送门"。窗户开在客厅,但你装在天台——位置变了,但事件(声音)依然顺着原来的"管道"(React 树)传到隔壁老父亲。

加分回答

  • 用 Portal 实现 Modal 时,键盘事件、点击外部关闭都按 React 树处理,因此父容器仍可监听
  • SSR 中 portal 的 server 端不渲染,要用 effect + 客户端 mount

追问

  • 在 portal 里点击会触发外层 onClickOutside 吗?
  • Modal 怎么处理无障碍(ARIA、Focus Trap)?

Q11:Suspense 是什么?解决了什么问题?

考察点:异步渲染、代码分割

标准答案: Suspense 让组件可以"挂起"——当组件还没准备好(异步加载代码或数据)时,渲染最近的 <Suspense fallback={...}> 中的占位内容,准备好后再换成真实组件。

主要用法

  1. 代码分割(最常见):React.lazy + Suspense
  2. 数据获取(React 18+):配合 Server Components 或 React Query/Relay 等支持 Suspense 的库

代码示例

jsx
const Heavy = lazy(() => import('./Heavy'));
<Suspense fallback={<Spinner />}>
  <Heavy />
</Suspense>

通俗理解: Suspense 像电视台的"请稍候"画面——主节目还没准备好,先放占位画面,准备好瞬间无缝切换。

加分回答

  • 多个 Suspense 嵌套:内层的 fallback 优先显示
  • React 18 起支持 SSR 流式 Suspense:服务端可以先发出 fallback、再补发真实内容
  • 错误边界(ErrorBoundary)和 Suspense 是互补的

追问

  • Suspense 能捕获 fetch 抛出的 Promise 吗?需要什么条件?
  • React.lazy 不能加载命名导出,怎么解决?

Q12:React 性能优化常见手段有哪些?

考察点:综合优化思路

标准答案

  1. 避免重渲染React.memo + useCallback + useMemo
  2. 拆分组件:把高频变化的状态局部化,不污染父组件
  3. 虚拟列表:长列表只渲染可视区(react-window
  4. 代码分割React.lazy + Suspense
  5. 并发更新useTransition / useDeferredValue 把不紧急的更新降级
  6. 避免内联对象/数组/函数:用 useMemo 稳定引用
  7. Key 用稳定 id:避免 Diff 误判
  8. 生产构建NODE_ENV=production,去掉开发警告

通俗理解: 就像优化餐厅效率:

  • 不必要的客人少接待(memo)
  • 大菜单分批做(虚拟列表)
  • 一次性做几十道菜不现实,按优先级安排(useTransition)
  • 厨房热菜用专用工具(lazy 拆分)

加分回答

  • React Profiler 看每个组件渲染耗时
  • React DevTools 的"Highlight updates" 找无效重渲染
  • React 19 起官方编译器自动加 memo/useMemo/useCallback,开发更省心

追问

  • 重渲染一定不好吗?
  • 你优化过哪些真实场景的性能问题?