阅读视图

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

重排、重绘与合成——浏览器渲染性能的底层逻辑

有一段时间我一直搞不明白一件事:同样是"移动一个元素",用 transform: translateX() 就很丝滑,用 left 就会掉帧——明明做的是同一件事,为什么差这么多?后来真正把浏览器渲染的这三个概念搞清楚之后,才发现这不是玄学,是完全可以用机制解释的。这篇是我的学习笔记。


一、先厘清一个容易混淆的概念

在进入正题前,必须先把这两件事分开:React re-render浏览器重排/重绘

它们经常被放在一起讨论,但其实是两个不同层的事情:

筛选条件变化
    ↓
React re-render(React 层)
→ 组件函数重新执行,生成新的虚拟 DOM
→ Diff 算出最小变更
→ 更新真实 DOM
    ↓
浏览器重排 / 重绘(浏览器层)
→ 浏览器感知到 DOM 变化,重新计算布局/绘制

React re-render 可能触发浏览器重排/重绘,但两者不是同一回事。React re-render 是 JS 层面的虚拟 DOM 计算,浏览器重排/重绘是渲染引擎层面的像素计算。优化方向也不同:useMemo/React.memo 减少的是 React re-render,transform 替代 top 优化的是浏览器渲染层。


二、浏览器渲染流程回顾

在"从 URL 到页面"的完整链路里,最后一段是浏览器拿到 DOM + CSSOM 之后的渲染工作:

DOM + CSSOM
    ↓
Render Tree(渲染树)
    ↓
Layout(重排)   ← 计算每个元素的位置和大小
    ↓
Paint(重绘)    ← 填充颜色、边框、阴影……
    ↓
Composite(合成)← 合并图层,输出到屏幕

重排、重绘、合成,是这条流水线的最后三步。理解它们的代价差异,是理解所有 CSS 性能优化的基础。


三、重排(Reflow):最贵的一步

什么是重排?

当元素的几何属性(位置、大小)发生变化,浏览器需要重新计算所有受影响元素的布局信息——这个过程叫重排,也叫 Reflow。

典型触发场景:

// 修改几何属性
element.style.width = '200px';
element.style.height = '100px';
element.style.margin = '20px';
element.style.padding = '10px';

// 改变元素显示状态
element.style.display = 'none';   // 从文档流移除,触发重排
element.style.display = 'block';  // 重新加入文档流,触发重排

// DOM 结构变化
document.body.appendChild(newElement);
parent.removeChild(child);

// 窗口大小变化
window.addEventListener('resize', handler);

为什么代价大?

重排的代价在于连锁反应。HTML 元素的布局是相互影响的——一个元素的宽度变了,它的兄弟元素可能需要重新排列,父元素的高度可能随之变化,父元素的父元素又可能受影响……

浏览器需要从受影响的节点开始,向上向下重新计算整棵子树的几何信息。如果变化发生在页面顶层,几乎等于重算整个页面布局。


四、重绘(Repaint):比重排轻,但不是没有代价

什么是重绘?

当元素的外观发生变化,但位置和大小没变,浏览器只需要重新绘制受影响区域的像素——这叫重绘,也叫 Repaint。

典型触发场景:

// 颜色类变化
element.style.color = '#333';
element.style.backgroundColor = '#f5f5f5';

// 装饰性变化
element.style.boxShadow = '0 2px 8px rgba(0,0,0,0.1)';
element.style.borderColor = 'red';
element.style.outline = '2px solid blue';

// 可见性(注意:visibility 不触发重排,display 触发)
element.style.visibility = 'hidden';

重绘不需要重新计算布局,只需要重新"上色"——所以比重排轻得多,但仍然有开销,不是免费的。


五、关键规律:三者的包含关系

重排 ⊃ 重绘 ⊃ 合成

重排一定触发重绘(几何变了,外观也要重画)
重绘不一定触发重排(外观变了,位置不一定变)
合成不触发重排和重绘(完全跳过前两步)

开销排序:重排 > 重绘 > 合成


六、合成层与 transform 为什么快

这是整篇文章最关键的部分。

三种操作的完整流程对比

操作 触发流程 性能
width / height / top / left 重排 → 重绘 → 合成 最差
color / background-color 重绘 → 合成 中等
transform / opacity 只合成 最好

transform 的工作原理

当浏览器发现一个元素使用了 transformopacity 动画,它会把这个元素提升到独立的合成层(Compositing Layer) ,交给 GPU 处理。

普通元素动画(left/top):
    修改样式
        ↓
    重新 Layout(计算位置)    ← CPU,影响其他元素
        ↓
    重新 Paint(绘制像素)     ← CPU,绘制整个区域
        ↓
    Composite(合成)          ← GPU

transform/opacity 动画:
    修改样式
        ↓
    Composite(合成)          ← GPU 直接处理
    (跳过 Layout 和 Paint)

关键在于:transform 是在已经绘制好的图层上做变换(平移、缩放、旋转),不改变元素在文档流中的实际位置,所以浏览器不需要重新计算布局,也不需要重新绘制像素——只需要 GPU 把这个图层的矩阵变换一下,直接合成输出。

实际代码对比

/* 触发重排 + 重绘,动画掉帧 */
.box-bad {
  position: absolute;
  left: 0;
  transition: left 0.3s ease;
}
.box-bad:hover {
  left: 200px; /* 每一帧都触发重排 */
}

/* 只触发合成,动画丝滑 */
.box-good {
  position: absolute;
  transform: translateX(0);
  transition: transform 0.3s ease;
}
.box-good:hover {
  transform: translateX(200px); /* 每一帧只触发合成,GPU 处理 */
}

视觉效果完全一样,但渲染代价天壤之别。这就是为什么 CSS 动画优先推荐使用 transform

主动触发合成层提升

除了 transformopacity,还可以通过 will-change 提示浏览器提前创建合成层:

/* 告诉浏览器:这个元素即将发生 transform 变化,提前准备合成层 */
.animated-card {
  will-change: transform;
}
// 动画结束后,记得移除(合成层有内存开销)
element.addEventListener('animationend', () => {
  element.style.willChange = 'auto';
});

will-change 不是越多越好——每个合成层都占用 GPU 内存,滥用反而会导致内存压力和性能下降。只在真正需要优化的动画元素上使用。


七、实际开发陷阱:循环里交替读写 DOM

这是一个在真实项目里很容易踩的坑,也是面试的高频考题。

为什么读取布局属性会触发强制重排?

当你读取 offsetHeightclientWidthgetBoundingClientRect() 等属性时,浏览器必须给你一个当前准确的值

如果在读取之前你刚刚写入了一些样式变化,而浏览器还没来得及执行重排,它就必须立即同步执行重排,才能返回准确数值。这叫强制同步重排(Forced Synchronous Layout)。

问题代码:循环内交替读写

// 每次循环都触发一次强制重排——100 次循环 = 100 次重排
const boxes = document.querySelectorAll('.box');

for (let i = 0; i < boxes.length; i++) {
  const height = boxes[i].offsetHeight;        // 读:强制触发重排,获取准确值
  boxes[i].style.height = height + 10 + 'px'; // 写:标记待重排
  // 下一次循环读 offsetHeight,又强制清算上面的标记
}

浏览器原本会把多次样式修改批量处理(一次重排),但读写交替打破了这个批处理——每次读取都迫使浏览器立即清算之前积累的修改。

修复:先批量读,再批量写

// 先读完所有值,再批量写——只触发 1 次重排
const boxes = document.querySelectorAll('.box');

// 第一步:批量读取(此时触发 1 次重排)
const heights = Array.from(boxes).map(box => box.offsetHeight);

// 第二步:批量写入(浏览器合并成 1 次重排处理)
boxes.forEach((box, i) => {
  box.style.height = heights[i] + 10 + 'px';
});

本质是:把读操作和写操作分离,让浏览器能够合批处理写操作。

如果修改逻辑更复杂,可以借助 requestAnimationFrame 把写操作推到下一帧的开头执行:

// 环境:浏览器
// 场景:确保在下一帧开始时批量执行所有 DOM 写操作
const heights = Array.from(boxes).map(box => box.offsetHeight);

requestAnimationFrame(() => {
  boxes.forEach((box, i) => {
    box.style.height = heights[i] + 10 + 'px';
  });
});

八、浏览器完整渲染流程总图

把前面所有内容串起来,完整看一遍:

URL 输入 → DNS → TCP → TLS → HTTP 请求/响应
                                    ↓
                              解析 HTML → DOM 树
                              解析 CSS  → CSSOM 树
                                    ↓
                              Render Tree(去掉不可见节点)
                                    ↓
┌───────────────────────────────────────────────────────────┐
│                    浏览器渲染流水线                         │
│                                                           │
│  Layout(重排)                                            │
│  触发条件:width/height/top/left/margin/display 等改变      │
│       ↓                                                   │
│  Paint(重绘)                                             │
│  触发条件:color/background/shadow/visibility 等改变        │
│       ↓                                                   │
│  Composite(合成)                                         │
│  所有操作最终都到这一步                                       │
│                                                           │
│  ✦ transform / opacity                                    │
│    → 元素提升为独立合成层,GPU 直接处理                        │
│    → 跳过 Layout 和 Paint,直达 Composite                   │
└───────────────────────────────────────────────────────────┘
                                    ↓
                               屏幕显示 🎉

延伸思考

梳理完这些,还有几个问题没完全搞清楚:

  1. 合成层的内存代价怎么量化? 什么情况下合成层的开销会超过它带来的性能收益,Chrome DevTools 里怎么观测?
  2. React 的批量更新(Batching)和浏览器的批量渲染是什么关系? React 18 的自动批处理,是不是某种程度上也在减少强制同步重排?
  3. CSS contain 属性是什么? 据说它可以把一个元素声明为"独立的渲染作用域",让重排影响范围收敛到局部——这个机制是怎么运作的?

🧠 面试常问版(核心记忆点)

5 条浓缩,面试前快速过一遍:

  1. React re-render ≠ 浏览器重排:React re-render 是 JS 层虚拟 DOM 的重新计算,浏览器重排是渲染引擎的布局重算。前者可能触发后者,但优化手段不同,不要混淆。
  2. 三层开销排序:重排(Reflow)> 重绘(Repaint)> 合成(Composite)。重排必触发重绘,重绘不必触发重排,合成跳过前两步。
  3. transform 快的原因:浏览器把 transform/opacity 的元素提升到独立合成层,由 GPU 直接处理矩阵变换,完全跳过 Layout 和 Paint。left/top 每帧都触发重排,transform 每帧只做合成——这是动画性能差异的根源。
  4. 强制同步重排陷阱:读取 offsetHeightgetBoundingClientRect() 等属性会强制浏览器立即执行重排。循环内交替读写 DOM = 每次循环触发一次重排。解决:先批量读,再批量写。
  5. will-change 的正确用法:提前声明元素将发生 transform 变化,让浏览器预先创建合成层。但合成层有内存开销,不要滥用,动画结束后用 will-change: auto 释放。

参考资料

虚拟 DOM 与 Diff 算法——React 性能优化的底层逻辑

用了两三年 React,我一直对"虚拟 DOM 更快"这个说法半信半疑。直到有一次优化一个长列表卡顿问题,才真正逼着自己把这套底层逻辑摸清楚。这篇是我的学习笔记,试图用具体例子把"为什么"和"怎么做"说清楚,而不是把概念堆在一起。


一、为什么需要虚拟 DOM?

先从"直接操作真实 DOM 有什么问题"聊起。

真实 DOM 操作慢在哪?

上一篇聊浏览器渲染时提到过,每次修改 DOM,浏览器都要重跑一遍渲染流水线:

修改 DOM → 重新计算样式 → Layout(重排)→ Paint(重绘)→ Composite

这个流水线本身没问题,问题在于频率。如果你有一个复杂页面,状态变化触发了 100 次 DOM 修改,流水线就要跑 100 次。每次都是真实的浏览器渲染工作,代价不低。

那"每次重新渲染整个页面"呢?

你可能会想:干脆每次状态变化,把整个页面 innerHTML 全部重写,不就省事了?

理论上是"最简单"的方案,但问题是:

  1. :重建整个 DOM 树,触发全量 Layout + Paint,比局部更新慢得多
  2. 丢失用户状态:用户正在输入的文本框内容会被清空、滚动位置跳回顶部、当前 focus 的元素失焦——体验直接崩掉

虚拟 DOM 要解决的,正是这两个问题之间的矛盾:既不想每次手动挑出要更新的 DOM 节点,又不想粗暴地全量重建。


二、虚拟 DOM 是什么?

虚拟 DOM(Virtual DOM)本质上就是用普通 JS 对象来描述 DOM 结构

操作真实 DOM 慢,但操作 JS 对象快得多(快几百倍)。所以 React 的思路是:先在内存里用 JS 对象"演练"要做的改动,算出最小改动集,再一次性更新到真实 DOM。

来看一个具体的对应关系:

<!-- 真实 DOM -->
<div class="card">
  <h1>标题</h1>
  <p>描述内容</p>
</div>
// 对应的虚拟 DOM(JS 对象)
{
  type: 'div',
  props: { className: 'card' },
  children: [
    {
      type: 'h1',
      props: {},
      children: ['标题']
    },
    {
      type: 'p',
      props: {},
      children: ['描述内容']
    }
  ]
}

React 的 JSX 语法,本质上就是在写这样的对象描述,只是换了一套更好看的语法糖。

// 你写的 JSX
const element = (
  <div className="card">
    <h1>标题</h1>
    <p>描述内容</p>
  </div>
);

// Babel 编译后,等价于
const element = React.createElement(
  'div',
  { className: 'card' },
  React.createElement('h1', null, '标题'),
  React.createElement('p', null, '描述内容')
);

三、虚拟 DOM 的工作流程

有了虚拟 DOM,React 的渲染流程变成了这样:

状态变化(setState / useState)
        ↓
生成新的虚拟 DOM 树
        ↓
与上一次的旧虚拟 DOM 树做 Diff(对比)
        ↓
找出差异部分(patch)
        ↓
只把差异更新到真实 DOM

核心价值只有一句话:最小化真实 DOM 操作次数

举个例子——一个有 1000 个节点的页面,某次状态变化只影响了其中 3 个节点。

方案 真实 DOM 操作次数
全量重建 1000 次
手动精准更新 3 次(但需要你自己写逻辑)
虚拟 DOM + Diff 3 次(自动计算)

虚拟 DOM 让你享受到了"手动精准更新"的性能,但不需要你自己写那些繁琐的 DOM 操作逻辑。


四、Diff 算法:如何高效比较两棵树?

现在问题来了:比较两棵树,算出最小改动,怎么做?

理论最优解有多慢?

计算机科学中,对比两棵树的最优算法复杂度是 O(n³)

100 个节点?10⁶ = 100 万次计算。 1000 个节点?10⁹ = 10 亿次计算

每次状态更新都跑 10 亿次操作,页面直接冻住。这个路走不通。

React 的解法:三个假设,换来 O(n)

React 选择了一个工程上的妥协:基于三个在实际开发中几乎总是成立的假设,把复杂度降到 O(n)。


假设 1:不同类型的节点,直接替换

如果一个节点从 <div> 变成了 <p>,React 不会试图比较它们的内部差异——直接销毁整棵旧树,重建新树。

// 旧的虚拟 DOM
<div>
  <input value="用户输入的内容" />
  <span>子元素</span>
</div>

// 新的虚拟 DOM(根节点类型变了)
<p>
  <input value="用户输入的内容" />
  <span>子元素</span>
</p>

这种情况下,React 会:

  1. 卸载整个 <div> 及其所有子节点(包括 input 里用户输入的内容)
  2. 重新挂载整个 <p>

所以如果你的根节点类型频繁切换,会造成不必要的子组件销毁重建。这个假设告诉我们:组件的根节点类型,能稳定就稳定


假设 2:只比较同层节点,不跨层级

React 的 Diff 是逐层对比的,不会尝试找跨层移动的节点。

旧树                    新树

    A                       A
   / \                     / \
  B   C        →          B   C
 / \                           \
D   E                           E

如果你把节点 D 从 B 的子节点移动到了 C 的子节点下,React 看到的是:

  • B 层:少了 D → 删除 D
  • C 层:多了 D → 新建 D

它不会识别出"这是同一个节点在移动",而是执行一次删除 + 一次创建。

这意味着:跨层级移动 DOM 节点,在 React 里代价比你想象的高。在实际组件设计中,尽量避免通过条件渲染在不同层级之间"搬运"同一个组件。


假设 3:用 key 识别列表节点

这是三个假设里和日常开发最紧密的一个。

当对比一组子节点(列表)时,如果没有 key,React 只能按顺序逐一对比:

// 旧列表
<ul>
  <li>张三</li>   // 位置 0
  <li>李四</li>   // 位置 1
  <li>王五</li>   // 位置 2
</ul>

// 在开头插入"赵六"后的新列表
<ul>
  <li>赵六</li>   // 位置 0
  <li>张三</li>   // 位置 1
  <li>李四</li>   // 位置 2
  <li>王五</li>   // 位置 3
</ul>

没有 key,React 按位置对比:位置 0 内容变了(张三→赵六)→ 更新;位置 1 内容变了 → 更新;位置 2 内容变了 → 更新;位置 3 是新增 → 新建。改了 4 个节点,实际上只是新增了 1 个。

有了 key,React 能识别出哪些节点是"同一个",从而准确复用:

<ul>
  <li key="zhaoliu">赵六</li>   // 新增
  <li key="zhangsan">张三</li>  // 复用,不更新
  <li key="lisi">李四</li>      // 复用,不更新
  <li key="wangwu">王五</li>    // 复用,不更新
</ul>

只做 1 次插入操作,剩下三个节点直接复用。


五、为什么不能用 index 做 key?

这是 React 开发中最经典的"坑"之一,我觉得有必要把例子说完整。

场景:删除列表项

初始列表 [张三, 李四, 王五],用 index 做 key:

// 初始状态
<ul>
  <li key={0}>张三</li>
  <li key={1}>李四</li>
  <li key={2}>王五</li>
</ul>

现在删除张三,列表变成 [李四, 王五]

// 删除后
<ul>
  <li key={0}>李四</li>  // key=0,内容从"张三"变成了"李四"
  <li key={1}>王五</li>  // key=1,内容从"李四"变成了"王五"
                          // key=2 消失 → 删除
</ul>

React 看到的是:

  • key=0:内容变了 → 更新
  • key=1:内容变了 → 更新
  • key=2:消失了 → 删除

结果:3 次 DOM 操作。但我们实际上只删了 1 个元素,只需要 1 次 DOM 操作


改用唯一 ID 做 key:

// 初始状态
<ul>
  <li key="zhangsan">张三</li>
  <li key="lisi">李四</li>
  <li key="wangwu">王五</li>
</ul>

// 删除后
<ul>
  <li key="lisi">李四</li>   // key 没变,内容没变 → 跳过
  <li key="wangwu">王五</li> // key 没变,内容没变 → 跳过
                              // key="zhangsan" 消失 → 删除
</ul>

React 准确识别出只有"zhangsan"消失了:1 次 DOM 操作,完全正确。


更严重的 bug:输入框状态错乱

上面的例子只是性能问题,但下面这个是功能 bug

场景:列表每一项有一个输入框,用户在第一项(张三)的输入框里填了内容,然后删除第一项。

// 每一项带输入框的组件
function ListItem({ name }) {
  return (
    <li>
      <span>{name}</span>
      <input placeholder={`备注 ${name}`} />
    </li>
  );
}

// 用 index 做 key
{list.map((item, index) => (
  <ListItem key={index} name={item.name} />
))}

删除"张三"后,React 对 key=0 做的是更新(把 name prop 改成"李四"),而不是销毁重建。

React 复用了原来"张三"那个 DOM 节点,只更新了 name 属性——但输入框是非受控的,它的内部状态(用户输入的内容)跟着 DOM 节点走,不跟着数据走。

结果:删掉张三之后,李四的输入框里还显示着刚才给张三写的备注内容。数据删了,UI 状态还留着

这种 bug 在测试环境容易被漏掉,到了生产环境才被用户发现,排查起来也很头疼。


结论

✅ 用数据的唯一 ID 做 key(数据库主键、UUID 等)
❌ 不用 index 做 key(除非列表永远不会增删排序)
❌ 不用随机数做 key(每次渲染都会强制重建,比没有 key 更差)

六、整体流程回顾

用户交互 / 数据请求
        ↓
setState / useState 触发更新
        ↓
React 调用 render,生成新的虚拟 DOM 树
        ↓
┌─────────────────────────────────────┐
│           Diff 算法(O(n))          │
│                                     │
│  类型不同?→ 直接替换                  │
│  只比同层  → 不跨层                   │
│  有 key?  → 精准识别复用              │
└─────────────────────────────────────┘
        ↓
生成最小 patch(差异集合)
        ↓
批量更新到真实 DOM
        ↓
浏览器渲染(只有变化的部分触发重排/重绘)

延伸思考

梳理完这些,我产生了几个新问题,暂时还没完全搞清楚:

  1. React Fiber 和虚拟 DOM 是什么关系? Fiber 架构是 React 16 引入的,它把虚拟 DOM 的 Diff 过程变成了可中断的,这对长列表渲染有什么具体影响?
  2. Vue 的 Diff 和 React 的 Diff 有什么区别? 听说 Vue 3 的双端对比算法在某些场景下效率更高,是什么原理?
  3. React.memouseMemo 和虚拟 DOM 的 Diff 是什么关系? 它们是在 Diff 之前就跳过了,还是 Diff 之后的优化?

这些可能是下一篇的方向,也欢迎有研究的朋友交流。


🧠 面试常问版(核心记忆点)

5 条浓缩,面试前快速过一遍:

  1. 虚拟 DOM 的本质:用 JS 对象描述 DOM 结构,在内存中做 Diff,最小化真实 DOM 操作次数,解决"全量重建"导致的慢和状态丢失问题。
  2. Diff 算法的三个假设:① 不同类型节点直接替换;② 只对比同层节点;③ 用 key 识别列表节点。三个假设把复杂度从 O(n³) 降到 O(n)。
  3. key 的作用:帮助 React 识别哪些节点是"同一个",从而在列表更新时准确复用,避免不必要的 DOM 操作。
  4. index 做 key 的两种问题:性能问题(删除头部节点会触发全量更新)+ 功能 bug(非受控组件的状态跟 DOM 节点走,不跟数据走,导致状态错乱)。
  5. key 的正确选择:用数据的唯一 ID(数据库主键、UUID 等),不用 index,不用随机数。

参考资料

从输入 URL 到页面显示——浏览器工作原理全解析

这篇文章的起因很朴素:被面试官问到"浏览器输入 URL 后发生了什么",我当时答得磕磕绊绊。事后复盘,发现自己其实每天都在和这条链路打交道,却从没认真梳理过它。所以这篇更多是我的学习笔记——不追求教科书式的完整,而是希望用对话感把每个概念说清楚。如果你也对这条链路模糊,欢迎一起往下读。


一、为什么要理解这条链路?

先说面试:这是前端面试里的"经典送命题"。问法很宽,可以从 DNS 聊到渲染,每个环节都能展开一个小时。

但比面试更重要的是:理解浏览器在帮你做什么

为什么 <script> 放底部?为什么 transformtop 流畅?为什么 HTTPS 比 HTTP 安全?这些问题的答案,都藏在这条链路里。

完整流程如下,我们逐段拆解:

URL 输入
  ↓
DNS 解析(域名 → IP)
  ↓
TCP 三次握手(建立连接)
  ↓
TLS 握手(HTTPS 加密,若有)
  ↓
HTTP 请求 / 响应
  ↓
浏览器解析渲染(HTML → 像素)
  ↓
页面显示

二、DNS 解析:找到服务器地址

域名是个"电话簿"

你输入的是 www.example.com,但网络层面真正认的是 IP 地址(比如 93.184.216.34)。域名只是给人看的别名。

DNS(Domain Name System)就是把域名翻译成 IP 的"电话簿"。

查询顺序:从近到远

浏览器不会每次都跑去问根服务器,它有一套缓存优先的查询链:

浏览器缓存
  ↓(没有?)
操作系统缓存(hosts 文件 / 系统 DNS 缓存)
  ↓(没有?)
路由器缓存
  ↓(没有?)
ISP(运营商)DNS 服务器
  ↓(没有?)
根域名服务器 → 顶级域服务器(.com)→ 权威域名服务器
  ↓
返回 IP 地址,逐层缓存

类比一下:你想找某个老同学的电话,你会先翻自己的手机通讯录,再问共同朋友,最后才去翻毕业纪念册。每一层都比下一层"近"。

TTL:为什么不能永久缓存?

DNS 记录带有 TTL(Time To Live,缓存有效期),过期后必须重新查询。

原因很简单:映射关系会变。比如网站迁移服务器,IP 换了,如果客户端永久缓存旧 IP,就再也找不到新服务器了。TTL 的存在,是在"缓存命中率"和"数据新鲜度"之间做权衡。


三、TCP 三次握手:建立可靠连接

找到 IP 之后,浏览器需要和服务器建立连接。HTTP 跑在 TCP 之上,而 TCP 是面向连接的协议——发数据之前,双方必须先"握手"确认线路通畅。

为什么是三次,不是两次或四次?

这是个很好的问题。我的理解是,三次握手需要确认三件事:

次序 方向 目的
第一次 客户端 → 服务器(SYN) 确认:客户端能发
第二次 服务器 → 客户端(SYN+ACK) 确认:服务器能收、能发
第三次 客户端 → 服务器(ACK) 确认:客户端能收

三次之后,双方都知道对方能收能发,通信信道建立完毕。

少一次(两次握手)的问题:客户端能收这件事没人确认,存在单向通道风险,且会引发"历史连接"问题(旧的延迟 SYN 包触发服务器建立无效连接)。

多一次没必要:四次就是冗余了,三次已经能确认所有需要确认的状态。

类比:打电话前的确认——"喂,你能听到我吗?"→"能,你能听到我吗?"→"能"。三句话,线路通畅,开始正式通话。


四、TLS 握手:加密 + 身份验证(HTTPS)

TCP 建好连接后,如果是 HTTPS,还要多一步:TLS 握手。

为什么需要它?

HTTP 是明文传输的。你发出去的每一个请求,路径上的任何节点(路由器、运营商、同一 WiFi 下的其他人)理论上都能看到完整内容。用户密码、信用卡号……全部裸奔。

TLS 解决了两个问题:

  • 身份验证:你连接的是真的 example.com,不是被人劫持的钓鱼站
  • 加密传输:内容只有你和服务器能读

握手流程(简化版)

1. 浏览器 → 服务器:我支持这些加密算法 [列表],给我你的证书

2. 服务器 → 浏览器:用这个算法,这是我的证书(含公钥)

3. 浏览器验证证书(向 CA 机构核实真实性)
   生成随机数,用服务器公钥加密后发过去

4. 服务器用私钥解密,得到随机数

5. 双方用这个随机数生成"会话密钥"(对称密钥)

6. 后续所有通信用会话密钥加密

两个角色分开理解

初学时我一直搞混"证书"和"加密",其实它们是两件事:

角色 类比 作用
证书 身份证 + 公证处盖章 证明"我真的是 example.com"
加密 双方约定的暗语本 保证通信内容只有双方能读

证书由 CA(证书颁发机构)签发,浏览器内置了受信任的 CA 列表。如果证书是自签名的或已过期,浏览器会弹出警告。

为什么不全程用公钥加密?

这是个常被忽略的细节。非对称加密(RSA)安全,但比对称加密(AES)慢约 100 倍

所以 TLS 的设计是:非对称加密只用于握手阶段安全交换密钥,真正的通信内容用对称密钥(AES)加密。兼顾了安全性和性能。

加密类型 代表算法 速度 用途
非对称加密 RSA、ECDH 密钥交换、签名
对称加密 AES 实际数据加密

TLS 管加密,Cookie 管身份

还有一个常见混淆点:TLS 建立的是加密信道,不等于"记住了你是谁"。

服务器如何区分不同用户?那是 HTTP 层面 Cookie / session_id 的事。TLS 每次连接都会重新握手(虽然有会话恢复机制),但识别"这个请求属于哪个用户",靠的是请求头里的 Cookie。


五、HTTP 请求与响应

握手完成,浏览器发出第一个 HTTP 请求:

GET /index.html HTTP/1.1
Host: www.example.com
User-Agent: Mozilla/5.0 ...
Accept: text/html
Cookie: session_id=abc123
Cache-Control: no-cache

几个重要的请求头:

  • Host:告诉服务器你访问的是哪个域名(一台服务器可能托管多个域名)
  • Cookie:带上本地存储的会话标识
  • Cache-Control:告诉服务器/中间缓存怎么处理这个请求的缓存

服务器返回响应:

HTTP/1.1 200 OK
Content-Type: text/html; charset=UTF-8
Cache-Control: max-age=3600

<!DOCTYPE html>...

常见状态码速查:

状态码 含义 常见场景
200 成功 正常响应
301 永久重定向 域名迁移
304 内容未修改 使用本地缓存
404 资源不存在 路径错误
500 服务器内部错误 后端异常

六、浏览器解析:从 HTML 到像素

拿到 HTML 之后,浏览器开始做"最后一公里"的工作:把代码变成屏幕上的像素。这个过程叫关键渲染路径(Critical Rendering Path)

Step 1:解析 HTML → DOM 树

浏览器从上到下解析 HTML,构建 DOM(Document Object Model)树。DOM 是页面结构的内存表示,每个标签对应一个节点。

<!-- 这段 HTML -->
<body>
  <div class="container">
    <p>Hello</p>
  </div>
</body>

<!-- 对应的 DOM 树(简化) -->
body
  └── div.container
        └── p
              └── "Hello"

Step 2:下载并解析 CSS → CSSOM 树

并行下载 CSS 文件,解析生成 CSSOM(CSS Object Model)树。结构和 DOM 类似,但存的是样式信息。

关键阻塞规则

这里有个绕不开的问题,很多性能优化都源于此:

资源类型 阻塞什么 原因
CSS 阻塞渲染 没 CSSOM 就没法确定元素最终样式
JS(无属性) 阻塞HTML 解析 JS 可能操作 DOM,所以得等 JS 执行完
JS(defer 不阻塞解析 延迟到 HTML 解析完才执行
JS(async 下载不阻塞,执行阻塞 下载完立刻执行

这就是为什么 <script> 推荐放在 </body> 前,或者使用 defer:避免阻塞 HTML 解析,提升首屏速度。

Step 3:DOM + CSSOM → Render Tree

合并 DOM 和 CSSOM,生成只包含可见节点的 Render Tree(渲染树)。

注意:display: none 的元素不进入 Render Tree(它不占空间、不显示);但 visibility: hidden 的元素会进入(它仍然占位)。

Step 4:Layout(重排 / Reflow)

基于 Render Tree,计算每个节点的精确位置和尺寸——相对视口的坐标、宽高、边距……

这步代价较高。任何改变元素几何属性的操作(改 widthmarginposition)都会触发 Reflow,浏览器需要重新计算布局。

Step 5:Paint(重绘 / Repaint)

按照布局结果,把每个元素"画"出来:填充颜色、绘制边框、阴影、文字……

Step 6:Composite(合成)

浏览器把不同图层合并,最终送到屏幕显示。

这里有个重要的性能优化点:

/* 只触发 Composite,性能最好 */
.card {
  transform: translateY(-4px);
  opacity: 0.9;
}

/* 触发 Layout + Paint + Composite,代价最高 */
.card {
  top: -4px; /* 改变几何属性 */
}

transformopacity 的变化不影响布局,浏览器可以直接在 GPU 层面处理,跳过 Layout 和 Paint,性能最优。这就是为什么 CSS 动画推荐优先用 transform


七、整条链路总结

用户输入 URL
        │
        ▼
┌──────────────────┐
│   DNS 解析        │  域名 → IP(电话簿查询,逐层缓存)
└──────────────────┘
        │
        ▼
┌──────────────────┐
│  TCP 三次握手     │  确认双方能收发(SYN → SYN+ACK → ACK)
└──────────────────┘
        │
        ▼
┌──────────────────┐
│  TLS 握手(HTTPS)│  证书验证 + 交换会话密钥(非对称→对称)
└──────────────────┘
        │
        ▼
┌──────────────────┐
│  HTTP 请求/响应   │  发送 Request,接收 HTML/CSS/JS
└──────────────────┘
        │
        ▼
┌──────────────────────────────────────────┐
│             浏览器渲染流水线              │
│  HTML → DOM  ┐                           │
│              ├→ Render Tree → Layout     │
│  CSS → CSSOM ┘         → Paint → 合成   │
└──────────────────────────────────────────┘
        │
        ▼
      页面显示 🎉

延伸与发散

在梳理这条链路的过程中,我产生了一些新的疑问,记录在这里:

  1. HTTP/2 和 HTTP/3 对这条链路的影响是什么? HTTP/2 的多路复用是不是意味着 TCP 握手的成本被摊薄了?HTTP/3 基于 UDP 的 QUIC 协议又是如何处理可靠性的?
  2. Service Worker 如何介入这条链路? PWA 的离线缓存是在哪个环节"截胡"请求的?
  3. 浏览器的预加载机制<link rel="preconnect"><link rel="prefetch">)是在提前做哪几步?

这些可能会是后续文章的方向,也欢迎有经验的朋友交流。


🧠 面试常问版(核心记忆点)

如果只有 5 分钟时间,记住这 5 条:

  1. DNS:域名→IP,查询链是浏览器缓存→OS→路由→ISP→根服务器,TTL 控制缓存时效。
  2. TCP 三次握手:确认双方能收发,三次刚好,少一次有安全隐患,多一次冗余。
  3. TLS:证书验证身份,非对称加密只用于交换密钥,实际内容用 AES(对称)加密,快 100 倍。
  4. 渲染阻塞:CSS 阻塞渲染,JS 阻塞 HTML 解析,所以 <script> 放底部或用 defer
  5. 渲染性能transform/opacity 只触发合成层,跳过 Layout 和 Paint,动画优先使用。

参考资料

遇到前端题目,我现在会先问自己这四个问题

备考面试的过程里,我发现一件有意思的事:很多题目表面上考的是不同的知识点,但解题的起点其实是一样的——先搞清楚题目的结构,再选对应的模型

后来我把这个过程提炼成四个问题,每次看到新题目,不管是面试题还是实际业务需求,都先把这四个问题过一遍。这篇文章把这四个问题展开来讲,每个配上真实的代码场景,是我整理这套思考框架的过程记录。


为什么需要判断框架

先说一个让我意识到这件事重要性的场景。

同样是"批量操作",有人问的是"批量审批,允许部分失败",有人问的是"批量上传,限制同时最多 3 个"。表面上都是批量,但前者的核心是容错并发Promise.allSettled),后者的核心是背压控制(并发池)。如果只靠关键词匹配,看到"批量"就想到同一个答案,就会答错。

判断框架的作用是:在选答案之前,先把题目的维度看清楚

四个问题的完整决策树

image.png


问题一:这里的数据是什么形状?

数据的形状决定了应该用什么数据结构和遍历方式。常见的两个场景:

场景 A:需要聚合/分组 → Map + 复合 key

触发信号是"按 X 统计 Y"、"分组"、"去重后计数"。

// 环境:浏览器 / Node.js
// 场景:统计每个用户每个月的消费金额

const records = [
  { userId: 'u1', month: '2024-01', amount: 300 },
  { userId: 'u1', month: '2024-01', amount: 200 },
  { userId: 'u1', month: '2024-02', amount: 150 },
  { userId: 'u2', month: '2024-01', amount: 400 },
];

// ✅ 复合 key + Map:O(1) 查询,结构清晰
function aggregateByUserAndMonth(records) {
  const map = new Map();

  for (const record of records) {
    const key = `${record.userId}|${record.month}`;
    map.set(key, (map.get(key) ?? 0) + record.amount);
  }

  return map;
}

const result = aggregateByUserAndMonth(records);
console.log(result.get('u1|2024-01')); // 500
console.log(result.get('u2|2024-01')); // 400

为什么不用嵌套 reduce? 嵌套 reduce 能跑,但读起来难受——你要先理解"外层在做什么"再理解"内层在做什么"。Map + 复合 key 把两个维度拍平,逻辑是线性的,写的人和读的人都轻松。

场景 B:树形/嵌套结构 → 递归 + 状态提升

触发信号是"组织架构"、"分类目录"、"嵌套评论"。

// 环境:浏览器(React)
// 场景:部门树,展开状态集中管理,子节点只负责渲染

// 关键决策:展开状态存在父组件的 Set 里,不存在每个节点自己身上
// 好处:全部展开/折叠只需要操作父组件的 Set,不需要广播给所有节点

function DeptTree({ data }) {
  const [expandedIds, setExpandedIds] = useState(new Set());

  const toggle = useCallback((id) => {
    setExpandedIds((prev) => {
      const next = new Set(prev);
      next.has(id) ? next.delete(id) : next.add(id);
      return next;
    });
  }, []);

  return data.map((node) => (
    <DeptNode
      key={node.id}
      node={node}
      expandedIds={expandedIds}
      onToggle={toggle}
    />
  ));
}

// 递归节点:纯渲染,不持有状态
const DeptNode = React.memo(function DeptNode({ node, expandedIds, onToggle }) {
  const isExpanded = expandedIds.has(node.id);
  const hasChildren = node.children?.length > 0;

  return (
    <div style={{ paddingLeft: 16 }}>
      <div onClick={() => hasChildren && onToggle(node.id)}>
        {hasChildren ? (isExpanded ? '▼' : '▶') : '·'} {node.name}
      </div>
      {isExpanded && hasChildren && node.children.map((child) => (
        <DeptNode
          key={child.id}
          node={child}
          expandedIds={expandedIds}
          onToggle={onToggle}
        />
      ))}
    </div>
  );
});

问题二:这里的时间维度是什么?

"时间维度"是指:这个操作有没有等待、顺序、取消、重试的需求?有时间维度的题,核心都在异步控制。

场景 A:多个操作需要同时进行 → 并发模型

// 环境:浏览器 / Node.js
// 场景:页面初始化需要同时请求三个接口

// 需要全部成功才能渲染 → Promise.all(一个失败全部失败)
async function initDashboard() {
  const [user, orders, stats] = await Promise.all([
    fetchUser(),
    fetchOrders(),
    fetchStats(),
  ]);
  return { user, orders, stats };
}

// 允许部分失败,每条都要有结果 → Promise.allSettled
async function batchExport(orderIds) {
  const tasks = orderIds.map((id) =>
    exportOrder(id)
      .then(() => ({ id, ok: true }))
      .catch((err) => ({ id, ok: false, reason: err.message }))
  );

  const results = await Promise.allSettled(tasks);
  return results.map((r) => (r.status === 'fulfilled' ? r.value : r.reason));
}

场景 B:需要限制并发数 → 并发池(背压控制)

// 环境:浏览器 / Node.js
// 场景:批量上传 100 个文件,同时最多 3 个并发
// 核心思路:维护一个"正在执行"的集合,完成一个立刻补充下一个

async function asyncPool(limit, tasks) {
  const results = new Array(tasks.length);
  const executing = new Set();

  for (let i = 0; i < tasks.length; i++) {
    const task = tasks[i];

    const p = Promise.resolve().then(() => task()).then((result) => {
      results[i] = result;
      executing.delete(p); // 完成后从执行集合移除
    });

    executing.add(p);

    // 达到上限时,等待任意一个完成再继续
    if (executing.size >= limit) {
      await Promise.race(executing);
    }
  }

  // 等待所有剩余任务完成
  await Promise.all(executing);
  return results;
}

// 使用示例
const uploadTasks = files.map((file) => () => uploadFile(file));
const results = await asyncPool(3, uploadTasks);

为什么此处的 const p 需要在 Promise.resolve().then() 中执行 task?

核心原因:控制执行时机

for (const p of promises) {
  // ❌ 直接执行 - 会立即开始所有任务
  const result = p.task();
  
  // ✅ 延迟执行 - 按顺序控制并发
  const result = Promise.resolve().then(() => p.task());
}

具体场景对比

场景 1:直接执行(无 Promise.resolve().then)
const tasks = [task1, task2, task3];

for (const task of tasks) {
  const p = task();  // 立即执行!
  running.add(p);
}
// 结果:3 个任务同时开始执行(并发爆炸)
场景 2:使用 Promise.resolve().then(延迟执行)
const tasks = [task1, task2, task3];

for (const task of tasks) {
  const p = Promise.resolve().then(() => task());  // 延迟到微任务
  running.add(p);
}
// 结果:先注册所有延迟任务,再按事件循环执行

关键机制:事件循环

同步代码(for 循环)→ 微任务队列(Promise.then)→ 执行 task()
     ↓                        ↓                    ↓
  立即执行               注册回调但不执行        真正开始执行

实际用途:并发控制

这种模式常见于 限制并发数 的场景:

async function* asyncPool(concurrency, tasks) {
  const executing = new Set();
  
  for (const task of tasks) {
    // 关键:延迟执行,让控制器先"占坑"
    const p = Promise.resolve().then(() => task());
    executing.add(p);
    
    // 清理完成的
    p.then(() => executing.delete(p));
    
    // 控制并发:满了就等一个完成
    if (executing.size >= concurrency) {
      await Promise.race(executing);
    }
    
    yield p;
  }
  
  await Promise.all(executing);
}

// 使用:最大 2 个并发
for await (const result of asyncPool(2, heavyTasks)) {
  console.log(result);
}

一句话总结

Promise.resolve().then()同步创建异步执行 分离,让你有机会在任务真正开始前做控制(如限制并发、错误处理、取消等)。

如果没有这层包装,任务会在 for 循环遍历时就立即执行,失去了控制的机会。

场景 C:有取消需求 → AbortController + ref

// 环境:浏览器(React)
// 场景:搜索框,用户快速输入时取消上一个请求,只保留最新一个

function useSearch(query) {
  const abortRef = useRef(null);
  const [results, setResults] = useState([]);

  useEffect(() => {
    if (!query.trim()) return;

    // 取消上一个还在进行的请求
    abortRef.current?.abort();
    const controller = new AbortController();
    abortRef.current = controller;

    fetch(`/api/search?q=${encodeURIComponent(query)}`, {
      signal: controller.signal,
    })
      .then((res) => res.json())
      .then(setResults)
      .catch((err) => {
        if (err.name !== 'AbortError') console.error(err);
        // AbortError 是主动取消,不是错误,静默处理
      });

    return () => controller.abort();
  }, [query]);

  return results;
}

场景 D:有重试需求 → 指数退避

// 环境:浏览器 / Node.js
// 场景:请求失败后重试,每次等待时间翻倍,避免持续打服务器

async function fetchWithRetry(url, options = {}, retries = 3) {
  const { baseDelay = 1000, ...fetchOptions } = options;

  for (let attempt = 0; attempt <= retries; attempt++) {
    try {
      const res = await fetch(url, fetchOptions);
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      return await res.json();
    } catch (err) {
      // 最后一次也失败,直接抛出
      if (attempt === retries) throw err;

      // 指数退避:1s → 2s → 4s,加随机抖动避免多个客户端同时重试
      const delay = baseDelay * Math.pow(2, attempt) + Math.random() * 500;
      await new Promise((resolve) => setTimeout(resolve, delay));
    }
  }
}

问题三:这里谁需要感知变化?

这个问题专门针对 React 组件里的状态设计。搞清楚"谁需要感知",能避免大量不必要的重渲染。

场景 A:变化只有局部需要感知 → 状态下沉 + 精确订阅

// 环境:浏览器(React)
// 场景:50 个订单卡片,只有状态变化的那张需要重渲染
// 依赖:zustand

import { create } from 'zustand';

const useOrderStore = create((set) => ({
  statuses: {}, // { [orderId]: status }
  update: (orderId, status) =>
    set((state) => ({
      statuses: { ...state.statuses, [orderId]: status },
    })),
}));

// 每个 OrderCard 精确订阅自己那条
// 其他订单状态变化时,这个组件完全不感知,不重渲染
const OrderCard = React.memo(function OrderCard({ orderId }) {
  const status = useOrderStore(
    useCallback((state) => state.statuses[orderId], [orderId])
  );

  return (
    <div className={`card status-${status}`}>
      Order {orderId}: {status}
    </div>
  );
});

场景 A-2:从零手写一个精确订阅 store

用 Zustand 能解决问题,但理解"精确订阅为什么能工作",需要从零实现一次。这道题是一个很好的切入点:实现一个点赞 store,要求每个 item 的订阅者只在自己那条数据变化时才被通知。

// 环境:浏览器 / Node.js
// 场景:轻量点赞 store,精确订阅——只通知真正变化的那条

function createLikeStore(initialLikes = {}) {
  // 状态:点赞数据的真实来源
  const likes = { ...initialLikes };

  // 订阅表:每个 itemId 对应一个 listener Set
  // 用 Map<itemId, Set<listener>> 而不是 Map<itemId, listener[]>
  // 原因:Set 保证同一个 listener 不会被重复添加,且删除是 O(1)
  const listenersById = new Map();

  return {
    // 返回快照(浅拷贝),防止外部直接修改内部状态
    getState() {
      return { ...likes };
    },

    toggleLike(itemId) {
      const nextValue = !likes[itemId];
      likes[itemId] = nextValue;

      // 精确通知:只触发这个 itemId 的订阅者,其他 item 的订阅者完全不感知
      const listeners = listenersById.get(itemId);
      if (listeners) {
        for (const listener of listeners) {
          listener(nextValue);
        }
      }

      return nextValue;
    },

    subscribeById(itemId, listener) {
      // 懒初始化:第一次有人订阅这个 itemId 时才创建 Set
      if (!listenersById.has(itemId)) {
        listenersById.set(itemId, new Set());
      }
      listenersById.get(itemId).add(listener);

      // 返回卸载函数——这个设计的必要性见下方说明
      return () => {
        const listeners = listenersById.get(itemId);
        if (!listeners) return;

        listeners.delete(listener);

        // 当这个 itemId 没有任何订阅者时,清理 Map 中的条目
        // 防止 Map 无限增长(内存泄漏)
        if (listeners.size === 0) {
          listenersById.delete(itemId);
        }
      };
    },
  };
}

为什么 subscribeById 必须返回卸载函数?

这是这道题里最值得单独理解的设计决策。

想象一个商品列表页,有 100 个商品卡片,每个都订阅了自己的点赞状态。用户从这个页面跳转到详情页,列表页的 100 个组件全部卸载。如果没有卸载函数:

// 没有卸载函数时会发生什么

// 组件挂载时订阅
useEffect(() => {
  store.subscribeById(itemId, (nextValue) => {
    setIsLiked(nextValue); // 更新已卸载组件的 state
  });
  // 没有返回卸载函数
}, [itemId]);

// 问题一:内存泄漏
// listenersById 里仍然持有 100 个已卸载组件的 listener 引用
// 这 100 个闭包无法被垃圾回收,页面停留越久内存占用越高

// 问题二:调用已卸载组件的 setState
// 用户在详情页点了某个商品的赞,store 触发通知
// 那个已卸载的 listener 还在,被调用后尝试 setIsLiked
// React 会报警告:Can't perform a React state update on an unmounted component

有了卸载函数,就能在 useEffect 的 cleanup 里调用它:

// 环境:浏览器(React)
// 场景:组件卸载时自动清理订阅,避免内存泄漏

function LikeButton({ itemId, store }) {
  const [isLiked, setIsLiked] = useState(
    () => store.getState()[itemId] ?? false
  );

  useEffect(() => {
    // 订阅这个 item 的状态变化
    const unsubscribe = store.subscribeById(itemId, (nextValue) => {
      setIsLiked(nextValue);
    });

    // cleanup:组件卸载时调用 unsubscribe
    // → listener 从 Set 中删除
    // → 如果这个 itemId 没有其他订阅者,Map 中的条目也被清理
    return unsubscribe;
  }, [itemId, store]);

  return (
    <button onClick={() => store.toggleLike(itemId)}>
      {isLiked ? '❤️' : '🤍'}
    </button>
  );
}

这个模式在 React 生态里几乎无处不在——useEffect 返回的 cleanup 函数,本质上就是在调用"注册时拿到的卸载句柄"。Redux 的 store.subscribe、RxJS 的 subscription.unsubscribe、浏览器原生的 removeEventListener,都是同一个模式:注册时拿到句柄,卸载时用句柄清理

这也是为什么"cleanup 对称原则"(问题四的场景 C)和这里的"卸载函数设计"是同一个底层思想——你开了什么,就要有对应的关闭路径。

场景 B:变化需要跨 render 保留,但不触发渲染 → useRef

判断标准只有一句话:这个值变化时需要更新 UI 吗? 需要 → useState,不需要 → useRef

// 环境:浏览器(React)
// 场景:轮询实现,timer id 变化不需要触发渲染

function usePolling(fetchFn, interval = 5000) {
  const timerRef = useRef(null);     // timer id → 不需要触发渲染 → useRef
  const [data, setData] = useState(null); // 数据 → 需要更新 UI → useState

  useEffect(() => {
    const tick = async () => {
      const result = await fetchFn();
      setData(result);
    };

    tick(); // 立即执行一次
    timerRef.current = setInterval(tick, interval);

    // cleanup:组件卸载时停止轮询
    return () => clearInterval(timerRef.current);
  }, [fetchFn, interval]);

  return data;
}

场景 C:派生状态 → 不要单独存一份 state

// 环境:浏览器(React)
// 场景:购物车,"是否全选"可以从 items 推导,不需要单独管理

function Cart() {
  const [items, setItems] = useState([
    { id: 1, name: 'Item A', selected: true },
    { id: 2, name: 'Item B', selected: false },
  ]);

  // ✅ 派生状态:从 items 计算,不单独存 state
  // 避免 items 和 isAllSelected 不同步的 bug
  const isAllSelected = items.every((item) => item.selected);
  const selectedCount = items.filter((item) => item.selected).length;

  const toggleAll = () => {
    setItems((prev) =>
      prev.map((item) => ({ ...item, selected: !isAllSelected }))
    );
  };

  return (
    <div>
      <label>
        <input type="checkbox" checked={isAllSelected} onChange={toggleAll} />
        全选(已选 {selectedCount} 件)
      </label>
      {/* 渲染列表 */}
    </div>
  );
}

问题四:这里可能出什么错?

最后这个问题是防御性思维——在写"正常路径"的代码之前,先想清楚异常路径。

场景 A:部分操作可能失败 → allSettled + 结果收集

// 环境:浏览器 / Node.js
// 场景:批量发送通知,有些用户可能发送失败,需要知道哪些失败了

async function sendBatchNotifications(userIds, message) {
  const tasks = userIds.map((userId) =>
    sendNotification(userId, message)
      .then(() => ({ userId, success: true }))
      .catch((err) => ({ userId, success: false, error: err.message }))
  );

  const settled = await Promise.allSettled(tasks);

  const succeeded = [];
  const failed = [];

  for (const result of settled) {
    // allSettled 不会 reject,每个结果都是 fulfilled
    // value 里才是我们自己封装的成功/失败信息
    if (result.status === 'fulfilled' && result.value.success) {
      succeeded.push(result.value.userId);
    } else {
      failed.push(
        result.status === 'fulfilled'
          ? result.value
          : { userId: 'unknown', error: result.reason }
      );
    }
  }

  return { succeeded, failed };
}

场景 B:异步操作可能竞态 → 标记最新请求

竞态的本质是:多个异步操作在同一个"目标"上竞争,需要决定哪个的结果有效,过期的丢弃。

处理竞态有三种方案,力度递增:

方案一:isCurrent 标记      结果回来了,但我忽略它(被动丢弃,React 组件内)
方案二:requestId 计数器    结果回来了,但我忽略它(被动丢弃,框架无关)
方案三:AbortController    结果还没回来,我让它停止(主动取消)

方案一: isCurrent —— React 组件内的局部标记

// 环境:浏览器(React)
// 场景:Tab 切换时快速加载不同内容,只展示最后一次请求的结果

function useTabData(activeTab) {
  const [data, setData] = useState(null);

  useEffect(() => {
    // 标记活在这次 effect 的闭包里,只属于"这一次"
    // 每次 activeTab 变化,产生新的闭包和新的 isCurrent
    let isCurrent = true;

    fetchTabData(activeTab).then((result) => {
      if (isCurrent) setData(result); // 过期就不更新
    });

    return () => {
      isCurrent = false; // cleanup 让这次请求过期
    };
  }, [activeTab]);

  return data;
}

isCurrent二值凭证——只有有效/无效两种状态。标记的生命周期和单次 effect 绑定,cleanup 执行就过期,下次 effect 重新产生一个新的。

方案二:requestId 计数器 — 框架无关的通用包装

// 环境:浏览器 / Node.js
// 场景:包装任意异步函数,只让最新一次调用的结果被应用
// 可在 React 组件外使用,适合封装成通用工具

function createLatestOnlyRunner(asyncFn) {
  // 计数器活在外层闭包,跨所有调用共享
  // 每次新调用让计数器加一,旧调用的 id 永久地比最新 id 小
  let latestRequestId = 0;

  return async (...args) => {
    const requestId = ++latestRequestId; // 拿到这次调用的"身份 id"

    try {
      const value = await asyncFn(...args);

      // 结果回来时,检查自己是不是还是最新的那次
      if (requestId !== latestRequestId) {
        return { applied: false }; // 已过期,丢弃结果
      }
      return { applied: true, value };

    } catch (e) {
      // 失败时也要判断:是最新请求的失败才需要处理
      if (requestId !== latestRequestId) {
        return { applied: false }; // 过期请求的失败,静默丢弃
      }
      throw e; // 最新请求的失败,正常抛出
    }
  };
}

// 使用示例:搜索场景
const searchRunner = createLatestOnlyRunner(fetchSearchResults);

async function handleSearch(query) {
  const result = await searchRunner(query);
  if (result.applied) {
    renderResults(result.value); // 只有最新请求的结果才渲染
  }
  // applied: false 的直接忽略
}

requestId单调递增的数字凭证——通过大小关系自动判断新旧,不需要手动让旧的过期,新调用天然让旧调用失效。标记的生命周期和 runner 整个生命周期绑定。

两种方案的核心差异对比

                    isCurrent 方案              requestId 方案
标记存在哪里         useEffect 闭包(局部)       外层闭包(全局共享)
生命周期             和单次 effect 绑定           和 runner 整个生命周期绑定
过期机制             cleanup 主动设为 false        新调用让旧 id 自然过期
能否在组件外用        不能(依赖 useEffect)        能(纯函数,任何地方可用)
适合场景             React 组件内的异步请求        通用工具函数、非 React 环境

两者的底层思想完全相同:给每次操作打上身份凭证,结果回来时验证凭证是否仍然是最新的。 差异只在凭证的形态和管理方式。

方案三:AbortController — 主动取消,不等结果回来

// 环境:浏览器(React)
// 场景:搜索框,用户继续输入时直接取消上一个网络请求
// 不只是忽略结果,而是中止请求,节省服务器和网络资源

function useSearch(query) {
  const abortRef = useRef(null);
  const [results, setResults] = useState([]);

  useEffect(() => {
    if (!query.trim()) return;

    abortRef.current?.abort(); // 取消上一个请求
    const controller = new AbortController();
    abortRef.current = controller;

    fetch(`/api/search?q=${encodeURIComponent(query)}`, {
      signal: controller.signal,
    })
      .then((res) => res.json())
      .then(setResults)
      .catch((err) => {
        if (err.name !== 'AbortError') console.error(err);
      });

    return () => controller.abort();
  }, [query]);

  return results;
}

AbortController 是主动取消凭证——不只是标记"这个结果过期了",而是直接发出取消信号,让网络层停止这个请求。代价更低(请求未完成就终止),但依赖 fetch 原生支持 signal 参数。

三种方案的选择建议

在 React 组件里,请求和组件生命周期绑定
  → isCurrent 标记,最简单,足够用

需要在组件外复用,或包装第三方异步函数
  → createLatestOnlyRunner,框架无关,可测试

请求体积大、耗时长,取消能节省明显资源
  → AbortController,主动中止,力度最强

场景 C:副作用可能泄漏 → cleanup 对称原则

// 环境:浏览器(React)
// 场景:WebSocket 连接,组件卸载时必须关闭,否则内存泄漏

function useOrderMonitor(orderId) {
  const [status, setStatus] = useState(null);

  useEffect(() => {
    const ws = new WebSocket(`wss://api.example.com/orders/${orderId}`);

    ws.onmessage = (event) => {
      const { status } = JSON.parse(event.data);
      setStatus(status);
    };

    // 启动了 WebSocket → cleanup 里就关闭 WebSocket
    // 对称原则:你在 effect 里做了什么,cleanup 里就撤销什么
    return () => ws.close(1000, 'component unmounted');
  }, [orderId]);

  return status;
}

场景 D:数据可能过期 → TTL + 重新验证

// 环境:浏览器
// 场景:缓存接口数据,超过有效期后重新请求

const cache = new Map(); // key → { data, timestamp }
const CACHE_TTL = 5 * 60 * 1000; // 5 分钟

async function fetchWithCache(url) {
  const cached = cache.get(url);

  if (cached) {
    const isExpired = Date.now() - cached.timestamp > CACHE_TTL;
    if (!isExpired) return cached.data; // 缓存有效,直接返回
  }

  // 缓存不存在或已过期,重新请求
  const data = await fetch(url).then((r) => r.json());
  cache.set(url, { data, timestamp: Date.now() });
  return data;
}

延伸与发散

整理这四个问题的过程里,我发现它们之间有一些有趣的交叉:

问题三和问题四的交叉:竞态问题(问题四)本质上也是"谁应该感知变化"的问题(问题三)——当两个异步操作都想更新同一个状态时,你需要决定"只有最新的那个有资格更新"。isCurrent 标记和 AbortController 都是在回答这个问题。

问题二和问题四的交叉:需要取消和需要重试,本质上都是"时间维度里的异常路径"。一个是"有更新的请求来了,旧的不需要了";一个是"这个请求失败了,隔一段时间再试"。两者都是对异步操作生命周期的管理。

还有一类题目这四个问题都没有完全覆盖:乐观更新。先更新 UI,再发请求,失败时回滚。这既涉及"谁感知变化"(状态管理),也涉及"可能出什么错"(失败回滚),是一个跨维度的场景。这个模型值得单独展开讨论。


小结

这四个问题不是一套算法,不能保证"输入题目,输出答案"。它们更像是一套减少遗漏的检查清单——帮助你在开始写代码之前,把题目的关键维度都想到。

实际使用时,四个问题往往不是顺序回答的,而是同时浮现的。看到"批量上传",同时会想到"时间维度(并发)"和"可能出错(部分失败)"。这种并行思考是熟练之后自然发生的,不需要刻意按顺序走。

如果你有其他觉得有用的判断维度,欢迎交流——这套框架本身也是在持续迭代的。


参考资料

面试题里的 Custom Hook 思维:从三道题总结「异步状态管理」通用模式

最近在准备面试,翻到几道关于 Custom Hook 的模拟题。表面上看各不相同——轮询、筛选、防抖搜索——但仔细分析之后,发现它们背后有一套共同的思维框架。这篇文章是我整理这套框架的笔记,希望对同样在备战面试的你有参考价值。


三道题,三个场景

先简单描述一下这三道题在考什么:

  • useRideTracking:行程进行中轮询状态,每 5s 请求一次,页面隐藏时暂停,连续失败 3 次停止
  • useExpenseFilter:报表筛选 Hook,多维联动筛选,需要 useMemo 优化
  • useEmployeeSearch:员工搜索,防抖 500ms + AbortController 取消请求

三个场景,但核心都指向同一个问题:如何在 Hook 里正确管理「副作用」和「派生状态」?


归纳出的通用思维框架

在我看来,一个合格的 Custom Hook 需要从四个维度去思考:

1. 状态层(State)     ── 管什么数据?
2. 副作用层(Effect)  ── 什么时候做什么?
3. 清理层(Cleanup)   ── 离开时怎么收尾?
4. 优化层(Optimization) ── 怎么不做多余的工作?

下面逐层展开,结合题目来理解。


第一层:状态层 — 先想清楚「管什么」

拿到题目,第一步应该问自己:这个 Hook 需要对外暴露哪些状态?

这三道题都有一个共同的「三元组」:

// 几乎所有「异步请求型」Hook 的状态骨架
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);

除了这个骨架,每道题还有「额外状态」:

  • useRideTracking:需要 failCount(连续失败次数),但这是内部状态,不对外暴露
  • useExpenseFilter:需要 filters 对象,并对外暴露 setFilter / resetFilters
  • useEmployeeSearch:需要 keyword,并对外暴露 setKeyword

一个实用技巧:区分「对外暴露」和「内部管理」的状态。对外的是接口契约,对内的是实现细节。面试中如果能主动说出这种区分,往往加分。

// useRideTracking 的状态设计示意
// 对外:{ status, loading, error }
// 对内:failCountRef(用 ref 而非 state,因为改变它不需要触发重渲染)
const failCountRef = useRef(0);

第二层:副作用层 — 明确「触发时机」

useEffect 的依赖数组,本质上是在描述「什么变化了我才需要重新执行」。

模式 A:挂载即执行 + 定时触发(useRideTracking)

// 环境:React 18+
// 场景:行程状态轮询

function useRideTracking(rideId) {
  const [status, setStatus] = useState(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  const failCountRef = useRef(0);
  const timerRef = useRef(null);
  const stoppedRef = useRef(false);

  const fetchStatus = async () => {
    if (stoppedRef.current) return;

    setLoading(true);
    try {
      const res = await fetch(`/api/rides/${rideId}`);
      if (!res.ok) throw new Error(`HTTP error: ${res.status}`);
      const data = await res.json();
      setStatus(data.status);
      setError(null);
      // success: reset fail counter
      failCountRef.current = 0;
    } catch (err) {
      failCountRef.current += 1;
      if (failCountRef.current >= 3) {
        stoppedRef.current = true;
        setError(err);
        setLoading(false);
        clearInterval(timerRef.current);
        return;
      }
    } finally {
      if (!stoppedRef.current) setLoading(false);
    }
  };

  useEffect(() => {
    // fetch immediately on mount
    fetchStatus();
    timerRef.current = setInterval(fetchStatus, 5000);

    const handleVisibility = () => {
      if (document.visibilityState === 'hidden') {
        clearInterval(timerRef.current);
      } else {
        fetchStatus(); // refetch immediately on visible
        timerRef.current = setInterval(fetchStatus, 5000);
      }
    };

    document.addEventListener('visibilitychange', handleVisibility);

    return () => {
      clearInterval(timerRef.current);
      document.removeEventListener('visibilitychange', handleVisibility);
      stoppedRef.current = true;
    };
  }, [rideId]);

  return { status, loading, error };
}

这道题的难点有两个:

  1. visibilitychange 事件——很多人第一反应想不到,但这是真实产品里节省资源的常见做法
  2. 连续失败计数用 ref 还是 state——改变它不需要重渲染,用 ref 更合适

模式 B:受控输入 + 派生计算(useExpenseFilter)

// 环境:React
// 场景:多维度联动筛选

const emptyFilter = {
  departments: [],
  dateRange: null,
  statuses: [],
  amountRange: null,
};

function useExpenseFilter(data) {
  const [filters, setFilters] = useState({ ...emptyFilter });

  const setFilter = useCallback((key, value) => {
    setFilters((prev) => ({ ...prev, [key]: value }));
  }, []);

  const resetFilters = useCallback(() => {
    setFilters({ ...emptyFilter });
  }, []);

  const filteredData = useMemo(() => {
    return data.filter((trip) => {
      if (filters.departments.length && !filters.departments.includes(trip.department)) return false;
      if (filters.statuses.length && !filters.statuses.includes(trip.status)) return false;
      if (filters.amountRange) {
        const [min, max] = filters.amountRange;
        if (trip.amount < min || trip.amount > max) return false;
      }
      if (filters.dateRange) {
        const [start, end] = filters.dateRange;
        if (trip.date < start || trip.date > end) return false;
      }
      return true;
    });
  }, [data, filters]);

  return { filters, setFilter, filteredData, resetFilters };
}

这道题相对直接,但有两个容易踩的坑:

  1. resetFilters 里要用 { ...emptyFilter } 而非直接传引用——否则 emptyFilter 对象可能被意外修改
  2. setFilter 要用 useCallback 包裹——否则每次渲染都会生成新函数,可能导致消费方的 memo 失效

模式 C:防抖 + 请求竞态处理(useEmployeeSearch)

// 环境:React
// 场景:带防抖的搜索请求,需要处理竞态

function useEmployeeSearch() {
  const [keyword, setKeyword] = useState('');
  const [results, setResults] = useState([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  const abortControllerRef = useRef(null);

  useEffect(() => {
    const trimmed = keyword.trim();

    // empty keyword: reset state immediately
    if (!trimmed) {
      setResults([]);
      setError(null);
      setLoading(false);
      return;
    }

    const timer = setTimeout(async () => {
      // abort previous pending request
      if (abortControllerRef.current) {
        abortControllerRef.current.abort();
      }

      const controller = new AbortController();
      abortControllerRef.current = controller;

      setLoading(true);
      setError(null);

      try {
        const res = await fetch(
          `/api/employees/search?q=${encodeURIComponent(trimmed)}`,
          { signal: controller.signal }
        );
        if (!res.ok) throw new Error(`HTTP error: ${res.status}`);
        const data = await res.json();
        setResults(data);
      } catch (err) {
        if (err.name === 'AbortError') return; // ignore abort errors
        setError(err);
      } finally {
        setLoading(false);
      }
    }, 500);

    return () => {
      clearTimeout(timer);
      // abort on cleanup (keyword changed or unmount)
      if (abortControllerRef.current) {
        abortControllerRef.current.abort();
      }
    };
  }, [keyword]);

  return { keyword, setKeyword, results, loading, error };
}

这道题的核心考点是「竞态条件」(race condition):用户快速输入时,后发出的请求可能比先发出的先返回,导致界面显示旧数据。AbortController 是解决这个问题的标准方案。


第三层:清理层 — 「离开时」的责任感

这是很多初学者写 Hook 时最容易忽略的部分,但在面试中往往是区分「会用」和「理解」的分水岭。

一个简单的清理检查清单:

□ 定时器(setInterval / setTimeout)需要 clearInterval / clearTimeout
□ 事件监听器需要 removeEventListener
□ 进行中的网络请求需要 AbortController.abort()
□ 组件卸载后不应再 setState(会产生 warning)

上面三道题都涉及清理,总结一下各自的清理策略:

Hook 需要清理的东西
useRideTracking clearInterval + removeEventListener + 标记 stoppedRef 防止 setState
useExpenseFilter 无(纯状态计算,无副作用)
useEmployeeSearch clearTimeout + AbortController.abort()

第四层:优化层 — 「不做多余的工作」

优化不是一开始就要做的事,但 Hook 里有几个固定场景需要考虑:

场景一:派生状态用 useMemo

useExpenseFilter 里的 filteredData 是典型案例。如果直接在函数体里 data.filter(...),每次任何状态变化都会重新过滤,即使 datafilters 没有变化。

// 不好:每次渲染都重新计算
const filteredData = getFilterData(data);

// 好:只在 data 或 filters 变化时重新计算
const filteredData = useMemo(() => getFilterData(data), [data, filters]);

场景二:回调函数用 useCallback

暴露给外部的函数,如果作为 props 传递给子组件,或者出现在其他 Hook 的依赖数组里,应该用 useCallback 包裹。

场景三:不需要触发重渲染的值用 useRef

failCountReftimerRefabortControllerRef 都属于这类。它们是「进行中的工作凭证」,改变它们不需要更新 UI。


一个「答题」的思维顺序

整理完这三道题,我发现面试时可以按这个顺序思考:

1. 明确返回值契约
   └── 对外暴露哪些状态和方法?

2. 识别副作用触发时机
   └── 依赖什么变化?立即执行还是延迟?

3. 规划清理策略
   └── 定时器 / 事件 / 请求,哪些要清理?

4. 考虑优化点
   └── 有无派生状态?回调需不需要 useCallback?

这个顺序不是铁律,但至少能保证不遗漏关键点。


延伸思考

整理这几道题时,有几个问题让我觉得值得继续探索:

  • useReducer vs 多个 useStateuseExpenseFilter 里的多个筛选条件,用 useReducer 管理会更清晰吗?什么情况下应该做这个选择?
  • 请求库的抽象层:SWR / React Query 的 revalidateOnFocus 本质上就是 useRideTracking 里的 visibilitychange 逻辑,只是封装层次不同。面试中能提到这层联系,可能会有加分
  • TypeScript 的泛型设计:这几个 Hook 如果要做成通用的,类型怎么设计?这可能是下一篇笔记的方向

小结

Custom Hook 的核心,我理解是「把复杂的副作用逻辑封装成可复用的、有明确接口的黑盒」。面试考这类题,考的其实不只是「能不能写出来」,更是「能不能清晰地描述你在解决什么问题」。

这篇文章是我自己的思考整理,不一定全对,如果你有不同的看法或者更好的方案,欢迎交流讨论。


参考资料

❌