本文聚焦 React 如何完成一次更新。设计思想已拆到 React 设计哲学与数据流,组件 API 与 Hooks 见 React 组件与 Hooks 实践总结。
一、一次更新经历什么
可以把一次 React 更新概括为三个阶段:
- 触发(Trigger):首次挂载,或 state、props、Context、外部订阅发生变化。
- 渲染(Render):React 调用组件,计算新的元素树。
- 提交(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)。
useTransition 与 useDeferredValue
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 等开发服务器为例,一次热更新通常经历以下步骤:
- 开发服务器监听到源文件变化。
- 构建工具重新转换受影响模块,并更新模块图。
- 服务器通过 WebSocket 等通道通知浏览器发生了哪些变化。
- 浏览器中的 HMR runtime 拉取或接收新模块代码。
- 旧模块执行 dispose 清理,新模块重新求值。
- 更新沿模块依赖图传播,直到找到能够接受更新的 HMR boundary。
- 如果没有模块能够安全接收更新,则退化为整页刷新。
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,并暂时忽略 useEffect、useMemo 或 useCallback 的依赖数组是否发生变化。
因此 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 应关闭,它们只服务于开发体验。
排查热更新失效
遇到修改组件后整页刷新、状态无法保留或更新不生效,可以依次检查:
- React 插件是否只在开发环境启用且配置完整。
- 文件是否混合导出了组件与被非 React 模块使用的普通值。
- 组件是否使用稳定的具名声明。
- 是否改变了 Hook 顺序、组件 key 或组件在树中的位置。
- 是否有模块级单例、订阅或定时器缺少 HMR dispose 清理。
- 浏览器控制台是否出现 Refresh Boundary 或 HMR propagation 错误。
- 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 和调度让未提交的渲染工作具备优先级与可中断能力。
参考资料:
- React:渲染和提交
- React:State 如同一张快照
- React 18:自动批处理
- React 17:事件委托变更
- React DOM Server APIs
- React Refresh 源码
- Webpack React Refresh Plugin
- Vite React Plugin