React 运行模型与资深面试
从组件、状态和 Effect 深入到 Fiber、协调、并发渲染、闭包、状态架构与性能诊断。
React 的核心模型是“界面是状态的函数”。先把状态和数据流设计清楚,再考虑性能优化和抽象复用。
组件与 JSX
组件是返回 React 元素的普通函数。组件名使用大写,输入通过 props 传入。
function ArticleCard({ article, onSelect }) {
return (
<article>
<h2>{article.title}</h2>
<p>{article.description}</p>
<button type="button" onClick={() => onSelect(article.id)}>
阅读文章
</button>
</article>
)
}
JSX 中的表达式放在花括号内。React 默认转义字符串,避免把普通文本当成 HTML 执行。只有内容已经过可信清洗时,才使用 dangerouslySetInnerHTML。
渲染函数应保持纯净:相同的 props、state 和 context 应得到相同输出,不要在渲染期间修改外部变量或发起请求。
Props 与单向数据流
Props 是父组件传给子组件的只读输入。子组件通过回调表达事件,由拥有状态的父组件决定如何更新。
function SearchBox({ query, onQueryChange }) {
return (
<input
value={query}
onChange={(event) => onQueryChange(event.target.value)}
placeholder="搜索文档"
/>
)
}
这种受控模式让状态来源明确。不要在子组件中复制 props 到 state,除非确实需要独立于后续 props 更新的初始值。
状态建模
useState 保存会影响渲染、并且随交互变化的数据:
import { useState } from 'react'
function Counter() {
const [count, setCount] = useState(0)
function incrementTwice() {
setCount((current) => current + 1)
setCount((current) => current + 1)
}
return <button onClick={incrementTwice}>{count}</button>
}
下一状态依赖上一状态时使用函数式更新。React 会批量处理同一事件中的更新,直接写 setCount(count + 1) 两次可能只得到一次增量。
状态设计原则:
- 只保存无法从现有 props/state 推导的数据。
- 避免互相矛盾的多个布尔状态,优先使用单个状态字段。
- 状态放在所有消费者最近的共同父组件中。
- 更新数组和对象时创建新值,不直接修改原值。
setArticles((current) =>
current.map((article) =>
article.id === id ? { ...article, read: true } : article
)
)
条件与列表渲染
function ArticleList({ articles }) {
if (articles.length === 0) {
return <p>暂无文章</p>
}
return (
<ul>
{articles.map((article) => (
<li key={article.id}>{article.title}</li>
))}
</ul>
)
}
key 应来自数据中的稳定身份。数组索引只适合不会插入、删除或重排的静态列表,否则组件状态可能跟随错误的列表项。
Effect 的正确边界
Effect 用来把 React 与外部系统同步,例如浏览器 API、订阅、网络连接或第三方组件。它不是通用的数据转换工具。
import { useEffect } from 'react'
function DocumentTitle({ title }) {
useEffect(() => {
const previousTitle = document.title
document.title = title
return () => {
document.title = previousTitle
}
}, [title])
return null
}
如果一个值能在渲染期间从 props/state 计算,就直接计算:
// 推荐
const visibleArticles = articles.filter((article) => article.published)
// 不需要用 Effect 再维护一份 visibleArticles 状态
请求数据时要处理竞态和取消:
useEffect(() => {
const controller = new AbortController()
loadArticle(articleId, controller.signal).then(setArticle).catch((error) => {
if (error.name !== 'AbortError') setError(error)
})
return () => controller.abort()
}, [articleId])
在支持 Server Components 或路由数据加载的框架中,优先使用框架的数据层,减少客户端请求瀑布。
Context 与状态共享
Context 适合主题、语言、登录用户等跨层级数据。频繁变化且结构复杂的业务状态需要谨慎设计,否则所有消费者可能一起重渲染。
const ThemeContext = createContext('light')
function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light')
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
)
}
自定义 Hook
自定义 Hook 用于复用有状态逻辑,而不是复用 UI:
function useMediaQuery(query) {
const [matches, setMatches] = useState(false)
useEffect(() => {
const media = window.matchMedia(query)
const update = () => setMatches(media.matches)
update()
media.addEventListener('change', update)
return () => media.removeEventListener('change', update)
}, [query])
return matches
}
Hook 必须在组件或自定义 Hook 顶层调用,不能放入条件、循环或普通回调。
性能原则
- 先用 React DevTools Profiler 找到真实热点。
- 状态尽量靠近使用位置,避免顶层状态让整棵树更新。
useMemo、useCallback有维护成本,只在稳定引用或昂贵计算确实有价值时使用。- 大列表考虑虚拟化,重资源模块考虑按需加载。
- 不要通过不稳定的随机 key 强制重建组件。
Render、Commit 与 Fiber
一次更新可以分为两个主要阶段:
- Render 阶段:React 调用组件,计算新的元素树并协调差异。这个阶段必须纯净;在并发模式下可能被暂停、重做或放弃。
- Commit 阶段:React 把确认的变更应用到宿主环境,更新 DOM 和 ref,并执行布局相关 Effect。提交过程必须保持一致,不能展示半棵新树。
Fiber 是 React 对工作单元和组件树节点的内部表示。它让渲染工作可以按优先级切分与恢复,并保存当前树和正在构建的树。面试中不要把 Fiber 简化成“某种虚拟 DOM”:虚拟元素描述期望界面,Fiber 更侧重可调度的工作与实例状态。
状态更新不会立即修改当前渲染中的变量,而是请求一次新渲染。每次渲染都拿到自己的 props、state 与事件处理函数快照:
function DelayedCounter() {
const [count, setCount] = useState(0)
function alertLater() {
setTimeout(() => alert(count), 3000)
}
return (
<>
<button onClick={() => setCount((value) => value + 1)}>{count}</button>
<button onClick={alertLater}>稍后显示</button>
</>
)
}
alertLater 看到的是创建该回调那次渲染的 count。这是词法闭包与 React 渲染快照共同作用,不是定时器“缓存了 state”。需要读取最新但不触发渲染的值时可谨慎使用 ref。
协调、身份与 key
React 通过元素类型和 key 判断组件身份。相同位置、相同类型和相同 key 通常复用已有状态;类型或 key 改变会卸载旧子树并创建新子树。
<Editor key={articleId} article={article} />
这里 key 明确表达“文章变化时编辑器是另一个实体,需要重置局部状态”。key 不只用于消除列表警告,也不应随意变化。使用随机 key 会让每次渲染都重建 DOM、丢失输入焦点和组件状态。
列表协调的常见解释:React 基于 key 建立旧子节点映射,尽量复用相同身份的节点,并对插入、删除和移动产生最小必要宿主操作。它不是对任意两棵树执行理论上的全局最优 diff,而是依赖类型与 key 的启发式策略获得可接受复杂度。
更新队列、批处理与优先级
React 18 在 createRoot 下会对更多来源的更新自动批处理,包括 Promise、定时器和原生事件回调。批处理减少中间渲染,但每次渲染中的 state 仍是固定快照。
当下一状态依赖之前排队的状态时,用 updater:
setCount((value) => value + 1)
setCount((value) => value + 1)
startTransition 用于标记可以被更紧急输入打断的非紧急渲染更新,不会让昂贵计算自动变快:
const [isPending, startTransition] = useTransition()
function handleQueryChange(nextQuery) {
setQuery(nextQuery)
startTransition(() => {
setFilter(nextQuery)
})
}
输入框值本身应保持紧急更新,庞大结果列表可以过渡更新。useDeferredValue 则延后消费某个值,适合调用方无法控制更新来源时。两者都需要配合组件拆分与 memoization,否则昂贵子树仍可能随紧急状态一起重渲染。
Effect 生命周期与依赖推导
Effect 的依赖不是“想什么时候执行”的开关,而是 Effect 读取的响应式值集合。代码与依赖不一致会产生陈旧闭包:
useEffect(() => {
const connection = connect(roomId)
return () => connection.disconnect()
}, [roomId])
每次依赖变化,React 先清理上一次同步,再建立新同步。开发模式 Strict Mode 可能额外执行一次 setup → cleanup → setup,帮助暴露不可重复初始化或缺少清理的问题;这不是生产环境中的“双请求规则”,也不应通过全局标志绕开。
减少不必要 Effect 的顺序:
- 能在渲染期间推导的值直接计算。
- 用户动作引起的逻辑放在事件处理器。
- 数据获取优先使用框架或缓存层,避免请求瀑布与重复状态。
- 真正与外部系统保持同步时才使用 Effect。
- 若对象/函数依赖每次变化,先把它移入 Effect 或稳定数据边界,再考虑缓存。
视觉测量或同步 DOM 调整需要 useLayoutEffect,它在浏览器绘制前执行并会阻塞绘制,应保持短小。普通同步优先 useEffect。
外部 Store 与撕裂
直接在组件渲染时读取可变外部对象,再用 Effect 订阅,可能在并发渲染期间看到不一致快照。useSyncExternalStore 规定订阅与读取快照的契约:
function useOnlineStatus() {
return useSyncExternalStore(
(notify) => {
window.addEventListener('online', notify)
window.addEventListener('offline', notify)
return () => {
window.removeEventListener('online', notify)
window.removeEventListener('offline', notify)
}
},
() => navigator.onLine,
() => true
)
}
客户端快照在 store 未变化时必须保持 Object.is 相等,否则会无限触发更新。第三个参数为服务端渲染提供一致初始快照,避免 hydration 不匹配。
状态架构与服务端状态
状态应按性质区分,而不是全部进入一个全局仓库:
- URL 状态:筛选、分页、可分享查询,通常应进入路由。
- 服务端状态:远程数据、缓存、失效和请求去重,由数据层管理。
- 跨组件客户端状态:主题、会话、编辑器草稿等,根据更新频率与作用域选择 Context 或外部 store。
- 局部交互状态:hover、展开、临时输入,尽量靠近组件。
服务端状态不仅是一个 data 变量,还包含 stale/fresh、加载、重试、失效、乐观更新和并发写入。手写 useEffect + useState 很快会遗漏这些语义。
Context 更新按 Provider value 身份传播给消费者。拆分高低频 Context、稳定 value、让状态靠近使用位置通常比直接给所有消费者加 memo 更有效。memo 只跳过 props 未变的父级重渲染,组件自己的 state 或所读 Context 变化仍会更新。
SSR、Hydration 与 Server Components 边界
传统 SSR 在服务端生成 HTML,客户端再 hydration,把事件和组件状态附着到已有 DOM。服务端与客户端首次渲染必须一致;使用当前时间、随机数、浏览器专属 API 或不同地区格式都可能造成不匹配。
Server Components 是框架层提供的另一种组件执行边界:组件只在服务端运行,不把其实现 JavaScript 发给客户端,并可直接接近数据源。Client Components 承担状态、事件和浏览器 API。它们不等于 SSR 与 CSR 的简单二选一,通常由框架共同组合。
边界设计要考虑:
- 交互组件尽量小,避免无意把大依赖图变成客户端代码。
- 数据在服务端获取时处理权限与缓存,不能把密钥序列化给客户端。
- 传给客户端边界的数据必须可序列化。
- hydration 前的 HTML 仍应具备可读、可操作的基本结构。
Suspense 与错误边界
Suspense 表达“子树暂时不能完成渲染”的加载边界。它只有在框架数据源、懒加载组件或支持 Suspense 的缓存集成中才生效,并不会自动捕获任意 useEffect 请求。
错误边界捕获子树渲染、生命周期和构造过程中的错误,显示降级 UI;它不自动捕获事件处理器、异步回调或服务端的所有错误。边界应围绕可独立恢复的产品区域放置,并记录版本、路由和组件上下文,但不泄露用户敏感数据。
加载边界过细会造成大量闪烁,过粗会让整个页面一起等待。设计时结合页面骨架、流式响应和布局稳定性决定边界位置。
性能诊断方法
一次 React 性能优化应回答:哪个交互慢、哪个组件为什么渲染、render 本身贵还是 commit 后浏览器渲染贵。
- 用浏览器 Performance 确认主线程长任务和用户可见延迟。
- 用 React Profiler 查看 commit、组件渲染耗时和触发来源。
- 区分父级重渲染、Context 传播、外部 store 更新和组件自身 state。
- 通过状态下沉、组件拆分或数据结构优化减少工作。
- 最后在确有稳定性契约时使用
memo、useMemo、useCallback。
缓存本身也要比较依赖并保留值,占用内存且增加理解成本。一个永远变化的对象 prop 就能破坏整条 memo 链。大列表优先考虑窗口化和分页,因为少渲染数量级的节点比缓存每个节点更有效。
资深面试追问
React 为什么要求组件纯净?
React 可能多次调用、暂停或放弃 render。若渲染时发送请求、修改全局变量或操作 DOM,这些行为就无法与最终 commit 一一对应。纯净让 React 能安全调度、服务端渲染和重试,也让组件更容易测试。(知识点:组件与 JSX)
useMemo 与 useCallback 有什么区别?
useMemo 缓存计算结果,useCallback(fn, deps) 可视为缓存函数本身。它们都只提供性能提示,不是语义保证;依赖变化或 React 内部需要时缓存可能失效。只有昂贵计算、稳定引用影响 memo 子组件或 Hook 依赖时才有明确收益。(知识点:性能诊断方法)
为什么不能把数组索引作为 key?
key 绑定的是业务身份与组件状态。插入或重排后,相同索引对应了另一条数据,输入值、焦点或局部 state 可能被错误复用。如果列表静态、无状态且永不重排,索引才可能可接受;稳定业务 ID 更通用。(知识点:协调、身份与 key)
如何设计一个大型表单?
先按字段依赖与提交边界拆分,区分输入中的临时字符串和已验证领域值;选择受控、非受控或表单库时比较实时联动、渲染成本和动态字段;校验规则在可信边界复用或对应;异步校验可取消且防竞态;提交具备幂等、错误映射、草稿恢复和可访问焦点策略。(知识点:状态建模)
React 与 Vue 的响应式更新有什么核心差异?
React 通过显式 state 更新请求重新执行组件,再协调元素树;Vue 通过代理或 ref 在运行时追踪属性依赖,精确调度相关响应式 effect。两者都批量更新,也都需要稳定身份和合理状态建模,但依赖收集与组件重新求值方式不同。(深入阅读:Vue 响应式依赖追踪)
实践检查
- 渲染逻辑纯净,不在渲染阶段产生副作用。
- 状态没有保存可推导数据,也没有互相矛盾的字段。
- 列表 key 稳定且来自业务身份。
- Effect 只负责外部同步,并提供必要的清理逻辑。
- 表单、加载、错误和空状态都能被用户理解和操作。
- 性能优化建立在测量结果上,而不是默认给所有值加缓存。
- 能区分 render、commit 和浏览器 paint,不混用概念。
- 并发更新、Effect 清理和外部 store 都有一致性策略。
- SSR 首次输出与客户端 hydration 保持确定性。