Appearance
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 字节显存,层太多会「图层爆炸」。
- 这一整段不在主线程,所以主线程被 JS 卡住时,
各阶段速查表:
| 阶段 | 别名 | 输入 → 产物 | 在哪执行 | 关键细节 |
|---|---|---|---|---|
| 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 / width | Layout → 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 属性分级速查表(面试直接背):
| 属性 | Layout | Paint | Composite |
|---|---|---|---|
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较低(层压合 / 隐式提升)。
- 3D 变换:
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① 网络段指标(反映服务端 + 链路质量)
| 指标 | 全称 / 计算方式 | 含义 | 良好阈值 | 优化手段 |
|---|---|---|---|---|
| Redirect | redirectEnd - redirectStart | 重定向耗时 | 0(最好没有) | 去掉多余 301/302、HSTS 免跳转 |
| DNS | domainLookupEnd - domainLookupStart | 域名解析耗时 | < 100ms | dns-prefetch、减少域名数、HTTPDNS |
| TCP | connectEnd - connectStart | 连接建立(含 TLS) | < 200ms | preconnect、HTTP/2 多路复用、keep-alive |
| TLS | connectEnd - secureConnectionStart | HTTPS 握手耗时 | < 100ms | TLS1.3、会话复用、OCSP Stapling |
| TTFB ⭐ | responseStart - requestStart | 首字节时间,服务端处理 + 网络往返 | < 800ms | 接口优化、SSR 缓存、CDN 近源、边缘计算 |
| Content Download | responseEnd - responseStart | 响应体下载耗时 | — | Gzip / Brotli、减小 HTML 体积、流式输出 |
| DNS+TCP+TTFB | 合称「网络耗时」 | 到拿到第一个字节为止 | — | 这段慢 = 后端/网络问题,前端优化无力 |
TTFB 是分水岭:TTFB 之前的问题归后端/运维,TTFB 之后的问题归前端。排查白屏先看 TTFB,能省掉一半无效优化。
② 渲染段指标(反映「用户什么时候看到东西」)
| 指标 | 全称 | 含义 | 良好阈值 | 优化手段 |
|---|---|---|---|---|
| 白屏时间 | Blank / Start Render | 从导航开始到屏幕出现第一个像素,约等于 FP | < 1s | 内联关键 CSS、减少阻塞资源、SSR 直出 |
| FP | First Paint | 首次有任何像素绘制(可能只是背景色) | — | 同上 |
| FCP ⭐ | First Contentful Paint | 首次绘制文本 / 图片 / canvas 等真实内容 | < 1.8s | 减少阻塞资源、SSR、CDN、字体 font-display |
| FSP | First Screen Paint(首屏时间) | 首屏区域内容全部渲染完成(无标准 API,需自测) | < 2s | 首屏数据直出、骨架屏、首屏资源优先加载 |
| LCP ⭐ | Largest Contentful Paint | 视口内最大内容元素绘制完成(Google 用它近似首屏) | < 2.5s | 首屏大图压缩/preload、拆分 JS、字体优化 |
| CLS ⭐ | Cumulative Layout Shift | 累计布局偏移(页面跳动程度,无单位) | < 0.1 | 图片/广告位预留宽高、aspect-ratio、字体兜底 |
| SI | Speed Index | 首屏内容的平均填充速度(越小越好) | < 3.4s | 提高首屏渲染完成度、减少渐进加载 |
| DCL | DOMContentLoaded | DOM 解析完成(不等图片) | — | JS 用 defer、减少同步脚本 |
| L | Load | 所有资源(含图片)加载完成 | — | 图片懒加载、非关键资源延后 |
白屏 vs FCP vs 首屏 vs LCP 的区别(高频追问):
- 白屏/FP:屏幕「不再是白的」,可能只是背景色或骨架屏;
- FCP:出现了第一个有意义的内容;
- FSP 首屏:首屏可视区域全部渲染完 —— 最贴近用户主观感受,但没有标准 API;
- LCP:首屏里最大那块内容渲染完 —— Google 用它作为「首屏」的可量化近似。
③ 交互段指标(反映「用户点得动吗」)
| 指标 | 全称 | 含义 | 良好阈值 | 优化手段 |
|---|---|---|---|---|
| TTI | Time to Interactive | 可稳定交互时间(主线程连续 5s 无长任务) | < 3.8s | 减少主线程长任务、代码分割 |
| TBT | Total Blocking Time | FCP→TTI 间长任务超出 50ms 的阻塞总时长 | < 200ms | 长任务切片、延迟非关键 JS |
| FID | First Input Delay | 首次输入延迟(已被 INP 取代) | < 100ms | 拆分长任务 |
| INP ⭐ | Interaction to Next Paint | 所有交互到下一帧绘制的延迟(取 98 分位) | < 200ms | 拆长任务、节流防抖、Web Worker |
| FPS | Frames Per Second | 运行时帧率,反映滚动/动画流畅度 | ≥ 55 | 合成属性动画、虚拟列表、减少重绘 |
| Long Task | 长任务 | 单个任务执行 > 50ms | 0 个 | 时间切片、让渡主线程 |
带 ⭐ 的 LCP / CLS / INP 是 Google 当前的 三大 Core Web Vitals,直接影响搜索排名,面试必须能准确报出阈值。
④ 业务自定义指标(线上监控真正要看的)
| 指标 | 计算方式 | 为什么要自己埋 |
|---|---|---|
| 首屏时间 FSP | MutationObserver监听首屏 DOM 变化,取最后一次变化时间;或在首屏关键组件渲染完手动打点 | 标准 API 测不准 SPA 的真实首屏 |
| 接口可用时间 | 首屏核心接口 response 回来的时刻 | 很多「慢」其实是接口慢,不是渲染慢 |
| 首屏图片加载完成 | 首屏图 onload 全部完成 | 电商/内容页的真实可看时间 |
| 路由切换耗时 | 路由 beforeEach → 组件 mounted/useEffect | SPA 里 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,而且合成在非主线程完成,所以动画性能最好。
连环追问链:
- 追问:修改
width触发什么?修改background-color触发什么?- 答:
width→ 回流 + 重绘 + 合成;background-color→ 重绘 + 合成,不回流。
- 答:
- 追问:只走 Composite 的 CSS 属性有哪些?为什么它们快?
- 答:
transform、opacity(filter多数场景也是)。因为它们只改变图层的变换矩阵或透明度,不改变文档几何结构,主线程无需重算布局和绘制,合成线程直接用已有位图重新合成。
- 答:
- 追问:
display: none和visibility: hidden的渲染差异?- 答:
display:none不生成渲染树节点,不占位,切换时触发回流;visibility:hidden仍在渲染树中、仍占位,切换只触发重绘。另外opacity: 0不仅占位还能响应事件,且走合成。
- 答:
- 追问:渲染树和 DOM 树是一一对应的吗?
- 答:不是。
display:none的节点、<head>/<script>/<meta>等不可见节点不会进渲染树;而伪元素::before/::after在 DOM 里没有节点,却会出现在渲染树中。
- 答:不是。
- 追问:为什么 CSS 放头部、JS 放尾部?
- 答:CSS 阻塞渲染树构建(也阻塞后续 JS 执行),早下载早构建 CSSOM 可避免白屏;JS 会阻塞 HTML 解析,放尾部或加
defer能让 DOM 先解析完。
- 答:CSS 阻塞渲染树构建(也阻塞后续 JS 执行),早下载早构建 CSSOM 可避免白屏;JS 会阻塞 HTML 解析,放尾部或加
✅ 项目关联话术(按真实经历替换):
项目里的弹窗、侧边抽屉、列表项动画统一用
transform: translate3d+opacity实现,不再用top/left/height做位移和展开;低端安卓机上原来滑动抽屉掉到 30fps 左右,改成合成属性动画后稳定在 60fps。
题 2:什么是强制同步布局?如何规避?
精简口述答案:
浏览器为了性能会把样式修改批量进队列,到帧末统一布局。但如果修改之后立刻读取
offsetWidth、getBoundingClientRect这类布局属性,浏览器必须 flush 队列、立即同步计算布局,才能返回准确值 —— 这就是强制同步布局。如果在循环里反复「写一次读一次」,就会触发 N 次回流,也叫布局抖动。规避的核心是读写分离:先一次性把所有布局属性读出来缓存,再统一批量修改样式。
连环追问链:
- 追问:哪些属性读取会触发强制同步布局?
- 答:
offsetTop/Left/Width/Height、clientXxx、scrollTop/Height、getBoundingClientRect()、getComputedStyle()读几何属性、window.innerWidth、scrollIntoView()、focus()等。
- 答:
- 追问:浏览器对多次 DOM 修改做了什么优化?
- 答:维护脏标记 + 渲染队列,短时间多次样式修改会合并成一次 Layout;批量插入节点用
DocumentFragment或先cloneNode离线修改再整体替换,也能把多次回流压成一次。
- 答:维护脏标记 + 渲染队列,短时间多次样式修改会合并成一次 Layout;批量插入节点用
- 追问:还有哪些减少回流次数的工程手段?
- 答:① 用
class/cssText一次性改多个样式;② 复杂动画元素脱离文档流(absolute/fixed),缩小回流影响范围;③ 修改前先display:none离线改完再显示(两次回流换 N 次);④contain/content-visibility限制回流范围。
- 答:① 用
- 追问:
requestAnimationFrame里读布局会不会强制同步布局?- 答:会。
rAF在布局之前执行,如果前面有未 flush 的样式修改,读取布局同样触发同步布局。正确做法是rAF里只写不读,需要读就放在上一帧或使用ResizeObserver/IntersectionObserver回调。
- 答:会。
✅ 项目关联话术(按真实经历替换):
做瀑布流列表的高度计算时,原来在循环里一边读
offsetHeight一边写style.top,列表一多滚动就卡顿明显。用 Performance 面板看到大量紫色 Layout 块后,改成「先一次性读完所有高度存数组,再统一写样式」,Layout 从几十次降到 1 次,滚动恢复流畅。
题 3:前端页面性能优化手段有哪些?
精简口述答案:
我按四个维度答:加载层面 —— 资源压缩、Gzip/Brotli、CDN、HTTP 缓存、代码分割与路由懒加载、图片 webp/avif + 懒加载、关键资源
preload;渲染层面 —— 关键 CSS 内联、JSdefer、减少回流重绘、合成属性做动画、长列表虚拟滚动、content-visibility;运行时层面 —— 拆分长任务、时间切片、Web Worker、事件节流防抖;感知层面 —— 骨架屏、SSR / 预渲染直出首屏。落地上先用 Lighthouse 和 Performance 定位瓶颈指标,再针对性优化,最后接入性能监控持续观测。
连环追问链:
- 追问:LCP 偏大怎么优化?
- 答:先确认 LCP 元素是什么(多半是首屏大图或标题块)。图片侧:压缩成 webp、
preload+fetchpriority="high"、CDN 近源、响应式srcset、首屏图禁止懒加载;资源侧:减小关键 JS/CSS 体积、路由分包;服务端侧:降 TTFB、SSR 直出、边缘缓存。
- 答:先确认 LCP 元素是什么(多半是首屏大图或标题块)。图片侧:压缩成 webp、
- 追问:CLS 高是什么原因,怎么治?
- 答:图片/视频/广告位没写宽高导致加载后撑开布局、动态插入内容把下方元素挤走、字体回退造成的 FOUT 跳动。解法:给媒体元素写死
width/height或aspect-ratio、占位骨架、font-display: optional/swap+size-adjust、插入内容用transform而非改变文档流。
- 答:图片/视频/广告位没写宽高导致加载后撑开布局、动态插入内容把下方元素挤走、字体回退造成的 FOUT 跳动。解法:给媒体元素写死
- 追问:首屏白屏时间长,你怎么一步步定位?
- 答:① Network 看 TTFB 是否过高(服务端/网络问题);② 看关键资源瀑布图有没有阻塞和排队;③ Performance 看主线程是否有长任务和大量 Layout;④ Coverage 看 JS/CSS 未使用率是否过高;⑤ Lighthouse 对照 FCP / LCP / TBT 给出的具体建议。
- 追问:图片优化具体做哪些事?
- 答:格式选型(webp/avif,渐进式 JPEG)、按 DPR 和容器尺寸裁剪(CDN 动态裁剪)、
loading="lazy"+IntersectionObserver懒加载、首屏图预加载、雪碧图或内联小图减少请求、骨架图/LQIP 模糊占位。
- 答:格式选型(webp/avif,渐进式 JPEG)、按 DPR 和容器尺寸裁剪(CDN 动态裁剪)、
- 追问:怎么做前端性能监控?
- 答:用
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 数量问题。
连环追问链:
- 追问:不定高的列表怎么做?
- 答:维护一个位置缓存数组(每项记录 top / bottom / height),先用预估高度占位,渲染后通过真实
offsetHeight回填并修正后续项的偏移,查找startIndex用二分搜索;工程上可直接用ResizeObserver监听高度变化。
- 答:维护一个位置缓存数组(每项记录 top / bottom / height),先用预估高度占位,渲染后通过真实
- 追问:快速滚动出现白屏怎么办?
- 答:上下各多渲染几屏缓冲区(buffer / overscan);滚动用
transform偏移而非改top;必要时节流到requestAnimationFrame,并对复用的 DOM 做 key 复用减少重建。
- 答:上下各多渲染几屏缓冲区(buffer / overscan);滚动用
- 追问:为什么用
transform而不是top做偏移?- 答:
top会触发回流 + 重绘,滚动时每帧回流必然卡顿;transform只走合成,在合成线程完成。
- 答:
- 追问:虚拟列表和
content-visibility: auto怎么选?- 答:数据量上万、要精确控制 DOM 数量选虚拟列表;只是长文档/中等列表、不想改造逻辑,直接加
content-visibility: auto+contain-intrinsic-size成本最低。
- 答:数据量上万、要精确控制 DOM 数量选虚拟列表;只是长文档/中等列表、不想改造逻辑,直接加
✅ 项目关联话术(按真实经历替换):
数据看板有一个一万多条的明细表格,一次性渲染直接卡死几秒。我实现了固定行高的虚拟列表,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 分钟内完整口述「渲染流程 + 回流重绘 + 性能优化四维度」,确保逻辑连贯无卡顿