20道区分“会用”与“真正理解”的React面试题
虚拟 DOM、键值、副作用、记忆化、上下文、SSR 以及数据注入——基于面试官实际会考察的要点进行讲解,而非教科书上的定义。
使用 React 与解释 React 是不同的技能。面试中会考察后者:调用 setState 时会发生什么、为什么键值很重要、效果函数何时会重新执行。以下这二十个问题在面试中频繁出现。回答应侧重于每个功能所解决的问题以及在生产环境中可能出现的隐患,而非照本宣科的复述。
1. 虚拟 DOM 究竟是什么
虚拟 DOM 并非什么神奇的加速魔法。它只是一棵普通的 JavaScript 树,用于描述界面应有的样子。当状态发生变化时,React 会构建一棵新的虚拟树,将其与之前的虚拟树进行差异对比,然后仅对浏览器中的 DOM 应用必要的修改来完成同步更新。真正的 DOM 操作会触发布局和绘制过程;而修改 JavaScript 对象的成本很低,因此 React 选择在内存中处理操作,从而减少对浏览器资源的消耗。这种设计旨在最小化浏览器的负担,而非让每一次比较都无需成本。如果不考虑这些细节就声称“虚拟 DOM 总是更快”,这是初级开发者常犯的错误。
2. 虚拟 DOM 与浏览器 DOM
真实的 DOM 更新代价高昂,会导致布局重排和重绘。而虚拟更新则是基于内存中的对象差异对比;React 会批量处理昂贵的 DOM 写入操作,而非每次状态更新都单独执行。使用 track-changes 进行编辑比因每个拼写错误就重写整个文档更为高效。
3. 为何在行顺序变动时列表键很重要
键用于在不同渲染过程中识别元素。如果没有键,React 就会依赖索引位置,而当项目被插入、删除或重新排序时,这种方式就会出问题。虽然使用数组索引作为键在大多数情况下可行,但重新排序后可能会导致输入框和复选框被绑定到错误的行上——这其实是一种“键错误”,而非真正的状态泄露。建议从数据中获取稳定且唯一的 ID 作为键,仅在对真正静态的列表才使用索引。如果产品支持拖放重新排序或过滤功能,以索引作为键最终会破坏每行的局部状态。
4. 从基本原理解释 useState
普通本地变量在函数返回时会丢失。而 useState 能为函数组件提供持久化的内存,并在内存发生变化时安排重新渲染:
const [count, setCount] = useState(0);
count 表示当前值;setCount 用于请求更新。更新不会在渲染过程中立即应用:因为在 setCount 执行后立即打印 count 仍然会显示旧值,新值会在下一次渲染时出现。
5. 为何 useState 的更新会显得有延迟或被批量处理
React 会将同一事件触发的多次更新合并为一次重新渲染。从 React 18 开始,这种批量处理不仅适用于 React 事件处理程序,还涵盖 Promise 和定时器。如果需要基于之前的状态获取最新值,请使用函数式更新器:
setCount(prev => prev + 1);
该形式会读取最新队列中的值,而非过时的闭包快照。面试官常常会进一步询问:如果在超时情况下没有使用更新器形式就对count进行闭包操作,会出现什么问题?
6. useEffect实际上解决了什么问题?
渲染应当是一个将属性和状态转换为JSX的纯函数。而应用还需要获取数据、绑定事件监听器、安排定时器以及操作DOM——这些都是副作用。useEffect会在渲染之后、状态提交后执行这些非纯函数操作:
useEffect(() => {
const id = setInterval(() => console.log("tick"), 1000);
return () => clearInterval(id); // cleanup
}, []);
返回的清理函数会在下一次效应执行之前以及组件卸载时运行。如果省略它,面试官会询问是否会引发定时器重复触发以及监听器在组件卸载后仍持续工作的问题。
7. 依赖数组实际上控制着什么?
它通过浅比较来告诉React何时重新运行该效应:
[]— 组件挂载后执行一次[count]— 当count值改变时再次执行- 未指定 — 每次渲染后执行(很少需要)
如果在数组中遗漏了某个引用值,就会产生过时闭包:该效果会永久保留最初捕获的值。由于这类错误极为常见,因此有了全面依赖检查的代码规范。
8. 受控组件与不受控组件
受控输入从React状态中获取value值,并通过onChange事件进行更新;数据真实性由React掌控。
不受控输入则维护DOM状态;需要在时通过ref读取(通常在表单提交时)。
// Controlled
<input value={name} onChange={e => setName(e.target.value)} />
// Uncontrolled
<input ref={inputRef} defaultValue="Dev" />
受控模式可实现实时验证与格式化,但每次按键都会触发重新渲染。非受控模式在只需最终值时更为轻量。虽然可以存在部分字段受控、部分不受控的混合表单,但在审查时会更难理解其逻辑。
9. 属性传递及何时停止
属性传递是通过仅负责转发数据的层来传输信息的。重命名操作会影响到许多文件。在处理主题、认证或区域设置时,上下文机制很有用;而当状态图变得复杂时,则需要Redux或Zustand来辅助管理。需要注意的是,上下文并非无需成本——每当值发生变化时,所有使用该值的组件都会重新渲染——因此它并不适合用于所有需要共享的字段。对于仅需传递一次值的场景,直接通过两层属性传递通常比专门创建上下文提供者更为清晰。
10. 何时应该选择useReducer而非useState?
当更新需要根据动作类型分叉处理、下一个状态以复杂方式依赖于上一个状态,或多个字段需同步变化时,应使用 useReducer:
function reducer(state, action) {
switch (action.type) {
case "increment": return { count: state.count + 1 };
case "reset": return { count: 0 };
default: return state;
}
}
const [state, dispatch] = useReducer(reducer, { count: 0 });
它相当于组件内的迷你 Redux。布尔值的切换可通过 useState 实现;复杂的状态转换逻辑则应放入可测试的 reducer 中。将这类逻辑从 JSX 事件处理函数中分离出来,还能避免渲染整个组件树,从而简化单元测试。
11. 详细解释 useMemo 与 useCallback,而不仅仅是复述文档内容
对于不同类型的数据,二者都能避免在渲染过程中重复执行冗余操作:
useMemo用于缓存计算结果useCallback用于缓存函数引用
const sorted = useMemo(() => expensiveSort(list), [list]);
const handleClick = useCallback(() => doThing(id), [id]);
函数身份很重要,因为每次渲染都会创建一个新的函数对象。将一个全新的函数传递给 memo 子组件会破坏缓存机制;而 useCallback 能保持函数引用的稳定性。不要到处滥用这些钩子——缓存机制是有代价的,应仅在需要执行昂贵操作或处理已缓存的子组件时使用它们。过早进行缓存是初级开发者常见的错误,面试官常常会借此提问。
12. React.memo 很有用——但也容易被绕过
React.memo 在属性仅存在浅层相等时就会跳过重新渲染。即使内容完全相同,新的对象或数组字面量仍被视为不同的,因此直接使用 { style: { color: 'red' } } 会让 memo 失去作用,除非父组件通过 useMemo/useCallback 保持属性的稳定性。否则 memo 只会增加比较开销,却无法阻止子组件的渲染工作。
13. 键作为身份标识,而不仅仅是控制台警告
键是 React 在多次渲染之间用于识别组件的系统。使用错误的键会导致为错误的数据重复使用错误的 DOM 节点:表单状态会附着在另一行上,动画会在错误元素上触发,列表项的 useState 会保留前一个项目的值。这看似是状态损坏,实则是键的错误。在沙箱环境中演示有问题的 index-key 列表是最快掌握这一规则的方法之一。
14. 上下文:合适的用法与错误的用法
上下文无需通过属性传递即可共享许多组件所需的值——如已登录用户信息、主题设置、区域设置等:
const ThemeContext = createContext();
<ThemeContext.Provider value={theme}>
<App />
</ThemeContext.Provider>
对于大型树结构中的高频状态(每次按键产生的表单值),这种方式并不适用,因为每个消费者都会在所有变化时立即更新,且没有选择性订阅机制。对于这类场景,应选择具备更精细订阅功能的存储方案。主题切换是典型的适合使用上下文管理的场景;而协作编辑器中的光标位置通常不适合。
15. 将类生命周期映射到效应上
大致的类映射关系如下:
componentDidMount→useEffect(..., [])componentDidUpdate→useEffect(..., [dep])componentWillUnmount→ 效应清理操作
更深层次的差异在于:生命周期以时间为维度来思考(加载/更新/卸载);而效应则着眼于同步——即让外部系统与这些值保持一致,这也是为什么当依赖关系发生变化时效应会重新执行。如果将效应视为逐行对应的生命周期方法,就会导致人们与依赖数组产生冲突。
16. 状态与属性的比较
属性是从父组件传入的只读输入数据;状态则是组件所拥有的数据,一旦发生变化就会触发重新渲染。属性用于从外部配置组件,而状态则是组件对自身情况的记录。按钮的label属于属性;而在请求处理期间该按钮是否被禁用则属于状态。将两者混淆会导致一些不良设计,比如试图修改属性或过度提升临时性UI状态的层级。
17>无需猜测即可找出不必要的重新渲染
常见原因包括:父组件传递了新的对象/数组/函数字面量,上下文频繁变化导致所有使用该数据的组件重新渲染,或是状态层级过深。不要凭猜测处理——应使用 React DevTools Profiler 记录交互过程,查看哪些属性发生了变化。解决方案通常是将状态下移或拆分组件,避免昂贵的子树随廉价的更新一同重新渲染,而非首先在整个代码中滥用 useMemo。Profiler 能将“感觉很慢”的问题转化为具体的父组件/属性问题,便于你进行修复。
18. SSR 与 CSR 的对比
CSR仅发送轻量的HTML框架和JS代码;浏览器在下载后会构建页面——加载速度快,但内容呈现速度较慢,且在JS执行之前在SEO方面的表现通常较差。SSR则会在每次请求时发送HTML,随后通过添加监听器实现数据注入——这样能提升首次内容呈现速度和SEO表现,但会增加服务器负载。Next.js等框架虽然增加了静态生成和流式传输功能,核心的权衡依然在于响应时间/SEO与服务器成本及复杂度之间的平衡。如果只说“SSR总是更好”而不提及这一权衡,那显然是个不完善的面试回答。
19. 数据注入不匹配及其成因
Hydration功能将React与服务器端的HTML结合,而不会丢弃原有标记。当服务器端HTML与客户端首次渲染的结果不同时就会出现问题:比如在渲染中使用Date.now()或Math.random(),这些在服务器端无法正常工作;又或者有插件注入了新的节点。React会发出强烈警告,并在客户端频繁重新渲染以恢复状态——这不仅增加了处理负担,还会导致错误内容短暂显示。通常的预防措施是将仅适用于浏览器的API封装在useEffect或特性组件中。
20. 为何React会将原生事件封装在SyntheticEvent中
React的SyntheticEvent能够规范不同浏览器间的差异(从而使onChange的行为保持一致),它过去采用根级事件委托机制,而非为每个节点设置一个原生监听器。React 17及更高版本将事件委托给应用根容器而非document,但核心理念依然不变。过去为了重复使用事件对象以便异步访问时能得到null值,会采用对象池技术;自React 17起该机制已被取消,但了解浏览器事件与处理程序之间的层级关系仍能体现对框架结构的理解。提到底层仍然存在e.nativeEvent,说明你明白这种抽象层只是DOM事件模型的封装,并非替代品。
面试真正看重的能力
出色的面试回答会解释问题所在、关键难点以及你将如何帮助队友解决困境——而非仅仅复述背过的定义。面试官更关心的是你是否曾因过时的闭包、缺失的清理代码或依赖关系错误而遇到问题,以及你能否说明原因,而非你是否能够定义useEffect。在面试前请在沙箱环境中重现这些错误;实际经历过的教训才能体现真正的理解力。定义只能帮你度过最初的几分钟,而权衡考量与失败经历则能推动整个面试的深入交流。
准备一份简短的个人故障清单,记录你已解决的各类问题——如过时的效果、错误的键值、水分状态不匹配等,并练习在1分钟内解释每一种问题。这样的准备比前一天晚上死记API签名更有效,当面试官要求举例说明你的工作成果时,你也能给出具体的案例。将每个案例与你实际实现的解决方案结合起来,这样回答不仅能体现问题带来的困扰,更能展现你的判断力。正是这最后的环节,区分了“只是读了文档”与“在压力下成功操作这套技术栈”的人。