主题
第 12 章 · React 进阶 面试题集
Q1:什么是自定义 Hook?为什么名字必须以 use 开头?
考察点:Hooks 复用、ESLint 约定
标准答案: 自定义 Hook 是一个普通函数,内部调用了其他 Hook(useState 等),用于抽离和复用组件之间的状态逻辑。约定以 use 开头有两个作用:
- React/ESLint 的
react-hooks/rules-of-hooks通过函数名识别它是 Hook,强制遵循 Hooks 调用规则 - 让代码读者一眼看出"这是一个 Hook",需注意
useState等只能在顶层调用
通俗理解: 自定义 Hook 像把"日常组合动作"打包成一个工具盒。命名 useXxx 就像在工具盒上贴标签——告诉同事和工具检查员:这盒子里有"魔法"(内部用了 React 状态),别乱动顺序。
代码示例:
jsx
function useToggle(initial = false) {
const [on, setOn] = useState(initial);
return [on, () => setOn(o => !o)];
}加分回答:
- 自定义 Hook 不共享状态,每次调用都是独立实例
- 实战常用:
useFetch、useLocalStorage、useDebounce、usePrevious、useEventListener - 配合
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.Provider 把 value 注入到内部组件子树。任何调用 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),依赖三个启发式假设:
- 同层比较:跨层级移动一律销毁重建
- type 不同直接替换:组件类型变了不复用
- 列表用 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 能够:
- 可中断渲染:每处理一个 Fiber 节点就检查是否还有时间,没时间就让出主线程
- 优先级调度:用户输入/动画 > 数据加载 > 离屏内容
- 双缓存:current 树(已显示) vs workInProgress 树(构建中),失败可丢弃 wip
- 副作用收集 + 一次性提交:避免中间态展示
渲染分两阶段:
- 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 传递
标准答案: 函数组件默认无法接收 ref。forwardRef 把父组件传入的 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 起函数组件可以直接接
refprop,无需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={...}> 中的占位内容,准备好后再换成真实组件。
主要用法:
- 代码分割(最常见):
React.lazy+ Suspense - 数据获取(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 性能优化常见手段有哪些?
考察点:综合优化思路
标准答案:
- 避免重渲染:
React.memo+useCallback+useMemo - 拆分组件:把高频变化的状态局部化,不污染父组件
- 虚拟列表:长列表只渲染可视区(
react-window) - 代码分割:
React.lazy+Suspense - 并发更新:
useTransition/useDeferredValue把不紧急的更新降级 - 避免内联对象/数组/函数:用
useMemo稳定引用 - Key 用稳定 id:避免 Diff 误判
- 生产构建:
NODE_ENV=production,去掉开发警告
通俗理解: 就像优化餐厅效率:
- 不必要的客人少接待(memo)
- 大菜单分批做(虚拟列表)
- 一次性做几十道菜不现实,按优先级安排(useTransition)
- 厨房热菜用专用工具(lazy 拆分)
加分回答:
- React Profiler 看每个组件渲染耗时
- React DevTools 的"Highlight updates" 找无效重渲染
- React 19 起官方编译器自动加 memo/useMemo/useCallback,开发更省心
追问:
- 重渲染一定不好吗?
- 你优化过哪些真实场景的性能问题?