跳到正文
前端知识库
React

React 运行模型与资深面试

从组件、状态和 Effect 深入到 Fiber、协调、并发渲染、闭包、状态架构与性能诊断。

10 分钟React · Hooks · 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 找到真实热点。
  • 状态尽量靠近使用位置,避免顶层状态让整棵树更新。
  • useMemouseCallback 有维护成本,只在稳定引用或昂贵计算确实有价值时使用。
  • 大列表考虑虚拟化,重资源模块考虑按需加载。
  • 不要通过不稳定的随机 key 强制重建组件。

Render、Commit 与 Fiber

一次更新可以分为两个主要阶段:

  1. Render 阶段:React 调用组件,计算新的元素树并协调差异。这个阶段必须纯净;在并发模式下可能被暂停、重做或放弃。
  2. 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 的顺序:

  1. 能在渲染期间推导的值直接计算。
  2. 用户动作引起的逻辑放在事件处理器。
  3. 数据获取优先使用框架或缓存层,避免请求瀑布与重复状态。
  4. 真正与外部系统保持同步时才使用 Effect。
  5. 若对象/函数依赖每次变化,先把它移入 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 后浏览器渲染贵。

  1. 用浏览器 Performance 确认主线程长任务和用户可见延迟。
  2. 用 React Profiler 查看 commit、组件渲染耗时和触发来源。
  3. 区分父级重渲染、Context 传播、外部 store 更新和组件自身 state。
  4. 通过状态下沉、组件拆分或数据结构优化减少工作。
  5. 最后在确有稳定性契约时使用 memouseMemouseCallback

缓存本身也要比较依赖并保留值,占用内存且增加理解成本。一个永远变化的对象 prop 就能破坏整条 memo 链。大列表优先考虑窗口化和分页,因为少渲染数量级的节点比缓存每个节点更有效。

资深面试追问

React 为什么要求组件纯净?

React 可能多次调用、暂停或放弃 render。若渲染时发送请求、修改全局变量或操作 DOM,这些行为就无法与最终 commit 一一对应。纯净让 React 能安全调度、服务端渲染和重试,也让组件更容易测试。(知识点:组件与 JSX

useMemouseCallback 有什么区别?

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 保持确定性。