React 核心原理与渲染机制总结

Posted by Qz on January 21, 2018

本文聚焦 React 如何完成一次更新。设计思想已拆到 React 设计哲学与数据流,组件 API 与 Hooks 见 React 组件与 Hooks 实践总结

一、一次更新经历什么

可以把一次 React 更新概括为三个阶段:

  1. 触发(Trigger):首次挂载,或 state、props、Context、外部订阅发生变化。
  2. 渲染(Render):React 调用组件,计算新的元素树。
  3. 提交(Commit):React 将必要变化应用到 DOM,并运行布局相关逻辑。

渲染阶段应保持纯粹,可以被暂停、重新执行或放弃。提交阶段才真正修改 DOM。

二、每次渲染都是一个快照

函数组件每执行一次,就会形成一份属于本次渲染的 props、state 和事件处理函数。

function Counter() {
  const [count, setCount] = useState(0);

  function handleClick() {
    setTimeout(() => {
      console.log(count);
    }, 1000);
  }

  return <button onClick={handleClick}>{count}</button>;
}

handleClick 读取的是创建它的那次渲染中的 count。之后的 state 更新会触发新渲染,但不会改写旧闭包中的值。

这就是常说的 Capture Value。理解它之后,Effect 依赖、异步回调和“旧状态”问题都会更清晰。

三、状态更新与批处理

调用 state setter 不会立刻修改当前渲染中的变量,而是请求下一次渲染:

setCount((count) => count + 1);
setCount((count) => count + 1);

函数式更新会依次处理队列,因此最终增加 2。

React 18 的 createRoot 支持自动批处理:React 事件、Promise、定时器和原生事件中的多次更新通常会被合并,减少重复渲染。不要再用“setState 在 React 事件中异步、在 setTimeout 中同步”作为通用规则。

更准确的表述是:state 更新会被调度和批处理;若确实需要同步刷新 DOM,可以谨慎使用 flushSync,但它会损失性能优化空间。

四、协调:React 如何比较前后结果

React 不会对两棵任意树执行理论上的最优 diff,而是使用适合 UI 的启发式规则:

  • 元素类型不同,通常替换对应子树;
  • 类型相同,复用现有节点并更新变化的 props;
  • 列表通过 key 匹配项目身份;
  • 组件在树中的位置、类型和 key 一起决定 state 是否保留。

为什么 key 重要

items.map((item) => <Row key={item.id} item={item} />)

稳定 key 能让 React 在插入、删除和移动后继续识别同一项目。key 只需在当前兄弟列表内唯一,但必须稳定;随机数和易变化的索引都会破坏复用与状态连续性。

为什么不是简单的“双端对比”

React 的目标不只是最小化 DOM 操作,还要支持组件、状态、优先级、Suspense 和可中断渲染。协调过程建立在 Fiber 树及其调度模型之上,不能仅用传统虚拟 DOM 的数组双端 diff 来概括。

五、Fiber 与并发调度

Fiber 是 React 内部表示工作单元和组件树的结构。它让渲染工作可以被拆分,并为更新记录优先级、父子关系和副作用信息。

在并发渲染中,React 可以:

  • 暂停低优先级渲染;
  • 先处理输入等高优先级更新;
  • 继续或放弃之前尚未提交的工作;
  • 在结果准备好后一次性提交,避免用户看到半成品 UI。

“并发”不等于多个 JavaScript 线程同时修改 DOM,而是 React 在主线程上更灵活地安排可中断工作。

时间切片

Scheduler 会在合适的时机把控制权交还浏览器,避免长任务持续阻塞输入和绘制。具体宿主调度实现会随 React 版本和环境演进,不应把它永久等同于某一个 API(例如 MessageChannel)。

useTransitionuseDeferredValue

  • useTransition:把某类 state 更新标记为非紧急更新。
  • useDeferredValue:允许界面暂时使用某个值的旧版本,同时在后台准备新结果。

它们不会自动让昂贵计算变快,而是让紧急交互可以优先响应。

六、HMR 与 React Refresh 原理

开发时修改组件代码,页面经常能在不整页刷新的情况下立即更新。这里其实包含两个不同层次:

  • HMR(Hot Module Replacement):构建工具负责发现文件变化、重新编译模块,并在浏览器中替换对应模块。
  • React Refresh(常称 Fast Refresh):React 感知组件类型变化,决定能否保留组件 state,并触发安全的重新渲染。

HMR 不理解 React 组件和 Hooks。只使用普通 HMR 虽然可以替换 JavaScript 模块,但无法可靠判断新旧组件是否代表同一个组件,也不知道 Hook state 应该怎样继续复用。React Refresh 是建立在 HMR 之上的 React 专用刷新机制。

Live Reload、HMR 与 Fast Refresh 的区别

能力 更新方式 是否保留页面状态
Live Reload 文件变化后刷新整个页面 通常不保留
HMR 替换发生变化的 JavaScript 模块 由模块自行处理
React Refresh 在 HMR 基础上更新 React 组件 条件允许时保留组件 state

HMR 的基本流程

以 Vite、Webpack 等开发服务器为例,一次热更新通常经历以下步骤:

  1. 开发服务器监听到源文件变化。
  2. 构建工具重新转换受影响模块,并更新模块图。
  3. 服务器通过 WebSocket 等通道通知浏览器发生了哪些变化。
  4. 浏览器中的 HMR runtime 拉取或接收新模块代码。
  5. 旧模块执行 dispose 清理,新模块重新求值。
  6. 更新沿模块依赖图传播,直到找到能够接受更新的 HMR boundary。
  7. 如果没有模块能够安全接收更新,则退化为整页刷新。

HMR boundary 是模块系统的概念。例如原生 ESM 风格的 HMR API 可以显式接受更新:

if (import.meta.hot) {
  import.meta.hot.accept((nextModule) => {
    // 根据新模块内容更新当前应用
  });

  import.meta.hot.dispose(() => {
    // 清理旧模块创建的订阅或资源
  });
}

React 项目通常不需要为每个组件手写这些代码,React 插件会把 React Refresh 接入构建工具的 HMR 生命周期。

react-refresh 做了什么

react-refresh 主要由编译转换和运行时两部分组成:

  • react-refresh/babel 或框架对应的 SWC、esbuild 转换负责给组件和 Hooks 添加标记。
  • react-refresh/runtime 在浏览器中登记组件类型、比较更新前后的组件,并通知 React 执行刷新。

编译后的代码可以抽象理解为:

function Counter() {
  const [count, setCount] = useState(0);
  return <button>{count}</button>;
}

$RefreshReg$(Counter, 'Counter');

使用 Hooks 的组件还会记录一份近似的 Hook signature,用来判断编辑前后的 Hook 调用结构是否兼容:

const _s = $RefreshSig$();

function useCustomHook() {
  _s();
  const [value, setValue] = useState(0);
  return [value, setValue] as const;
}

// 签名字符串由转换插件根据 Hook 调用生成
_s(useCustomHook, 'useState{[value, setValue](0)}');

$RefreshReg$$RefreshSig$ 是理解原理时常见的占位名称,不是业务代码需要手写的公共 API。

React Refresh 的更新链路

文件变化
  → 构建工具重新编译模块
  → HMR runtime 执行新模块
  → Refresh runtime 注册新的组件类型和 Hook signature
  → 比较新旧组件是否属于同一个 family
  → React 对受影响根节点安排刷新
  → 协调阶段使用新的组件实现重新渲染
  → 兼容时保留 state,不兼容时重新挂载

Refresh runtime 会为组件维护一个稳定的“family”。旧组件函数和编辑后生成的新函数虽然不是同一个 JavaScript 引用,但如果被识别为同一 family,React 在协调时可以把新实现视为原组件的新版本,而不是完全不同的元素类型。

这也是 React Refresh 能保留 state 的关键:它并不是修改旧函数,而是让 React 在开发模式下知道“这个新函数仍然代表原来的组件”。

什么是 Refresh Boundary

如果一个模块只导出 React 组件,构建工具通常可以把它视为 Refresh Boundary:

export function Button() {
  return <button>保存</button>;
}

export function Toolbar() {
  return <Button />;
}

模块更新后,只需重新执行该模块并刷新这些组件。

如果模块同时导出组件和会被 React 树之外代码使用的普通值,更新可能继续向上游传播,甚至触发整页刷新:

export const THEME_VERSION = 2;

export function ThemeProvider() {
  return <div>...</div>;
}

因此,组件模块最好主要导出组件;常量、工具函数和环境配置可以拆到独立模块。不同框架对 Refresh Boundary 的校验细节不完全相同,应以所用插件为准。

什么时候可以保留 state

函数组件通常在以下条件满足时保留本地 state:

  • 新旧实现被注册为同一个组件 family;
  • 组件在树中的位置和 key 没有变化;
  • Hook 的调用顺序和签名仍然兼容;
  • 模块仍然是有效的 Refresh Boundary。

例如只修改文案或样式,计数值通常可以继续保留:

function Counter() {
  const [count, setCount] = useState(0);
  return <button onClick={() => setCount(count + 1)}>当前值:{count}</button>;
}

什么时候会重新挂载或整页刷新

以下变化可能导致组件 state 被重置:

  • 改变 Hook 的数量或调用顺序;
  • 修改自定义 Hook 后,签名不再兼容;
  • 修改组件的 key 或在树中的身份;
  • class 组件更新——Fast Refresh 通常不能可靠保留其实例 state;
  • 匿名默认导出等写法让工具难以稳定识别组件;
  • 模块不再满足 Refresh Boundary 条件;
  • 更新传播到无法接收 HMR 的模块,最终退化为整页刷新。

为了让组件身份更稳定,推荐使用具名组件:

// 推荐
export default function Profile() {
  return <main>...</main>;
}

// 不利于调试和组件识别
export default () => <main>...</main>;

某些 React Refresh 集成支持在文件中加入 // @refresh reset,强制该文件中的组件每次编辑后重新挂载。它适合调试只在挂载时发生的动画或初始化逻辑,但属于开发工具约定,需要确认当前框架是否支持。

Effect 在热更新期间的表现

为了让代码修改能够立即生效,Fast Refresh 可能在刷新期间重新运行 Effect,并暂时忽略 useEffectuseMemouseCallback 的依赖数组是否发生变化。

因此 Effect 必须具备正确的清理逻辑,不能假设空依赖数组意味着“整个开发会话只执行一次”:

useEffect(() => {
  const subscription = source.subscribe(handleMessage);
  return () => subscription.unsubscribe();
}, [source]);

React Refresh 和 Strict Mode 都会让不完整的清理逻辑更容易暴露,但二者目的不同:前者服务于代码热更新,后者用于检查组件是否能安全地重复执行。

常见工具如何接入

  • Vite@vitejs/plugin-react@vitejs/plugin-react-swc 在开发模式下接入 React Refresh。
  • Webpack:通常组合 react-refresh/babel@pmmmwh/react-refresh-webpack-plugin
  • Next.js、React Native、Expo:框架或 Metro 已经集成 Fast Refresh,一般不需要手动配置 runtime。
  • 生产环境:React Refresh 转换和 runtime 应关闭,它们只服务于开发体验。

排查热更新失效

遇到修改组件后整页刷新、状态无法保留或更新不生效,可以依次检查:

  1. React 插件是否只在开发环境启用且配置完整。
  2. 文件是否混合导出了组件与被非 React 模块使用的普通值。
  3. 组件是否使用稳定的具名声明。
  4. 是否改变了 Hook 顺序、组件 key 或组件在树中的位置。
  5. 是否有模块级单例、订阅或定时器缺少 HMR dispose 清理。
  6. 浏览器控制台是否出现 Refresh Boundary 或 HMR propagation 错误。
  7. monorepo、软链接或重复 React 依赖是否导致插件未转换目标文件。

七、父子组件的渲染与提交顺序

渲染时,React 从父组件开始调用,逐步得到子树。提交阶段则包含 DOM 变更、ref 更新、布局 Effect 和普通 Effect 等不同步骤。

不要再依赖旧 class 生命周期的简单口诀推断所有顺序。尤其在并发渲染、Suspense 和 Strict Mode 下,渲染可能重复或被放弃。业务逻辑应放在正确的事件或 Effect 中,而不是依赖偶然的调用次数。

八、事件系统

React 通过 SyntheticEvent 提供跨浏览器一致的事件接口:

function Button() {
  function handleClick(event: React.MouseEvent<HTMLButtonElement>) {
    console.log(event.currentTarget);
    console.log(event.nativeEvent);
  }

  return <button onClick={handleClick}>点击</button>;
}

需要区分历史实现与现代实现:

  • React 17 起,大多数事件监听器委托到 React 根容器,而不是统一绑定到 document
  • React Web 17 起不再使用旧的 SyntheticEvent 事件池,通常不再需要 event.persist()
  • Portal 中的事件仍按 React 组件树传播,而不只按 DOM 树传播。

合成事件与原生事件混用

两套监听器的触发先后与阻止传播结果,取决于监听位置、捕获/冒泡阶段及 React 根容器。不要使用“原生事件永远先于合成事件”之类的绝对规则。

确需混用时,应画出具体 DOM 路径并明确每个监听器注册在哪个阶段;常规业务优先统一使用 React 事件。

九、错误边界

错误边界可以捕获子树在渲染、构造函数和生命周期中的错误,并展示备用 UI。

class ErrorBoundary extends React.Component<
  React.PropsWithChildren,
  { hasError: boolean }
> {
  state = { hasError: false };

  static getDerivedStateFromError() {
    return { hasError: true };
  }

  componentDidCatch(error: Error, info: React.ErrorInfo) {
    reportError(error, info);
  }

  render() {
    if (this.state.hasError) return <Fallback />;
    return this.props.children;
  }
}

传统错误边界不会自动捕获:

  • 事件处理函数中的错误;
  • 任意异步回调中的错误;
  • 服务端渲染错误;
  • 错误边界自身抛出的错误。

事件和异步流程应在其调用边界中处理,并将异常上报到监控系统。应用通常需要按页面或关键区域设置错误边界,而不是只在根节点放一个兜底。

十、服务端渲染、流式传输与水合

传统 SSR 先在服务端生成 HTML,再在客户端通过 hydration 绑定事件并恢复交互。

Suspense 让框架能够进行流式 SSR:页面不必等待所有区域完成后才发送 HTML。较慢区域可以先显示 fallback,准备好后再进入流中。

选择性水合允许 React 优先水合用户正在交互的区域,而不是必须先让整棵页面完成水合。

十一、Server Components

React Server Components(RSC)在服务端执行,并将可序列化的结果交给客户端。它与 SSR 相关,但不是同一个概念:

  • SSR 关注如何把组件输出为 HTML,并在客户端水合;
  • RSC 关注哪些组件代码和数据读取只留在服务端;
  • Client Components 承担状态、Effect、浏览器 API 和交互;
  • Server Components 可以直接访问服务端资源,并减少发送到客户端的 JavaScript。

RSC 的具体路由、缓存、数据请求和构建约束由 Next.js 等框架实现。不要在没有框架支持的普通 React 客户端项目中直接照搬其边界规则。

十二、旧结论更新

旧说法 更准确的现代结论
React 事件都绑定在 document React 17 起主要委托到根容器
SyntheticEvent 会被事件池回收 React Web 17 起已不再使用旧事件池
setTimeout 中的 setState 一定同步 React 18 自动批处理覆盖更多异步来源
diff 总能找到最小 DOM 差异 React 使用面向 UI 的启发式协调算法
父组件渲染,子组件一定完成并提交 渲染可能暂停、重复或放弃,提交才修改 DOM
useMemo / useCallback 越多越快 只有真实性能热点且依赖稳定时才可能获益
HMR 会自动理解并保留 React 状态 HMR 只替换模块,状态保留由 React Refresh 判断

总结

理解 React 原理时,最重要的边界是:渲染只计算结果,提交才改变宿主环境;每次渲染拥有自己的状态快照;协调通过类型、位置和 key 判断复用;Fiber 和调度让未提交的渲染工作具备优先级与可中断能力。

参考资料: