Skip to content

Day3 浏览器原理:渲染流程、回流重绘、性能优化 ​

📖 基础知识讲解【基础原理 + 高级深挖知识点】 ​

基础原理 ​

1. 从输入 URL 到页面展示(渲染的上游) ​

阶段做的事性能抓手
DNS 解析域名 → IP,走本地缓存 / hosts / DNS 服务器dns-prefetch、减少域名数
TCP 三次握手建立连接;HTTPS 还要 TLS 握手preconnect、HTTP/2 多路复用、keepalive
发送请求带上 Cookie、缓存校验头强缓存命中可直接跳过网络
服务端响应返回 HTML 流TTFB 指标、SSR 直出、CDN
浏览器渲染解析 HTML → 构建 DOM/CSSOM → 渲染树 → 布局 → 绘制 → 合成本篇重点

面试回答时先给「网络 → 渲染」两大段,再细化渲染六步,层次感比直接背六步强很多。

2. 渲染六阶段详解(每个阶段到底干了什么) ​

整条流水线的输入是字节流,输出是屏幕上的一帧像素:

text
字节流  ──解码/分词/建树──>  DOM Tree ─┐
                                        ├─> Render Tree ─> Layout ─> Paint ─> Layer/Raster ─> Composite
CSS 文本 ──解析/层叠计算──>  CSSOM ────┘    「长什么样」   「在哪」  「怎么画」  「切块成位图」   「拼成一帧」

一句话记忆:先知道有什么(DOM) → 再知道长什么样(CSSOM/样式计算) → 算在哪(Layout) → 记录怎么画(Paint) → 切块烤成图片(Raster) → 拼起来上屏(Composite)。

① 解析 HTML → 构建 DOM 树

  • 输入: 网络进程传来的 HTML 字节流(边下边解析,不必等整个文档下载完)。
  • 干了什么: 字节 → 按编码解码成字符 → 分词器(Tokenizer)切成 StartTag / EndTag / Text 等 Token → 按 HTML 规范的栈结构构建节点并建立父子关系 → 生成 DOM 树。
  • 产物: DOM 树,一棵可被 JS 操作的对象树(document 开始)。
  • 关键细节:
    • 遇到 <script>(无 async / defer)会暂停解析,先下载并执行脚本,因为 JS 可能 document.write 改变文档结构。
    • 浏览器有 预解析线程(Preload Scanner),会提前扫描后面的 img / link / script 并发起下载,所以阻塞的只是「建树」,不是「下载」。
    • HTML 容错极强,标签没闭合也会被自动修正,不会像 XML 一样直接报错。

② 解析 CSS → 构建 CSSOM 树 + 样式计算

  • 输入: 内联 <style>、外链 <link> 下载到的 CSS 文本、行内 style 属性、浏览器默认样式表(User Agent StyleSheet)。
  • 干了什么: 把 CSS 文本解析成 styleSheets 结构 → 标准化属性值(2em→32px、red→rgb(255,0,0)、bold→700)→ 按继承和层叠规则(来源优先级 + 选择器权重 + 书写顺序)为每个 DOM 节点算出一份最终样式 ComputedStyle。
  • 产物: CSSOM 树 / 每个节点的计算后样式。
  • 关键细节:
    • CSS 不阻塞 DOM 解析,但阻塞渲染树构建(没算完样式画出来会闪烁)。
    • CSS 还会阻塞其后 JS 的执行,因为 JS 可能读 getComputedStyle,必须等 CSSOM 就绪。
    • 选择器匹配是从右往左的,所以后代选择器层级越深越慢。

③ 合并生成渲染树(Render Tree)

  • 输入: DOM 树 + 每个节点的计算后样式。
  • 干了什么: 从 DOM 根节点开始遍历,挑出最终会显示在页面上的节点,附上样式信息,组成渲染树(Chromium 中叫 LayoutTree)。
  • 产物: 只包含可见节点的渲染树。
  • 关键细节:
    • display: none 的节点不进渲染树;visibility: hidden 会进(它仍占位);opacity: 0 也会进。
    • <head> / <script> / <meta> 这类本身不渲染的节点不进。
    • ::before / ::after 伪元素在 DOM 里没有节点,但会出现在渲染树中。
    • 所以:渲染树和 DOM 树不是一一对应的(高频考点)。

④ 布局 Layout(回流 / Reflow)

  • 输入: 渲染树(知道有哪些元素、什么样式,但还不知道在哪)。
  • 干了什么: 从根节点递归遍历,结合视口尺寸计算出每个节点的几何信息 —— 盒模型尺寸、相对于包含块的坐标、浮动与定位的影响、文本换行与行盒拆分、百分比与 flex / grid 的最终解析值。
  • 产物: 布局树(每个节点带有精确的 x / y / width / height)。
  • 关键细节:
    • 整条流水线里开销最大的一步,一次布局可能波及父级、兄弟和整棵子树。
    • 只要改动几何属性(宽高、位置、字体、显隐、文本内容)就会重新触发。
    • 读 offsetWidth / getBoundingClientRect() 会强制浏览器立刻执行布局(强制同步布局,见高级部分)。
    • 现代浏览器只会重算「脏」的子树,但最坏情况仍可能全文档重排。

⑤ 绘制 Paint(重绘 / Repaint)

  • 输入: 带几何信息的布局树。
  • 干了什么: 并不是直接往屏幕上画像素,而是把「怎么画」翻译成一条条绘制指令:先画背景、再画边框、再画文本、最后画阴影/轮廓,并按正确的**绘制顺序(层叠上下文 / z-index)**排好序。
  • 产物: 绘制记录列表(Paint Records / DisplayList),类似一份「施工图纸」。
  • 关键细节:
    • 改 color、background、box-shadow 只需重新生成绘制指令,跳过 Layout,所以比回流便宜。
    • 绘制是按层进行的,一个层内任意元素变化,整层都要重绘。

⑥ 分层 + 光栅化 + 合成(Layer / Raster / Composite)

  • 分层(Layerize): 主线程根据层叠上下文、transform、will-change、position: fixed、video / canvas 等条件,把页面拆成若干合成层(Compositing Layer),并把绘制指令列表连同层树一起提交给合成线程。
  • 分块(Tiling): 合成线程把每个图层切成固定大小的图块(Tile,通常 256×256 或 512×512),并优先处理视口附近的图块。
  • 光栅化(Raster): 光栅化线程池(通常借助 GPU)把图块的绘制指令真正执行成位图像素,结果存在 GPU 显存中。
  • 合成(Composite): 所有图块光栅化完成后,合成线程生成一个 DrawQuad 命令集合(CompositorFrame),提交给 GPU 进程;GPU 按各层的变换矩阵把位图拼成最终一帧,显示到屏幕上。
  • 关键细节:
    • 这一整段不在主线程,所以主线程被 JS 卡住时,transform / opacity 动画和页面滚动仍能继续。
    • 改 transform 只是改变图层的变换矩阵,位图可以直接复用,既不 Layout 也不 Paint —— 这就是「GPU 加速」的真相。
    • 层不是越多越好,每层都要占 宽 × 高 × 4 字节 显存,层太多会「图层爆炸」。

各阶段速查表:

阶段别名输入 → 产物在哪执行关键细节
Parse HTML—字节流 → DOM 树主线程边下边解析;<script> 阻塞解析;预解析线程提前下载资源
Parse CSS—CSS 文本 → CSSOM / ComputedStyle主线程不阻塞 DOM 解析,但阻塞渲染树构建,也阻塞其后 JS 执行
Render Tree渲染树DOM × CSSOM → 可见节点集合主线程display:none 不进,visibility:hidden 会进,伪元素会进
Layout回流渲染树 → 每个节点的几何信息主线程开销最大,影响范围可能是整棵子树甚至整个文档
Paint重绘布局树 → 绘制指令列表主线程只生成「图纸」,不出像素;按层进行,一层变整层重绘
Layerize / Tiling分层·分块绘制指令 → 图层树 + 图块主线程 → 合成线程层越多显存越大,小心图层爆炸
Raster光栅化图块 → 位图(存 GPU 显存)光栅化线程/GPU优先光栅化视口附近图块
Composite合成位图 + 变换矩阵 → 最终帧上屏合成线程 + GPU不占主线程,transform/opacity 流畅的根因

3. 浏览器多进程架构(渲染发生在哪) ​

打开一个 Tab,背后不是「一个浏览器程序」在跑,而是一群各司其职的进程:

text
┌─────────────────────────────────────────────────────────┐
│ 浏览器主进程   地址栏 / 书签 / 前进后退 / 窗口管理        │
└───────┬─────────────────────┬───────────────────────────┘
        │                     │
┌───────▼────────┐   ┌────────▼────────┐   ┌──────────────┐
│ 网络进程        │   │ 渲染进程(每Tab) │   │  GPU 进程    │
│ 统一发请求/缓存 │──>│  ← 页面内核     │──>│ 图层合成上屏 │
└────────────────┘   └────────┬────────┘   └──────────────┘
                              │
      ┌───────────────────────┼─────────────────────┐
      │                       │                     │
  主线程(1个)            合成线程             光栅化/Worker 线程
  解析 HTML/CSS          图层分块、滚动         图块转位图
  执行 JS                动画帧推进             后台计算
  Layout / Paint
角色通俗理解职责
浏览器主进程「前台接待」界面、地址栏、前进后退、文件访问
网络进程「采购部」所有资源请求、缓存校验
渲染进程「车间」每个 Tab 一个(站点隔离),页面内核所在
└ 主线程「唯一的师傅」解析 HTML/CSS、执行 JS、Layout、Paint
└ 合成线程「装配工」图层分块、滚动与合成动画推进
└ 光栅化线程「打印机」把图块(Tile)渲染成位图
GPU 进程「显示屏驱动」把图层合成为最终帧并上屏

要理解的两个关键点:

① 主线程只有一个,GUI 渲染和 JS 执行必须排队。 渲染页面和跑 JS 用的是同一个师傅,所以 JS 死循环会让页面卡死不动 —— 不是浏览器坏了,是师傅被 JS 占着,没空去画。

② 合成(Composite)不在主线程,所以它「卡不住」。 假设一个元素做移动动画,两种写法的代价完全不同:

text
改 left/top  ──> 主线程: Layout → Paint ──> 合成线程 → GPU   ❌ 每帧都要麻烦师傅
改 transform ──────────────────────────────> 合成线程 → GPU   ✅ 直接跳到装配环节
动画写法走过的阶段主线程是否参与卡顿风险
left / top / widthLayout → Paint → Composite参与(每帧)高,JS 一忙动画就掉帧
transform / opacity仅 Composite不参与低,JS 卡住动画照样跑

一句话结论: 平时说的「渲染流程」基本都是渲染进程主线程上的事;只有最后的合成环节跑在合成线程 + GPU 上。因此 transform / opacity 能绕开主线程直接交给 GPU 合成 —— 这就是它们动画流畅的根本原因,也是「GPU 加速」这个说法的真实含义。

4. 回流(Reflow)与重绘(Repaint) ​

维度回流 Reflow重绘 Repaint
触发条件几何属性变化:尺寸、位置、内容、字体、显示隐藏外观属性变化:颜色、背景、阴影、边框色
影响范围可能波及父级、子级、兄弟节点,最坏重排整个文档仅当前节点所在图层
开销极大(重新计算布局 + 后续全部阶段)较小(跳过 Layout,从 Paint 开始)
关系回流必定触发重绘重绘不一定触发回流

常见触发回流的操作:

  • 增删改 DOM 节点、修改文本内容;
  • 修改 width / height / padding / margin / border / top / left / font-size / display;
  • 窗口 resize、滚动条出现或消失、激活 CSS 伪类(如 :hover 改变尺寸);
  • 读取布局属性触发强制同步布局(见高级部分)。

只触发重绘的操作: color、background-color、background-image、border-color、visibility、box-shadow、outline。

CSS 属性分级速查表(面试直接背):

属性LayoutPaintComposite
width / height / top / left / margin✅✅✅
font-size / display / padding / border-width✅✅✅
color / background / box-shadow / visibility❌✅✅
transform / opacity / filter❌❌✅

filter 在多数现代浏览器中同样走合成,但不同实现可能仍需重绘,动画首选 transform + opacity 这两个最稳的合成属性。

5. 图层合成(Composite) ​

  • 浏览器会把页面拆成多个 合成层(Compositing Layer),每层独立光栅化成位图,再由 GPU 合成为一帧。
  • 某一层内容变化时,只需重新光栅化该层并重新合成,不影响其他层。
  • 提升为独立合成层的常见条件:
    • 3D 变换:transform: translateZ(0) / translate3d(0,0,0)(经典 GPU hack);
    • will-change: transform / opacity;
    • position: fixed / sticky(部分场景);
    • video、canvas、iframe;
    • opacity / transform 正在执行 CSS 动画;
    • 有合成层后代且自身 z-index 较低(层压合 / 隐式提升)。

GPU 动画流畅的本质: transform 改变的只是图层的变换矩阵,主线程不需要重新 Layout、Paint,合成线程即使在主线程被 JS 阻塞时也能继续推帧。

6. 一帧里到底发生了什么(16.6ms 预算) ​

text
┌─────────────────────── 一帧(约 16.6ms @60fps)───────────────────────┐
│ 1. 处理输入事件(click / touch / scroll)                             │
│ 2. 执行 JS(定时器、微任务清空)                                      │
│ 3. requestAnimationFrame 回调  ← 动画改样式的最佳时机                 │
│ 4. Layout(回流) → Paint(重绘) → Composite(合成,交 GPU)         │
│ 5. 帧有空闲时间 → requestIdleCallback 回调                            │
└───────────────────────────────────────────────────────────────────────┘

结论三连:

  • 微任务执行在渲染之前,此时读布局拿到的是旧值;
  • requestAnimationFrame 在样式计算与布局之前执行,是改动画样式的最佳位置;
  • 单个任务执行超过 50ms 即为 Long Task,会直接掉帧、阻塞交互。

7. 脚本与样式对解析的阻塞规则 ​

写法下载执行时机是否阻塞 HTML 解析
<script>同步立即阻塞(DOM 解析暂停)
<script async>并行下载完立即执行(顺序不保证)执行时阻塞
<script defer>并行DOMContentLoaded 前按顺序执行不阻塞(推荐)
<link rel=stylesheet>并行—不阻塞 DOM 解析,阻塞渲染

关键结论:CSS 阻塞渲染,也阻塞其后 JS 的执行(因为 JS 可能读样式,必须等 CSSOM 就绪);因此 CSS 放 <head>、JS 用 defer 放尾部,是最经典的关键渲染路径优化。

✨ 高级深挖知识点 ​

1. 强制同步布局(Layout Thrashing / 布局抖动) ​

浏览器为了性能会把样式修改批量队列化,到帧末统一 Layout。但只要你在修改之后读取布局属性,浏览器为了给你一个准确值,必须立刻同步执行 Layout —— 这就是强制同步布局。循环里一读一写,就变成「回流 N 次」。

js
// ❌ 反面教材:每次循环都强制一次同步布局(布局抖动)
boxes.forEach((box) => {
  box.style.width = box.offsetWidth + 10 + "px"; // 先读后写,读写交替
});

// ✅ 正解:读写分离(先批量读,再批量写)
const widths = boxes.map((box) => box.offsetWidth); // 1. 集中读(只回流一次)
boxes.forEach((box, i) => {
  box.style.width = widths[i] + 10 + "px"; // 2. 集中写(批量进队列)
});

会触发强制同步布局的「读」清单(必背):

分类属性 / 方法
元素盒子offsetTop/Left/Width/Height、clientTop/Left/Width/Height
滚动scrollTop/Left/Width/Height、scrollTo()、scrollIntoView()
几何getBoundingClientRect()、getClientRects()
样式getComputedStyle()(读取几何相关属性时)
窗口window.innerWidth/Height、window.scrollX/Y
其他focus()、getSelection()、elem.innerText(需要布局才能算可见文本)

加分表述:浏览器维护了一个 dirty 标记,样式修改只是打脏标记进队列;读布局属性时若发现队列非空,就必须 flush 队列、立即重算布局。读写分离 + 批量操作是规避的唯一正解。

2. 图层爆炸与层压合(隐式提升) ​

问题成因后果解法
图层爆炸给大量元素加 will-change / translateZ(0)每层都要独立位图,GPU 显存暴涨、反而更卡只给真正在动的元素提升,动画结束立刻移除
层压合某元素 z-index 低于已有合成层,却与其重叠被隐式提升为合成层,出现意料外的层调整 z-index 层级,避免与合成层重叠
js
// ✅ 按需提升:动画开始前加,结束后立刻撤
el.addEventListener("mouseenter", () => {
  el.style.willChange = "transform";
});
el.addEventListener("animationend", () => {
  el.style.willChange = "auto"; // 释放图层与显存
});

经验值:一个合成层的内存 = 宽 × 高 × 4 字节。一张 1920×1080 的全屏层约 8MB,几十个就能把低端机显存吃满。

3. 核心性能指标(全景版:网络段 + 渲染段 + 交互段) ​

性能指标别只背 Web Vitals 那几个,面试更想听你按时间轴分段讲清楚。一次加载的时间轴长这样:

text
点击链接
  │
  ├─ redirect ─┬─ DNS ─┬─ TCP ─┬─ TLS ─┬─ request ─┬─ response ─┬─ 解析渲染 ─┬─ 可交互
  │            │       │       │       │           │            │            │
  └─ 重定向耗时 └ DNS   └ TCP   └ TLS   └──TTFB────┘            │            │
                                                       白屏/FP          TTI
                                                           FCP → 首屏(FSP) → LCP

① 网络段指标(反映服务端 + 链路质量)

指标全称 / 计算方式含义良好阈值优化手段
RedirectredirectEnd - redirectStart重定向耗时0(最好没有)去掉多余 301/302、HSTS 免跳转
DNSdomainLookupEnd - domainLookupStart域名解析耗时< 100msdns-prefetch、减少域名数、HTTPDNS
TCPconnectEnd - connectStart连接建立(含 TLS)< 200mspreconnect、HTTP/2 多路复用、keep-alive
TLSconnectEnd - secureConnectionStartHTTPS 握手耗时< 100msTLS1.3、会话复用、OCSP Stapling
TTFB ⭐responseStart - requestStart首字节时间,服务端处理 + 网络往返< 800ms接口优化、SSR 缓存、CDN 近源、边缘计算
Content DownloadresponseEnd - responseStart响应体下载耗时—Gzip / Brotli、减小 HTML 体积、流式输出
DNS+TCP+TTFB合称「网络耗时」到拿到第一个字节为止—这段慢 = 后端/网络问题,前端优化无力

TTFB 是分水岭:TTFB 之前的问题归后端/运维,TTFB 之后的问题归前端。排查白屏先看 TTFB,能省掉一半无效优化。

② 渲染段指标(反映「用户什么时候看到东西」)

指标全称含义良好阈值优化手段
白屏时间Blank / Start Render从导航开始到屏幕出现第一个像素,约等于 FP< 1s内联关键 CSS、减少阻塞资源、SSR 直出
FPFirst Paint首次有任何像素绘制(可能只是背景色)—同上
FCP ⭐First Contentful Paint首次绘制文本 / 图片 / canvas 等真实内容< 1.8s减少阻塞资源、SSR、CDN、字体 font-display
FSPFirst Screen Paint(首屏时间)首屏区域内容全部渲染完成(无标准 API,需自测)< 2s首屏数据直出、骨架屏、首屏资源优先加载
LCP ⭐Largest Contentful Paint视口内最大内容元素绘制完成(Google 用它近似首屏)< 2.5s首屏大图压缩/preload、拆分 JS、字体优化
CLS ⭐Cumulative Layout Shift累计布局偏移(页面跳动程度,无单位)< 0.1图片/广告位预留宽高、aspect-ratio、字体兜底
SISpeed Index首屏内容的平均填充速度(越小越好)< 3.4s提高首屏渲染完成度、减少渐进加载
DCLDOMContentLoadedDOM 解析完成(不等图片)—JS 用 defer、减少同步脚本
LLoad所有资源(含图片)加载完成—图片懒加载、非关键资源延后

白屏 vs FCP vs 首屏 vs LCP 的区别(高频追问):

  • 白屏/FP:屏幕「不再是白的」,可能只是背景色或骨架屏;
  • FCP:出现了第一个有意义的内容;
  • FSP 首屏:首屏可视区域全部渲染完 —— 最贴近用户主观感受,但没有标准 API;
  • LCP:首屏里最大那块内容渲染完 —— Google 用它作为「首屏」的可量化近似。

③ 交互段指标(反映「用户点得动吗」)

指标全称含义良好阈值优化手段
TTITime to Interactive可稳定交互时间(主线程连续 5s 无长任务)< 3.8s减少主线程长任务、代码分割
TBTTotal Blocking TimeFCP→TTI 间长任务超出 50ms 的阻塞总时长< 200ms长任务切片、延迟非关键 JS
FIDFirst Input Delay首次输入延迟(已被 INP 取代)< 100ms拆分长任务
INP ⭐Interaction to Next Paint所有交互到下一帧绘制的延迟(取 98 分位)< 200ms拆长任务、节流防抖、Web Worker
FPSFrames Per Second运行时帧率,反映滚动/动画流畅度≥ 55合成属性动画、虚拟列表、减少重绘
Long Task长任务单个任务执行 > 50ms0 个时间切片、让渡主线程

带 ⭐ 的 LCP / CLS / INP 是 Google 当前的 三大 Core Web Vitals,直接影响搜索排名,面试必须能准确报出阈值。

④ 业务自定义指标(线上监控真正要看的)

指标计算方式为什么要自己埋
首屏时间 FSPMutationObserver监听首屏 DOM 变化,取最后一次变化时间;或在首屏关键组件渲染完手动打点标准 API 测不准 SPA 的真实首屏
接口可用时间首屏核心接口 response 回来的时刻很多「慢」其实是接口慢,不是渲染慢
首屏图片加载完成首屏图 onload 全部完成电商/内容页的真实可看时间
路由切换耗时路由 beforeEach → 组件 mounted/useEffectSPA 里 Load / DCL 只在首次有效,二跳必须自己埋
资源错误率 / JS 错误率error、unhandledrejection、资源 onerror性能与稳定性要一起看

⑤ 完整采集代码

js
// 1. 网络段 + DCL/Load:Navigation Timing Level 2
function getNavTiming() {
  const [nav] = performance.getEntriesByType("navigation");
  if (!nav) return null;
  return {
    redirect: nav.redirectEnd - nav.redirectStart,
    dns: nav.domainLookupEnd - nav.domainLookupStart,
    tcp: nav.connectEnd - nav.connectStart,
    tls: nav.secureConnectionStart
      ? nav.connectEnd - nav.secureConnectionStart
      : 0,
    ttfb: nav.responseStart - nav.requestStart, // 首字节
    download: nav.responseEnd - nav.responseStart,
    domParse: nav.domInteractive - nav.responseEnd,
    dcl: nav.domContentLoadedEventEnd - nav.startTime,
    load: nav.loadEventStart - nav.startTime,
  };
}

// 2. 白屏 / FP / FCP
new PerformanceObserver((list) => {
  list.getEntries().forEach((e) => {
    // e.name: 'first-paint' | 'first-contentful-paint'
    console.log(e.name, Math.round(e.startTime));
  });
}).observe({ type: "paint", buffered: true });

// 3. LCP(注意:会多次上报,取最后一次;用户交互后停止更新)
new PerformanceObserver((list) => {
  const entries = list.getEntries();
  console.log("LCP:", entries[entries.length - 1].startTime);
}).observe({ type: "largest-contentful-paint", buffered: true });

// 4. CLS(累加,排除交互引起的偏移,页面隐藏时上报)
let cls = 0;
new PerformanceObserver((list) => {
  list.getEntries().forEach((e) => {
    if (!e.hadRecentInput) cls += e.value;
  });
}).observe({ type: "layout-shift", buffered: true });
addEventListener("visibilitychange", () => {
  if (document.visibilityState === "hidden") report("CLS", cls);
});

// 5. 长任务(> 50ms)
new PerformanceObserver((list) => {
  list.getEntries().forEach((e) => console.warn("Long Task:", e.duration));
}).observe({ entryTypes: ["longtask"] });

// 6. 自定义首屏时间:监听首屏容器的 DOM 变化
function observeFSP(selector = "#app") {
  let lastTime = 0;
  const ob = new MutationObserver(() => {
    lastTime = performance.now(); // 每次 DOM 变化刷新时间戳
  });
  ob.observe(document.querySelector(selector), {
    childList: true,
    subtree: true,
  });
  // 页面稳定后(如 load + 2s)停止观察,lastTime 即近似首屏时间
  addEventListener("load", () =>
    setTimeout(() => {
      ob.disconnect();
      report("FSP", lastTime);
    }, 2000)
  );
}

// 7. 上报用 sendBeacon,避免页面卸载时请求被取消
function report(name, value) {
  navigator.sendBeacon?.(
    "/api/perf",
    JSON.stringify({ name, value: Math.round(value), url: location.href })
  );
}

// 生产环境推荐直接用官方库,已处理各种边界
// import { onLCP, onCLS, onINP, onFCP, onTTFB } from 'web-vitals'

统计口径提醒: 线上数据一律看 75 分位(P75),不要看平均值 —— 平均值会被少数极端慢的用户拉偏,也会被大量快用户掩盖长尾问题。

4. 性能排查工具链 ​

工具定位能力
Performance 面板火焰图看长任务、Layout / Paint 耗时、掉帧;紫色块 = Layout,绿色 = Paint
Layers 面板查看合成层数量、内存占用、提升原因(Compositing Reasons)
Rendering 面板Paint flashing(重绘高亮)、Layer borders、FPS meter
Lighthouse自动化打分 + 优化建议(LCP / CLS / TBT 一次性给全)
Coverage 面板统计 CSS / JS 未使用代码占比,指导 Tree Shaking 与按需加载
Network 面板(瀑布图)TTFB、排队阻塞、关键资源优先级

5. 长任务拆分与主线程让渡 ​

js
// 方案一:时间切片(借鉴 React Scheduler 思路)
function chunkedWork(list, handler, chunkTime = 5) {
  let i = 0;
  function run(deadline) {
    while (i < list.length && deadline.timeRemaining() > chunkTime) {
      handler(list[i++]);
    }
    if (i < list.length) requestIdleCallback(run);
  }
  requestIdleCallback(run);
}

// 方案二:分帧渲染(每帧固定条数,适合有视觉要求的场景)
function renderByFrame(total, perFrame, render) {
  let rendered = 0;
  function loop() {
    const end = Math.min(rendered + perFrame, total);
    for (; rendered < end; rendered++) render(rendered);
    if (rendered < total) requestAnimationFrame(loop);
  }
  requestAnimationFrame(loop);
}

// 方案三:纯计算甩给 Web Worker,彻底不占主线程
const worker = new Worker("./calc.js");
worker.postMessage(bigData);
worker.onmessage = (e) => renderResult(e.data);

requestIdleCallback 不保证一定被调用(页面一直繁忙时会饿死),强制执行需配 timeout 参数;Safari 兼容性差,生产建议用 MessageChannel 或 scheduler.postTask 兜底。

6. 现代 CSS 性能武器 ​

特性作用适用场景
contain: layout paint隔离子树,布局/绘制不外溢,缩小回流影响范围卡片、列表项、独立组件
content-visibility: auto视口外子树跳过渲染(布局+绘制),首屏渲染成本骤降超长文档、长列表
contain-intrinsic-size配合上一条,给未渲染区域一个占位尺寸,避免滚动条跳动与 content-visibility 搭配
aspect-ratio提前锁定宽高比,图片加载前就占位治 CLS 的特效药
transform / opacity只走合成,不回流不重绘所有动画首选

7. 资源加载优先级提示 ​

html
<!-- 提前建连,省掉 DNS + TCP + TLS -->
<link rel="preconnect" href="https://cdn.example.com" />
<link rel="dns-prefetch" href="https://cdn.example.com" />

<!-- 高优先级预加载首屏关键资源(LCP 元素常用) -->
<link rel="preload" as="image" href="/hero.webp" fetchpriority="high" />
<link rel="preload" as="font" type="font/woff2" href="/f.woff2" crossorigin />

<!-- 预取下一个路由可能用到的资源(低优先级,空闲时下载) -->
<link rel="prefetch" href="/next-page.js" />

<!-- 图片懒加载 + 解码不阻塞 + 尺寸占位(防 CLS) -->
<img src="x.webp" loading="lazy" decoding="async" width="750" height="400" />

preload 是「这次导航一定要用,赶紧下」;prefetch 是「下次可能用,闲了再下」。字体 preload 必须带 crossorigin,否则会重复下载两次。

❓ 高频面试题【基础简答 + 连环追问链 + 精简口述答案 + 项目关联话术】 ​

题 1:浏览器完整渲染流程?回流重绘区别,哪些操作只触发图层合成? ​

精简口述答案:

HTML 解析生成 DOM,CSS 解析生成 CSSOM,两者合并成渲染树;接着 Layout 计算每个节点的几何位置(回流),Paint 生成绘制指令(重绘),最后分层、光栅化,由合成线程和 GPU 完成 Composite 合成上屏。回流一定触发重绘,重绘不一定触发回流;transform 和 opacity 只走合成阶段,跳过 Layout 和 Paint,而且合成在非主线程完成,所以动画性能最好。

连环追问链:

  1. 追问:修改 width 触发什么?修改 background-color 触发什么?
    • 答:width → 回流 + 重绘 + 合成;background-color → 重绘 + 合成,不回流。
  2. 追问:只走 Composite 的 CSS 属性有哪些?为什么它们快?
    • 答:transform、opacity(filter 多数场景也是)。因为它们只改变图层的变换矩阵或透明度,不改变文档几何结构,主线程无需重算布局和绘制,合成线程直接用已有位图重新合成。
  3. 追问:display: none 和 visibility: hidden 的渲染差异?
    • 答:display:none 不生成渲染树节点,不占位,切换时触发回流;visibility:hidden 仍在渲染树中、仍占位,切换只触发重绘。另外 opacity: 0 不仅占位还能响应事件,且走合成。
  4. 追问:渲染树和 DOM 树是一一对应的吗?
    • 答:不是。display:none 的节点、<head> / <script> / <meta> 等不可见节点不会进渲染树;而伪元素 ::before / ::after 在 DOM 里没有节点,却会出现在渲染树中。
  5. 追问:为什么 CSS 放头部、JS 放尾部?
    • 答:CSS 阻塞渲染树构建(也阻塞后续 JS 执行),早下载早构建 CSSOM 可避免白屏;JS 会阻塞 HTML 解析,放尾部或加 defer 能让 DOM 先解析完。

✅ 项目关联话术(按真实经历替换):

项目里的弹窗、侧边抽屉、列表项动画统一用 transform: translate3d + opacity 实现,不再用 top / left / height 做位移和展开;低端安卓机上原来滑动抽屉掉到 30fps 左右,改成合成属性动画后稳定在 60fps。

题 2:什么是强制同步布局?如何规避? ​

精简口述答案:

浏览器为了性能会把样式修改批量进队列,到帧末统一布局。但如果修改之后立刻读取 offsetWidth、getBoundingClientRect 这类布局属性,浏览器必须 flush 队列、立即同步计算布局,才能返回准确值 —— 这就是强制同步布局。如果在循环里反复「写一次读一次」,就会触发 N 次回流,也叫布局抖动。规避的核心是读写分离:先一次性把所有布局属性读出来缓存,再统一批量修改样式。

连环追问链:

  1. 追问:哪些属性读取会触发强制同步布局?
    • 答:offsetTop/Left/Width/Height、clientXxx、scrollTop/Height、getBoundingClientRect()、getComputedStyle() 读几何属性、window.innerWidth、scrollIntoView()、focus() 等。
  2. 追问:浏览器对多次 DOM 修改做了什么优化?
    • 答:维护脏标记 + 渲染队列,短时间多次样式修改会合并成一次 Layout;批量插入节点用 DocumentFragment 或先 cloneNode 离线修改再整体替换,也能把多次回流压成一次。
  3. 追问:还有哪些减少回流次数的工程手段?
    • 答:① 用 class / cssText 一次性改多个样式;② 复杂动画元素脱离文档流(absolute / fixed),缩小回流影响范围;③ 修改前先 display:none 离线改完再显示(两次回流换 N 次);④ contain / content-visibility 限制回流范围。
  4. 追问:requestAnimationFrame 里读布局会不会强制同步布局?
    • 答:会。rAF 在布局之前执行,如果前面有未 flush 的样式修改,读取布局同样触发同步布局。正确做法是 rAF 里只写不读,需要读就放在上一帧或使用 ResizeObserver / IntersectionObserver 回调。

✅ 项目关联话术(按真实经历替换):

做瀑布流列表的高度计算时,原来在循环里一边读 offsetHeight 一边写 style.top,列表一多滚动就卡顿明显。用 Performance 面板看到大量紫色 Layout 块后,改成「先一次性读完所有高度存数组,再统一写样式」,Layout 从几十次降到 1 次,滚动恢复流畅。

题 3:前端页面性能优化手段有哪些? ​

精简口述答案:

我按四个维度答:加载层面 —— 资源压缩、Gzip/Brotli、CDN、HTTP 缓存、代码分割与路由懒加载、图片 webp/avif + 懒加载、关键资源 preload;渲染层面 —— 关键 CSS 内联、JS defer、减少回流重绘、合成属性做动画、长列表虚拟滚动、content-visibility;运行时层面 —— 拆分长任务、时间切片、Web Worker、事件节流防抖;感知层面 —— 骨架屏、SSR / 预渲染直出首屏。落地上先用 Lighthouse 和 Performance 定位瓶颈指标,再针对性优化,最后接入性能监控持续观测。

连环追问链:

  1. 追问:LCP 偏大怎么优化?
    • 答:先确认 LCP 元素是什么(多半是首屏大图或标题块)。图片侧:压缩成 webp、preload + fetchpriority="high"、CDN 近源、响应式 srcset、首屏图禁止懒加载;资源侧:减小关键 JS/CSS 体积、路由分包;服务端侧:降 TTFB、SSR 直出、边缘缓存。
  2. 追问:CLS 高是什么原因,怎么治?
    • 答:图片/视频/广告位没写宽高导致加载后撑开布局、动态插入内容把下方元素挤走、字体回退造成的 FOUT 跳动。解法:给媒体元素写死 width/height 或 aspect-ratio、占位骨架、font-display: optional/swap + size-adjust、插入内容用 transform 而非改变文档流。
  3. 追问:首屏白屏时间长,你怎么一步步定位?
    • 答:① Network 看 TTFB 是否过高(服务端/网络问题);② 看关键资源瀑布图有没有阻塞和排队;③ Performance 看主线程是否有长任务和大量 Layout;④ Coverage 看 JS/CSS 未使用率是否过高;⑤ Lighthouse 对照 FCP / LCP / TBT 给出的具体建议。
  4. 追问:图片优化具体做哪些事?
    • 答:格式选型(webp/avif,渐进式 JPEG)、按 DPR 和容器尺寸裁剪(CDN 动态裁剪)、loading="lazy" + IntersectionObserver 懒加载、首屏图预加载、雪碧图或内联小图减少请求、骨架图/LQIP 模糊占位。
  5. 追问:怎么做前端性能监控?
    • 答:用 PerformanceObserver 采集 FCP / LCP / CLS / INP / longtask,配合 performance.getEntriesByType('navigation') 拿到 DNS、TCP、TTFB、DOMContentLoaded 等分段耗时,再按用户维度上报做 75 分位统计,而不是只看平均值。

✅ 项目关联话术(按真实经历替换):

后台管理系统首屏 LCP 一度到 3.2s。排查后做了三件事:首屏主图转 webp 并加 preload,非首屏图片全部懒加载;路由全部改成动态 import 分包,首包体积从 2.1MB 降到 680KB;长列表接入虚拟滚动。最终 LCP 降到 1.4s,Lighthouse 性能分从 52 提升到 90+。

题 4:虚拟列表的原理是什么?和懒加载、分页有什么区别? ​

精简口述答案:

虚拟列表的核心是只渲染可视区域内的元素:用一个占位元素撑起 总条数 × 行高 的滚动高度,监听滚动计算 startIndex = scrollTop / itemHeight,只截取可见区间的数据渲染,再用 transform: translateY 把渲染容器偏移到正确位置。这样 DOM 数量恒定在几十个,不随数据量增长。它和分页、懒加载的区别是:分页/懒加载只是延迟加载数据,DOM 还是会越堆越多;虚拟列表解决的是 DOM 数量问题。

连环追问链:

  1. 追问:不定高的列表怎么做?
    • 答:维护一个位置缓存数组(每项记录 top / bottom / height),先用预估高度占位,渲染后通过真实 offsetHeight 回填并修正后续项的偏移,查找 startIndex 用二分搜索;工程上可直接用 ResizeObserver 监听高度变化。
  2. 追问:快速滚动出现白屏怎么办?
    • 答:上下各多渲染几屏缓冲区(buffer / overscan);滚动用 transform 偏移而非改 top;必要时节流到 requestAnimationFrame,并对复用的 DOM 做 key 复用减少重建。
  3. 追问:为什么用 transform 而不是 top 做偏移?
    • 答:top 会触发回流 + 重绘,滚动时每帧回流必然卡顿;transform 只走合成,在合成线程完成。
  4. 追问:虚拟列表和 content-visibility: auto 怎么选?
    • 答:数据量上万、要精确控制 DOM 数量选虚拟列表;只是长文档/中等列表、不想改造逻辑,直接加 content-visibility: auto + contain-intrinsic-size 成本最低。

✅ 项目关联话术(按真实经历替换):

数据看板有一个一万多条的明细表格,一次性渲染直接卡死几秒。我实现了固定行高的虚拟列表,DOM 节点稳定在 20 个左右,首次渲染从 4s 多降到 200ms 以内;后来表格支持展开详情变成不定高,又改成位置缓存 + 二分查找的版本。

✍️ 手撕代码【核心实现 + 边界 Case 自测清单 + 变种拓展题】 ​

题 1:DocumentFragment 批量插入 DOM ​

核心实现:

js
// ❌ 反面:循环里直接 appendChild,节点一进文档就可能触发布局计算
for (let i = 0; i < 1000; i++) {
  const div = document.createElement("div");
  div.innerText = i;
  document.body.appendChild(div); // 1000 次 DOM 变更
}

// ✅ 正解:先在内存中的文档碎片里组装,最后只插入一次
const frag = document.createDocumentFragment();
for (let i = 0; i < 1000; i++) {
  const div = document.createElement("div");
  div.innerText = i;
  frag.appendChild(div); // 不在文档树中,不触发回流
}
document.body.appendChild(frag); // 仅一次 DOM 插入,一次回流

为什么有效: DocumentFragment 不属于文档树,对它的操作不会触发任何布局计算;appendChild(frag) 时 fragment 本身不会被插入,只会把它的子节点整体搬进去,所以只有一次 DOM 变更。

✅ 边界 Case 自测:

  • 循环 1000 次插入,Performance 面板中 Layout 次数应为 1 次。
  • frag 插入后自身被清空(frag.childNodes.length === 0)。
  • 已在文档中的节点 appendChild 到 frag 会被移动而非复制(需要保留原节点要 cloneNode(true))。

🔁 变种拓展题: 批量修改已有节点样式的三种零回流写法。

js
// 写法一:一次性改 class(最推荐,浏览器只算一次样式)
el.className = "box active large";

// 写法二:cssText 一次性赋值
el.style.cssText = "width:100px;height:100px;transform:translateX(10px)";

// 写法三:离线修改(大量改动时)
const parent = el.parentNode;
const next = el.nextSibling;
parent.removeChild(el); // 脱离文档
/* ...这里随便改多少次都不触发回流... */
parent.insertBefore(el, next); // 改完再放回去

题 2:节流 / 防抖 / rAF 节流(滚动与 resize 必备) ​

核心实现:

js
// 防抖:停止触发 delay 毫秒后才执行(搜索联想、resize 结束后重算)
function debounce(fn, delay = 300, immediate = false) {
  let timer = null;
  return function (...args) {
    if (timer) clearTimeout(timer);
    if (immediate && !timer) fn.apply(this, args); // 首次立即执行
    timer = setTimeout(() => {
      timer = null;
      if (!immediate) fn.apply(this, args);
    }, delay);
  };
}

// 节流:固定频率执行(滚动加载、按钮防连点)
function throttle(fn, interval = 200) {
  let last = 0;
  return function (...args) {
    const now = Date.now();
    if (now - last >= interval) {
      last = now;
      fn.apply(this, args);
    }
  };
}

// rAF 节流:与屏幕刷新率对齐,一帧最多执行一次(动画 / 滚动视差最佳)
function rafThrottle(fn) {
  let ticking = false;
  return function (...args) {
    if (ticking) return;
    ticking = true;
    requestAnimationFrame(() => {
      fn.apply(this, args);
      ticking = false;
    });
  };
}

window.addEventListener("scroll", rafThrottle(onScroll), { passive: true });

✅ 边界 Case 自测:

  • this 指向与参数透传是否正确(用 apply 而非直接调用)。
  • 防抖连续触发只执行最后一次;immediate 模式首次立即执行。
  • 滚动监听加 { passive: true },告诉浏览器不会 preventDefault,避免滚动被阻塞。
  • 组件卸载时记得 removeEventListener,并清掉未执行的定时器。

🔁 变种拓展题: 给防抖加 cancel 与 flush 方法;用 IntersectionObserver 替代 scroll 监听做「触底加载」,彻底免去节流。

题 3:简易虚拟列表(固定高度版) ​

核心实现:

html
<div id="container" style="height:500px;overflow:auto;position:relative">
  <!-- 撑开滚动条的占位元素 -->
  <div id="phantom" style="position:absolute;top:0;left:0;width:100%"></div>
  <!-- 真实渲染的可视区容器 -->
  <div id="visibleWrap" style="position:absolute;top:0;left:0;width:100%"></div>
</div>
js
const data = Array.from({ length: 10000 }, (_, i) => ({
  id: i,
  text: `item ${i}`,
}));
const ITEM_HEIGHT = 50;
const BUFFER = 3; // 上下缓冲区,防快速滚动白屏

const container = document.getElementById("container");
const phantom = document.getElementById("phantom");
const visibleWrap = document.getElementById("visibleWrap");

// 1. 占位元素撑起总高度,让滚动条长度正确
phantom.style.height = `${data.length * ITEM_HEIGHT}px`;
const showCount = Math.ceil(container.clientHeight / ITEM_HEIGHT);

function render() {
  const scrollTop = container.scrollTop;
  // 2. 由 scrollTop 反推起始下标,并带上缓冲区
  const start = Math.max(0, Math.floor(scrollTop / ITEM_HEIGHT) - BUFFER);
  const end = Math.min(data.length, start + showCount + BUFFER * 2);

  // 3. 用 transform 整体偏移(只走合成,不回流)
  visibleWrap.style.transform = `translateY(${start * ITEM_HEIGHT}px)`;
  visibleWrap.innerHTML = data
    .slice(start, end)
    .map((v) => `<div style="height:${ITEM_HEIGHT}px">${v.text}</div>`)
    .join("");
}

// 4. 滚动用 rAF 节流,一帧只渲染一次
let ticking = false;
container.addEventListener(
  "scroll",
  () => {
    if (ticking) return;
    ticking = true;
    requestAnimationFrame(() => {
      render();
      ticking = false;
    });
  },
  { passive: true }
);

render();

✅ 边界 Case 自测:

  • 快速拖动滚动条、直接拖到底部:DOM 数量始终稳定(约 showCount + 2*BUFFER)。
  • 数据量小于一屏时不出现负下标、不越界(Math.max / Math.min 兜底)。
  • 容器高度变化(resize)后重新计算 showCount。
  • 滚动到最底部时最后一条完整可见。

🔁 变种拓展题: 不定高虚拟列表(位置缓存 + 二分查找)。

js
// 预估高度先占位,渲染后用真实高度回填修正
const positions = data.map((_, i) => ({
  index: i,
  height: ESTIMATED_HEIGHT,
  top: i * ESTIMATED_HEIGHT,
  bottom: (i + 1) * ESTIMATED_HEIGHT,
}));

// 二分查找第一个 bottom >= scrollTop 的下标
function getStartIndex(scrollTop) {
  let low = 0,
    high = positions.length - 1,
    res = 0;
  while (low <= high) {
    const mid = (low + high) >> 1;
    if (positions[mid].bottom >= scrollTop) {
      res = mid;
      high = mid - 1;
    } else {
      low = mid + 1;
    }
  }
  return res;
}

// 渲染完成后用真实高度更新缓存,并修正后续所有项的 top/bottom
function updatePositions(nodes) {
  nodes.forEach((node) => {
    const idx = +node.dataset.index;
    const real = node.getBoundingClientRect().height;
    const diff = real - positions[idx].height;
    if (diff === 0) return;
    positions[idx].height = real;
    positions[idx].bottom += diff;
    for (let i = idx + 1; i < positions.length; i++) {
      positions[i].top = positions[i - 1].bottom;
      positions[i].bottom += diff;
    }
  });
}

题 4:图片懒加载(IntersectionObserver 版) ​

核心实现:

js
// HTML: <img data-src="real.webp" width="750" height="400" alt="" />
const io = new IntersectionObserver(
  (entries, observer) => {
    entries.forEach((entry) => {
      if (!entry.isIntersecting) return;
      const img = entry.target;
      img.src = img.dataset.src;
      img.removeAttribute("data-src");
      observer.unobserve(img); // 加载过就取消监听,避免重复
    });
  },
  {
    root: null, // 相对视口
    rootMargin: "200px 0px", // 提前 200px 预加载,避免滑到才开始下载
    threshold: 0.01,
  }
);

document.querySelectorAll("img[data-src]").forEach((img) => io.observe(img));

对比传统 scroll 方案:

方案原理问题
scroll + getBoundingClientRect滚动时读元素位置判断是否进入视口每次读取强制同步布局,必须节流,易卡顿
IntersectionObserver浏览器异步回调,计算在渲染流水线内完成推荐,不触发同步布局,不占主线程
loading="lazy"浏览器原生懒加载,零 JS预加载距离不可控,老浏览器需降级

✅ 边界 Case 自测:

  • 首屏图片不要加懒加载(会拖慢 LCP),只对首屏外的图片启用。
  • 必须给 width / height 或 aspect-ratio 占位,否则图片加载后撑开布局,CLS 飙高。
  • 加载失败时的兜底:img.onerror = () => (img.src = fallback)。
  • 动态新增的图片需要再次 io.observe,组件卸载时 io.disconnect()。

🔁 变种拓展题: 用同一个 IntersectionObserver 实现「触底加载更多」与「曝光埋点」。

js
// 触底加载:监听列表末尾的哨兵元素,彻底不用 scroll 监听
const sentinelIO = new IntersectionObserver(([entry]) => {
  if (entry.isIntersecting) loadNextPage();
});
sentinelIO.observe(document.getElementById("sentinel"));

题 5:性能数据采集 + 长任务切片 ​

核心实现:

js
// 1. 采集导航分段耗时(DNS / TCP / TTFB / DOM / Load)
function getNavigationTiming() {
  const [nav] = performance.getEntriesByType("navigation");
  if (!nav) return null;
  return {
    dns: nav.domainLookupEnd - nav.domainLookupStart,
    tcp: nav.connectEnd - nav.connectStart,
    ttfb: nav.responseStart - nav.requestStart,
    response: nav.responseEnd - nav.responseStart,
    domParse: nav.domInteractive - nav.responseEnd,
    domReady: nav.domContentLoadedEventEnd - nav.startTime,
    load: nav.loadEventStart - nav.startTime,
  };
}

// 2. 统一采集核心 Web Vitals
function observePerf(onReport) {
  const observe = (type, cb) => {
    try {
      new PerformanceObserver((list) => cb(list.getEntries())).observe({
        type,
        buffered: true,
      });
    } catch (e) {
      /* 浏览器不支持则静默降级 */
    }
  };

  observe("paint", (entries) =>
    entries.forEach((e) => onReport(e.name, e.startTime))
  ); // FP / FCP
  observe("largest-contentful-paint", (entries) =>
    onReport("LCP", entries[entries.length - 1].startTime)
  );
  observe("layout-shift", (entries) => {
    const cls = entries
      .filter((e) => !e.hadRecentInput) // 排除用户交互引起的偏移
      .reduce((sum, e) => sum + e.value, 0);
    onReport("CLS", cls);
  });
  observe("longtask", (entries) =>
    entries.forEach((e) => onReport("LongTask", e.duration))
  );
}

observePerf((name, value) => console.log(name, Math.round(value)));
js
// 3. 长任务切片:把一个会卡死主线程的大循环拆成多段
function sliceTask(list, handler, chunkSize = 100) {
  return new Promise((resolve) => {
    let i = 0;
    function run() {
      const start = performance.now();
      // 每片最多跑 5ms,保证不超过一帧预算
      while (i < list.length && performance.now() - start < 5) {
        for (let n = 0; n < chunkSize && i < list.length; n++) {
          handler(list[i++]);
        }
      }
      if (i < list.length) {
        setTimeout(run, 0); // 让出主线程,让浏览器有机会渲染与响应交互
      } else {
        resolve();
      }
    }
    run();
  });
}

✅ 边界 Case 自测:

  • buffered: true 保证能拿到脚本执行前就已产生的指标(否则 FCP 会漏采)。
  • CLS 要排除 hadRecentInput 的交互型偏移,且需在页面隐藏时(visibilitychange)最终上报。
  • 不支持 PerformanceObserver 的环境要 try-catch 静默降级,不能影响业务。
  • 上报统计用 75 分位而非平均值,平均值会被少量极端值拉偏。
  • 上报请求用 navigator.sendBeacon,避免页面卸载时请求被取消。

🔁 变种拓展题: 实现一个 FPS 监测器,低于 30 帧时自动降级动画效果。

js
function startFPSMonitor(onLowFPS) {
  let frames = 0;
  let last = performance.now();
  function loop(now) {
    frames++;
    if (now - last >= 1000) {
      const fps = Math.round((frames * 1000) / (now - last));
      if (fps < 30) onLowFPS(fps); // 降级:关闭阴影/模糊、减少粒子数
      frames = 0;
      last = now;
    }
    requestAnimationFrame(loop);
  }
  requestAnimationFrame(loop);
}

⚠️ 当日踩坑实录 ​

常见坑解决方案
❌ 用 top / left / width 做动画,低端机掉帧严重✅ 改用 transform: translate3d() + opacity,跳过 Layout 与 Paint,只走合成线程
❌ 给所有元素无脑加 will-change: transform,内存暴涨反而更卡✅ will-change 只在动画即将开始前加、结束后 removeProperty 移除,或改用 transform 隐式提层
❌ 循环里「读 offsetTop → 改 style」,1000 个元素卡死✅ 读写分离:先一次性读完所有布局值存数组,再统一批量写
❌ 首屏主图也加了 loading="lazy",LCP 反而变差✅ 首屏图用 fetchpriority="high" + preload,懒加载只给首屏外资源
❌ 图片 / 广告位不写宽高,加载后内容被推下去,CLS 爆红✅ 显式写 width/height 或 aspect-ratio,骨架屏尺寸与真实内容保持一致
❌ 在 Promise.then 里读 offsetHeight,拿到的还是旧值✅ 微任务仍在同一帧内,渲染未发生;需 requestAnimationFrame 的下一帧再读,或双层 rAF
❌ scroll / touchmove 监听没加 passive,滚动有明显延迟✅ addEventListener('scroll', fn, { passive: true }),告诉浏览器不会 preventDefault
❌ requestIdleCallback 任务一直不执行(主线程繁忙被饿死)✅ 传 { timeout: 1000 } 兜底,或改用 setTimeout 分片 / Web Worker
❌ 大 JSON 解析、长列表计算放主线程,点击按钮毫无响应✅ 拆成 5ms 以内的切片,或整体丢进 Web Worker,主线程只做渲染
❌ 用 display: none 做显隐切换动画,没有过渡效果✅ 动画用 opacity + visibility / transform;display 变化会触发回流且无法过渡
❌ @import 引入 CSS,首屏白屏时间变长✅ 改用 <link> 并行加载;@import 是串行请求,会推迟 CSSOM 构建
❌ 把 <script> 放 <head> 且不加 defer,解析被阻塞✅ 非首屏必需脚本加 defer,独立第三方脚本加 async
❌ 虚拟列表滚动出现白屏闪烁✅ 上下各多渲染 BUFFER 条缓冲项,并用 transform: translateY 而非 margin-top 做偏移
❌ 本地 Mac / 高配机测不出问题,线上用户反馈卡✅ DevTools 开 CPU 4x~6x 降速 + Slow 4G 模拟,用真机(低端安卓)回归
❌ 只看 Lighthouse 实验室分数,线上体验依旧差✅ 实验室数据只做回归门槛,真实优化要看线上 RUM 采集的 75 分位 Web Vitals

📌 当日复盘任务 ​

  • [ ] 不看资料,默画一遍「URL → 页面」全链路,以及 DOM/CSSOM → Render Tree → Layout → Paint → Layer → Composite 渲染链路
  • [ ] 整理一份自己的「CSS 属性分级清单」:哪些触发 Layout、哪些只 Paint、哪些只 Composite
  • [ ] 用一句话向他人解释清楚「回流 vs 重绘 vs 只合成」的区别和性能代价
  • [ ] 口述「强制同步布局(Layout Thrashing)」的成因、如何在 Performance 面板识别(紫色 Layout 块 +红色警告三角)、以及读写分离的修复思路
  • [ ] 动手写一个 1000 个 DOM 的 Demo,分别用「循环直接插入」和 DocumentFragment 对比耗时,记录数字
  • [ ] 不看答案手写一遍固定高度虚拟列表,再尝试写不定高版本(位置缓存 + 二分查找)
  • [ ] 手写 debounce / throttle / rafThrottle 三个版本,说清各自适用场景
  • [ ] 用 Lighthouse 跑一次自己真实项目,记录 LCP / CLS / TBT / INP 四个指标的数值与改进建议
  • [ ] 打开 DevTools 的 Layers 面板,找出项目中是否存在「图层爆炸」,并用 Rendering 面板勾选 Paint flashing 观察重绘区域
  • [ ] 口述一套完整的 LCP 优化方案(服务端 + 资源 + 渲染 + 缓存四个维度,至少 8 条)
  • [ ] 口述 CLS 的三大来源(无尺寸图片 / 动态插入内容 / 字体切换 FOUT)及对应解法
  • [ ] 复述一套「用户反馈页面卡顿」的完整排查链路:指标定位 → Performance 录制 → 归因(长任务 / 回流 / 重绘 / 内存)→ 优化 → 回归验证
  • [ ] 2 分钟内完整口述「渲染流程 + 回流重绘 + 性能优化四维度」,确保逻辑连贯无卡顿