阅读视图

发现新文章,点击刷新页面。

面试官问"try-catch影响性能吗",我用数据打脸

面试官问"try-catch影响性能吗",我用数据打脸

面试的时候被问到这个问题:try-catch 会影响性能吗?

当时我有点懵,回答了一个模糊的"会有一点影响吧"。面试官追问:影响多大?什么情况下影响大?我就说不上来了。

回来之后认真研究了一下,发现这个问题的答案比想象中有意思。

先说结论

在现代 JavaScript 引擎中,try-catch 本身几乎不影响性能,但异常抛出是昂贵的操作。

听起来有点绕?用人话说就是:

  • 代码外面套一层 try-catch → 基本没影响
  • 代码里频繁 throw Error → 性能会很差

下面用数据说话。

实测数据

我写了个简单的测试:

const iterations = 1000000;

// 测试1:不用 try-catch
console.time('无 try-catch');
for (let i = 0; i < iterations; i++) {
    Math.sqrt(i);
}
console.timeEnd('无 try-catch');

// 测试2:用 try-catch,但不抛异常
console.time('有 try-catch,不抛异常');
for (let i = 0; i < iterations; i++) {
    try {
        Math.sqrt(i);
    } catch (e) {
        // 不会执行
    }
}
console.timeEnd('有 try-catch,不抛异常');

// 测试3:用 try-catch,每次都抛异常(注意迭代次数少很多)
console.time('有 try-catch,每次抛异常');
for (let i = 0; i < 10000; i++) {
    try {
        throw new Error('test');
    } catch (e) {
        // 处理异常
    }
}
console.timeEnd('有 try-catch,每次抛异常');

结果(Node.js v20,M1 Mac):

场景 迭代次数 耗时
无 try-catch 1,000,000 1.8ms
有 try-catch,不抛异常 1,000,000 1.2ms
有 try-catch,每次抛异常 10,000 13.9ms

有意思的是,加了 try-catch 反而更快了?

是的,这可能是 V8 引擎的优化效果。但重点是:只要不抛异常,try-catch 的开销可以忽略不计。

而抛异常的场景呢?迭代次数少了100倍,耗时却多了10倍。换算一下,异常抛出比正常执行慢约1000倍

为什么异常抛出这么慢?

因为 JavaScript 引擎需要做三件事:

  1. 创建 Error 对象 - 这个对象包含了错误信息
  2. 捕获堆栈跟踪 - 遍历调用栈,记录每一层的函数名、文件名、行号
  3. 展开调用栈 - 从抛出点一直往上找,直到找到匹配的 catch 块

其中第 2 步最耗时。调用栈越深,捕获堆栈跟踪的开销越大。

什么时候该用 try-catch?

记住一个原则:异常是用来处理异常情况的,不是用来控制正常流程的

正确用法:处理真正的异常

// JSON 解析可能失败
try {
    const data = JSON.parse(userInput);
    processData(data);
} catch (e) {
    showError('输入的格式不对');
}

// 网络请求可能失败
try {
    const response = await fetch('/api/data');
    const data = await response.json();
} catch (e) {
    showError('网络连接失败');
}

这些场景下,异常是"意外情况",不是每次都会发生。用 try-catch 完全没问题。

错误用法:用异常控制流程

// 错误示范:用异常来判断用户是否存在
function findUser(id) {
    try {
        return database.query(`SELECT * FROM users WHERE id = ${id}`);
    } catch (e) {
        return null;  // 用异常来返回"找不到"
    }
}

如果大部分查询都找不到用户,那每次都会抛异常,性能会很差。

正确做法是先检查,再操作:

// 正确做法
function findUser(id) {
    const result = database.query(`SELECT * FROM users WHERE id = ${id}`);
    return result || null;
}

循环里怎么用 try-catch?

这是另一个常见问题。看两种写法:

// 写法1:try-catch 在循环内
for (const item of items) {
    try {
        processItem(item);
    } catch (e) {
        console.error('处理失败:', item);
    }
}

// 写法2:try-catch 在循环外
try {
    for (const item of items) {
        processItem(item);
    }
} catch (e) {
    console.error('处理失败:', e);
}

性能上,两者差不多。因为只要不抛异常,try-catch 本身几乎没开销。

区别在于错误处理策略

  • 写法1:某一项失败了,继续处理其他项
  • 写法2:某一项失败了,整个循环终止

根据业务需求选择,别纠结性能。

关于早期 V8 的问题

网上有些老文章说"try-catch 会阻止 V8 优化",这在早期版本确实存在。但在 V8 6.0+(Node.js 8.3+,Chrome 60+)之后,这个问题已经解决了。

所以如果你看到有人说"try-catch 会让函数无法被优化",看看文章发布时间。2018 年之前的文章可以参考,但别太当真。

最佳实践总结

  1. 放心用 try-catch - 现代引擎下,性能影响可以忽略
  2. 异常是异常 - 用于处理真正的错误情况,不是控制流程
  3. 先检查,再操作 - 能用 if 判断的,别用异常处理
  4. catch 里要做事 - 空的 catch 块是代码坏味道
  5. 错误要有上下文 - catch 里记录足够的信息方便排查
// 最佳实践示例
async function fetchUserData(userId) {
    if (!userId) {
        return null;  // 先检查,不用异常
    }

    try {
        const response = await fetch(`/api/users/${userId}`);
        if (!response.ok) {
            // HTTP 错误,但不一定是异常
            console.warn(`获取用户失败: ${response.status}`);
            return null;
        }
        return await response.json();
    } catch (e) {
        // 真正的异常:网络断开、JSON 解析失败等
        console.error('获取用户数据异常:', {
            userId,
            error: e.message,
            stack: e.stack
        });
        throw e;  // 根据需要决定是否重新抛出
    }
}

面试怎么答?

下次再被问到这个问题,可以这样回答:

try-catch 本身在现代 JavaScript 引擎中几乎没有性能开销。真正影响性能的是异常的抛出和捕获,因为需要创建 Error 对象和捕获堆栈跟踪。所以建议把 try-catch 用于处理真正的异常情况,而不是用来控制正常的程序流程。比如用户输入校验,应该用 if 判断而不是 try-catch。


如果你觉得这篇文章有帮助,欢迎关注我的 GitHub,下面是我的一些开源项目:

Claude Code Skills(按需加载,意图自动识别,不浪费 token,介绍文章):

全栈项目(适合学习现代技术栈):

  • prompt-vault - Prompt 管理器,用的都是最新的技术栈,适合用来学习了解最新的前端全栈开发范式:Next.js 15 + React 19 + tRPC 11 + Supabase 全栈示例,clone 下来配个免费 Supabase 就能跑
  • chat_edit - 双模式 AI 应用(聊天+富文本编辑),Vue 3.5 + TypeScript + Vite 5 + Quill 2.0 + IndexedDB

改了CSS刷新没反应-你可能不懂HTTP缓存

改了 CSS 刷新没反应?你可能不懂 HTTP 缓存

做前端的应该都遇到过这种情况:明明改了 CSS,浏览器里看到的还是旧样式。网上搜一下,告诉你"清缓存",Ctrl+F5 一刷新就好了。

但问题是:

  • 缓存到底是怎么工作的?
  • 为什么有时候会用缓存,有时候不会?
  • Network 面板里的 (disk cache)304 Not Modified 有什么区别?

今天把这个问题彻底搞清楚。

HTTP 缓存的两层机制

浏览器缓存分两层:强缓存协商缓存

简单说:

  • 强缓存:直接用本地缓存,不问服务器
  • 协商缓存:问一下服务器"我这个还能用吗?"服务器说"能",就用缓存
浏览器想请求资源
    │
    ▼
检查本地缓存
    │
    ├── 有缓存且没过期 → 强缓存命中 → 返回 200 (from cache)
    │
    └── 没缓存 / 过期了
            │
            ▼
        发请求给服务器,带上验证信息
            │
            ├── 服务器说没变 → 协商缓存命中 → 返回 304
            │
            └── 服务器说变了 → 返回 200 + 新资源

强缓存:完全不发请求

强缓存命中时,浏览器直接从本地拿资源,Network 面板会显示:

  • Size 列:(disk cache)(memory cache)
  • Time 列:0ms 或很小的数字

控制强缓存的响应头:

# 方式1:Cache-Control(推荐)
Cache-Control: max-age=31536000

# 方式2:Expires(老方式,优先级低于 Cache-Control)
Expires: Wed, 21 Oct 2025 07:28:00 GMT

max-age=31536000 意思是"这个资源 31536000 秒(一年)内有效"。

memory cache vs disk cache

两者区别:

类型 存储位置 速度 什么时候用
memory cache 内存 最快 当前会话、小文件、频繁访问的资源
disk cache 硬盘 稍慢 跨会话、大文件

关了浏览器 Tab,memory cache 就没了,disk cache 还在。

协商缓存:问一下服务器

当强缓存过期(或被设置为 no-cache),浏览器会发请求给服务器验证资源是否变化。

有两种验证方式:

方式1:ETag / If-None-Match(推荐)

基于内容哈希:

# 第一次请求,服务器返回
HTTP/1.1 200 OK
ETag: "abc123"
Cache-Control: no-cache

# 后续请求,浏览器带上 If-None-Match
GET /style.css HTTP/1.1
If-None-Match: "abc123"

# 如果内容没变,服务器返回
HTTP/1.1 304 Not Modified
ETag: "abc123"

# 如果内容变了,服务器返回
HTTP/1.1 200 OK
ETag: "xyz789"
[新的文件内容]

方式2:Last-Modified / If-Modified-Since

基于修改时间:

# 第一次请求,服务器返回
HTTP/1.1 200 OK
Last-Modified: Wed, 21 Oct 2024 07:28:00 GMT

# 后续请求,浏览器带上 If-Modified-Since
GET /style.css HTTP/1.1
If-Modified-Since: Wed, 21 Oct 2024 07:28:00 GMT

# 如果文件没改过,服务器返回 304

ETag vs Last-Modified

优先用 ETag,因为 Last-Modified 有问题:

  1. 精度只到秒 - 1 秒内改多次,检测不到
  2. 时间可能不准 - 服务器时间、部署时间等问题
  3. 只看时间不看内容 - 文件内容没变但时间变了,也会重新下载

不同资源该用什么策略?

静态资源(JS/CSS/图片)

Cache-Control: max-age=31536000, immutable

配合文件名哈希:app.abc123.js

内容变了 → 文件名变了 → 请求新文件,老文件继续用缓存。

这是现代前端打包工具(Webpack、Vite)的标准做法。

HTML 文件

Cache-Control: no-cache
ETag: "page-v1"

no-cache 不是"不缓存",而是"每次都要验证"。HTML 文件要保证用户能拿到最新版本,否则会引用旧的 JS/CSS 文件名。

API 接口

根据数据特性选择:

# 几乎不变的数据(如配置)
Cache-Control: max-age=3600

# 实时性要求高的数据
Cache-Control: no-cache

# 敏感数据(不要缓存)
Cache-Control: no-store

no-store 才是真正的"不缓存",连验证都不做。

常见问题排查

改了文件,浏览器没反应

原因:强缓存还没过期。

解决方案:

  1. 开发环境 - 打开 DevTools,勾选 "Disable cache"
  2. 生产环境 - 用文件名哈希,别用 ?v=1.0 这种

用了 ?v=1.0 还是不行

<link rel="stylesheet" href="/css/style.css?v=1.0">

问题:有些 CDN 会忽略查询参数,或者浏览器缓存策略不一致。

正确做法:

<link rel="stylesheet" href="/css/style.abc123.css">

让打包工具自动生成哈希,内容变了文件名才变。

CDN 缓存了旧文件

部署了新版本,但用户还是拿到旧文件。

原因:CDN 节点还在缓存期内。

解决方案:

  1. 部署时清除 CDN 缓存
  2. 用文件名哈希(CDN 会认为是新文件)

Nginx 配置参考

# 静态资源长期缓存
location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
    expires 1y;
    add_header Cache-Control "public, immutable";
}

# HTML 文件协商缓存
location ~* \.html$ {
    add_header Cache-Control "no-cache";
    etag on;
}

# API 不缓存
location /api/ {
    add_header Cache-Control "no-store";
}

快速判断缓存类型

打开 DevTools Network 面板:

看到什么 缓存类型 含义
200 + (disk cache) 强缓存 从硬盘读取
200 + (memory cache) 强缓存 从内存读取
304 Not Modified 协商缓存 服务器说没变
200 + 正常大小 无缓存 重新下载

一句话总结

  • 强缓存:不问服务器,直接用。适合长期不变的静态资源。
  • 协商缓存:问服务器,没变就用缓存。适合需要保持最新的内容。
  • 文件名哈希:解决缓存更新问题的银弹。

如果你觉得这篇文章有帮助,欢迎关注我的 GitHub,下面是我的一些开源项目:

Claude Code Skills(按需加载,意图自动识别,不浪费 token,介绍文章):

全栈项目(适合学习现代技术栈):

  • prompt-vault - Prompt 管理器,用的都是最新的技术栈,适合用来学习了解最新的前端全栈开发范式:Next.js 15 + React 19 + tRPC 11 + Supabase 全栈示例,clone 下来配个免费 Supabase 就能跑
  • chat_edit - 双模式 AI 应用(聊天+富文本编辑),Vue 3.5 + TypeScript + Vite 5 + Quill 2.0 + IndexedDB

面试官问 React Fiber,这一篇文章就够了

看 React 源码或者技术文章的时候,经常会遇到 Fiber 这个词:

  • Fiber 到底是什么?为什么 React 要搞出这么个东西?
  • 都说 Fiber 让渲染可以"中断",具体是怎么实现的?
  • Fiber 和 Hooks、Suspense、Concurrent Mode 这些特性有什么关系?

这篇文章会把 Fiber 架构从头到尾讲清楚,包括它的设计动机、数据结构、工作流程,以及它是如何支撑 React 18/19 那些"并发特性"的。

为什么需要 Fiber?

Stack Reconciler 的困境

React 16 之前用的是 Stack Reconciler(栈调和器)。名字来源于它的工作方式——依赖 JavaScript 的调用栈来递归处理组件树。

当你调用 setState() 的时候,React 会从根节点开始,递归遍历整棵组件树,计算出哪些节点需要更新,然后一次性把变更应用到 DOM 上。

这个过程有个致命问题:同步且不可中断

假设你有一个包含 1000 个节点的列表,用户在输入框里打了个字,触发了状态更新。React 必须一口气把这 1000 个节点都 diff 完、更新完,才能把控制权还给浏览器。在这期间:

  • 用户输入没有响应(输入框卡住)
  • 动画掉帧(因为主线程被占用)
  • 整个页面感觉"卡顿"

问题的根源在于:所有更新都被当成同等优先级处理。用户的输入响应、动画渲染、后台数据更新,在 Stack Reconciler 眼里都一样——必须按顺序执行,谁也不能插队。

Fiber 的解题思路

2015 年,Facebook 开始开发 Fiber,2017 年随 React 16 正式发布。

Fiber 的核心思想是:把不可中断的递归调用,改成可中断的循环遍历

用 Andrew Clark(React 核心开发者)的话说:

"Fiber 是对调用栈的重新实现,专门为 React 组件设计。你可以把一个 fiber 想象成一个虚拟的栈帧,好处是你可以把这些栈帧保存在内存里,然后随时随地执行它们。"

这段话有点抽象,展开来说就是:

  1. 把大任务拆成小任务:每个组件的处理变成一个独立的"工作单元"(fiber)
  2. 每个小任务执行完都可以暂停:检查是否有更紧急的事情要做
  3. 高优先级任务可以插队:用户输入比后台数据更新重要
  4. 被中断的任务可以恢复:从上次暂停的地方继续

Fiber 数据结构

Fiber 不只是一个概念,它是一个具体的数据结构。每个 React 组件在内部都对应一个 fiber 节点,这些节点组成一棵树,但不是普通的树——是用链表串联的树。

FiberNode 的关键字段

直接看 React 源码中的 FiberNode 构造函数(简化版):

function FiberNode(tag, pendingProps, key, mode) {
  // ========== 身份标识 ==========
  this.tag = tag;           // fiber 类型:FunctionComponent、ClassComponent、HostComponent 等
  this.key = key;           // 用于 diff 的唯一标识
  this.type = null;         // 组件函数/类,或者 DOM 标签名(如 'div')
  this.stateNode = null;    // 对应的真实 DOM 节点,或者类组件的实例

  // ========== 树结构指针 ==========
  this.return = null;       // 父节点
  this.child = null;        // 第一个子节点
  this.sibling = null;      // 下一个兄弟节点
  this.index = 0;           // 在兄弟节点中的位置

  // ========== 状态相关 ==========
  this.pendingProps = pendingProps;   // 新的 props(待处理)
  this.memoizedProps = null;          // 上次渲染用的 props
  this.memoizedState = null;          // 上次渲染的 state(Hooks 链表也存这里)
  this.updateQueue = null;            // 状态更新队列

  // ========== 副作用 ==========
  this.flags = NoFlags;               // 副作用标记:Placement、Update、Deletion 等
  this.subtreeFlags = NoFlags;        // 子树中的副作用标记
  this.deletions = null;              // 需要删除的子节点

  // ========== 调度相关 ==========
  this.lanes = NoLanes;               // 当前节点的优先级
  this.childLanes = NoLanes;          // 子树中的优先级

  // ========== 双缓冲 ==========
  this.alternate = null;              // 指向另一棵树中对应的 fiber
}

tag 字段:fiber 的类型标识

tag 决定了 React 如何处理这个 fiber。常见的类型包括:

tag 值 类型名称 说明
0 FunctionComponent 函数组件
1 ClassComponent 类组件
3 HostRoot 根节点(ReactDOM.createRoot() 创建的)
5 HostComponent 原生 DOM 元素,如 <div>
6 HostText 文本节点
7 Fragment <React.Fragment>
11 ForwardRef React.forwardRef() 创建的组件
14 MemoComponent React.memo() 包装的组件
15 SimpleMemoComponent 简单的 memo 组件

React 根据 tag 来决定调用什么方法。比如遇到 FunctionComponent 就执行函数拿返回值,遇到 ClassComponent 就调用 render() 方法。

链表树结构

Fiber 树用三个指针串联:

         ┌─────────────────────────────────────────────┐
         │                   App                        │
         │          (return: null)                      │
         └─────────────────────────────────────────────┘
                           │
                         child
                           ↓
         ┌─────────────────────────────────────────────┐
         │                 Header                       │
         │            (return: App)                     │
         └─────────────────────────────────────────────┘
                           │
                         child                  sibling
                           ↓                       ↓
         ┌─────────────────┐              ┌─────────────────┐
         │      Logo       │   sibling    │      Nav        │
         │  (return: Header)│ ─────────→  │  (return: Header)│
         └─────────────────┘              └─────────────────┘
  • child:指向第一个子节点
  • sibling:指向下一个兄弟节点
  • return:指向父节点

为什么用链表而不是数组存子节点?因为链表可以方便地暂停和恢复遍历——只需要记住当前处理到哪个节点就行。

双缓冲机制

Fiber 使用"双缓冲"技术,同时维护两棵树:

  • current 树:当前屏幕上显示的内容
  • workInProgress 树:正在构建的新树

两棵树的节点通过 alternate 指针互相引用:

   current 树                    workInProgress 树

   ┌─────────┐      alternate     ┌─────────┐
   │  App    │ ←─────────────────→│  App    │
   │(current)│                    │ (WIP)   │
   └─────────┘                    └─────────┘
       │                              │
   ┌─────────┐      alternate     ┌─────────┐
   │ Header  │ ←─────────────────→│ Header  │
   │(current)│                    │ (WIP)   │
   └─────────┘                    └─────────┘

这个设计的好处:

  1. 渲染过程中不影响当前显示:所有变更都在 workInProgress 树上进行
  2. 原子化提交:构建完成后,直接把 workInProgress 变成 current(交换指针)
  3. 复用 fiber 节点:下次更新时,current 树变成 workInProgress 树的基础,减少内存分配

工作循环:Fiber 如何执行

Fiber 的核心执行逻辑在 workLoop 函数中。整个过程分为两个阶段:Render 阶段Commit 阶段

整体流程

flowchart TB
    subgraph render["Render 阶段(可中断)"]
        direction TB
        A[开始更新] --> B[选取下一个工作单元]
        B --> C{有工作单元?}
        C -->|是| D[beginWork: 处理当前节点]
        D --> E{有子节点?}
        E -->|是| B
        E -->|否| F[completeWork: 完成当前节点]
        F --> G{有兄弟节点?}
        G -->|是| B
        G -->|否| H[返回父节点继续 complete]
        H --> I{回到根节点?}
        I -->|否| G
        I -->|是| J[Render 阶段结束]
        C -->|否| J
    end

    subgraph commit["Commit 阶段(不可中断)"]
        direction TB
        K[开始提交] --> L[Before Mutation]
        L --> M[Mutation: 操作 DOM]
        M --> N[Layout: 执行副作用]
        N --> O[完成]
    end

    J --> K

    style render fill:#cce5ff,stroke:#0d6efd
    style commit fill:#d4edda,stroke:#28a745

Render 阶段:构建 workInProgress 树

Render 阶段的目标是:遍历 fiber 树,找出哪些节点需要更新,打上标记(flags),构建完整的 workInProgress 树。

这个阶段是可中断的——React 可以在处理完任意一个 fiber 后暂停,把控制权交还给浏览器。

workLoop:工作循环

function workLoop() {
  // 循环处理每个工作单元
  while (workInProgress !== null) {
    performUnitOfWork(workInProgress);
  }
}

function performUnitOfWork(unitOfWork) {
  const current = unitOfWork.alternate;

  // 1. beginWork:处理当前节点,返回子节点
  const next = beginWork(current, unitOfWork, renderLanes);

  // 2. 更新 memoizedProps
  unitOfWork.memoizedProps = unitOfWork.pendingProps;

  if (next === null) {
    // 没有子节点了,完成当前节点的工作
    completeUnitOfWork(unitOfWork);
  } else {
    // 有子节点,继续处理子节点
    workInProgress = next;
  }
}

beginWork:向下遍历,标记更新

beginWork 负责处理当前 fiber 节点,主要做这些事:

  1. 判断是否可以跳过:如果 props 和 state 都没变,直接跳过这个子树
  2. 根据 fiber 类型执行不同逻辑:函数组件就执行函数,类组件就调用 render 方法
  3. 创建/更新子 fiber 节点:对比新旧 children,进行 diff
  4. 返回第一个子 fiber:作为下一个工作单元
function beginWork(current, workInProgress, renderLanes) {
  // 优化:如果没有更新,可以跳过
  if (current !== null) {
    const oldProps = current.memoizedProps;
    const newProps = workInProgress.pendingProps;

    if (oldProps === newProps && !hasContextChanged()) {
      // 没有变化,尝试跳过
      return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes);
    }
  }

  // 根据 fiber 类型分发处理
  switch (workInProgress.tag) {
    case FunctionComponent:
      return updateFunctionComponent(current, workInProgress, renderLanes);
    case ClassComponent:
      return updateClassComponent(current, workInProgress, renderLanes);
    case HostComponent:
      return updateHostComponent(current, workInProgress, renderLanes);
    // ... 其他类型
  }
}

completeWork:向上回溯,准备 DOM

当一个 fiber 节点没有子节点(或所有子节点都处理完了),就会调用 completeWork

  1. 创建/更新真实 DOM 节点:但还不挂载到页面上
  2. 收集副作用:把有 flags 的节点串成链表,方便 Commit 阶段处理
  3. 冒泡 subtreeFlags:让父节点知道子树中有没有需要处理的副作用
function completeWork(current, workInProgress, renderLanes) {
  const newProps = workInProgress.pendingProps;

  switch (workInProgress.tag) {
    case HostComponent: {
      const type = workInProgress.type; // 如 'div'

      if (current !== null && workInProgress.stateNode !== null) {
        // 更新:对比新旧 props,计算需要更新的属性
        updateHostComponent(current, workInProgress, type, newProps);
      } else {
        // 新建:创建 DOM 元素
        const instance = createInstance(type, newProps);
        // 把所有子 DOM 节点挂到这个元素上
        appendAllChildren(instance, workInProgress);
        workInProgress.stateNode = instance;
      }

      // 收集副作用标记到父节点
      bubbleProperties(workInProgress);
      return null;
    }
    // ... 其他类型
  }
}

遍历顺序示意

假设有这样一棵组件树:

       App
      /   \
   Header  Main
    /  \      \
  Logo  Nav   Content

Fiber 的遍历顺序是深度优先,但会在每个节点上执行 beginWork(向下)和 completeWork(向上):

1. beginWork(App)
2. beginWork(Header)
3. beginWork(Logo)
4. completeWork(Logo)     ← Logo 没有子节点,开始 complete
5. beginWork(Nav)         ← 回到 Header,处理下一个子节点
6. completeWork(Nav)
7. completeWork(Header)   ← Header 所有子节点处理完,complete 自己
8. beginWork(Main)
9. beginWork(Content)
10. completeWork(Content)
11. completeWork(Main)
12. completeWork(App)     ← 回到根节点,Render 阶段结束

Commit 阶段:应用变更

Render 阶段完成后,workInProgress 树已经构建好了,所有需要的变更也都标记好了。接下来就是 Commit 阶段——把这些变更实际应用到 DOM 上。

Commit 阶段是同步的、不可中断的。因为这个阶段要操作真实 DOM,必须一次性完成,否则用户会看到不一致的 UI。

Commit 阶段分为三个子阶段:

1. Before Mutation(DOM 操作前)

  • 调用 getSnapshotBeforeUpdate 生命周期方法
  • 调度 useEffect 的清理函数(异步)

2. Mutation(执行 DOM 操作)

这是真正修改 DOM 的阶段:

function commitMutationEffects(root, finishedWork) {
  while (nextEffect !== null) {
    const flags = nextEffect.flags;

    // 处理 DOM 插入
    if (flags & Placement) {
      commitPlacement(nextEffect);
    }

    // 处理 DOM 更新
    if (flags & Update) {
      commitWork(current, nextEffect);
    }

    // 处理 DOM 删除
    if (flags & Deletion) {
      commitDeletion(root, nextEffect);
    }

    nextEffect = nextEffect.nextEffect;
  }
}

3. Layout(DOM 操作后)

  • 调用 componentDidMount / componentDidUpdate
  • 调用 useLayoutEffect 的回调
  • 更新 ref

最后,React 把 current 指针指向 workInProgress 树,完成树的切换。

优先级调度:让重要的事先做

Fiber 架构的一大优势是支持优先级调度。不是所有更新都同等重要——用户输入应该比后台数据刷新更快响应。

Lane 优先级模型

React 18 使用 Lane 模型来表示优先级。每个优先级是一个 32 位整数中的一个位:

const NoLanes = 0b0000000000000000000000000000000;
const SyncLane = 0b0000000000000000000000000000001;         // 最高优先级:同步
const InputContinuousLane = 0b0000000000000000000000000100; // 连续输入,如拖拽
const DefaultLane = 0b0000000000000000000000000010000;      // 默认优先级
const TransitionLane1 = 0b0000000000000000000001000000;     // Transition
const IdleLane = 0b0100000000000000000000000000000;         // 空闲时执行

位运算的好处是可以高效地合并、比较多个优先级:

// 合并优先级
const mergedLanes = lane1 | lane2;

// 判断是否包含某优先级
const includesLane = (lanes & lane) !== 0;

// 获取最高优先级
const highestLane = lanes & -lanes;

Scheduler:任务调度器

React 有一个独立的 Scheduler 包,负责按优先级调度任务。它的核心思想是时间切片(Time Slicing):

  1. 把渲染工作拆成多个小任务
  2. 每个小任务执行完后,检查是否有更高优先级的任务
  3. 如果有,暂停当前工作,先处理高优先级任务
  4. 如果时间片用完了(通常是 5ms),让出主线程给浏览器
function workLoopConcurrent() {
  // 循环执行工作单元,但会在时间片用完时暂停
  while (workInProgress !== null && !shouldYield()) {
    performUnitOfWork(workInProgress);
  }
}

function shouldYield() {
  // 检查是否还有剩余时间,或者是否有更高优先级的任务
  return getCurrentTime() >= deadline || hasHigherPriorityWork();
}

时间切片的效果

没有时间切片的情况下,一个耗时 200ms 的渲染任务会完全阻塞主线程:

主线程:[==== 200ms 渲染任务 ====]
         ^                    ^
         |                    |
    用户点击                200ms 后才响应

有时间切片后,渲染任务被拆成多个 5ms 的小块,中间可以响应用户交互:

主线程:[5ms][响应点击][5ms][5ms][5ms]...[5ms]
         ^      ^
         |      |
    用户点击   立即响应

startTransition:标记低优先级更新

React 18 提供了 startTransition API,让开发者可以手动标记哪些更新是"可以延迟的":

import { startTransition } from 'react';

function SearchPage() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);

  function handleChange(e) {
    // 高优先级:立即更新输入框
    setQuery(e.target.value);

    // 低优先级:搜索结果可以稍后更新
    startTransition(() => {
      setResults(search(e.target.value));
    });
  }

  return (
    <>
      <input value={query} onChange={handleChange} />
      <SearchResults results={results} />
    </>
  );
}

在这个例子中,用户每次输入都会触发两个更新:

  1. setQuery:高优先级,输入框立即响应
  2. setResults:低优先级,包裹在 startTransition 里,可以被打断

如果用户快速输入,搜索结果的渲染可能会被跳过几次,直到用户停止输入。这保证了输入框始终流畅响应,而不会被搜索结果的渲染阻塞。

Hooks 与 Fiber 的关系

Hooks 是怎么实现的?答案就在 Fiber 节点的 memoizedState 字段里。

Hooks 链表

每个函数组件的 fiber 节点都有一个 memoizedState 字段,存储着该组件所有 hooks 的状态。这些状态以链表的形式串联:

FiberNode
├── memoizedState ─→ Hook1 ─→ Hook2 ─→ Hook3 ─→ null
│                    (useState) (useEffect) (useMemo)
└── ...

每个 hook 节点的结构大致如下:

const hook = {
  memoizedState: any,     // 这个 hook 存储的值
  baseState: any,         // 更新前的基础状态
  baseQueue: Update | null,
  queue: UpdateQueue,     // 待处理的更新队列
  next: Hook | null       // 指向下一个 hook
};

useState 的实现原理

当你在组件里调用 useState(0) 时,React 做的事情:

首次渲染(mount):

function mountState(initialState) {
  // 1. 创建一个新的 hook 节点,挂到链表上
  const hook = mountWorkInProgressHook();

  // 2. 计算初始值
  if (typeof initialState === 'function') {
    initialState = initialState();
  }

  // 3. 保存状态
  hook.memoizedState = hook.baseState = initialState;

  // 4. 创建更新队列
  hook.queue = {
    pending: null,
    dispatch: null,
    // ...
  };

  // 5. 返回 [state, setState]
  const dispatch = dispatchSetState.bind(null, currentlyRenderingFiber, hook.queue);
  hook.queue.dispatch = dispatch;

  return [hook.memoizedState, dispatch];
}

后续更新(update):

function updateState() {
  // 1. 找到当前 hook(按顺序从链表取)
  const hook = updateWorkInProgressHook();

  // 2. 处理所有待处理的更新
  const queue = hook.queue;
  let newState = hook.baseState;

  let update = queue.pending;
  while (update !== null) {
    // 应用每个更新
    newState = typeof update.action === 'function'
      ? update.action(newState)
      : update.action;
    update = update.next;
  }

  // 3. 保存新状态
  hook.memoizedState = newState;

  return [newState, queue.dispatch];
}

为什么 Hooks 不能在条件语句里调用?

因为 hooks 是按调用顺序存储在链表里的,React 靠顺序来匹配每次渲染时的 hook。

假设你这样写:

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

  if (count > 0) {
    useEffect(() => { /* ... */ }); // 危险!
  }

  const [name, setName] = useState('');
  // ...
}

第一次渲染时 count 是 0,hooks 链表是:

Hook1(useState: count) → Hook2(useState: name) → null

第二次渲染时 count 变成 1,useEffect 被执行了,链表变成:

Hook1(useState: count) → Hook2(useEffect) → Hook3(useState: name) → null

React 按顺序取 hook,第二个位置取到了 useEffect,但代码里第二个 hook 是 useState,类型对不上,直接报错。

所以 React 的规则是:Hooks 只能在函数组件的最顶层调用,不能在条件、循环或嵌套函数里。

useEffect 的实现原理

useEffect 的状态存储方式略有不同,它的副作用会被收集到 fiber 的 updateQueue 中:

function mountEffect(create, deps) {
  const hook = mountWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;

  // 标记这个 fiber 有 Passive 副作用
  currentlyRenderingFiber.flags |= PassiveEffect;

  // 创建 effect 对象,挂到 fiber 的 updateQueue
  hook.memoizedState = pushEffect(
    HasEffect | Passive,
    create,
    undefined,
    nextDeps
  );
}

Effect 链表结构:

FiberNode.updateQueue.effectsEffect1Effect2Effect3Effect1 (循环链表)
  ├── create: () => { ... }     // 执行的函数
  ├── destroy: () => { ... }    // 清理函数(上次 create 的返回值)
  ├── deps: [a, b]              // 依赖数组
  └── next: Effect2

在 Commit 阶段完成后,React 会异步执行所有 Passive 类型的 effects:

  1. 先执行所有 effect 的 destroy(清理函数)
  2. 再执行所有 effect 的 create(新的副作用)

这也是为什么 useEffect 总是在 DOM 更新后异步执行。

Fiber 支撑的高级特性

Fiber 架构不只是让渲染可以中断,它还是很多 React 高级特性的基础。

Suspense:优雅处理异步

Suspense 让你可以在等待异步数据时显示 fallback UI:

function ProfilePage() {
  return (
    <Suspense fallback={<Spinner />}>
      <ProfileDetails />
    </Suspense>
  );
}

function ProfileDetails() {
  const user = use(fetchUser()); // 这个 promise 还没 resolve 时会"挂起"
  return <h1>{user.name}</h1>;
}

Suspense 的实现依赖 Fiber 的以下能力:

  1. 抛出 Promise:当组件需要等待异步数据时,可以 throw 一个 Promise
  2. 捕获并暂停:Fiber 在遍历时捕获这个 Promise,标记当前子树为"挂起"状态
  3. 显示 fallback:渲染最近的 Suspense 边界的 fallback
  4. 恢复渲染:Promise resolve 后,从挂起的地方重新开始渲染
sequenceDiagram
    participant App
    participant Suspense
    participant ProfileDetails
    participant Fiber

    App->>Suspense: 渲染
    Suspense->>ProfileDetails: 渲染
    ProfileDetails->>Fiber: throw Promise
    Fiber->>Suspense: 捕获 Promise,显示 fallback
    Note over Suspense: 显示 <Spinner />
    ProfileDetails-->>Fiber: Promise resolved
    Fiber->>ProfileDetails: 重新渲染
    ProfileDetails->>Suspense: 返回真实内容
    Note over Suspense: 显示 <h1>用户名</h1>

Concurrent Rendering:并发渲染

React 18 的并发模式完全建立在 Fiber 之上。它的核心能力是:React 可以同时准备多个版本的 UI

比如使用 useTransition

function TabContainer() {
  const [isPending, startTransition] = useTransition();
  const [tab, setTab] = useState('home');

  function selectTab(nextTab) {
    startTransition(() => {
      setTab(nextTab);
    });
  }

  return (
    <>
      <TabButton onClick={() => selectTab('home')}>Home</TabButton>
      <TabButton onClick={() => selectTab('posts')}>Posts</TabButton>
      <TabButton onClick={() => selectTab('contact')}>Contact</TabButton>

      {isPending && <Spinner />}

      <TabPanel tab={tab} />
    </>
  );
}

当用户点击 "Posts" tab 时:

  1. React 开始在"后台"渲染 Posts 页面(构建 workInProgress 树)
  2. 同时保持显示当前的 Home 页面(current 树不变)
  3. 如果 Posts 渲染很慢,用户可以点击其他 tab,之前的渲染会被放弃
  4. 渲染完成后,React 才会切换到新的 UI

这就是"并发"的含义:多个渲染任务可以同时存在,React 可以在它们之间切换。

Error Boundaries:错误边界

Error Boundaries 也依赖 Fiber 的树结构:

class ErrorBoundary extends React.Component {
  state = { hasError: false };

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

  componentDidCatch(error, info) {
    logError(error, info.componentStack);
  }

  render() {
    if (this.state.hasError) {
      return <h1>Something went wrong.</h1>;
    }
    return this.props.children;
  }
}

当子组件抛出错误时,Fiber 会:

  1. 沿着 return 指针向上查找最近的 Error Boundary
  2. 标记该 Error Boundary 的 fiber 有 ShouldCapture flag
  3. 在 Commit 阶段调用 componentDidCatch
  4. 重新渲染 Error Boundary,显示降级 UI

Server Components:服务端组件

React Server Components 也依赖 Fiber 架构。服务端渲染时,React 会:

  1. 在服务端构建 Fiber 树
  2. 将 Fiber 树序列化成特殊的"Flight"格式
  3. 客户端接收后,重建 Fiber 树,进行 Hydration

由于 Fiber 是一个可序列化的数据结构(本质上是一棵树),它可以在服务端和客户端之间传输。

实际影响:性能优化建议

理解了 Fiber 架构,可以更好地进行性能优化。

1. 避免频繁创建新对象

每次渲染时创建新对象会导致 props 变化,触发不必要的子组件更新:

// 差:每次渲染都创建新的 style 对象
function Bad() {
  return <div style={{ color: 'red' }}>...</div>;
}

// 好:把常量提到组件外面
const style = { color: 'red' };
function Good() {
  return <div style={style}>...</div>;
}

在 Fiber 的 beginWork 阶段,React 会比较新旧 props。如果是同一个引用,可以快速跳过子树的处理。

2. 合理使用 key

key 帮助 React 在 diff 时识别哪些元素改变了位置:

// 差:用 index 作为 key,列表重排序时会有问题
items.map((item, index) => <Item key={index} {...item} />)

// 好:用稳定的唯一 ID
items.map(item => <Item key={item.id} {...item} />)

Fiber 在 reconcile children 时,会用 key 来复用已有的 fiber 节点,而不是删除再创建。

3. 使用 startTransition 处理大量更新

如果某个操作会触发大量组件更新(比如筛选长列表),用 startTransition 标记为低优先级:

function FilteredList({ items }) {
  const [filter, setFilter] = useState('');
  const [filteredItems, setFilteredItems] = useState(items);

  function handleFilterChange(e) {
    const value = e.target.value;
    setFilter(value); // 高优先级:输入框立即响应

    startTransition(() => {
      // 低优先级:过滤操作可以稍后执行
      setFilteredItems(items.filter(item =>
        item.name.includes(value)
      ));
    });
  }

  return (
    <>
      <input value={filter} onChange={handleFilterChange} />
      <ItemList items={filteredItems} />
    </>
  );
}

4. 使用 useDeferredValue 延迟非关键更新

useDeferredValue 可以让某个值的更新延后:

function SearchResults({ query }) {
  // 这个值会延迟更新,不阻塞输入
  const deferredQuery = useDeferredValue(query);

  // 基于延迟的值进行渲染
  const results = useMemo(
    () => slowSearch(deferredQuery),
    [deferredQuery]
  );

  return <ResultList results={results} />;
}

5. Suspense 配合 lazy 做代码分割

const HeavyComponent = lazy(() => import('./HeavyComponent'));

function App() {
  return (
    <Suspense fallback={<Loading />}>
      <HeavyComponent />
    </Suspense>
  );
}

Fiber 会在 HeavyComponent 加载时挂起,显示 Loading,加载完成后自动恢复渲染。

调试 Fiber

React DevTools 可以直接查看 Fiber 树。打开 DevTools 的 Components 面板,你看到的组件树本质上就是 Fiber 树的可视化。

你还可以在浏览器控制台里访问 fiber:

// 获取某个 DOM 元素对应的 fiber
const fiber = domElement._reactFiber$...; // 键名是动态的

// 查看 fiber 的结构
console.log(fiber.type);         // 组件类型
console.log(fiber.memoizedState); // 状态(hooks 链表)
console.log(fiber.memoizedProps); // props
console.log(fiber.child);        // 第一个子节点
console.log(fiber.sibling);      // 兄弟节点
console.log(fiber.return);       // 父节点

总结

React Fiber 是 React 从 16 版本开始的核心架构,它解决了旧版 Stack Reconciler 的几个关键问题:

  1. 可中断渲染:把递归调用改成循环遍历,每处理完一个 fiber 就可以暂停
  2. 优先级调度:不同类型的更新有不同优先级,重要的先做
  3. 并发渲染:可以同时准备多个版本的 UI,按需切换
  4. 增量渲染:把渲染工作拆成小块,分散到多个帧中

Fiber 的核心是一个链表树结构的数据模型,加上双缓冲技术和两阶段渲染(Render + Commit)。它是 Hooks、Suspense、Concurrent Mode、Server Components 等现代 React 特性的基础设施。

理解 Fiber 不是为了在日常开发中直接操作它,而是为了:

  • 理解 React 的工作原理,写出更高效的代码
  • 更好地使用 startTransitionuseDeferredValue 等 API
  • 排查性能问题时知道从哪里入手
  • 读懂 React 源码和技术文章

最后放几个深入学习的资源:


如果你觉得这篇文章有帮助,欢迎关注我的 GitHub,下面是我的一些开源项目:

Claude Code Skills(按需加载,意图自动识别,不浪费 token,介绍文章):

全栈项目(适合学习现代技术栈):

  • prompt-vault - Prompt 管理器,用的都是最新的技术栈,适合用来学习了解最新的前端全栈开发范式:Next.js 15 + React 19 + tRPC 11 + Supabase 全栈示例,clone 下来配个免费 Supabase 就能跑
  • chat_edit - 双模式 AI 应用(聊天+富文本编辑),Vue 3.5 + TypeScript + Vite 5 + Quill 2.0 + IndexedDB
❌