05 · 浏览器原理与 Web API(详解版)
一句话定位:浏览器是前端代码唯一的运行环境。所有「为什么会卡 / 为什么会白屏 / 为什么这个 API 只在 Safari 出问题」的答案,最终都落在这台机器的内部机制上。
读完本篇你应该能做到
- 说出输入 URL 到首屏渲染的完整链路,并在每个环节知道用什么工具观测;
- 看到掉帧、白屏、内存增长,能判断该查主线程、合成、网络还是存储;
- 解释同源策略、CORS、Cookie 属性、Service Worker 缓存这些「配置项」背后的机制;
- 说清进程/线程、站点隔离、合成层与滚动为什么互不阻塞,并能用面板取证;
- 在 Cookie、存储、Worker、fetch 流、页面生命周期之间做出有依据的选型。
0. 领域导论
0.1 这个领域解决什么问题
大部分前端 bug 不是「写错了」,而是「不知道运行环境怎么工作」:
- 改了
transform为什么还卡?(动画值仍由主线程计算,或触发了光栅化) - 为什么 Service Worker 更新后用户还是看到旧页面?(等待中的 worker + cache-first + HTML 被缓存)
- 为什么
localStorage存 20MB 数据后页面卡死?(同步 API 阻塞主线程) - 为什么跨域带 Cookie 请求失败?(
Allow-Origin: *与 credentials 互斥,或缺SameSite=None; Secure) - 为什么
SameSite=None配好了,Safari 还是丢登录态?(第三方 Cookie 被 ITP 拦了) - 为什么页面滚到某个区域就白一下?(瓦片还没光栅完,或图片解码抢了光栅线程)
- 为什么一个标签页 OOM,另一个也不行了?(它们可能复用了同一个渲染进程)
0.2 一个类比
把浏览器想成一家公司:
- 老板(浏览器主进程):管窗口、地址栏、书签、文件下载,不干具体活。
- 各部门(渲染进程):每个「站点实例」一个部门,各自有独立办公室(进程隔离);同站点的标签页偶尔会挤一间,跨站 iframe 通常是独立部门。
- 专业外包(GPU 进程、网络进程):全公司共享,负责画图、收发快递、存 Cookie。
- 部门里的员工(主线程):一个人干所有活——解析 HTML、执行 JS、算布局、发事件。他忙不过来,整个部门就停摆(页面卡死)。
- 助理团队(合成线程、光栅线程):独立于主线程,负责把已经画好的东西滚动、合成,所以滚动可以很流畅——只要主线程不阻塞输入。
理解这个组织结构,就能理解「为什么 JS 阻塞会卡 UI」「为什么滚动有时流畅有时卡」「为什么一个标签页崩溃不影响其他标签页」。
0.3 学习地图
05 浏览器原理
├─ 前置:01 HTML、03 JavaScript
├─ 紧接着:10 网络(请求链路)、11 性能(渲染与指标)
├─ 交叉:02 CSS(渲染管线)、12 安全(同源/沙箱)、15 跨端(容器差异)
└─ 长期:18 可观测(现场数据采集)
0.4 分级标尺
| 级别 | 能力特征 | 典型产出 | 自检标准 |
|---|---|---|---|
| L1 基础 | 会用 DevTools 与常用 API 完成开发 | 页面调试、基础存储 | 能定位 404、样式不生效、缓存问题 |
| L2 进阶 | 理解渲染管线与存储/跨域机制 | 性能自查报告、缓存策略配置 | 能解释重排重绘与 CORS 预检 |
| L3 高级 | 理解进程模型、调度与 Worker 架构 | 长任务拆分、Worker 方案、性能专项 | 能解释一帧里发生了什么 |
| L4 专家 | 建立可观测与兼容治理体系 | 性能归因平台、兼容矩阵策略 | 能对某浏览器行为给出规范依据 |
1. L1 基础:先把工具用熟
1.1 多进程架构:为什么一个标签页崩了别的没事
┌──────────────────────────────────────────────────────────┐
│ 浏览器主进程(Browser Process) │
│ UI 线程:地址栏 / 标签页 / 书签 / 权限 / 下载 / 导航调度 │
│ 还有负责窗口合成协调、会话恢复、扩展管理的线程 │
└───────────┬──────────────────────────────────────────────┘
│ 通过 IPC(Mojo)创建与协作
┌───────────▼────────────┐ ┌─────────────────────────────┐
│ 渲染进程(每个站点实例) │ │ GPU 进程(共享,通常 1 个) │
│ 主线程 / 合成线程 │ │ Display Compositor │
│ 光栅线程 / IO 线程 │ │ 光栅化 / 视频解码 / WebGL │
│ Worker / Service Worker │ └─────────────────────────────┘
└────────────────────────┘ ┌─────────────────────────────┐
│ 网络进程(Network Service) │
│ DNS / TCP / TLS / HTTP 缓存 │
│ Cookie 存储 │
└─────────────────────────────┘
┌─────────────────────────────┐
│ Utility 进程(按需) │
│ 音频 / PDF / 打印 / 解码 │
└─────────────────────────────┘
1.1.1 先把「进程」和「线程」分清
| 概念 | 是什么 | 隔离性 | 开销 | 类比 |
|---|---|---|---|---|
| 进程(Process) | 操作系统分配资源的单位:独立地址空间、独立 PID、独立文件描述符 | 强,默认不能读对方内存 | 大:启动慢、占内存、通信要序列化 | 一间独立办公室 |
| 线程(Thread) | CPU 调度的单位,共享所属进程的内存与资源 | 弱,同进程线程可直接读写同一块内存 | 小:创建快、切换快 | 办公室里的员工 |
浏览器选择多进程,主要不是为了「快」,而是为了三件事:
- 稳定性:一个页面崩溃 / OOM,不会带走整个浏览器;
- 安全:渲染进程跑在沙箱里,且不同站点不共享地址空间,降低 Spectre 这类侧信道攻击的收益;
- 可回收:后台标签页可以整进程冻结或丢弃,内存能真正还给系统。
代价也很直接:每个渲染进程都有一份 V8 堆、Blink 对象树、字体与图片缓存,基础开销常见几十 MB;进程越多,IPC 与调度成本越高。所以浏览器一直在「隔离」和「省内存」之间做平衡。
记住一句话:进程管隔离,线程管并发。 你写的 JS 永远跑在渲染进程的主线程上(除非显式放进 Worker);「浏览器是多线程的」不等于「你的 JS 不阻塞 UI」。
1.1.2 五类进程各自负责什么
| 进程 | 数量 | 主要负责 | 崩了会怎样 | 怎么观察 |
|---|---|---|---|---|
| 浏览器主进程 | 1 | 窗口与标签页、地址栏、书签、权限、下载、导航决策、会话恢复 | 整个浏览器退出 | 任务管理器里没有单独条目 |
| 渲染进程 | 按站点实例创建(同站可能复用) | HTML/CSS 解析、JS 执行、样式、布局、绘制、合成、Worker | 当前标签页显示「Aw, Snap」,其他标签页通常不受影响 | 任务管理器逐条列出 |
| GPU 进程 | 通常 1 个 | 光栅化、图层合成与上屏、视频解码、WebGL/WebGPU、部分 CSS 效果 | 所有标签页闪一下、动画短暂卡住,通常自动恢复 | chrome://gpu |
| 网络进程 | 1 个 | DNS、TCP/TLS、HTTP、缓存、Cookie 存储、代理 | 所有页面网络请求失败,浏览器会自动重启它 | chrome://net-export |
| Utility 进程 | 按需 | 音频、PDF、打印、图片解码、扩展宿主等 | 对应功能失效,页面通常不受影响 | 任务管理器 |
两个容易忽略的细节:
- 渲染进程里不止一个线程:主线程之外,还有合成线程(compositor)、光栅线程(raster)、IO 线程、Worker 线程、Service Worker 线程。主线程卡住,页面不一定全卡——滚动和已合成动画仍可能继续(详见 3.1)。
- 网络进程持有 Cookie:页面里的
document.cookie只是一层 API,真正的 Cookie 存储在浏览器侧,读写要过 IPC。这也是「HttpOnly 的 Cookie 在 JS 里读不到」的实现基础。
1.1.3 什么情况会新开一个渲染进程
浏览器判断「要不要新进程」,核心看站点(site),而不是完整 URL:
站点 = 协议(scheme)+ 可注册域名(eTLD+1)
| 两个 URL | 是否同站 | 说明 |
|---|---|---|
https://a.example.com 与 https://b.example.com | ✅ 同站 | 都是 example.com,但跨源(域名不同) |
http://example.com 与 https://example.com | ❌ 跨站 | 协议不同,属于 schemeful same-site 规则 |
https://example.com:443 与 https://example.com:8080 | ✅ 同站 | 端口不参与「站点」判断 |
https://foo.github.io 与 https://bar.github.io | ❌ 跨站 | github.io 在公共后缀列表(PSL)里,各自算独立站点 |
https://example.com 与 https://example.com.evil.com | ❌ 跨站 | 后者是另一个域名 |
在此基础上,浏览器的默认模型是 process-per-site-instance(每个站点实例一个进程):
- 在同一个标签页里跨站导航(比如从
a.com点链接到b.com)→ 通常新建渲染进程,旧进程可能被回收; - 在同一个标签页里同站导航(
a.com→a.com/about)→ 复用同一个进程; - 跨站 iframe → 触发 OOPIF(Out-of-Process Iframe),iframe 单独占一个进程;
- 同站点的多个标签页 → 可能复用同一个进程(受浏览器启发式、内存压力、站点隔离策略影响),不要假设它们一定隔离。
这就解释了几件常见的事:
- 一个页面可能对应多个进程:主文档一个,跨站 iframe 各自一个;任务管理器里能看到多行。
- 同站点的另一个标签页 OOM,可能连累你:如果它们共用了进程。
- 跨站 iframe 里不能直接拿父页面的 DOM:不仅是同源策略,很多时候连内存都不在一起,只能
postMessage。 - 站点隔离不是「同源隔离」:
a.example.com和b.example.com同站,可能同进程;要更细的隔离需要Origin-Agent-Cluster或IsolateOrigins这类配置。
1.1.4 站点隔离(Site Isolation):安全目标与真实代价
它防的是什么:Spectre 一类侧信道攻击。攻击者利用 CPU 的推测执行,在自己进程的地址空间里「猜」相邻内存的内容;如果两个站点共用一个进程,理论上就有机会读到对方的敏感数据(比如密码、Token)。
它怎么做:让不同站点跑在不同进程,各自有独立地址空间;跨站 iframe 也拆出去(OOPIF)。Chrome 在桌面端从 67 起默认开启,Android 随后跟进。
它带来的约束(这些是前端能直接感知的):
| 约束 | 表现 | 应对 |
|---|---|---|
| 跨站 iframe 独立进程 | 无法共享 JS 对象、DOM 引用 | 只能 postMessage,并校验 event.origin |
SharedArrayBuffer 默认不可用 | new SharedArrayBuffer() 抛错 | 设置 COOP/COEP 进入「跨源隔离」 |
| 高精度计时器被降级 | performance.now() 分辨率变粗 | 需要高精度时同样要跨源隔离 |
document.domain 被弃用 | 不能再靠它放宽同源 | 用 postMessage / CORS / 同源代理 |
| 内存占用上升 | 多站点页面进程数多 | 减少不必要的跨站 iframe 与第三方嵌入 |
注意边界:站点隔离防的是「跨站读内存」,不防你自己站点的 XSS。XSS 仍然能在同源内为所欲为,所以 CSP、输出转义、HttpOnly 依然要做。
**跨源隔离(cross-origin isolation)**是另一层:同时设置
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
页面才会进入「跨源隔离」状态,才能用 SharedArrayBuffer、performance.measureUserAgentSpecificMemory() 等能力。代价是:嵌入的跨源资源必须显式带 CORP 头或 CORS 头,否则加载失败。细节见 3.3。
1.1.5 IPC:为什么「读个 Cookie」也可能卡
进程之间不能直接读内存,所有跨进程操作都要走 IPC。Chromium 的 IPC 框架叫 Mojo,默认是异步消息:
渲染进程主线程 ──发出请求──► 浏览器/网络/GPU 进程
│ │
│◄──── 异步回调 ──────────┘ (不阻塞,主线程可以继续干活)
但有些 API 被设计成同步的:主线程发出请求后必须原地等回复。等待期间主线程什么都做不了,直接表现为卡顿。
常见会触发同步 IPC 或阻塞主线程的操作:
| 操作 | 为什么会阻塞 | 建议 |
|---|---|---|
首次读大块 localStorage | 需要向浏览器侧取数据,且要反序列化 | 启动时少量读取,大数据用 IndexedDB |
频繁读写 document.cookie | 每次都可能跨进程同步 | 合并读写,别在循环里碰 |
alert() / confirm() / prompt() | 阻塞整个渲染进程,直到用户操作 | 生产环境禁用,改用自定义弹窗 |
同步 XMLHttpRequest | 主线程等待网络 | 已不推荐,改用 fetch |
| 同步访问剪贴板(旧 API) | 需要浏览器进程仲裁 | 用异步 navigator.clipboard |
showOpenFilePicker() 等文件选择 | 需要用户手势与浏览器进程参与 | 本来就是异步,不要包在同步流程里 |
实践结论:任何「在事件处理里同步等待另一个进程」的调用,都可能变成掉帧源。Performance 面板里看到一段没有任何 JS 调用栈、却耗时很长的任务,要怀疑同步 IPC、同步布局或同步 XHR。
1.1.6 崩溃、卡死、OOM:分别是谁出了问题
| 现象 | 出问题的位置 | 证据 | 前端能做什么 |
|---|---|---|---|
| 标签页显示「Aw, Snap」/「页面崩溃」 | 渲染进程崩溃 | chrome://crashes;RUM 里该页心跳中断 | JS 捕获不到进程崩溃;做心跳 + 自动重载提示 |
| 整个浏览器消失 | 浏览器主进程崩溃,或系统 OOM | 系统日志;chrome://crashes | 无法在前端处理;减少整体内存占用 |
| 所有标签页闪一下、动画停住又恢复 | GPU 进程崩溃/重启 | chrome://gpu;控制台可能有 GPU 相关警告 | 避免过度依赖 GPU 效果;准备降级样式 |
| 多个页面同时网络失败 | 网络进程崩溃 | Network 面板连续 ERR_* | 重试 + 降级提示 |
| 标签页无响应、弹「页面无响应」 | 渲染进程主线程被长任务/死循环占住 | Performance 长任务;CPU 100% | 拆任务、移 Worker、加超时 |
| 标签页变成灰色、点击后重新加载 | 标签页被冻结/丢弃(Memory Saver) | document.wasDiscarded;freeze/resume 事件 | 在 pagehide 保存状态;恢复时重建 |
| 只有某个 iframe 崩 | 该 iframe 独立进程崩溃 | 任务管理器对应进程消失 | 监听 iframe 的 error/超时,展示兜底 |
两个关键认知:
window.onerror/try...catch抓不到进程崩溃和 OOM。它们只能抓当前 JS 上下文里的异常;进程没了,代码也不在了。要靠外部心跳(定时上报 + 服务端判断中断)和document.wasDiscarded这类信号。- OOM 往往不是「一次性申请太大」,而是「长期不释放」。常见来源:全局缓存无上限、Detached DOM、未解绑的监听器、不断增长的数组/Map、闭包持有大对象。排查用 Memory 面板的堆快照对比,见 3.8。
1.1.7 对前端的 10 条实践推论
- 不要假设两个标签页共享内存:跨标签通信走
BroadcastChannel、storage事件或服务端;SharedWorker也不是所有容器都支持。 - 跨站 iframe 当独立应用对待:通信只走
postMessage,必须校验origin,不要传函数或 DOM 节点。 - 需要
SharedArrayBuffer/ 高精度计时:先确认 COOP/COEP 是否配好,否则直接降级为消息传递。 localStorage只放小配置:它是同步的,且可能走 IPC;大数据、二进制、需要索引的放 IndexedDB / OPFS。performance.memory只代表当前渲染进程:多进程页面的总内存要看任务管理器或measureUserAgentSpecificMemory()(需跨源隔离)。- 生产环境不要用
alert/confirm/prompt:它们会阻塞整个渲染进程,移动端体验尤其差。 - 进程崩溃要有兜底:心跳中断 → 提示刷新;iframe 加超时与占位;不要指望错误边界能接住一切。
- 减少不必要的跨站嵌入:每个跨站 iframe 可能是一个进程,内存与 IPC 成本都会上升。
- 别再用
document.domain放宽同源:它已被弃用,且与站点隔离冲突;用postMessage、CORS 或同源网关。 - 看到「同站不同源」的请求别混淆:同站只影响 Cookie 的
SameSite判断与进程复用,不改变同源策略;a.example.com调b.example.com仍然是跨源,仍要 CORS。
怎么验证:
- Chrome 菜单 → 更多工具 → 任务管理器:看每个标签页 / iframe / 扩展的进程、CPU、内存;勾选「显示 GPU 进程」。
chrome://process-internals:看站点与进程的对应关系。chrome://sandbox:看渲染进程的沙箱状态。chrome://gpu:看光栅化、合成、WebGL 是否走硬件。chrome://crashes:看最近的崩溃记录(需开启崩溃上报)。
1.2 从输入 URL 到首屏:完整链路
这条链路是排查白屏、慢、点了没反应的总骨架。先记住三段式:
① 取回字节 网络进程:DNS → TCP → TLS → HTTP → 响应
② 变成像素 渲染进程:解析 → 样式 → 布局 → 绘制 → 合成
③ 变得可交互 主线程:JS 下载/执行 → 水合 → 事件监听生效
三段之间是流水线关系:HTML 还在下载时,解析器已经开始工作;首屏已经画出来时,JS 可能还在下载。所以「首屏时间」和「可交互时间」是两个独立指标。
1.2.1 全景:每一步谁在干活
地址栏输入
│ 浏览器主进程(UI 线程)
▼
判断搜索/URL → 发起导航 ──────────────► 网络进程
│ ① DNS
│ ② TCP / TLS
│ ③ 发请求 / 收响应头
▼
┌────────────────────────┐
│ 响应是 HTML? │
└───────┬────────────────┘
│ 是
▼
渲染进程(新进程或复用)
④ 解析 HTML → DOM
⑤ 加载 CSS → CSSOM
⑥ 样式 → 布局 → 绘制 → 合成
⑦ 下载并执行 JS → 水合
▼
屏幕像素
| 阶段 | 执行者 | 输入 | 输出 | 观测点 |
|---|---|---|---|---|
| 地址栏判断 | 主进程 UI 线程 | 用户输入 | 导航请求 | 无 |
| 导航发起 | 主进程 + 网络进程 | URL、Cookie、缓存 | 请求 | Network → Queueing/Stalled |
| DNS | 网络进程 | 域名 | IP | Timing → DNS Lookup |
| TCP/TLS | 网络进程 | IP + 端口 | 加密连接 | Timing → Initial connection / SSL |
| HTTP | 网络进程 | 请求头 | 响应头 + 流式 body | Timing → TTFB |
| 解析 | 渲染进程主线程 | HTML 字节 | DOM | Performance → Parse HTML |
| 样式/布局 | 渲染进程主线程 | DOM + CSSOM | 布局树 | Recalculate Style / Layout |
| 绘制/合成 | 主线程 + 合成/光栅线程 | 布局树 | 帧 | Paint / Composite Layers |
| 水合 | 渲染进程主线程 | HTML + JS | 可交互页面 | TBT、INP |
1.2.2 第 1 步:地址栏判断(< 1ms)
浏览器要回答一个问题:你输入的是网址,还是搜索词?
- 含协议(
https://)→ 直接当 URL; - 含点且不像句子(
example.com/a)→ 补全协议后当 URL; - 其他(
react hooks)→ 交给默认搜索引擎; - 命中 HSTS 列表的域名 → 强制升级为 HTTPS,即使你输入的是
http://。
常见坑:本地开发输入 localhost:3000 有时被当搜索——因为部分浏览器把「带端口但不带协议」的输入判为搜索词。加上 http:// 即可。
1.2.3 第 2 步:导航发起与 Service Worker 拦截(0–50ms)
主进程的 UI 线程把导航请求交给网络进程之前,会先做三件事:
- 查 Service Worker:如果当前源注册过 SW 且作用域匹配,请求会先走 SW 的
fetch事件,可能直接由缓存返回,一个字节都不上网; - 处理
beforeunload:如果旧页面注册了它,可能弹确认框(这也是它破坏 BFCache 的原因); - 决定是否复用渲染进程:同站点同实例可以复用,跨站点要新建进程。
观测:Network 面板里 Size 列显示 (ServiceWorker) 就是被 SW 接管的请求;Timing → Stalled 很长,通常是在等连接池、代理或 SW。
1.2.4 第 3 步:DNS 解析(0–120ms,缓存命中接近 0)
域名要变成 IP 才能建连。查找顺序大致是:
浏览器自己的 DNS 缓存(Chrome 约 60s)
→ 操作系统缓存 / hosts 文件
→ 本地 DNS 服务器(运营商/公司)
→ 递归查询:根 → 顶级域(.com)→ 权威服务器
三个要点:
- TTL 决定缓存多久,改解析后没生效通常是 TTL 没到;
- 每个新域名都是一次额外往返,首屏依赖 5 个第三方域名就是 5 次串行等待;
- DoH/DoT 把 DNS 查询加密,防劫持,但也可能绕过公司内网解析。
优化:<link rel="dns-prefetch" href="//cdn.example.com"> 只解析不建连;preconnect 更进一步(DNS + TCP + TLS)。
1.2.5 第 4 步:TCP 建连 + TLS 握手(1–3 个 RTT)
TCP 三次握手:SYN → SYN-ACK → ACK (1 RTT)
TLS 1.3 握手:ClientHello(含 key share) → ServerHello + 证书 + Finished
(1 RTT;会话复用可 0-RTT)
TLS 1.2 握手:需要 2 RTT
握手期间浏览器还会做:
- 证书校验:域名是否匹配、是否过期、证书链是否可信、是否被吊销;
- SNI:在握手时告诉服务器要访问哪个域名(一个 IP 托管多个站点);
- ALPN:协商用 HTTP/1.1 还是 h2/h3。
常见坑:
| 现象 | 原因 |
|---|---|
NET::ERR_CERT_DATE_INVALID | 证书过期或设备时间不对 |
| 混合内容被拦 | HTTPS 页面里加载了 http:// 资源 |
| 首屏卡在 SSL 很久 | 证书链不完整、OCSP 超时、跨国链路 |
| HTTP/3 没生效 | UDP 443 被防火墙拦,浏览器回退到 h2 |
1.2.6 第 5 步:发送 HTTP 请求
请求头里带的东西,基本决定了服务端返回什么:
GET /index.html HTTP/2
Host: shop.example.com
Cookie: sid=abc123
Accept: text/html
Accept-Encoding: gzip, br
If-None-Match: "abc123" ← 缓存验证
If-Modified-Since: ...
- HTTP/2:一个 TCP 连接上多路复用多个请求,头部压缩(HPACK);但 TCP 层仍有队头阻塞;
- HTTP/3:基于 QUIC(UDP),解决 TCP 队头阻塞,支持连接迁移;
- 缓存验证头:
If-None-Match/If-Modified-Since是 304 的前提。
1.2.7 第 6 步:响应、重定向与缓存
服务端返回状态码后,浏览器分三种情况处理:
| 状态码 | 浏览器行为 | 对性能的影响 |
|---|---|---|
200 | 正常读取 body | — |
304 | 用本地缓存,不传 body | 省带宽,但仍有一次往返 |
301/302/307/308 | 重新发起导航(可能换域名) | 多一次完整请求,重定向链越长越慢 |
204 | 无内容 | 导航会失败或停在空白 |
TTFB(Time To First Byte) 就是从请求发出到收到第一个响应字节的时间,它包含:排队、DNS、连接、TLS、服务端处理。TTFB 高不一定是后端慢,先看 Timing 面板的分解。
1.2.8 第 7 步:Content-Type 与导航提交
浏览器根据 Content-Type 决定「这是网页还是文件」:
text/html→ 交给渲染进程,开始解析;application/pdf→ PDF 查看器;application/octet-stream/ 带Content-Disposition: attachment→ 下载;- 缺失或错误 → 浏览器可能嗅探内容;加
X-Content-Type-Options: nosniff可以禁止嗅探(安全最佳实践)。
导航提交(commit)发生在收到响应头时,而不是整个 body 下载完。提交后:
- 浏览器选中或新建渲染进程(site instance);
- 把响应 body 以流的方式交给渲染进程;
- 地址栏更新、历史记录写入;
- 旧页面触发
pagehide,新页面开始解析。
这就是「HTML 流式传输时页面能边下边解析」的原因。
1.2.9 第 8 步:解析 HTML → DOM / CSSOM
渲染进程拿到字节后:
字节 → 字符(按 charset)→ Token(标签/文本/注释)
→ 树构建(tree construction)→ DOM
CSS 字节 → CSSOM(同时进行)
这里有两个关键机制(细节见 1.10):
- 预加载扫描器(preload scanner):主解析器被同步脚本阻塞时,扫描器仍会继续往后看 HTML,提前发现
<script src>、<link rel=stylesheet>、<img>并发起请求; - 阻塞规则:CSS 阻塞渲染,同步
<script>阻塞解析;defer不阻塞解析、在 DOMContentLoaded 前按顺序执行;async下载完立刻执行、顺序不确定。
1.2.10 第 9 步:渲染管线与首屏像素
DOM + CSSOM 就绪后,浏览器开始算第一帧:
样式计算(Style)→ 布局(Layout)→ 绘制(Paint)→ 光栅化(Raster)→ 合成(Composite)
首屏相关的两个指标:
- FCP(First Contentful Paint):第一次画出「内容」(文字/图片),要求 CSSOM 就绪;
- LCP(Largest Contentful Paint):视口内最大内容元素画出来的时间,受图片/字体/阻塞资源影响。
注意:此时页面可能能看但不能点——因为 JS 还没执行完,事件还没绑上。
1.2.11 第 10 步:JS 执行、水合与可交互
下载 JS → 解析/编译 → 执行 → 水合(SSR 场景)→ 绑定事件 → 可交互
- CSR:首屏是空壳,JS 执行完才有内容;
- SSR:首屏 HTML 先到,但按钮要等水合完成才有反应;
- 水合失败:React 会丢掉服务端 HTML 重新渲染,表现为闪烁 + CLS 变差。
可交互性的指标是 INP(Interaction to Next Paint),它把一次交互拆成「输入延迟 + 处理时长 + 呈现延迟」,比 TTI 更贴近真实体验。
1.2.12 每一步的失败模式
排查故障的顺序永远是:先定位「断在哪一段」,再看具体错误。不要一上来就读源码,也不要把「白屏」当成一个原因——它只是结果。
| 步骤 | 典型故障 | 用户看到什么 | 第一证据 | 常见修复 |
|---|---|---|---|---|
| 地址栏 | URL 被当搜索词、协议被补错 | 跳到搜索引擎 / 错误站点 | 地址栏最终 URL | 输入完整协议;检查短链 |
| 导航 | beforeunload 卡住、下载而非打开 | 点了没反应 / 弹下载框 | Network 文档请求、响应头 | 移除阻塞逻辑;修 Content-Type |
| Service Worker | 旧 SW 返回旧页面、SW 死循环 | 一直旧版本 / 请求挂起 | Application → Service Workers | 版本化缓存、skipWaiting、注销旧 SW |
| DNS | 解析失败、被劫持、TTL 未生效 | DNS_PROBE_FINISHED_NXDOMAIN | Network → Timing;chrome://net-export | 换 DNS、预解析、等 TTL |
| TCP | 端口不通、被防火墙拦 | ERR_CONNECTION_REFUSED / 超时 | Network → Timing | 查服务、安全组、代理 |
| TLS | 证书过期、域名不匹配、链不完整 | 安全警告页 | Security 面板;openssl s_client | 续期、补中间证书、校时 |
| HTTP | 404/401/403/429/5xx | 空白、报错、跳登录 | Network → Status / Response | 修路由、鉴权、限流、重试 |
| 缓存 | 304 但内容错、CDN 缓存了旧 HTML | 部分用户旧页面 | Response Headers、Age、ETag | 改缓存键、清 CDN、HTML 不缓存 |
| 重定向 | 循环、跨域丢 Cookie、POST 变 GET | 一直转圈 / 登录态丢 | Network 多条 3xx | 修规则;用 307/308 保方法 |
| Content-Type | HTML 被当文件下载、JS 被当文本 | 下载框 / Unexpected token '<' | Response Headers | 设正确的 Content-Type + nosniff |
| 解析 | 同步脚本太久、document.write | 白屏很久 | Performance → Parse HTML / Long Task | defer/module、移除 document.write |
| 渲染 | CSS 404、字体阻塞、图片解码慢 | 无样式闪烁、文字不可见 | FCP/LCP、Paint flashing | 关键 CSS 内联、font-display、图片优化 |
| 合成 | 层爆炸、超大层、滤镜 | 滚动卡、内存高 | Layers 面板、Performance | 移除无用的 will-change、拆层 |
| 水合 | 服务端/客户端首帧不一致 | 闪烁、点击无反应 | Console hydration 警告 | 修不一致源;必要时降级为 CSR |
| 交互 | 长任务、事件监听未绑、遮罩层 | 点了没反应 | Performance、Event Listeners | 拆任务、修绑定、查 pointer-events |
1.2.12.1 白屏分诊:先分清「五种白」
「白屏」至少对应五种完全不同的故障,混在一起排查会浪费大量时间:
| 类型 | 特征 | 快速验证 | 根因方向 |
|---|---|---|---|
| ① 文档没到 | 地址栏转圈、Network 无文档或 5xx | 看 Network 第一条文档请求 | DNS/TLS/网关/服务端 |
| ② 文档到了但没内容 | view-source: 里根节点为空 | 查看源代码 / curl | SSR 报错、模板异常、构建产物缺失 |
| ③ 有 DOM 但看不见 | Elements 里有节点,屏幕空白 | 看 opacity、visibility、z-index、高度 | CSS 动画未结束、遮罩、height: 0 |
| ④ 有内容但样式错乱 | 文字裸奔、布局堆叠 | Network 看 CSS 状态与 MIME | CSS 404、CORS、Content-Type 错误 |
| ⑤ 有内容但不能点 | 视觉正常,交互无反应 | Console 报错、Event Listeners | JS 崩溃、水合失败、长任务、透明遮罩 |
一段可以直接粘进控制台的「三分钟体检」:
/**
* 白屏快速体检:输出根节点、可见性、关键资源与错误信息。
*/
(() => {
const root = document.getElementById('root') || document.body;
const style = getComputedStyle(root);
console.table({
childCount: root.childElementCount,
textLength: root.textContent.trim().length,
display: style.display,
visibility: style.visibility,
opacity: style.opacity,
height: root.getBoundingClientRect().height,
});
// 关键资源是否失败(CSS/JS 的 404 常常只在 Network 里可见)
const bad = performance
.getEntriesByType('resource')
.filter((r) => r.transferSize === 0 && r.decodedBodySize === 0);
console.log('可疑资源(可能 404/被缓存/跨域无 Timing):', bad.map((r) => r.name));
// 首屏标记
console.log(
performance.getEntriesByType('paint').map((p) => `${p.name}: ${p.startTime.toFixed(0)}ms`)
);
})();
注意:
transferSize === 0不等于一定失败——跨域资源没有 Timing 信息、缓存命中、304都可能是 0。它只用来「缩小怀疑范围」,最终以 Network 面板的状态码为准。
1.2.12.2 常见浏览器错误码对照
| 错误码 | 直白含义 | 常见原因 | 排查动作 |
|---|---|---|---|
ERR_NAME_NOT_RESOLVED | 域名解析不出来 | DNS 配错、域名过期、内网 DNS 缺失 | dig/nslookup;换网络对比 |
ERR_CONNECTION_REFUSED | 端口没人监听 | 服务没起、端口错、安全组拒绝 | curl -v;查服务与防火墙 |
ERR_CONNECTION_TIMED_OUT | 连不上,等到超时 | 网络不通、代理、跨境链路 | curl --connect-timeout;查代理 |
ERR_CONNECTION_RESET | 连接被重置 | 中间设备拦截、服务端主动断、TLS 不匹配 | 抓包;换网络;查网关 |
ERR_CERT_AUTHORITY_INVALID | 证书链不受信 | 自签证书、缺中间证书 | openssl s_client -showcerts |
ERR_CERT_DATE_INVALID | 证书过期或设备时间错 | 忘记续期、用户系统时间异常 | 看证书有效期;校时 |
ERR_SSL_PROTOCOL_ERROR | TLS 握手失败 | 协议/加密套件不匹配、HTTP 打 HTTPS 端口 | 查 TLS 配置与端口 |
ERR_HTTP2_PROTOCOL_ERROR | HTTP/2 帧层异常 | 代理不支持 h2、响应体被截断 | 回退 h1 对比;查网关 |
ERR_TOO_MANY_REDIRECTS | 重定向循环 | 登录跳转互相踢、尾斜杠规则冲突 | Network 看 3xx 链 |
ERR_BLOCKED_BY_CLIENT | 被扩展/客户端拦截 | 广告拦截器、企业策略 | 无痕模式/禁用扩展对比 |
ERR_BLOCKED_BY_CSP | 被 CSP 拦了 | connect-src / script-src 不允许 | Console 的 CSP 报告 |
ERR_BLOCKED_BY_RESPONSE | 响应被策略拦 | CORP/COEP 不匹配、X-Frame-Options | 看 Response Headers 与 Console |
ERR_FAILED | 泛化失败 | 最常见是 CORS,也可能是请求被 abort | Console 是否报 CORS;Network 是否 (failed) |
ERR_ABORTED | 请求被取消 | 页面跳转、AbortController、下载 | 确认是否用户行为;检查 abort 逻辑 |
ERR_EMPTY_RESPONSE | 服务端没回任何字节 | 服务崩溃、连接被对端关闭 | 看服务端日志;curl -v |
ERR_CONTENT_LENGTH_MISMATCH | 响应长度对不上 | 压缩/代理截断、服务端提前关闭 | 关代理对比;查 Content-Length |
ERR_INSUFFICIENT_RESOURCES | 资源耗尽 | 并发请求过多、内存不足 | 限制并发;查是否有请求风暴 |
ERR_UPLOAD_FILE_CHANGED | 上传时文件被改 | 用户编辑了正在上传的文件 | 重新选择文件 |
关于 ERR_FAILED 的一个实用判断:如果 Console 同时出现 Access to fetch ... has been blocked by CORS policy,那它就是 CORS;如果只有 Network 里一条 (failed),优先怀疑请求被 abort 或混合内容被拦。
1.2.12.3 按症状反查
用户说「打不开」
├─ 一直转圈,Network 没有文档
│ ├─ DNS 失败?→ ERR_NAME_NOT_RESOLVED
│ ├─ TCP/TLS 失败?→ ERR_CONNECTION_* / ERR_CERT_*
│ └─ 请求发出但无响应?→ TTFB 极高 / 网关超时
├─ 文档 200,但页面白
│ ├─ view-source 有内容?→ CSS/JS/水合问题
│ └─ view-source 也空?→ SSR/模板/构建问题
├─ 页面有内容,但点击没反应
│ ├─ Console 有报错?→ JS 崩溃 / 水合失败
│ ├─ Performance 有长任务?→ 主线程被占
│ └─ 有透明遮罩?→ z-index / pointer-events
└─ 只有部分用户 / 部分机型
├─ CDN 地域节点?→ 对比不同地域响应头
├─ 浏览器版本?→ 特性检测 / 语法降级
└─ 缓存/SW?→ 强制刷新 / 清 SW 对比
几个「看起来像前端,其实不是」的常见假故障:
- 只出现在你自己的浏览器:扩展、代理、公司证书、
hosts、Service Worker 残留。先用无痕窗口 + 禁用扩展复现。 - 只有刷新才正常、直接打开不正常:路由回退没配(
history模式刷新 404),或 CDN 把 HTML 缓存了。 - 只有第二次打开才慢:首次是缓存未命中,第二次走了 SW/HTTP 缓存,问题在缓存策略而不是代码。
- 接口 200 但页面报错:
Content-Type是text/html(登录页/网关页),前端按 JSON 解析失败。 - 移动端才白屏:语法/API 不支持(旧 WebView)、
100vh与地址栏、字体加载、内存不足。
1.2.12.4 排查证据清单
按这个顺序收集,基本能定位 90% 的问题:
- Network:文档请求的状态码、
Timing分解、Response Headers(尤其Content-Type、Cache-Control、CORS 头)、Size是否(ServiceWorker)。 - Console:JS 异常、CORS、CSP、混合内容、水合警告、
ResizeObserver loop。 - Performance:
FCP/LCP标记位置、长任务、Recalculate Style与Layout的次数。 - Application:Service Worker 是否在控制页面、缓存名与版本、Cookie 是否符合预期。
- Rendering:
Paint flashing看重绘范围;Frame Rendering Stats看 FPS。 - 外部证据:
chrome://net-export(完整网络日志)、服务端访问日志、CDN 日志、RUM 分桶数据。
一句话:错误码告诉你「哪一层断了」,面板告诉你「为什么断」,RUM 告诉你「影响多少人」。三者缺一不可。
1.2.13 耗时量级参考
以下只是量级,真实值随网络/设备变化,用来判断「这段是不是异常」:
| 阶段 | 正常量级 | 异常信号 |
|---|---|---|
| DNS(缓存命中) | < 1ms | > 100ms 说明没缓存或链路差 |
| DNS(未命中) | 20–120ms | > 300ms 考虑换 DNS/预解析 |
| TCP + TLS(同城) | 30–100ms | > 300ms 看证书链、跨地域 |
| TTFB | 50–500ms | > 1s 查服务端与网关 |
| HTML 下载 | 与体积线性 | 首屏 HTML > 100KB 要警惕 |
| JS 执行 | 与体积/设备线性 | 主线程长任务 > 50ms 就影响交互 |
一句话总结:这条链路上每一段都有对应的 DevTools 证据;排查时不要猜,先定位「慢在哪一段」,再决定是优化网络、减少阻塞资源,还是拆主线程任务。
1.3 DevTools 五大面板:各自解决什么问题
| 面板 | 解决什么 | 必会操作 |
|---|---|---|
| Elements | DOM 与样式 | 盒模型高亮、Computed 看最终样式、Event Listeners 看监听器、强制状态(:hover) |
| Console | 日志与执行 | $0 引用选中元素、copy()、monitorEvents()、console.table |
| Network | 请求链路 | 筛选 XHR/JS、看瀑布图、右键「Copy as cURL」、Throttling 模拟弱网、Disable cache |
| Sources | 调试 | 行断点、条件断点、DOM/XHR/Event 断点、Snippets、Overrides(本地改线上文件) |
| Application | 存储与缓存 | Local/Session Storage、IndexedDB、Cookies、Cache Storage、Service Workers、Clear storage |
进阶面板(L2 以后常用):Performance(火焰图)、Memory(堆快照)、Layers(合成层)、Rendering(帧率/重绘闪烁)、Coverage(未使用代码)。Application 里还有 Back/forward cache(测 BFCache 资格)与 Service Workers(更新/离线调试)。
实用技巧:Rendering 面板打开「Paint flashing」,页面上闪绿的区域就是发生重绘的地方。改一个样式后大面积闪绿,说明你的改动代价很大。
1.4 DOM API:查询、创建、批量插入
// 查询:id 最快,复杂选择器从右向左匹配
const el = document.getElementById('app');
const items = document.querySelectorAll('.list > li'); // 静态 NodeList
// 创建与插入
const frag = document.createDocumentFragment();
for (let i = 0; i < 1000; i++) {
const li = document.createElement('li');
li.textContent = `item ${i}`;
frag.appendChild(li);
}
list.appendChild(frag); // 只触发一次布局
// ⚠️ innerHTML 会重新解析 HTML 并替换子节点(还会带来 XSS 风险)
el.innerHTML = '<span>文本</span>';
// 安全插入文本
el.textContent = userInput;
三个高频问题:
querySelectorAll返回的是静态集合,DOM 变化后不会更新;getElementsByClassName返回动态集合(有性能与「遍历时变化」的坑)。- 在循环里
appendChild会触发 N 次布局,必须用DocumentFragment或先display: none。 innerHTML的性能与安全:大段 HTML 用innerHTML可能比逐个创建快,但会丢事件监听、触发 XSS。模板渲染优先用框架或template。
1.5 BOM:浏览器对象模型
// 地址与历史
location.href; // 完整 URL
location.pathname; // 路径
history.pushState({ page: 1 }, '', '/page/1'); // 改 URL 不刷新
history.back();
// 浏览器信息(UA 已冻结,优先用 userAgentData)
navigator.userAgentData?.platform;
// 屏幕与视口
window.innerWidth; // 含滚动条的视口宽
document.documentElement.clientWidth; // 不含滚动条
// 打开新窗口(会被拦截,除非由用户手势触发)
window.open('/help', '_blank', 'width=600,height=400');
1.6 存储 API 基础:四种存储怎么选
| 存储 | 容量 | 同步/异步 | 生命周期 | 适用 |
|---|---|---|---|---|
| Cookie | 4KB/个 | 同步(随请求发送) | 可设过期 | 登录态(HttpOnly) |
localStorage | 5-10MB | 同步 | 永久(除非清除) | 轻量偏好设置 |
sessionStorage | 5-10MB | 同步 | 标签页关闭即清 | 单次会话的临时数据 |
indexedDB | 配额制(可达数百 MB) | 异步 | 永久 | 大量结构化数据、离线缓存 |
// localStorage:字符串键值,读写都是同步的,会阻塞主线程
localStorage.setItem('theme', 'dark');
const theme = localStorage.getItem('theme'); // 'dark' | null
// 存对象要序列化
localStorage.setItem('user', JSON.stringify({ id: 1 }));
const user = JSON.parse(localStorage.getItem('user') ?? '{}');
// indexedDB:异步、支持索引与事务
const req = indexedDB.open('app', 1);
req.onupgradeneeded = () => {
const db = req.result;
db.createObjectStore('orders', { keyPath: 'id' });
};
为什么 localStorage 不能当数据库:同步 API 在主线程执行,读 20MB 字符串会卡住页面数百毫秒;没有索引,只能全量读取再过滤。
1.7 定时、帧与性能计时
1.7.1 先复习事件循环:任务、微任务与渲染机会
浏览器主线程的大致循环:
取一个宏任务(task)执行
→ 清空所有微任务(microtask)
→ 判断是否需要渲染:rAF 回调 → 样式 → 布局 → 绘制 → 合成
→ 取下一个宏任务……
| 类型 | 例子 | 执行时机 | 能否让出渲染 |
|---|---|---|---|
| 宏任务(task) | setTimeout、setInterval、MessageChannel、I/O、用户事件 | 一次取一个 | 可以(任务之间浏览器有机会渲染) |
| 微任务(microtask) | Promise.then、queueMicrotask、await 后续 | 当前任务结束后一次性清空 | 不能(微任务不清空,渲染轮不到) |
| 渲染回调 | requestAnimationFrame | 每帧渲染前 | 本身就是渲染流程的一部分 |
| 空闲回调 | requestIdleCallback | 帧末有空闲时 | 可能被跳过 |
实践含义:想「让出主线程」,必须让出到宏任务;await Promise.resolve()、queueMicrotask() 都不算,它们只会把工作推到当前微任务队列末尾,渲染仍然被堵住。
1.7.2 setTimeout / setInterval 的真实行为
const id = setTimeout(() => {
// 注意:0 不是真的 0
}, 0);
clearTimeout(id);
四个必须知道的事实:
- 最小延迟钳制:规范规定嵌套超过 5 层的定时器,最小延迟为 4ms。所以「
setTimeout(fn, 0)循环」并不快,反而会被钳。 - 后台节流:页面不可见时,定时器通常被压到 约 1 秒一次;Chrome 对隐藏超过 5 分钟的页面启用「intensive throttling」,进一步压到 约 1 分钟一次。
setInterval会漂移与堆积:回调执行时间不计入间隔;如果回调比间隔长,任务会排队。需要稳定节奏时用「递归setTimeout+ 校正」。- 定时器不保证准时:主线程忙时,回调只能排队;
setTimeout(fn, 100)可能 300ms 后才执行。
/**
* 固定节奏的循环:用递归 setTimeout 校正漂移。
* @param {(now: number) => void} fn 每轮要执行的任务
* @param {number} interval 期望间隔(ms)
* @returns {() => void} 停止函数
*/
function fixedInterval(fn, interval) {
let expected = performance.now();
let stopped = false;
/** 下一轮调度,按实际耗时补偿 */
const tick = () => {
if (stopped) return;
const now = performance.now();
fn(now);
expected += interval;
const delay = Math.max(0, expected - performance.now());
setTimeout(tick, delay);
};
setTimeout(tick, interval);
return () => {
stopped = true;
};
}
1.7.3 requestAnimationFrame:不是定时器
let rafId = requestAnimationFrame(function frame(ts) {
// ts 是「这一帧开始」的时间戳,单位 ms,来源与 performance.now() 同一条时间轴
rafId = requestAnimationFrame(frame);
});
cancelAnimationFrame(rafId);
| 特性 | setTimeout | requestAnimationFrame |
|---|---|---|
| 触发时机 | 任意任务队列 | 每帧渲染前,跟随刷新率 |
| 隐藏标签页 | 被节流到约 1 次/秒或更低 | 暂停 |
| 时间戳 | 无 | 有(帧开始时间) |
| 适合 | 延迟逻辑、轮询 | 动画、批量读写布局、滚动合并 |
| 高频屏 | 与刷新率无关 | 120Hz 屏自动变成 120 次/秒 |
两个常见坑:
- 不要用
rAF做计时:它跟随刷新率,不同设备频率不同;计时用performance.now()。 rAF回调里改样式是「正确时机」:它发生在样式/布局之前,浏览器能在同一帧里算完;反过来,在rAF里读布局再写样式,仍然可能强制同步布局。
1.7.4 高精度计时:performance.now() 与 Date.now()
| API | 时间轴 | 受系统时间调整影响 | 分辨率 | 用途 |
|---|---|---|---|---|
Date.now() | 墙上时钟(Unix 毫秒) | 会(用户改时间、NTP 校正) | 1ms | 显示时间、日志时间 |
performance.now() | 页面导航以来的单调时间 | 不会 | 通常 5µs–100µs(受隔离策略影响) | 性能测量 |
performance.timeOrigin | 页面起点对应的 Unix 时间 | — | — | 把两者换算 |
const t0 = performance.now();
doWork();
console.log(`耗时 ${(performance.now() - t0).toFixed(2)} ms`);
注意:为了防 Spectre,浏览器会降低 performance.now() 的分辨率;只有跨源隔离(COOP/COEP)页面才能拿到更高精度。
1.7.5 空闲与优先级:requestIdleCallback / scheduler
// 帧末有空闲才执行;可能一直不执行,所以给 timeout 兜底
requestIdleCallback(
(deadline) => {
while (deadline.timeRemaining() > 0 && tasks.length) {
tasks.shift()();
}
},
{ timeout: 2000 }
);
// 显式声明优先级(Chrome 支持,Safari 较弱)
scheduler.postTask(() => doBackgroundWork(), { priority: 'background' });
| API | 适合 | 不适合 |
|---|---|---|
requestIdleCallback | 埋点上报、缓存预热、低优先级计算 | 动画、输入响应、必须按时完成的任务 |
scheduler.postTask | 把任务分级、可中止的后台工作 | 需要广泛兼容的场景 |
MessageChannel | 手写「让出主线程」 | 需要延迟语义时 |
自检:为什么
setTimeout(fn, 0)不能做到「立刻执行」?——最小延迟钳制 + 任务排队;它只能保证「尽快排到宏任务队列」。
1.8 观察者 API:替代轮询
四个观察者的共同点:由浏览器在合适的时机批量回调,你不用轮询,也不该在回调里做重活。
| 观察者 | 观察什么 | 回调时机 | 典型用途 |
|---|---|---|---|
IntersectionObserver | 元素与视口/根容器的交叉 | 计算完交叉区域后异步批量回调 | 懒加载、曝光、无限滚动 |
ResizeObserver | 元素尺寸变化 | 布局之后、绘制之前 | 图表自适应、容器查询替代 |
MutationObserver | DOM 树变化 | 微任务时机 | 监听第三方脚本改 DOM、编辑器 |
PerformanceObserver | 性能条目 | 条目产生后异步 | 采集 LCP/INP/长任务 |
1.8.1 IntersectionObserver:懒加载的标准做法
const io = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
loadImage(entry.target);
io.unobserve(entry.target); // 只加载一次
}
},
{
root: null, // null = 视口
rootMargin: '200px', // 提前 200px 触发
threshold: 0, // 0 = 刚碰到就触发;1 = 完全可见
}
);
document.querySelectorAll('img[data-src]').forEach((img) => io.observe(img));
要点与坑:
- 回调是异步批量的,不保证每帧一次;不要指望它精确到像素。
rootMargin只能扩大/缩小 root 的判定区域,不能超过 root 自身边界(跨域 iframe 里更受限)。entry.isIntersecting是布尔值,intersectionRatio是比例;快速滚动时可能直接从 0 跳到 1。- 元素被
display: none时不会相交;恢复显示后会重新计算。 - 原生
loading="lazy"更省事,但它的触发距离由浏览器决定,且无法自定义占位逻辑;需要精细控制时用 IO。 - 不要用它做「滚动进度条」:那是
scroll+rAF或ScrollTimeline的活。
1.8.2 ResizeObserver:为什么它比 window.resize 好
const ro = new ResizeObserver((entries) => {
for (const entry of entries) {
// contentBoxSize / borderBoxSize 是数组(支持多列布局)
const { inlineSize, blockSize } = entry.contentBoxSize[0];
console.log(inlineSize, blockSize);
}
});
ro.observe(document.querySelector('.chart'));
- 它观察的是元素自身的尺寸,不受窗口 resize 影响(例如侧边栏收起导致的宽度变化也能捕捉)。
- 回调在布局之后、绘制之前,所以能读到新尺寸;但在回调里改尺寸会触发下一轮观察,浏览器会报
ResizeObserver loop completed with undelivered notifications。 - 正确做法:在回调里只记录/调度,把「改尺寸」放到
requestAnimationFrame或queueMicrotask之后。 - 需要像素级精确(Canvas)时用
entry.devicePixelContentBoxSize,避免 DPR 换算误差。
1.8.3 MutationObserver:强大但别拿来驱动渲染
const mo = new MutationObserver((records) => {
for (const record of records) {
if (record.type === 'childList') {
console.log('新增节点', record.addedNodes.length);
}
}
});
mo.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['class'],
});
- 回调在微任务时机执行,一批 DOM 变更合并成一次回调。
takeRecords()可以手动取走尚未回调的记录(在断开观察前清理时有用)。- 不要用它监听自己的渲染结果:
观察 → 改 DOM → 再触发很容易形成死循环或性能雪崩。 - 它看不到 Shadow DOM 内部的变化(除非单独观察 shadow root)。
1.8.4 PerformanceObserver:采集指标的入口
const po = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
report(entry.name, entry.startTime);
}
});
po.observe({ type: 'largest-contentful-paint', buffered: true });
buffered: true能拿到「观察者创建之前」已经产生的条目,适合在页面底部才加载的埋点脚本。- 不同 entry 类型的字段不同(
renderTime、loadTime、processingStart等),采集前先看规范。 - 长任务用
type: 'longtask';长动画帧用type: 'long-animation-frame'(LoAF)。
1.9 页面生命周期
1.9.1 五个状态,不是「打开/关闭」两个
┌──────────────┐ 切到后台/锁屏 ┌──────────────┐
│ active │ ───────────────► │ hidden │
│(可见且聚焦) │ ◄─────────────── │(不可见) │
└──────┬───────┘ 切回前台 └──────┬───────┘
│ │ 长时间后台/内存压力
│ ▼
│ ┌──────────────┐
│ │ frozen │
│ │(冻结,停定时器)│
│ └──────┬───────┘
│ │ 内存不足
│ ▼
│ ┌──────────────┐
│ │ discarded │
│ │(进程被丢弃) │
│ └──────────────┘
▼
页面关闭/导航(可能进入 BFCache)
| 状态 | 含义 | 定时器 | JS | 典型事件 |
|---|---|---|---|---|
| active | 可见且有焦点 | 正常 | 正常 | focus、visibilitychange |
| passive | 可见但失焦(如另一个窗口在前面) | 正常 | 正常 | blur |
| hidden | 不可见(切标签、锁屏、最小化) | 被节流 | 仍可执行 | visibilitychange |
| frozen | 被冻结(后台久了/省内存) | 暂停 | 暂停 | freeze |
| discarded | 标签页被丢弃,页面状态丢失 | 停止 | 停止 | 恢复时重新加载,document.wasDiscarded |
1.9.2 关键事件与正确用法
// 1) 可见性变化:最可靠的上报/暂停时机
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
flushAnalytics(); // 用 sendBeacon / fetch keepalive
pauseVideo();
} else {
resumeVideo();
}
});
// 2) BFCache 恢复:页面「复活」,数据可能过期
window.addEventListener('pageshow', (event) => {
if (event.persisted) {
refreshData();
}
});
// 3) 页面即将离开:移动端也不保证触发,但比 unload 可靠
window.addEventListener('pagehide', (event) => {
saveDraft();
// event.persisted 为 true 表示进入 BFCache,不是真正卸载
});
// 4) 冻结/恢复(Page Lifecycle API,Chrome 支持较好)
document.addEventListener('freeze', () => {
closeRealtimeConnection();
});
document.addEventListener('resume', () => {
reconnect();
});
1.9.3 为什么不要用 unload / beforeunload
| 事件 | 问题 | 替代 |
|---|---|---|
unload | 破坏 BFCache;移动端经常不触发;已废弃 | pagehide + visibilitychange |
beforeunload | 弹确认框、阻塞关闭;Chrome 中会让页面失去 BFCache 资格 | 只在「真有未保存内容」时用;平时用 pagehide |
pagehide | 真正卸载或进入 BFCache 都会触发 | ✅ 推荐 |
上报要用 navigator.sendBeacon() 或 fetch(..., { keepalive: true }):普通 fetch 在页面卸载后可能被取消;sendBeacon 由浏览器接管发送,不阻塞卸载。
/**
* 卸载前上报:优先 sendBeacon,失败回退 keepalive fetch。
* @param {string} url 上报地址
* @param {unknown} data 可序列化数据
*/
function reportOnExit(url, data) {
const body = JSON.stringify(data);
if (navigator.sendBeacon) {
navigator.sendBeacon(url, new Blob([body], { type: 'application/json' }));
return;
}
fetch(url, { method: 'POST', body, keepalive: true });
}
1.9.4 document.wasDiscarded 与「页面被回收」
当浏览器因内存压力丢弃标签页,用户再点回来时页面会重新加载。此时:
if (document.wasDiscarded) {
// 页面曾被丢弃,内存里的状态已经没了
restoreFromStorage();
}
它和 BFCache 的区别:
| 机制 | 页面状态 | 恢复速度 | 触发 |
|---|---|---|---|
| BFCache | JS 堆、DOM 全部保留 | 几乎瞬时 | 前进/后退 |
| discarded | 状态丢失,重新加载 | 取决于加载速度 | 内存压力/省内存模式 |
自检:为什么「在
unload里上报」既不准又有害?——移动端常不触发(不准),且会让页面无法进入 BFCache(有害)。
1.10 HTML 解析、脚本执行与资源加载
1.2 讲的是「链路」,这一节把其中最容易出问题的两步——解析与资源加载——拆开讲。首屏白屏、脚本顺序错乱、DOMContentLoaded 时机不对,根因几乎都在这里。
1.10.1 解析器怎么把字节变成 DOM
字节流
→ 解码(charset:BOM > HTTP header > <meta> > 默认)
→ 分词(tokenizer):开始标签、结束标签、文本、注释、DOCTYPE
→ 树构建(tree construction):按标签语义建节点、处理隐式闭合
→ DOM
三个容易忽略的点:
- 容错是规范的一部分:
<p><div>会自动闭合<p>,<table><div>会把div「寄养」到表格前面(foster parenting)。浏览器不会因为标签写错就停止解析,它按规范「猜」你的意图。 - 解析是流式的:HTML 还在下载时 DOM 就在增长,所以用户能更早看到内容;这也是服务端流式渲染能工作的前提。
- 解析会让出主线程:浏览器分块解析,中间可以处理输入与渲染;但同步脚本一旦开始执行,解析就完全停住。
1.10.2 为什么 <script> 会阻塞解析
经典脚本(不带 defer/async)的执行时机是「解析器遇到它 → 立刻停下来 → 下载 → 执行 → 再继续」。原因是脚本可能:
// 脚本可以改写文档结构,解析器必须先执行它再继续
document.write('<div id="injected">…</div>');
更隐蔽的一层:脚本执行还会被 CSSOM 阻塞。因为脚本里可能读取样式:
// 如果样式表还没加载完,这个值会是错的
getComputedStyle(document.body).color;
所以浏览器要等「脚本之前的所有样式表」加载并构建完 CSSOM,才执行脚本。时间线因此变成:
HTML 解析 ──► 遇到 <link rel=stylesheet> ──► 下载 CSS(不阻塞解析,阻塞渲染)
──► 遇到 <script> ──► 等 CSS 下载完 ──► 执行脚本 ──► 继续解析
这就是「一个同步脚本 + 一个慢样式表 = 整页白屏」的原因。
1.10.3 defer / async / module 到底差在哪
| 写法 | 下载 | 执行时机 | 顺序 | 阻塞解析 | 阻塞 DOMContentLoaded |
|---|---|---|---|---|---|
<script> | 立即 | 下载完立刻执行 | 按文档顺序 | 是 | 是(执行完才触发) |
<script defer> | 并行 | 解析完成后 | 按文档顺序 | 否 | 是(defer 脚本执行完才触发) |
<script async> | 并行 | 下载完立刻执行 | 不确定 | 否 | 否(可能在 DCL 前或后) |
<script type="module"> | 并行 | 解析完成后(默认 defer) | 按文档顺序 | 否 | 是 |
<script type="module" async> | 并行 | 下载完立刻执行 | 不确定 | 否 | 否 |
补充规则:
defer/async只对外部脚本有效,内联脚本写了也没用;- 模块脚本默认严格模式、独立作用域、跨域需要 CORS;
- 模块的依赖可以用
<link rel="modulepreload">提前下载; async脚本可能插队到DOMContentLoaded之前,别在它里面假设 DOM 已就绪。
选型:入口脚本用 defer 或 module;统计、广告等互不依赖的第三方脚本用 async;需要保证顺序又不想阻塞解析的,用 defer。
1.10.4 预加载扫描器:浏览器比你想得更聪明
主解析器被同步脚本卡住时,浏览器还有一个预加载扫描器在「偷看」后面的 HTML:
<body>
<script src="/slow.js"></script> ← 解析器在这里停住
<img src="/hero.jpg"> ← 扫描器已经发现并开始下载
<link rel="stylesheet" href="/app.css">
</body>
它能提前发现:<script src>、<link rel=stylesheet>、<img>、<video>、<source> 等写在 HTML 里的资源。
它发现不了:
- JS 动态创建的标签(
document.createElement('img')); - CSS 里的
background-image(要等 CSSOM 解析后才知道); - 通过
fetch/ XHR 请求的资源。
这解释了一个常见现象:把关键图片写进 HTML 比用 JS 插入快——前者能被扫描器提前发现,后者要等 JS 执行。
1.10.5 DOMContentLoaded、load 与首屏的关系
HTML 解析完成
→ defer / module 脚本执行完
→ DOMContentLoaded 触发 ← DOM 可用,图片/iframe 可能还没好
→ 图片、字体、iframe 等子资源加载完
→ load 触发 ← 所有资源就绪
三个常见误解:
| 误解 | 事实 |
|---|---|
「DOMContentLoaded 时页面已经完整」 | 图片、字体、样式可能还没加载完 |
「load 之后再优化首屏」 | 首屏(FCP/LCP)通常远早于 load |
「async 脚本一定在 DCL 前执行」 | 不一定,取决于下载速度 |
1.10.6 document.write 为什么危险
// 解析期间:会把内容插入当前位置
document.write('<p>hello</p>');
// 页面加载完成后:会清空整个文档,重新开始写
window.onload = () => document.write('<p>oops</p>');
它的问题:
- 阻塞解析,拖慢首屏;
- 与
async组合时不可预测(脚本可能在解析结束后才执行,此时write会清空页面); - 破坏流式渲染的增量输出。
现代替代:直接操作 DOM、insertAdjacentHTML、或用框架的渲染。
1.10.7 资源加载优化清单
| 问题 | 做法 |
|---|---|
| 同步脚本阻塞首屏 | 改 defer / module,非关键脚本 async |
| CSS 阻塞渲染 | 首屏关键 CSS 内联,其余异步加载 |
| 字体导致文字不可见 | font-display: swap + 预加载首屏字体 |
| 图片发现太晚 | 写进 HTML(不要 JS 插入)+ preload / fetchpriority="high" |
| 模块依赖瀑布 | <link rel="modulepreload"> |
| 第三方脚本不可控 | async + 超时降级 + 不放在关键路径 |
| 首屏 HTML 太大 | 流式渲染、减少内联数据、分块传输 |
自检:为什么「把
<script>放 body 末尾」在老项目里有效?——它让解析器先构建完 DOM,脚本执行时不会阻塞前面的内容渲染;但它仍然阻塞DOMContentLoaded,现代写法应直接用defer。
1.11 现代 Web API 能力地图
浏览器 API 很多,但真正需要「知道它存在、知道它的边界」的,集中在下面几类。把这张表当索引,用到时再查细节。
1.11.1 网络与流
| 能力 | 核心 API | 关键限制/坑 |
|---|---|---|
| 请求 | fetch、Request、Response、Headers | 4xx/5xx 不会 reject;CORS 错误只给 TypeError |
| 取消/超时 | AbortController、AbortSignal.timeout() | 取消后要清理 UI 状态;超时不是网络层超时 |
| 流式读取 | response.body.getReader()、TextDecoderStream | 需要服务端不缓冲;代理可能攒包 |
| 流式上传 | fetch(..., { duplex: 'half' }) | 兼容性有限;多数场景仍用分片上传 |
| 卸载上报 | navigator.sendBeacon、fetch(..., { keepalive: true }) | keepalive 有体积上限(约 64KB) |
| 实时双向 | WebSocket、WebTransport | 需要心跳、重连、背压处理 |
| 服务端推送 | EventSource / fetch 流 | SSE 只能 GET、不能自定义头 |
| 资源提示 | preload、prefetch、preconnect、fetchpriority | 用错 as/crossorigin 会白预加载 |
| URL 处理 | new URL()、URLSearchParams、encodeURIComponent | 手动拼字符串容易漏编码;相对路径要传 base |
1.11.2 文件与二进制
| 能力 | 核心 API | 关键限制/坑 |
|---|---|---|
| 文件选择 | <input type="file">、拖拽 DataTransfer | 需要用户手势;拿不到真实路径 |
| 读取 | File.text()、arrayBuffer()、FileReader | 大文件别一次性读进内存 |
| 分片 | Blob.slice()、File.stream() | 分片上传要处理顺序/重试/合并 |
| 内存 URL | URL.createObjectURL() | 用完必须 revokeObjectURL(),否则泄漏 |
| 下载 | <a download>、Blob + object URL | 跨域资源可能无法强制下载 |
| 文件系统 | File System Access API | 需要安全上下文 + 用户手势;兼容性有限 |
| 私有文件 | OPFS(navigator.storage.getDirectory()) | 同步句柄只在 Worker 可用 |
| 二进制转换 | TextEncoder/Decoder、ArrayBuffer、DataView | 注意字节序与编码 |
1.11.3 设备、权限与系统
| 能力 | 核心 API | 关键限制/坑 |
|---|---|---|
| 权限查询 | navigator.permissions.query() | 只反映状态,不负责申请 |
| 通知 | Notification.requestPermission() | 需要用户手势;被拒绝后很难再弹 |
| 定位 | navigator.geolocation | 需要 HTTPS + 权限;精度与耗电 |
| 摄像头/麦克风 | navigator.mediaDevices.getUserMedia() | 需要 HTTPS + 权限;用完要停 track |
| 屏幕共享 | getDisplayMedia() | 必须用户手势;移动端支持差 |
| 剪贴板 | navigator.clipboard | 需要安全上下文;读权限更严 |
| 屏幕常亮 | navigator.wakeLock | 需要 HTTPS;页面隐藏会自动释放 |
| 蓝牙/USB/串口 | Web Bluetooth / WebUSB / Web Serial | 基本仅 Chromium;需要手势 + 权限 |
| 凭据 | Credential Management、WebAuthn | 需要 HTTPS;依赖平台认证器 |
1.11.4 媒体与图形
| 能力 | 核心 API | 关键限制/坑 |
|---|---|---|
| 2D 绘图 | Canvas 2D | 大画布占内存;频繁 getImageData 很慢 |
| 3D | WebGL / WebGPU | 需要 GPU 支持;上下文丢失要恢复 |
| 离屏渲染 | OffscreenCanvas | Worker 中可用,但字体/图片加载要另处理 |
| 音频 | Web Audio API | 通常要用户手势后才能 resume() |
| 录制 | MediaRecorder | 编码格式因浏览器而异 |
| 视频 | <video>、Picture-in-Picture、Fullscreen | 自动播放受策略限制(muted 才常允许) |
| 动画 | Web Animations API、ScrollTimeline | 可合成属性才能脱离主线程 |
1.11.5 并发与后台
| 能力 | 核心 API | 关键限制/坑 |
|---|---|---|
| 专用 Worker | new Worker() | 无 DOM;通信靠消息,注意结构化克隆成本 |
| 共享 Worker | SharedWorker | 兼容性一般;生命周期由浏览器管理 |
| Service Worker | navigator.serviceWorker | 仅 HTTPS/localhost;会被随时终止 |
| Worklet | CSS.paintWorklet、AudioWorklet | 运行在独立线程,API 受限 |
| WASM | WebAssembly | 需要编译/实例化;与 JS 边界有调用成本 |
| 跨上下文消息 | BroadcastChannel、MessageChannel | 同源限制;消息要可序列化 |
1.11.6 观测与调试
| 能力 | 核心 API | 关键限制/坑 |
|---|---|---|
| 性能条目 | PerformanceObserver | 不同 entry 字段不同;buffered 很关键 |
| 长任务/长帧 | longtask、long-animation-frame | LoAF 信息更细,但兼容性较新 |
| 布局偏移 | layout-shift | 需要排除用户输入导致的偏移 |
| 元素可见性 | IntersectionObserver | 异步批量,不保证每帧 |
| 尺寸变化 | ResizeObserver | 回调里改尺寸会触发循环警告 |
| 错误上报 | window.onerror、unhandledrejection、ReportingObserver | 抓不到进程崩溃/OOM |
| 内存 | performance.memory(Chrome)、measureUserAgentSpecificMemory() | 前者仅当前渲染进程;后者需跨源隔离 |
1.11.7 交互、焦点与滚动
| 能力 | 核心 API | 关键限制/坑 |
|---|---|---|
| 滚动到元素 | element.scrollIntoView({ behavior, block, inline }) | 平滑滚动受 prefers-reduced-motion 影响;会触发滚动事件 |
| 程序化滚动 | window.scrollTo()、scrollBy() | 想拿到「滚完了」用 scrollend,不要用固定 setTimeout |
| 滚动行为 | CSS scroll-behavior: smooth、overscroll-behavior | 全局平滑滚动可能让「回到顶部」变慢 |
| 焦点管理 | focus()、blur()、:focus-visible | focus 不冒泡;用 focusin/focusout 委托 |
| 焦点锁定 | <dialog> 的 showModal()、inert | 自写弹窗要自己做焦点陷阱与 Esc 关闭 |
| 弹层 | Popover API(popover、showPopover()) | 兼容性较新;light-dismiss 行为因浏览器而异 |
| 文本选择 | window.getSelection()、Range | 跨 Shadow DOM 选择行为不同 |
| 元素可见性 | element.checkVisibility() | 新 API;老环境用 offsetParent/getClientRects() 兜底 |
// 平滑滚动到元素,同时尊重用户的「减少动态效果」偏好
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
element.scrollIntoView({ behavior: reduceMotion ? 'auto' : 'smooth', block: 'center' });
// 打开模态框:dialog 自带焦点陷阱、Esc 关闭、::backdrop
dialog.showModal();
两个实践建议:
- 不要用
tabindex为正数:它会打乱自然的 Tab 顺序;用 DOM 顺序 +tabindex="0"/-1。 - 弹窗打开时把背景设为
inert:它同时禁止聚焦、点击与无障碍访问,比手写「遮罩 + 阻止冒泡」可靠。
1.11.8 使用这些 API 的五条原则
- 安全上下文:绝大多数设备/文件/剪贴板 API 要求 HTTPS(
localhost例外)。上线前先确认协议。 - 用户手势:弹窗、全屏、剪贴板写、文件选择、摄像头、音频播放,通常必须由点击/按键触发;在
setTimeout里调用会被拦。 - 权限分离:
permissions.query()只查状态,申请要走具体 API;用户拒绝后不要反复弹。 - 特性检测优先:用
'showModal' in HTMLDialogElement.prototype这类检测,而不是 UA 判断;不支持的路径要有降级。 - 清理资源:
getUserMedia的 track、createObjectURL、WebSocket、Worker、ResizeObserver、事件监听,都要在适当时机释放,否则就是内存泄漏。
自检:为什么「本地
http://localhost能调摄像头,线上http://不行」?——localhost被视为安全上下文,普通 HTTP 域名不是;设备 API 在非安全上下文直接不可用。
1.12 L1 小结与自检
| 知识点 | 最该记住的一句 |
|---|---|
| 进程与线程 | 进程管隔离,线程管并发;JS 跑在渲染进程主线程 |
| 多进程 | 渲染进程按站点实例划分,GPU/网络共享;同站标签页可能复用进程 |
| 站点隔离 | 不同站点不同地址空间,防 Spectre;跨站 iframe 只能 postMessage |
| 导航链路 | DNS → TCP → TLS → 请求 → 解析 → 渲染 |
| 失败排查 | 先定位断在哪一段,再看错误码;白屏至少分五种 |
| DevTools | 五大面板各管一段;Rendering 面板能看重绘 |
| DOM | 批量插入用 fragment,innerHTML 有 XSS 风险 |
| 存储 | localStorage 同步会卡,大数据用 IndexedDB |
| 观察者 | 用 IO/RO 替代滚动与 resize 轮询,回调里别做重活 |
| 定时器 | setTimeout 有钳制与后台节流,rAF 跟刷新率且隐藏时暂停 |
| 生命周期 | visibilitychange 上报,pagehide 清理,别用 unload |
| Web API | 设备/文件/剪贴板要安全上下文 + 用户手势;特性检测优先 |
自检题
- 为什么
getElementsByClassName和querySelectorAll遍历时行为不同?——前者动态、后者静态。 localStorage存 10MB 会怎样?——解析与反序列化阻塞主线程。- 为什么建议用
IntersectionObserver做懒加载?——避免滚动事件 +getBoundingClientRect造成的强制同步布局。 - 同一个浏览器的两个标签页一定是两个进程吗?——不一定;同站点实例可能复用进程,跨站 iframe 通常独立进程。
- 为什么
setTimeout(fn, 0)不能用来做「让出主线程」的最佳方案?——有 4ms 嵌套钳制;MessageChannel/scheduler.yield更合适。 - 页面被丢弃后怎么知道?——
document.wasDiscarded(恢复后为 true),配合pagehide保存状态。
2. L2 进阶:渲染管线与网络边界
2.1 渲染管线五阶段
JS/样式修改
↓
① Style(样式计算):把 CSS 规则匹配到 DOM 节点,算出每个元素的最终样式
↓
② Layout(布局/重排):计算每个盒子的位置与大小
↓
③ Paint(绘制):生成绘制指令(先画什么、后画什么)
↓
④ Raster(光栅化):把指令变成像素(GPU/CPU 分块执行)
↓
⑤ Composite(合成):把多个图层按顺序叠起来,输出到屏幕
每一阶段内部在做什么:
| 阶段 | 输入 | 内部动作 | 关键概念 | 观测 |
|---|---|---|---|---|
| Style | DOM + CSSOM | 选择器匹配(从右往左)、层叠与继承、算出计算值 | 失效集、:has() 会反向影响祖先、CSS 变量改根节点影响全树 | Recalculate Style |
| Layout | 样式后的盒树 | 计算位置、尺寸、换行、对齐 | 包含块、格式化上下文、布局边界、contain: layout | Layout |
| Paint | 布局树 | 生成绘制指令(背景→边框→子元素) | 层叠上下文、绘制顺序、cull rect(重绘区域) | Paint、Paint flashing |
| Raster | 绘制指令 | 切成瓦片、转成像素 | tile、视口优先、checkerboarding、显存 | GPU/Raster 轨道 |
| Composite | 图层 + 属性树 | 按顺序拼图层、应用滚动/变换偏移 | 合成层、属性树、合成线程 | Composite Layers、Layers |
三个容易忽略的细节:
- Style 的失效范围会放大:改元素自己的 class 只影响子树;改祖先 class、改
:root上的 CSS 变量、用:has(),可能影响一大片甚至全树。 - Layout 的代价取决于上下文:改一个靠近根节点的宽度,可能让整页重排;
contain: layout/content-visibility可以把影响限制在局部。 - Paint 生成的是「指令」不是像素:真正变成像素在 Raster;所以「重绘」与「光栅」是两件事,DevTools 里也分属不同轨道。
关键认知:
- 不是每次改动都走全部五步。改
color→ 跳过 Layout;改transform(在合成层)→ 只走 Composite。 - Layout 最贵,因为它依赖其他元素(改一个元素宽度可能影响整行、整页)。
- 一次「重排」= Style + Layout + Paint + Composite;一次「重绘」= Paint + Composite。
- Raster 与 Composite 在主线程之外:主线程只负责到「提交绘制指令」,后面的光栅化与上屏由合成/光栅线程完成(详见 3.1)。
改动 → 触发阶段速查:
| 改动 | Style | Layout | Paint | Composite | 说明 |
|---|---|---|---|---|---|
width / height / margin / padding / border-width | ✅ | ✅ | ✅ | ✅ | 几何变化,最贵 |
top / left(定位元素) | ✅ | ✅ | ✅ | ✅ | 用 transform 代替 |
font-size / line-height | ✅ | ✅ | ✅ | ✅ | 可能引发大范围重排 |
display / position / float | ✅ | ✅ | ✅ | ✅ | 布局结构变化 |
color / background-color / background-image | ✅ | ❌ | ✅ | ✅ | 只重绘 |
box-shadow / border-radius / outline | ✅ | ❌ | ✅ | ✅ | 阴影/圆角重绘成本高 |
visibility | ✅ | ❌ | ✅ | ✅ | 与 display: none 不同,仍占位 |
transform(已合成层) | ❌ | ❌ | ❌ | ✅ | 合成线程处理 |
opacity(已合成层) | ❌ | ❌ | ❌ | ✅ | 同上 |
transform / opacity(未合成) | ✅ | ❌ | ✅ | ✅ | 仍要主线程重绘 |
filter / backdrop-filter | ✅ | ❌ | ✅ | ✅ | 常需重新光栅 |
will-change | ❌ | ❌ | ❌ | ✅ | 只是提示,可能提前建层 |
两个必须记住的细节:
transform不触发布局,但没被合成时仍要重绘;它还会让后代的position: fixed变成相对它定位(包含块改变)。- 这张表是「典型情况」,真假以 DevTools 为准:同样改
opacity,在不同元素/浏览器上可能走出不同路径。
// 会触发重排(Layout)
el.style.width = '200px';
el.style.marginLeft = '10px';
el.classList.add('expanded'); // 取决于类的属性
// 只触发重绘(Paint)
el.style.backgroundColor = 'red';
el.style.boxShadow = '0 0 10px #000';
// 只触发合成(Composite)——前提已在独立合成层
el.style.transform = 'translateX(10px)';
el.style.opacity = '0.5';
2.2 强制同步布局(layout thrashing)
浏览器会攒着样式改动,等到需要时一次性计算布局。但如果你读取布局属性,它就必须立刻算——这就是「强制同步布局」。
// ❌ 在循环里读写交错:每次读都触发一次布局
for (const box of boxes) {
box.style.width = `${box.offsetWidth + 10}px`; // 读 → 写 → 读 → 写...
}
// ✅ 批量读,再批量写
const widths = boxes.map((b) => b.offsetWidth);
boxes.forEach((b, i) => {
b.style.width = `${widths[i] + 10}px`;
});
会触发强制布局的属性(部分):offsetTop/Left/Width/Height、scrollTop/Height、clientWidth/Height、getComputedStyle()、getBoundingClientRect()、focus()。
在 Performance 面板里表现为紫色斜纹的「Forced reflow」警告。
2.3 关键渲染路径与首屏
浏览器必须等 CSSOM 构建完成才能渲染(否则会闪一下无样式内容),所以 CSS 是阻塞渲染的资源;JS 会阻塞解析(因为可能 document.write)。
首屏优化三件事:
<head>
<!-- 1. 关键 CSS 内联,避免首屏多一次请求 -->
<style>/* 首屏可见区域的最小样式 */</style>
<!-- 2. 非关键 CSS 异步加载 -->
<link rel="stylesheet" href="/rest.css" media="print" onload="this.media='all'" />
<!-- 3. JS 不阻塞解析 -->
<script src="/app.js" defer></script>
</head>
2.4 合成层与 GPU
一个元素在什么情况下会「提升」为独立合成层:
.layer {
will-change: transform; /* 显式提示 */
transform: translateZ(0); /* 强制开启 GPU 加速(hack) */
position: fixed; /* 某些情况下 */
opacity: 0.99; /* 有动画时 */
filter: blur(0);
}
好处:该元素的位移/透明度变化只走合成,不触发主线程布局,动画更顺。
代价:
- 每个合成层占用显存,层太多会导致内存暴涨(移动端尤其明显);
- 层的内容需要光栅化,大层或高频变化会产生大量光栅任务;
- 层数过多会让「合成」本身变慢。
will-change 的正确用法:在动画开始前加上,结束后移除。长期挂着等于长期占资源。
/**
* 动画期间提升合成层,结束后释放。
* @param {HTMLElement} el 目标元素
*/
async function animateOnce(el) {
el.style.willChange = 'transform';
await el.animate([{ transform: 'translateY(0)' }, { transform: 'translateY(-8px)' }], {
duration: 200,
easing: 'ease-out',
}).finished;
el.style.willChange = 'auto';
}
一个常见误解:「用了 transform 就一定不卡」。如果动画是 JS 每帧改 style.transform,值仍由主线程计算,主线程忙时照样掉帧;只有 CSS 动画 / WAAPI 驱动的可合成属性,才可能完全跑在合成线程。机制详见 3.1.3。
层爆炸的四个信号:
| 信号 | 观察方式 | 说明 |
|---|---|---|
| 内存随页面元素数量线性上涨 | Layers 面板看每层大小 | 每个层都有独立光栅存储 |
| 滚动/动画发涩 | Performance 的 Composite Layers 变长 | 合成本身也要时间 |
| 移动端 OOM/闪退 | 任务管理器、chrome://gpu | 大层在 2x/3x 屏上内存翻倍 |
| 重绘面积莫名其妙变大 | Rendering → Paint flashing | 层内一处变化可能导致整层重光栅 |
经验值:一个页面几十个合成层通常还好;成百上千个(例如列表每一项都加 will-change)几乎一定会出问题。优化方向是「减少层数 + 缩小层面积」,而不是「继续加层」。
2.5 事件机制:捕获、冒泡与 passive
2.5.1 一次事件经过三个阶段
捕获阶段(capture) 冒泡阶段(bubble)
window → document → html → body → target → body → html → document → window
▲
目标阶段(target)
// 第三个参数:true = 捕获,false/省略 = 冒泡(默认)
element.addEventListener('click', handler, {
capture: false,
passive: false,
once: false,
signal: abortController.signal, // 可用 AbortController 统一移除
});
| 概念 | 含义 | 常见误区 |
|---|---|---|
event.target | 实际触发事件的最深节点 | 事件委托里用它找来源 |
event.currentTarget | 当前正在执行监听器的节点 | 等于监听器绑定的元素 |
event.eventPhase | 1 捕获 / 2 目标 / 3 冒泡 | 目标阶段既不算捕获也不算冒泡 |
event.stopPropagation() | 阻止继续传播到其他节点 | 同一节点上的其他监听器仍会执行 |
event.stopImmediatePropagation() | 连同一节点的后续监听器也阻止 | 慎用,容易让排查变困难 |
event.preventDefault() | 阻止浏览器默认行为 | 不等于阻止冒泡 |
2.5.2 哪些事件会冒泡
不是所有事件都冒泡,这直接决定「事件委托」能不能用:
| 会冒泡 | 不冒泡 |
|---|---|
click、dblclick、mousedown/up/move | focus、blur(用 focusin/focusout 代替) |
input、change、keydown/up | mouseenter、mouseleave(用 mouseover/out 代替) |
pointerdown/up/move、touchstart/move/end | load、error、scroll(元素上的 scroll 不冒泡) |
submit、reset | DOMContentLoaded、pageshow |
两个实用替代:
// 想委托 focus:用会冒泡的 focusin
document.addEventListener('focusin', (event) => {
if (event.target.matches('.field')) highlight(event.target);
});
// 想委托 scroll:scroll 不冒泡,但可以在捕获阶段监听
document.addEventListener('scroll', onScroll, { capture: true, passive: true });
2.5.3 passive:为什么它能让滚动立刻开始
浏览器的滚动/触摸事件默认要等 JS 执行完,因为 JS 可能调用 preventDefault() 阻止滚动。passive: true 是一个承诺:「我不会阻止默认行为」,浏览器就可以先滚动,再执行你的 JS。
// ❌ 非 passive:浏览器等 JS 决定是否 preventDefault,滚动被拖慢
window.addEventListener('touchmove', onMove);
// ✅ passive:滚动立即开始
window.addEventListener('touchmove', onMove, { passive: true });
几个细节:
- Chrome 对
document/window/body上的touchstart、touchmove、wheel监听器默认按 passive 处理;其他元素上仍需显式声明。 - 在 passive 监听里调用
preventDefault()无效,Console 会有干预警告。 - 真正要阻止滚动时,优先用 CSS:
touch-action、overscroll-behavior,而不是preventDefault。 passive与capture可以同时用,顺序无关。
2.5.4 事件委托:优点与四个坑
document.addEventListener('click', (event) => {
const btn = event.target.closest('[data-action]');
if (!btn) return;
actions[btn.dataset.action]?.();
});
优点:动态节点自动生效、监听器数量少、内存占用低。
四个坑:
- 不冒泡的事件不能委托(见上表);
- 中间层调用了
stopPropagation(),委托就收不到; - Shadow DOM 会重定向
event.target:需要在宿主上用event.composedPath()找真实路径; closest可能命中意料之外的祖先:把选择器写具体,并校验btn是否在当前容器内。
2.5.5 Pointer Events:鼠标、触摸、笔的统一抽象
element.addEventListener('pointerdown', (event) => {
// 捕获指针:拖动时即使移出元素也能继续收到事件
element.setPointerCapture(event.pointerId);
console.log(event.pointerType); // 'mouse' | 'touch' | 'pen'
});
element.addEventListener('pointermove', (event) => {
// 高频指针事件会被合并,需要原始轨迹时取 coalesced events
for (const e of event.getCoalescedEvents?.() ?? [event]) {
draw(e.clientX, e.clientY);
}
});
element.addEventListener('pointercancel', () => {
// 手势被系统接管(来电话、浏览器手势)时清理状态
});
相比 mouse + touch 双套监听,Pointer Events 的优势:
- 一套代码覆盖鼠标/触摸/笔;
setPointerCapture解决「拖出元素就丢事件」;touch-action用 CSS 声明手势归属,比preventDefault更高效;- 移动端
click的 300ms 延迟在现代浏览器 +width=device-width下已基本消失。
2.5.6 事件优先级与「输入延迟」
用户输入在浏览器里优先级很高,但它仍然要排到主线程执行。如果主线程正在跑一个 200ms 的任务,点击事件只能等——这就是 INP 里的「输入延迟」。
优化方向:
- 事件处理里只做「记录 + 调度」,重活放到
rAF、postTask或 Worker; - 高频事件(
pointermove、scroll、resize)用rAF合并,不要每个事件都改 DOM; - 滚动/触摸监听加
passive: true; - 避免在
pointerdown里触发同步布局或同步 IPC。
自检:
stopPropagation和preventDefault有什么区别?——前者阻止事件继续传播,后者阻止浏览器默认行为;两者互不替代。
2.6 同源策略与 CORS
2.6.1 同源到底比什么
同源 = 协议 + 主机 + 端口,三者完全相同。
https://shop.example.com/a
https://shop.example.com/b ✅ 同源
http://shop.example.com/a ❌ 协议不同
https://api.example.com/a ❌ 主机不同
https://shop.example.com:8080/a ❌ 端口不同
几个容易忽略的边界:
- 端口必须显式一致:
https://example.com默认 443,和:443同源,和:8443不同源。 http://example.com与https://example.com不同源,即使域名相同。file://、data:、沙箱 iframe 的Origin是null(不透明源),不能做普通的跨源判断。- 同站 ≠ 同源:
a.example.com与b.example.com同站(Cookie 的SameSite够用),但跨源(要 CORS)。
同源策略限制的是读取,不是发送:
| 行为 | 是否受同源限制 |
|---|---|
用 fetch 读跨源响应 | ✅ 受限(要 CORS) |
用 <img> / <script> / <link> 加载跨源资源 | ❌ 通常不受限(但读不到内容) |
用 <iframe> 嵌入跨源页面 | ❌ 可以嵌入,但读不到 DOM |
提交跨源 <form> | ❌ 可以提交(这也是 CSRF 的基础) |
跨源 postMessage | ❌ 可以发,接收方要校验 origin |
2.6.2 简单请求与非简单请求
浏览器把跨源请求分成两类:
简单请求(不触发预检)必须同时满足:
- 方法是
GET、HEAD、POST之一; - 只带 CORS 安全列表内的请求头:
Accept、Accept-Language、Content-Language、Content-Type(且值受限)、Range(单段范围)等; Content-Type只能是application/x-www-form-urlencoded、multipart/form-data、text/plain。
非简单请求(先预检):用了 PUT/DELETE/PATCH、自定义头(如 Authorization、X-Token)、application/json 等。
非简单请求:
浏览器 ──OPTIONS /api/orders──► 服务端
Origin: https://shop.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type, authorization
◄── 204 + Allow-Origin / Allow-Methods / Allow-Headers / Max-Age
浏览器 ──POST /api/orders──────► 服务端
Origin: https://shop.example.com
◄── 200 + Allow-Origin
注意:
- 预检请求不带 Cookie(默认),所以不要在预检阶段做登录校验;
- 预检结果会被缓存,时长由
Access-Control-Max-Age决定(Chrome 有上限,通常 2 小时); - 预检失败时,真实请求根本不会发出,Network 里只能看到一条红色的
OPTIONS。
2.6.3 服务端要返回哪些头
Access-Control-Allow-Origin: https://shop.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Allow-Credentials: true
Access-Control-Expose-Headers: X-Request-Id, X-Total-Count
Access-Control-Max-Age: 7200
| 响应头 | 作用 | 常见错误 |
|---|---|---|
Allow-Origin | 允许哪些源读取 | 带 credentials 时不能用 * |
Allow-Methods | 预检允许的方法 | 漏了 PATCH/DELETE |
Allow-Headers | 预检允许的请求头 | 漏了自定义头 |
Allow-Credentials | 允许携带 Cookie/证书 | 与 * 互斥 |
Expose-Headers | 允许 JS 读取的非安全列表响应头 | 忘了它导致读不到 X-Total-Count |
Max-Age | 预检缓存时长 | 设太长导致权限变更不生效 |
默认 JS 能读的响应头只有:Cache-Control、Content-Language、Content-Length、Content-Type、Expires、Last-Modified、Pragma。其余(如 X-Request-Id、Content-Disposition)必须通过 Expose-Headers 暴露。
2.6.4 带 Cookie 的跨源请求
三件事必须同时成立:
- 前端
fetch写credentials: 'include'(或 XHRwithCredentials = true); - 服务端返回具体 Origin +
Access-Control-Allow-Credentials: true; - Cookie 本身满足
SameSite=None; Secure(跨站场景)且未被浏览器拦截。
await fetch('https://api.example.com/me', {
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
});
注意:
Access-Control-Allow-Origin: *与 credentials 互斥,浏览器会直接拒绝;- 跨源响应的
Set-Cookie只有在 CORS 校验通过且带了 credentials 时才生效; - Safari 的 ITP、Firefox 的 TCP、Chrome 的第三方 Cookie 限制,都可能让「配置全对但 Cookie 就是没有」;
- 跨站嵌入场景优先考虑 CHIPS、Storage Access API 或 BFF 同源代理。
2.6.5 CORS 错误的正确读法
CORS 失败在 JS 里表现为一个没有状态码的 TypeError:
try {
await fetch('https://api.example.com/data');
} catch (error) {
// 这里拿不到 status / response,只能看到 "Failed to fetch"
console.error(error);
}
真正的错误原因在 Console 和 Network:
| 现象 | 真实原因 |
|---|---|
Console 说 No 'Access-Control-Allow-Origin' header | 服务端没返回该头,或返回了但没匹配当前 Origin |
Console 说 credentials flag is true, but Allow-Origin is '*' | 通配符与 Cookie 冲突 |
Network 里 OPTIONS 返回 404/405 | 服务端/网关没处理预检 |
OPTIONS 200 但真实请求仍失败 | Allow-Methods / Allow-Headers 漏项 |
| 上传/下载时读不到响应头 | 缺 Expose-Headers |
| 重定向后失败 | 重定向链路上缺少 CORS 头 |
2.6.6 重定向、缓存与 Vary: Origin
- 跨源重定向:每一跳都要能通过 CORS 检查;最终响应必须带
Allow-Origin。如果重定向到登录页(不同源),通常会失败。 - CDN 缓存:同一个 URL 对不同
Origin返回不同的Allow-Origin,必须加Vary: Origin,否则会把 A 站的响应缓存给 B 站。 Origin: null:沙箱 iframe、data:URL、部分file://场景会发null;服务端不能把null当作可信来源。- 私有网络访问(PNA):公网页面访问内网地址时,Chrome 会额外要求预检带
Access-Control-Request-Private-Network,服务端回Access-Control-Allow-Private-Network: true。
2.6.7 CORS 不是 CSRF 防线
这是最需要纠正的认知:
- 简单请求可以跨站发出,服务端会收到并可能产生副作用;
- CORS 只阻止「攻击者的 JS 读取响应」,不阻止请求到达;
- 真正防 CSRF 靠:
SameSiteCookie、CSRF Token、校验Origin/Referer、不用 GET 做状态变更。
2.6.8 本地开发与网关的常见坑
| 场景 | 现象 | 解法 |
|---|---|---|
本地 localhost:3000 调测试环境 | 跨源 + Cookie 丢失 | 开发代理(同源转发)或后端加白名单 |
| 网关统一加 CORS 头,服务又加一遍 | 出现两个 Allow-Origin | 只在一层加,避免重复 |
Nginx 没转发 OPTIONS | 预检 405 | 显式处理 OPTIONS 并返回 204 |
带 Authorization 但没加 Allow-Headers | 预检失败 | 补上 Authorization |
| CDN 缓存了预检响应 | 权限变更不生效 | 预检响应不缓存,或缩短 Max-Age |
自检:为什么「接口在 Postman 里正常,在浏览器里 CORS 失败」?——Postman 不受同源策略约束;浏览器才会执行 CORS 检查。
2.7 Cookie:一条 header 如何决定登录态
Cookie 是浏览器里最「古老」也最容易出事的状态机制。它看起来只是 Set-Cookie 一行字符串,实际上涉及作用域匹配、跨站规则、安全属性、CORS 联动、缓存与性能五套机制。这一节把它拆开讲透。
一句话理解:把 Cookie 想成一张「贴在请求上的便签」——浏览器替你保管,遇到匹配的收件地址就自动贴上;服务器收到后能读,页面脚本却不一定能碰(
HttpOnly)。
2.7.1 Cookie 的本质:浏览器侧的一张表
Cookie 不是你页面里的变量,而是浏览器维护的一张键值表。每条记录大致长这样:
name = sid
value = abc123
domain = shop.example.com ← 作用域
path = /
expires = 2026-12-01T00:00:00Z ← 生命周期
secure = true ← 只走 HTTPS
httpOnly= true ← JS 不可读
sameSite= Lax ← 跨站规则
当浏览器要发一个请求时,它会自动从这张表里挑出匹配的 Cookie,拼成请求头:
GET /api/orders HTTP/1.1
Host: shop.example.com
Cookie: sid=abc123; theme=dark
三个关键认知:
- Cookie 由浏览器附加,不由 JS 附加。你写
fetch时不需要手动拼Cookie头(也拼不了,禁止设置)。 - Cookie 的作用域不是「源」,而是
domain + path。所以a.example.com和b.example.com可以共享 Cookie,即使它们是跨源。 HttpOnly只挡 JS 读取,不挡请求发送。它防的是 XSS 偷 Token,不防 CSRF。
2.7.2 一次请求会带哪些 Cookie:六道匹配
浏览器在发请求前,会按顺序过滤:
| 顺序 | 检查项 | 说明 |
|---|---|---|
| ① | Domain | 请求的 host 必须等于 Cookie 的 host(host-only),或是它的子域(domain cookie) |
| ② | Path | 请求路径必须等于 Cookie 的 path,或以 path + / 开头 |
| ③ | Secure | 标了 Secure 的 Cookie 只在 HTTPS(及 localhost)下发送 |
| ④ | 过期时间 | Expires / Max-Age 已过则删除,不发送 |
| ⑤ | SameSite | 根据「请求站点」与「顶层站点」的关系决定是否发送 |
| ⑥ | 分区键(Partitioned) | 第三方 Cookie 还要匹配顶层站点的分区 |
全部通过后,浏览器把 Cookie 拼进 Cookie 头;同名多条时,path 更长的排前面(RFC 6265 建议按 path 长度降序、创建时间升序)。
四个容易误解的点:
- 端口不参与匹配:
example.com:8080和example.com:443共享 Cookie。 - 协议只通过
Secure参与:非Secure的 Cookie 在 HTTP 和 HTTPS 下都会发送。 - 同源策略不决定 Cookie 是否发送:
a.example.com请求b.example.com是跨源,但只要是同站且 Cookie 的 domain 匹配,Cookie 照样会带上。是否「允许 JS 读响应」由 CORS 决定,这是两件事。 SameSite看的是「站点」不是「源」:a.example.com与b.example.com属于同站,它们之间的请求不受SameSite限制。
2.7.3 Domain 与 Path:作用域的两个维度
Domain:
| 写法 | 类型 | 发送范围 |
|---|---|---|
不写 Domain | host-only | 只发给当前 host,a.example.com 不会发给 b.example.com |
Domain=example.com | domain cookie | 发给 example.com 及其所有子域 |
Domain=.example.com | 同上 | 前导点会被忽略,等价于上一条 |
约束:
- 不能给「不是当前 host 后缀」的域设置 Cookie(
a.example.com不能设Domain=other.com); - 不能设公共后缀(
Domain=com、Domain=github.io会被拒绝); - 子域可以给自己和父域设 Cookie,这意味着一个被攻破的子域可以给父域「种」Cookie(cookie tossing)。
__Host-前缀就是为了防这个(见 2.7.6)。
Path:
Cookie Path=/admin
✅ /admin
✅ /admin/
✅ /admin/orders
❌ /administrator ← 不是「前缀字符串匹配」,必须命中路径边界
- 不写
Path时,默认是当前请求路径的目录:在/a/b页面写 Cookie,默认Path=/a; - 同名 Cookie 可以存在于不同 path,浏览器会都发送,服务端会收到重复的 name;
- 删除 Cookie 时,
Domain与Path必须和创建时一致,否则删不掉(这是「退出登录失败」的经典原因)。
2.7.4 生命周期:Session、Max-Age 与 Expires
Set-Cookie: sid=abc; Max-Age=86400; Secure; HttpOnly; SameSite=Lax
Set-Cookie: theme=dark; Expires=Wed, 21 Oct 2026 07:28:00 GMT; Path=/
| 写法 | 含义 |
|---|---|
不写 Max-Age / Expires | 会话 Cookie:关闭浏览器后删除(但「恢复上次会话」可能保留) |
Max-Age=86400 | 相对时间,单位秒,从收到响应时开始算 |
Expires=... | 绝对时间(HTTP 日期),受客户端时钟影响 |
| 两者都写 | Max-Age 优先 |
Max-Age=0 或负数 | 立即删除 |
Expires 是过去时间 | 立即删除 |
注意:「会话 Cookie」不等于「一定在关浏览器时消失」。很多浏览器有会话恢复功能,会把它们写盘;真正的「关闭即清」只有隐私模式能保证。
2.7.5 SameSite:跨站请求到底带不带
SameSite 回答一个问题:这个请求的「发起方站点」和「目标站点」是不是同一个站点? 不是同一个,就叫跨站。
先明确「站点」的定义:scheme + 可注册域名(eTLD+1)。
https://a.example.com → https://b.example.com 同站(example.com),跨源
http://example.com → https://example.com 跨站(scheme 不同)
https://foo.github.io → https://bar.github.io 跨站(github.io 是公共后缀)
三种取值的行为:
| 场景 | Strict | Lax | None |
|---|---|---|---|
| 从外站点链接跳转进来(顶层 GET 导航) | ❌ 不带 | ✅ 带 | ✅ 带 |
| 跨站表单 POST | ❌ | ❌ | ✅ |
跨站 iframe / 图片 / fetch | ❌ | ❌ | ✅ |
同站跨源请求(a.example.com → b.example.com) | ✅ | ✅ | ✅ |
| 地址栏直接输入、书签打开 | ✅ | ✅ | ✅ |
补充规则:
- 默认值:Chrome 80+ 把未声明
SameSite的 Cookie 当作Lax;Safari/Firefox 也有类似限制。但不要依赖默认值,显式写出来。 Lax + POST两分钟例外:Chrome 对「刚设置不超过 2 分钟、且未声明 SameSite」的 Cookie,允许跨站 POST 携带。这是为了兼容老站点,不能当作设计依据。None必须配Secure,否则浏览器直接拒收;SameSite=None在 HTTP 下无效。Strict的代价:用户从外部链接点进来时,第一次导航不带 Cookie,看起来像「没登录」;等页面内再发同站请求才恢复。对登录态要求高的场景要权衡。Lax不是 CSRF 的完整防线:它挡跨站 POST,但顶层 GET 导航仍会带 Cookie。所以「用 GET 做状态变更」依然危险。
2.7.6 安全属性:每个属性防的是什么
| 属性 | 作用 | 防什么 | 不防什么 |
|---|---|---|---|
Secure | 只在 HTTPS 发送 | 明文链路窃听 | 服务端被攻破、XSS |
HttpOnly | JS 无法通过 document.cookie 读取 | XSS 直接偷 Cookie | CSRF;SW 仍可能看到请求头 |
SameSite=Lax/Strict | 限制跨站发送 | 大部分 CSRF | 同站子域攻击;顶层 GET |
Max-Age / Expires | 限制有效期 | 长期滞留的旧凭证 | 已泄露的 Token |
Domain / Path | 限制作用域 | 跨域误发 | 同域内越权 |
Partitioned | 按顶层站点分区 | 第三方 Cookie 跨站追踪 | 同分区内的滥用 |
**前缀(prefix)**是更强的约束:
| 前缀 | 必须满足 | 目的 |
|---|---|---|
__Host- | Secure + Path=/ + 不写 Domain | 锁定到当前 host,防子域种 Cookie |
__Secure- | Secure + 从 HTTPS 页面设置 | 防降级到 HTTP 设置 |
Set-Cookie: __Host-sid=abc; Path=/; Secure; HttpOnly; SameSite=Lax
HttpOnly 与 Service Worker 的边界:HttpOnly 阻止的是页面脚本通过 document.cookie 读取;Service Worker 在 fetch 事件里能看到请求头(包括 Cookie),所以它不是对 SW 的绝对隐身。不要把「加了 HttpOnly」当成「XSS 完全无害」。
Partitioned(CHIPS):第三方 Cookie 按「顶层站点」分区存储,同一个嵌入方在 a.com 和 b.com 下拿到的是两份独立 Cookie。它需要 Secure,通常配合 SameSite=None:
Set-Cookie: widget=1; SameSite=None; Secure; Partitioned
这解决的是「第三方 Cookie 被全面拦截后,嵌入组件如何维持状态」,不是「跨站追踪」的许可。
2.7.7 Cookie 与 CORS / fetch 的联动
这是最容易混淆的一组关系,记住三句话:
- Cookie 是否发送,先由浏览器的 Cookie 规则决定;对
fetch/XHR 这类受控请求,credentials模式还能进一步要求「不带」或「允许跨源携带」。 - CORS 决定的是「跨源响应能不能被 JS 读」。
- 带 Cookie 的跨源请求,服务端必须返回具体 Origin +
Allow-Credentials: true。
fetch 的 credentials 三个值:
| 值 | 同源请求 | 跨源请求 | 典型用途 |
|---|---|---|---|
same-origin(默认) | 带 Cookie | 不带 | 大多数同源接口 |
include | 带 | 带 | 跨域登录态 |
omit | 不带 | 不带 | 公开资源、CDN |
跨域带 Cookie 的完整条件:
// 前端
await fetch('https://api.example.com/me', {
credentials: 'include',
});
Access-Control-Allow-Origin: https://shop.example.com ← 必须是具体域名
Access-Control-Allow-Credentials: true
同时:
- 目标 Cookie 必须是
SameSite=None; Secure(否则跨站请求不会带); - 如果浏览器拦截第三方 Cookie(Safari ITP、Firefox TCP、Chrome 的隐私沙箱),即使写了
include也可能带不上; - 跨源响应里的
Set-Cookie,只有在 CORS 校验通过且credentials: include时才会被接受; - 预检请求(
OPTIONS)默认不带 Cookie,不要指望在预检里做登录态判断。
常见错误组合:
| 现象 | 原因 |
|---|---|
| 跨域登录接口 200,但后续接口 401 | credentials 没写 include,Cookie 没带 |
浏览器报 Allow-Origin: * 与 credentials 冲突 | 服务端用了通配符 |
本地 localhost 正常,线上跨域失败 | 线上是跨站,Cookie 缺 SameSite=None; Secure |
| Safari 上登录态丢失 | ITP 拦截第三方 Cookie,或 SameSite 规则更严 |
| 预检 401 | 预检不带 Cookie,服务端却要求登录 |
2.7.8 Cookie 的性能账:每个请求都在为它买单
Cookie 会自动附加到所有匹配的请求上,包括 HTML、JS、CSS、图片、接口。后果:
- 请求头变大:4KB 的 Cookie × 50 个请求 = 200KB 额外上行流量,移动弱网下非常明显。
- CDN 缓存命中率下降:带
Cookie的请求默认更容易被当作私有内容;如果 CDN 缓存键包含 Cookie,缓存会被打散。 - 首屏变慢:HTML 请求带一堆业务 Cookie,TTFB 可能变长(尤其上行受限时)。
实践建议:
- 静态资源放独立域名(cookie-free domain),或至少把
Path限制到接口路径; - Cookie 只放会话标识,用户信息、权限列表、埋点参数不要塞进去;
- CDN 配置
Vary: Cookie要谨慎(会显著降低命中率),更常见的是「HTML 不缓存 / 接口按需缓存」; - 定期清理无用 Cookie,注意同名不同 path 的「僵尸 Cookie」。
2.7.9 前端读写 Cookie 的正确姿势
/**
* 读取指定名称的 Cookie(读不到 HttpOnly)。
* @param {string} name Cookie 名
* @returns {string|null} 解码后的值
*/
function getCookie(name) {
const target = `${encodeURIComponent(name)}=`;
const item = document.cookie
.split('; ')
.find((part) => part.startsWith(target));
return item ? decodeURIComponent(item.slice(target.length)) : null;
}
/**
* 写入 Cookie。
* @param {string} name Cookie 名
* @param {string} value Cookie 值
* @param {{maxAge?: number, path?: string, domain?: string, sameSite?: 'Lax'|'Strict'|'None', secure?: boolean}} [options]
*/
function setCookie(name, value, options = {}) {
const { maxAge, path = '/', domain, sameSite = 'Lax', secure = location.protocol === 'https:' } = options;
const parts = [
`${encodeURIComponent(name)}=${encodeURIComponent(value)}`,
`Path=${path}`,
`SameSite=${sameSite}`,
];
if (domain) parts.push(`Domain=${domain}`);
if (typeof maxAge === 'number') parts.push(`Max-Age=${maxAge}`);
if (secure) parts.push('Secure');
document.cookie = parts.join('; ');
}
/**
* 删除 Cookie:Domain 与 Path 必须和创建时一致。
* @param {string} name Cookie 名
* @param {{path?: string, domain?: string}} [options] 创建时的作用域
*/
function removeCookie(name, options = {}) {
const { path = '/', domain } = options;
const parts = [`${encodeURIComponent(name)}=`, `Path=${path}`, 'Max-Age=0'];
if (domain) parts.push(`Domain=${domain}`);
document.cookie = parts.join('; ');
}
注意:
document.cookie返回的是所有非 HttpOnly Cookie 的拼接字符串,解析要自己写;- 写入时同名同 path 会覆盖,不同 path 会新增;
- JS 无法设置
HttpOnly,也无法读取它; - 新版浏览器提供异步的 Cookie Store API(
cookieStore.get/set),Service Worker 里也能用,但兼容性还不如document.cookie。
调试:Application → Cookies 能看到每个 Cookie 的 Domain、Path、Expires、Secure、HttpOnly、SameSite、Partitioned;Network → 请求的 Cookies 标签能看到「这次实际带了哪些」。
2.7.10 什么时候不该用 Cookie
| 场景 | 更合适的选择 | 原因 |
|---|---|---|
| 大量结构化数据、离线缓存 | IndexedDB / OPFS | Cookie 只有 4KB 且每个请求都带 |
| 纯前端的 UI 偏好 | localStorage | 不需要发给服务端 |
| 跨域开放 API 的鉴权 | Authorization: Bearer + 短过期 + 刷新机制 | 不依赖第三方 Cookie,但要注意 XSS 风险 |
| 静态资源 | 独立 cookieless 域名 | 避免无谓的请求头与缓存污染 |
| 第三方嵌入组件 | CHIPS / Storage Access API / BFF 代理 | 第三方 Cookie 正在被系统性限制 |
2.7.11 安全基线(登录态)
一个「及格」的登录 Cookie 至少长这样:
Set-Cookie: __Host-sid=<random>; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=604800
再叠加:
- 服务端校验
Origin/Referer(防 CSRF 的补充); - 状态变更接口用 POST/PUT/DELETE,并带 CSRF Token 或双提交 Cookie;
- 登录成功后轮换 Session ID(防会话固定);
- 退出登录时同时删 Cookie(注意 Domain/Path)与服务端会话;
- 敏感操作二次验证,不把权限判断只放在前端。
2.7.12 自检
- 为什么
a.example.com的 Cookie 会发给b.example.com?——同站 +Domain=example.com,与同源策略无关。 SameSite=Lax能完全防 CSRF 吗?——不能,顶层 GET 导航仍会带 Cookie;状态变更必须用非 GET + Token/Origin 校验。HttpOnly能防 XSS 吗?——不能,它只阻止 JS 读 Cookie;XSS 仍可发起请求、篡改页面。- 跨域带 Cookie 为什么必须写
credentials: 'include'?——默认same-origin,跨源请求不会带凭证;同时服务端不能用*。 - 删不掉 Cookie 的常见原因?——
Domain/Path与创建时不一致,或 HttpOnly Cookie 只能由服务端删。
2.8 存储进阶:配额、驱逐与离线
2.8.1 六种存储的完整对照
| 存储 | 容量量级 | 同步/异步 | 作用域 | 生命周期 | 适合 |
|---|---|---|---|---|---|
| Cookie | 4KB/条,约 180 条/域 | 同步(且随请求发送) | domain + path | 可设置过期 | 会话标识、少量服务端可读状态 |
localStorage | 约 5–10MB/源 | 同步 | 源 | 永久(除非清理/驱逐) | 轻量偏好、草稿 |
sessionStorage | 约 5–10MB/源 | 同步 | 标签页 + 源 | 标签页关闭即清 | 单次会话的临时状态 |
| IndexedDB | 配额制,可达数百 MB | 异步 | 源 | 永久(除非清理/驱逐) | 结构化数据、离线业务数据 |
| Cache Storage | 共享配额 | 异步 | 源 | 永久(除非清理/驱逐) | Service Worker 静态资源 |
| OPFS | 共享配额 | 异步(Worker 里可同步) | 源 | 永久(除非清理/驱逐) | 大文件、二进制、高性能读写 |
选型一句话:要发给服务端的用 Cookie;少量键值用 localStorage;结构化/大量数据用 IndexedDB;静态资源用 Cache Storage;大文件/高性能用 OPFS。
2.8.2 localStorage:方便,但别当数据库
// 只能存字符串;对象要序列化
localStorage.setItem('theme', 'dark');
const theme = localStorage.getItem('theme'); // 'dark' | null
localStorage.setItem('user', JSON.stringify({ id: 1 }));
const user = JSON.parse(localStorage.getItem('user') ?? '{}');
// 监听其他标签页的修改(当前页面不会触发)
window.addEventListener('storage', (event) => {
console.log(event.key, event.oldValue, event.newValue, event.url);
});
四个硬限制:
- 同步 API:读写都在主线程,读一个几 MB 的字符串会卡住页面;部分实现还可能走同步 IPC。
- 只能存字符串:二进制要转 Base64,体积膨胀 33%。
- 没有索引/查询:只能全量读出再过滤。
- 不是永久存储:浏览器在存储压力、隐私模式、用户清理时都可能删除;Safari 对长期不访问的站点也会清理脚本可写存储。
localStorage 与 sessionStorage 的区别:
| 维度 | localStorage | sessionStorage |
|---|---|---|
| 共享范围 | 同源的所有标签页 | 仅当前标签页 |
| 刷新 | 保留 | 保留 |
| 新标签打开同源页面 | 共享 | 不共享(window.open 可能复制一份初始值) |
| 关闭标签 | 保留 | 清除 |
2.8.3 IndexedDB:异步、事务、索引
/**
* 打开数据库并按需建表。
* @param {string} name 数据库名
* @param {number} version 版本号(必须递增)
* @returns {Promise<IDBDatabase>}
*/
function openDB(name, version) {
return new Promise((resolve, reject) => {
const request = indexedDB.open(name, version);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains('orders')) {
const store = db.createObjectStore('orders', { keyPath: 'id' });
store.createIndex('byStatus', 'status', { unique: false });
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
request.onblocked = () => reject(new Error('旧版本页面未关闭,升级被阻塞'));
});
}
关键机制:
- 事务(transaction):
readonly/readwrite;事务内的操作要么全成功,要么全回滚。 - 事务会自动关闭:如果在事务里
await一个与 IndexedDB 无关的 Promise,事务可能已经提交,后续操作报TransactionInactiveError。不要在事务中间做网络请求。 - 索引:按字段建
index才能高效查询;否则只能游标全表扫。 - 游标 vs
getAll:大数据量用游标分批处理,避免一次性把几百 MB 读进内存。 - 版本迁移:
onupgradeneeded里做结构变更;线上要兼容旧版本数据。 onblocked:另一个标签页还开着旧版本时,升级会卡住;需要提示用户关闭其他标签页。
2.8.4 Cache Storage 与 OPFS
Cache Storage 是「Request → Response」的缓存,专为 Service Worker 设计:
const cache = await caches.open('assets-v1');
await cache.addAll(['/app.js', '/app.css']);
const hit = await cache.match('/app.js');
它适合存静态资源,不适合存业务对象(没有索引、没有事务)。注意 cache.addAll 是「全成功或全失败」,有一个 404 就整体失败;Cache API 只支持 GET 请求,POST 不能缓存;opaque 响应可以存,但读不到状态码和响应体。
OPFS(Origin Private File System) 给源一块私有文件系统,适合大文件与高性能读写:
// 在主线程或 Worker 中获取根目录
const root = await navigator.storage.getDirectory();
const handle = await root.getFileHandle('data.bin', { create: true });
// Worker 中可用同步句柄,性能更好(主线程不可用)
const access = await handle.createSyncAccessHandle();
access.write(new Uint8Array([1, 2, 3]), { at: 0 });
access.flush();
access.close();
它的优势是「像文件一样读写」,不用把整个文件塞进内存;适合日志、缓存分片、离线媒体。兼容性不如 IndexedDB,使用前要特性检测。
2.8.5 配额、驱逐与持久化
// 查看已用与配额
const { usage = 0, quota = 0 } = await navigator.storage.estimate();
console.log(`已用 ${(usage / 1024 / 1024).toFixed(1)}MB / 配额 ${(quota / 1024 / 1024).toFixed(0)}MB`);
// 申请持久化:降低被驱逐的概率(不保证 100%)
const persisted = await navigator.storage.persist();
console.log('持久化:', persisted);
驱逐规则(各浏览器略有差异,但方向一致):
- 配额紧张时,浏览器按「最近最少使用」等策略清理非持久化的源数据;
- 同一源下的 IndexedDB、Cache Storage、OPFS 共享配额;
- 持久化存储(persisted)不会被自动驱逐,但用户手动清理、隐私模式仍会清;
- 隐私模式配额更小,关闭窗口即清空;
- Safari 对「长期不访问的站点」有额外清理策略。
工程结论:
- 启动时申请
persist(); - 每次读取都处理「数据不在了」的情况,不要假设一定存在;
- 关键数据要有服务端兜底或可重建;
- 版本迁移要有回滚/兼容逻辑。
2.8.6 存储分区(Storage Partitioning)
为了防止第三方跨站追踪,浏览器会把第三方上下文的存储按「顶层站点」分区:
在 a.com 里嵌入 widget.com 的 iframe:
widget.com 的 localStorage / IndexedDB → 分区键 (a.com, widget.com)
在 b.com 里嵌入同一个 widget:
→ 另一份独立存储,读不到 a.com 里的那份
影响:
- 第三方嵌入组件的登录态、偏好设置不再跨站共享;
- 需要跨站状态时用 Storage Access API(
document.requestStorageAccess())或 CHIPS; - 不要假设「同一个 iframe 域名在所有站点共享一份存储」。
2.8.7 跨标签页通信
| 方式 | 特点 | 适用 |
|---|---|---|
storage 事件 | 同源、其他标签页修改 localStorage 时触发 | 简单的状态同步(登录态、主题) |
BroadcastChannel | 同源、任意标签页/Worker 广播 | 多标签协作、消息总线 |
SharedWorker | 同源共享一个 Worker 实例 | 需要共享状态的复杂场景(兼容性一般) |
postMessage | 跨窗口/iframe,需校验 origin | 嵌入页面通信 |
const channel = new BroadcastChannel('app');
channel.onmessage = (event) => console.log('收到', event.data);
channel.postMessage({ type: 'logout' });
2.8.8 安全与调试
- XSS 能读
localStorage/ IndexedDB / Cache Storage,所以不要把长期 Token、密钥放进去;登录态优先HttpOnlyCookie。 - 不要用
localStorage存权限判断结果:前端存储可被用户篡改,权限必须服务端校验。 - 调试:Application → Storage 面板能看各存储的键值、占用;
Clear site data可清空当前源;navigator.storage.estimate()看配额。
自检:为什么「
localStorage读写很快」不等于「适合放大数据」?——单次读写是同步的,数据越大阻塞主线程越久;而且没有索引,查询只能全量扫描。
2.9 Service Worker:让页面离线可用
2.9.1 它到底是什么
Service Worker(SW)是浏览器在页面之外维护的一个事件驱动 Worker,充当「网络代理」:
页面 fetch ──► SW 的 fetch 事件 ──► 缓存命中?直接返回
└─ 未命中?转发网络
它能做:
- 拦截作用域内的请求,做缓存策略;
- 离线可用(配合 Cache Storage);
- 后台同步(Background Sync)、推送通知(Push);
- 在页面关闭后仍能处理部分事件(但随时可能被终止)。
它不能做:
- 访问 DOM、
window、localStorage(SW 里没有); - 长期驻留内存(空闲会被终止,状态要放 IndexedDB);
- 拦截不在作用域内的请求;
- 在 HTTP 下注册(
localhost例外)。
2.9.2 生命周期:install → waiting → activate
register('/sw.js')
│
▼
install ──► installed(等待旧页面关闭)──► activating ──► activated
│ ▲ │
│ └── skipWaiting() 可跳过等待 │
└── 失败 ──► redundant(丢弃) └── fetch 事件
| 阶段 | 做什么 | 常见坑 |
|---|---|---|
install | 预缓存静态资源 | addAll 有一个 404 就整体失败 |
waiting | 新 SW 等旧页面全部关闭 | 用户一直不关页面,永远不更新 |
activate | 清理旧缓存、接管页面 | 要 clients.claim() 才能立即控制现有页面 |
fetch | 拦截请求 | 缓存策略写错会导致旧页面/旧接口 |
2.9.3 更新流程与「用户一直看到旧版本」
浏览器检查 SW 脚本是否变化的时机:导航时、register() 时、registration.update() 时;它有自己的节流(通常不超过 24 小时检查一次)。
发现字节变化后:
- 下载新脚本 →
install; - 进入
waiting,不立即接管(因为旧页面可能还在用旧资源); - 所有旧页面关闭后 →
activate; - 新页面才由新 SW 控制。
「一直看到旧版本」的三个根因:
- 旧 SW 还在控制页面(waiting 没结束);
- 缓存策略是 cache-first,且缓存名没变,永远命中旧缓存;
- HTML 被 SW 或 CDN 缓存了,用户根本没拿到新入口。
标准解法:
// 页面侧:发现新 SW 在等待,提示用户刷新
navigator.serviceWorker.register('/sw.js').then((registration) => {
registration.addEventListener('updatefound', () => {
const worker = registration.installing;
worker?.addEventListener('statechange', () => {
if (worker.state === 'installed' && navigator.serviceWorker.controller) {
showUpdateToast(); // 提示「有新版本,点击刷新」
}
});
});
});
// SW 侧:缓存名带版本;按需跳过等待
const CACHE = 'app-v2026-11-02';
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE).then((cache) => cache.addAll(['/', '/app.js', '/app.css']))
);
// 谨慎:skipWaiting 会让新 SW 立即接管,旧页面可能加载到不匹配的资源
// self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(
(async () => {
const keys = await caches.keys();
await Promise.all(keys.filter((key) => key !== CACHE).map((key) => caches.delete(key)));
await self.clients.claim();
})()
);
});
2.9.4 五种缓存策略
| 策略 | 行为 | 适合 | 风险 |
|---|---|---|---|
| Cache First | 先查缓存,未命中再网络 | 带 hash 的静态资源 | 缓存不更新会一直旧 |
| Network First | 先网络,失败回缓存 | 接口、HTML | 弱网慢;要设超时 |
| Stale-While-Revalidate | 先返回缓存,同时后台更新 | 变化不频繁的资源 | 用户可能看到一帧旧数据 |
| Network Only | 只走网络 | 支付、实时数据 | 离线不可用 |
| Cache Only | 只走缓存 | 预缓存的离线页 | 未命中就失败 |
一个可用的组合:
self.addEventListener('fetch', (event) => {
const { request } = event;
if (request.mode === 'navigate') {
// HTML:网络优先,失败回退离线页
event.respondWith(
fetch(request).catch(() => caches.match('/offline.html'))
);
return;
}
// 静态资源:缓存优先 + 后台更新
event.respondWith(
caches.match(request).then((cached) => {
const network = fetch(request)
.then((response) => {
const copy = response.clone();
caches.open(CACHE).then((cache) => cache.put(request, copy));
return response;
})
.catch(() => cached);
return cached ?? network;
})
);
});
2.9.5 导航预加载(Navigation Preload)
SW 启动本身需要时间;如果每次都等 SW 起来再发网络请求,首屏会变慢。navigationPreload 让浏览器在启动 SW 的同时并行发请求:
// activate 阶段启用
self.addEventListener('activate', (event) => {
event.waitUntil(self.registration.navigationPreload.enable());
});
// fetch 阶段读取预加载响应
self.addEventListener('fetch', (event) => {
if (event.request.mode === 'navigate') {
event.respondWith(
(async () => {
const preload = await event.preloadResponse;
return preload ?? fetch(event.request);
})()
);
}
});
2.9.6 常见坑清单
| 坑 | 后果 | 解法 |
|---|---|---|
| 缓存了 HTML 且 cache-first | 用户永远旧版本 | HTML 用 network-first;缓存名版本化 |
install 里缓存了不存在的资源 | SW 安装失败 | 校验资源;用 Promise.allSettled 容错 |
在 SW 里用 window / localStorage | 直接报错 | 用 self、IndexedDB |
| 依赖 SW 全局变量保存状态 | 空闲被终止后丢失 | 状态放 IndexedDB |
fetch 里没处理跨域/opaque 响应 | 缓存了错误内容 | 判断 response.ok / response.type |
| 开发时旧 SW 干扰 | 改了代码不生效 | DevTools 勾「Update on reload」/「Bypass for network」 |
忘记处理 activate 清理 | 缓存越积越多 | 按版本删旧 cache |
调试:Application → Service Workers 可以看状态、强制更新、注销、模拟离线;Cache Storage 可以逐条查看缓存内容。
自检:为什么 SW 更新后用户还是旧页面?——新 SW 停在 waiting + 旧缓存未失效 + HTML 被缓存,三者常同时出现。
2.10 Web Worker 基础
2.10.1 三种 Worker
| 类型 | 生命周期 | 共享范围 | 典型用途 |
|---|---|---|---|
| Dedicated Worker | 绑定创建它的页面,页面关闭即结束 | 仅当前页面 | 大计算、解析、图像处理 |
| Shared Worker | 同源多个页面共享一个实例 | 同源页面/iframe | 多标签共享状态(兼容性一般) |
| Service Worker | 事件驱动,空闲会被终止 | 作用域内的页面 | 离线缓存、拦截请求、推送 |
这一节主要讲 Dedicated Worker;Service Worker 见 2.9。
2.10.2 最小可用示例
// 主线程
const worker = new Worker('/worker.js', { type: 'module' });
worker.postMessage({ cmd: 'parse', text: bigText });
worker.onmessage = (event) => render(event.data);
worker.onerror = (event) => console.error('Worker 出错', event.message);
// worker.js
self.onmessage = (event) => {
const { cmd, text } = event.data;
if (cmd === 'parse') {
const result = heavyParse(text);
self.postMessage(result);
}
};
type: 'module'支持import;经典 Worker 用importScripts()。- Worker 里的全局对象是
self,没有window。 - 主线程与 Worker 通过消息通信,不共享普通 JS 对象。
2.10.3 能做什么、不能做什么
| Worker 里可用 | Worker 里不可用 |
|---|---|
self、postMessage、onmessage | window、document、DOM 操作 |
fetch、XMLHttpRequest、WebSocket | localStorage、sessionStorage |
indexedDB、caches | alert / confirm / prompt |
crypto、performance | 直接访问页面里的变量/函数 |
OffscreenCanvas、WebAssembly | 父页面的 DOM 引用 |
importScripts(经典) / import(模块) | 同步阻塞主线程的 API |
所以 Worker 的正确姿势是:主线程收集数据 → 发给 Worker 计算 → Worker 回传结果 → 主线程更新 DOM。
2.10.4 消息传递的成本:结构化克隆与 Transferable
postMessage 默认使用结构化克隆(structured clone),会把对象深拷贝一份:
worker.postMessage({ list: hugeArray }); // 复制,数据越大越慢
不可克隆的内容会抛 DataCloneError:函数、DOM 节点、Error 对象(部分浏览器)、带循环引用的自定义类实例等。
大块二进制(ArrayBuffer、ImageBitmap、OffscreenCanvas)可以用转移(transfer),避免复制:
const buffer = new ArrayBuffer(1024 * 1024 * 50);
// 第二个参数是「转移列表」:转移后主线程的 buffer 会被置空(byteLength = 0)
worker.postMessage({ buffer }, [buffer]);
// 需要共享内存时用 SharedArrayBuffer(必须跨源隔离)
const shared = new SharedArrayBuffer(1024);
worker.postMessage({ shared });
SharedArrayBuffer + Atomics 能实现真正的共享内存与原子操作,但:
- 需要 COOP/COEP 进入跨源隔离;
- 需要自己处理竞态与内存序;
Atomics.wait()不允许在主线程调用;- 调试困难,大多数场景用「消息 + Transferable」就够了。
2.10.5 生命周期与错误处理
// 主动结束
worker.terminate();
// Worker 内部主动退出
self.close();
// 结构化克隆失败时触发
worker.onmessageerror = (event) => console.error('消息反序列化失败', event);
terminate()是强制结束,Worker 里的清理逻辑不会执行;重要状态要提前回传。- Worker 的异常默认不会冒泡到主线程的
window.onerror,必须监听worker.onerror。 - 频繁创建/销毁 Worker 有启动成本(加载脚本、初始化上下文),大量短任务应使用 Worker 池(见 3.6)。
2.10.6 什么时候该用 Worker
| 适合 | 不适合 |
|---|---|
| 超过 50ms 的纯计算(加密、压缩、解析) | 需要频繁读写 DOM 的逻辑 |
| 大文件/大 JSON/CSV 处理 | 很短的同步任务(启动成本大于收益) |
| 图像/音视频处理(配合 OffscreenCanvas) | 依赖页面全局状态的逻辑 |
| 持续运行的心跳、同步、轮询 | 需要访问 localStorage 的逻辑 |
一个常见误区:把「所有耗时代码」都塞进 Worker 就万事大吉。Worker 与主线程之间的数据传输同样可能很贵;如果每帧都要传几十 MB,瓶颈只是从「计算」变成了「序列化 + 复制」。
2.10.7 调试
- DevTools → Sources → 左侧 Threads / Workers 可以选到 Worker 的脚本下断点;
- Worker 里的
console.log会输出到主页面 Console(带 Worker 标识); chrome://inspect可以附加到 Shared Worker / Service Worker;- 性能分析时,Performance 面板会单独显示 Worker 轨道。
自检:为什么 Worker 里不能操作 DOM?——DOM 属于渲染进程主线程的文档树,Worker 是独立执行上下文,只能通过消息把结果交回主线程处理。
2.11 往返缓存(BFCache)
2.11.1 它和 HTTP 缓存的区别
| 机制 | 缓存什么 | 恢复时 | 触发 |
|---|---|---|---|
| HTTP 缓存 | 响应字节(HTML/JS/CSS/图片) | 重新解析、重新执行 JS | 再次请求同一 URL |
| BFCache | 整个页面实例:DOM、JS 堆、事件监听、定时器状态 | 直接「解冻」,不重新执行 | 前进/后退、部分跨页导航 |
所以 BFCache 恢复是近乎瞬时的,而且页面里的变量、滚动位置、表单内容都还在。这对「后退」体验提升非常明显,尤其在移动端。
2.11.2 页面怎么知道自己是「恢复」还是「新加载」
window.addEventListener('pageshow', (event) => {
if (event.persisted) {
// 从 BFCache 恢复:内存状态还在,但数据可能过期
refreshData();
reconnectRealtime();
} else {
// 正常首次加载
init();
}
});
window.addEventListener('pagehide', (event) => {
// 离开页面:可能进入 BFCache(persisted=true),也可能是真正卸载
saveDraft();
closeRealtimeConnection();
});
event.persisted 是唯一可靠的判断依据;不要用 performance.navigation.type(已废弃)。
2.11.3 哪些情况会让页面「失去资格」
| 原因 | 说明 | 处理 |
|---|---|---|
监听了 unload | 最常见;unload 本身已废弃 | 删除,改用 pagehide |
监听了 beforeunload | Chrome 中会直接失去 BFCache 资格 | 只在必要时用;考虑改用页面内提示 |
主文档 Cache-Control: no-store | 浏览器不会保留页面 | 改成 no-cache(可缓存但每次验证) |
未关闭的 WebSocket / WebRTC | 部分浏览器不允许带活动连接进入缓存 | 在 pagehide 关闭,pageshow 重连 |
| 未完成的 IndexedDB 事务 | 部分场景会阻止进入 | 避免长事务;离开前提交/中止 |
Clear-Site-Data 响应头 | 明确要求清数据 | 非必要不用 |
| 页面使用了某些会阻止缓存的 API | 视浏览器而定 | 用 DevTools 的 BFCache 测试定位 |
注意:no-store 与 no-cache 不是一回事。前者禁止任何缓存(包括 BFCache),后者允许缓存但每次要验证。很多团队为了「HTML 永远最新」直接上 no-store,代价是牺牲了后退体验。
2.11.4 怎么验证
Chrome DevTools → Application → Back/forward cache → Test back/forward cache。它会告诉你:
- 页面是否有资格进入 BFCache;
- 如果没有,具体是哪个原因(
unload监听、no-store、连接未关闭等)。
也可以手动测:打开页面 → 跳到另一个页面 → 点后退 → 看 Network 是否重新请求文档、Console 是否重新打印初始化日志。
2.11.5 支持 BFCache 的正确姿势
- 移除
unload,把清理逻辑放到pagehide/visibilitychange; beforeunload只用于「真有未保存内容」,不要用它做埋点或清理;- 在
pagehide关闭长连接、暂停媒体、保存草稿; - 在
pageshow(persisted)重新拉数据、重连、恢复计时器; - HTML 不要用
no-store,用no-cache+ ETag/Last-Modified; - 不要假设定时器在冻结期间继续走:恢复后
Date.now()可能跳变,用时间戳校正; - 埋点注意:从 BFCache 恢复不会重新触发
load,需要监听pageshow补报。
2.11.6 BFCache、freeze 与 discarded 的关系
正常页面
│ 切后台
▼
hidden →(长时间/省内存)→ frozen →(内存压力)→ discarded
│ │
│ 前进/后退 │ 用户点回来
▼ ▼
BFCache(保留状态) 重新加载(状态丢失)
- BFCache:为了「后退」保留整页,恢复时
pageshow.persisted = true; - frozen:为了省 CPU/电,暂停页面;
freeze/resume事件; - discarded:为了省内存,直接丢掉页面;恢复时重新加载,
document.wasDiscarded = true。
三者可以叠加:页面可能先 hidden → frozen → 进入 BFCache,也可能被直接丢弃。
自检:为什么说
unload监听器是「有害的」?——它会让页面无法进入 BFCache,用户后退时被迫重新加载,体验和性能都变差。
2.12 fetch 与流式请求
fetch 是现代浏览器请求的默认入口,但它的「坑」集中在错误模型、凭证、流、取消四件事上。这一节按这四条展开。
2.12.1 fetch 与 XHR 的分工
| 维度 | fetch | XMLHttpRequest |
|---|---|---|
| 返回值 | Promise + Response | 事件回调 |
| 流式读取 | ✅ response.body | 部分支持(responseType) |
| 上传进度 | ❌ 不支持 | ✅ upload.onprogress |
| 请求进度 | ✅ 流式读取 | ✅ onprogress |
| 取消 | ✅ AbortController | ✅ abort() |
| 同步请求 | ❌ | ✅(已不推荐) |
| Cookie 默认 | same-origin | 同源自动带;跨源要 withCredentials |
| 4xx/5xx | 不 reject | 触发 onload,看 status |
| 超时 | 用 AbortSignal.timeout() | xhr.timeout |
选型:普通接口用 fetch;需要上传进度、或在老环境里做兼容时用 XHR;需要双向/流式上传时优先 fetch + Streams。
2.12.2 一个请求由哪些选项控制
const response = await fetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: 1 }),
mode: 'cors', // cors | same-origin | no-cors
credentials: 'same-origin', // omit | same-origin | include
cache: 'default', // no-store | reload | no-cache | force-cache | only-if-cached
redirect: 'follow', // follow | error | manual
referrerPolicy: 'strict-origin-when-cross-origin',
signal: AbortSignal.timeout(8000),
priority: 'high', // Fetch Priority(部分浏览器)
keepalive: false,
});
Response 上最常用的字段:
| 字段 | 说明 |
|---|---|
status | HTTP 状态码(CORS 失败时拿不到) |
ok | status 在 200–299 之间 |
headers | 响应头(受 Expose-Headers 限制) |
body | ReadableStream,只能读一次 |
type | basic / cors / opaque / opaqueredirect |
redirected | 是否发生过重定向 |
url | 最终 URL |
2.12.3 七个高频误区
fetch对 404/500 不 reject:必须自己判断response.ok。
const response = await fetch('/api/user');
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
- CORS 失败只给
TypeError:拿不到状态码、响应头、响应体;真正原因在 Console。 mode: 'no-cors'不是「绕过 CORS」:它返回opaque响应,JS 读不到任何内容,只适合<img>类场景。- 默认不带跨源 Cookie:跨源登录态要
credentials: 'include',服务端还要Allow-Credentials: true+ 具体 Origin。 response.body只能消费一次:需要读两次时先response.clone()(会占内存)。Content-Type不会自动猜:发 JSON 要手动设application/json;用FormData时不要手动设Content-Type,浏览器要自动加 boundary。fetch没有内置超时:不传 signal 就可能一直挂着(浏览器/代理层面的超时不可控)。
2.12.4 超时、取消与重试
/**
* 带超时的 fetch。
* @param {string} url 请求地址
* @param {RequestInit & {timeout?: number}} [options] 请求配置
* @returns {Promise<Response>}
*/
async function fetchWithTimeout(url, options = {}) {
const { timeout = 8000, ...init } = options;
try {
return await fetch(url, { ...init, signal: AbortSignal.timeout(timeout) });
} catch (error) {
if (error.name === 'TimeoutError') {
throw new Error(`请求超时(${timeout}ms)`);
}
if (error.name === 'AbortError') {
throw new Error('请求已取消');
}
throw error;
}
}
/**
* 指数退避重试(只对网络错误/5xx/429 重试)。
* @param {() => Promise<Response>} task 请求函数
* @param {number} [retries=3] 最大重试次数
* @returns {Promise<Response>}
*/
async function retry(task, retries = 3) {
let lastError;
for (let attempt = 0; attempt <= retries; attempt += 1) {
try {
const response = await task();
if (response.status < 500 && response.status !== 429) return response;
lastError = new Error(`HTTP ${response.status}`);
} catch (error) {
lastError = error;
}
// 指数退避 + 抖动,避免重试风暴
const delay = Math.min(3000, 200 * 2 ** attempt) * (0.5 + Math.random());
await new Promise((resolve) => setTimeout(resolve, delay));
}
throw lastError;
}
注意:
- 只重试幂等请求(GET/HEAD/PUT/DELETE,或服务端支持幂等键的 POST);
- 不要对 401/403/404 重试;
- 重试要有上限和总预算,避免把弱网拖成「一直转圈」。
2.12.5 流式读取:NDJSON 与 SSE
fetch 的真正优势是能边收边处理,而不是等整个响应下载完。
/**
* 逐行读取 NDJSON 流。
* @param {string} url 接口地址
* @param {(line: string) => void} onLine 每行回调
* @param {AbortSignal} [signal] 取消信号
*/
async function readNdjson(url, onLine, signal) {
const response = await fetch(url, { signal });
if (!response.ok || !response.body) throw new Error(`HTTP ${response.status}`);
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
let buffer = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += value;
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
if (line.trim()) onLine(line);
}
}
if (buffer.trim()) onLine(buffer);
}
SSE(text/event-stream)也可以用 fetch 读,这样能带 Authorization 自定义头(EventSource 不支持):
const response = await fetch('/api/stream', {
headers: { Authorization: `Bearer ${token}` },
});
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
// 按 \n\n 分事件块,再解析 event:/data:/id: 字段
流式接口的四个工程坑:
| 坑 | 现象 | 解法 |
|---|---|---|
| 代理缓冲 | 服务端明明在推,前端一直不显示 | Nginx proxy_buffering off、X-Accel-Buffering: no |
| 压缩缓冲 | gzip 攒够一块才吐 | 对 SSE 关闭压缩或调小缓冲 |
| 连接超时 | 一段时间后断开 | 心跳(注释行 : ping)+ 自动重连 |
| 背压 | 生产速度 > 消费速度,内存涨 | await reader.read() 自然背压,别无限缓存 |
2.12.6 上传:fetch 没有上传进度
需要进度条时,只能:
- 用
XMLHttpRequest的upload.onprogress; - 或者把文件分片,按「已上传分片数」估算进度。
const form = new FormData();
form.append('file', file);
form.append('biz', 'avatar');
await fetch('/api/upload', { method: 'POST', body: form });
// 不要手动设置 Content-Type:浏览器会带 boundary
大文件建议分片 + 断点续传:Blob.slice() 切片,服务端按 Content-Range 合并,客户端记录已完成分片。注意校验(hash)与并发上限。
2.12.7 缓存模式与优先级
cache 值 | 行为 |
|---|---|
default | 按 HTTP 缓存规则走 |
no-store | 绕过缓存,也不写缓存 |
reload | 绕过缓存,但会把响应写回缓存 |
no-cache | 有缓存也要向服务端验证(304 可用) |
force-cache | 有缓存直接用,哪怕过期 |
only-if-cached | 只读缓存,没有就失败(需 same-origin) |
优先级:priority: 'high' | 'low' | 'auto' 或 HTML 的 fetchpriority,用于让关键请求(LCP 图片、首屏接口)先走。不要把所有请求都设 high,那等于没设。
2.12.8 卸载时的上报
// 推荐:sendBeacon,浏览器接管发送,不阻塞卸载
navigator.sendBeacon('/api/metrics', new Blob([JSON.stringify(data)], {
type: 'application/json',
}));
// 备选:keepalive,有体积上限(约 64KB)
fetch('/api/metrics', {
method: 'POST',
body: JSON.stringify(data),
keepalive: true,
});
sendBeacon 的限制:不能自定义请求头、不能读响应、体积有限。它适合埋点,不适合业务接口。
2.12.9 错误对照表
| 现象 | 常见原因 | 排查 |
|---|---|---|
TypeError: Failed to fetch | 网络错误、CORS、混合内容、被扩展拦截 | Console 的 CORS 提示;Network 状态 |
response.ok 为 false | 4xx/5xx | 看 status 与响应体 |
Unexpected token '<' | 返回了 HTML(登录页/网关页) | 看 Content-Type 与响应前 200 字符 |
body used already | 同一个 Response 读了两次 | 先 clone() |
413 Payload Too Large | 请求体超限 | 调大限制或分片 |
415 Unsupported Media Type | Content-Type 与服务端不匹配 | 对齐 JSON/FormData |
429 Too Many Requests | 限流 | 退避重试;看 Retry-After |
net::ERR_ABORTED | 请求被取消/页面跳转 | 检查 signal 与导航逻辑 |
| 流式接口不吐数据 | 代理/压缩缓冲 | 关缓冲、加心跳 |
自检:为什么
fetch拿到 500 不抛错?——fetch只在网络层失败时 reject;HTTP 错误是「成功的响应」,必须自己检查response.ok。
2.13 L2 小结与自检
| 主题 | 关键结论 |
|---|---|
| 渲染管线 | Style → Layout → Paint → Raster → Composite;改什么决定走几步 |
| 强制布局 | 读写交错会导致 layout thrashing;批量读、再批量写 |
| 关键渲染路径 | CSS 阻塞渲染,JS 阻塞解析;defer/module 是默认选择 |
| 合成层 | 提升动画性能,但占显存;层数/层面积都要控制 |
| 事件 | 捕获→目标→冒泡;passive 让滚动不等 JS;不冒泡事件不能委托 |
| CORS | 限制读取不限制发送;带 credentials 不能用 *;预检不带 Cookie |
| Cookie | Domain/Path 决定作用域,SameSite 决定跨站,HttpOnly 防读取 |
| 存储 | 按「同步/异步、容量、是否随请求发送」选型;配额会被驱逐 |
| SW | 版本化缓存 + 更新提示;HTML 不要 cache-first |
| BFCache | 别监听 unload;在 pagehide 清理、pageshow 恢复 |
| fetch | 4xx/5xx 不 reject;流式读取要看代理缓冲;上传进度用 XHR |
自检题
- 为什么
position: fixed在某个容器里会「跟着滚动」?——祖先有transform,包含块变了。 - 带 Cookie 的跨域请求为什么被打回?——
Allow-Origin: *与 credentials 互斥。 - Service Worker 更新后为什么用户还是旧页面?——旧 SW 控制 + cache-first + 等待激活。
SameSite=Lax能防住所有 CSRF 吗?——不能,顶层 GET 导航仍带 Cookie。fetch返回 500 会进catch吗?——不会,只有网络层失败才 reject。- 为什么第三方 iframe 里的
localStorage和主站不共享?——存储按顶层站点分区。
3. L3 高级:调度、进程与架构
3.1 渲染引擎细节:主线程、合成线程与光栅线程
2.1 讲的是「有哪五个阶段」,这一节回答一个更底层的问题:这五个阶段分别跑在哪个线程上,为什么有的卡顿只影响 JS、有的却连滚动都拖下水。
一句话理解:把渲染进程想成一家餐厅。主线程是厨师,负责决定每道菜怎么做(样式、布局、绘制指令);合成线程是传菜员,负责把已经做好的菜端到客人面前(滚动、合成);光栅线程负责把菜装盘(把指令变成像素)。厨师忙不过来时,传菜员仍能把已经装好的菜端走——这就是「主线程卡了,滚动还能动」。
3.1.1 渲染进程里的线程清单
| 线程 | 主要工作 | 它被阻塞时 | 怎么观察 |
|---|---|---|---|
| 主线程(Main) | 解析 HTML/CSS、执行 JS、样式计算、布局、生成绘制指令、提交 | 页面「卡死」:点击无响应、JS 不跑、布局不更新 | Performance → Main 轨道 |
| 合成线程(Compositor) | 维护图层树与属性树、处理滚动、合成动画、输入命中测试 | 滚动与合成动画卡顿(较少见) | Performance → Compositor 轨道 |
| 光栅线程(Raster) | 把绘制指令变成像素瓦片(GPU 或 CPU) | 新内容出现白块、图片/模糊区域迟迟不上屏 | Performance → GPU / Raster 轨道 |
| IO 线程 | 接收浏览器进程的 IPC、转发网络资源给主线程 | 资源到达延迟、导航变慢 | 不直接可见 |
| Worker 线程 | 执行 new Worker() 的脚本 | 对应后台任务停住 | Performance → Worker 轨道 |
| Service Worker 线程 | 拦截 fetch、缓存管理 | 离线/缓存请求挂起 | Application → Service Workers |
关键结论:主线程只负责「算出要画什么」,不负责「把已有画面挪来挪去」。 后者交给合成线程,所以主线程忙的时候,滚动和某些动画仍能继续。
3.1.2 为什么滚动可以不走主线程
滚动在浏览器内部不是「重新布局」,而是改一个偏移量:
滚动前:图层 A 的 transform = translateY(0)
滚动后:图层 A 的 transform = translateY(-320)
这个偏移量记录在合成线程维护的**属性树(property trees)**里。合成线程拿到新的滚动偏移后,直接让 GPU 把已经光栅好的瓦片往上挪,不需要主线程重新计算样式或布局。
但这有三个前提:
- 内容已经光栅化过:新进入视口的区域如果还没瓦片,就要现场光栅,来不及就出现白块(checkerboarding)。
- 没有「非 passive」的滚动/触摸监听:如果页面在
wheel/touchmove上挂了非 passive 监听,浏览器必须先把事件送给主线程执行 JS,等它决定要不要preventDefault,滚动才能开始。 - 没有依赖主线程的滚动效果:
position: sticky、background-attachment: fixed、滚动驱动的 JS 逻辑、IntersectionObserver之外的自定义计算,都可能把工作拉回主线程。
// ❌ 非 passive:浏览器要等 JS 执行完才敢滚
window.addEventListener('wheel', onWheel);
// ✅ passive:明确告诉浏览器「我不会阻止滚动」,滚动立即开始
window.addEventListener('wheel', onWheel, { passive: true });
// ✅ 更彻底:用 CSS 接管滚动行为
// .scroller { overscroll-behavior: contain; touch-action: pan-y; }
一句话:滚动流畅不是因为「浏览器优化了滚动」,而是因为滚动被设计成不需要主线程参与。你一旦在滚动链路上加了非 passive 监听或同步布局读取,就把它拉回了主线程。
3.1.3 合成线程能做什么、不能做什么
| 能做(可合成) | 不能做(必须回主线程) |
|---|---|
| 滚动偏移 | 改 width / height / top / left / margin |
transform 动画 | 改 display / position / float |
opacity 动画 | 改文字内容、字体大小、换行 |
部分 filter 动画 | 改 background-color / box-shadow(要重绘) |
| 缩放(pinch zoom) | 触发布局的任何几何变化 |
一个极其重要的细分:transform 动画是否真的跑在合成线程,取决于谁在驱动它。
| 驱动方式 | 是否占用主线程 | 主线程卡住时动画 |
|---|---|---|
CSS transition / @keyframes(transform/opacity) | 否(合成线程驱动) | 仍能继续 |
Web Animations API(element.animate) | 否(可合成属性) | 仍能继续 |
JS requestAnimationFrame 每帧改 style.transform | 是 | 会卡,因为值要由 JS 算出来 |
JS 改 style.width / top | 是,且触发布局 | 会卡且更贵 |
这解释了一个常见困惑:「我都用 transform 了,为什么主线程一卡动画还是停?」——因为你是用 JS 每帧算 transform,主线程没空算,合成线程拿不到新值。想让动画彻底脱离主线程,要用 CSS 动画 / WAAPI,或把计算搬进 Worker 后只传关键帧。
3.1.4 分块与光栅化:白块和内存都从这里来
Chromium 把每个合成层切成 瓦片(tile),常见大小 256×256。光栅线程按「视口附近优先」把瓦片画出来,合成线程再从瓦片拼出画面。
由此产生四个可观测现象:
- 快速滚动出现白块:新区域还没光栅完。低端机、大图、复杂滤镜时最明显。
- 层越大,内存越高:一个层的显存约等于
宽 × 高 × 设备像素比² × 4 字节。一个 2000×10000 的层,在 2x 屏上约 320MB——足够让移动端直接 OOM。 - 滤镜/阴影/大渐变很贵:它们不是「贴一张图」,而是每帧可能都要重新光栅。
- 图片解码也占资源:大图缩小、频繁换图、滚动中解码,都会抢光栅线程的时间。
<!-- 屏外内容跳过布局/绘制,滚进视口再算 -->
<section style="content-visibility: auto; contain-intrinsic-size: 800px;">
...
</section>
<!-- 图片异步解码,避免解码卡住光栅 -->
<img src="/photo.jpg" decoding="async" loading="lazy" alt="" />
/**
* 等图片解码完成再插入,避免插入瞬间掉帧。
* @param {string} url 图片地址
* @param {HTMLElement} container 挂载容器
*/
async function appendDecodedImage(url, container) {
const img = new Image();
img.src = url;
await img.decode();
container.appendChild(img);
}
content-visibility: auto 的代价是:滚动条长度可能不准(用 contain-intrinsic-size 估高),且被跳过的内容无法被页内搜索/无障碍工具立即访问。它适合长列表/长文档,不适合首屏。
3.1.5 输入事件是怎么到达页面的
一次点击/滚动,浏览器大致经历:
① 合成线程做命中测试(hit test):这个坐标落在哪个图层/哪个 iframe
② 如果只是滚动,且监听器是 passive → 合成线程直接滚,不通知主线程
③ 如果需要 JS 参与 → 通过 IPC 把事件送到对应渲染进程的主线程
④ 主线程执行监听器 → 可能 preventDefault / 改 DOM
⑤ 下一帧把结果提交给合成线程 → 上屏
这解释了三件事:
- 为什么
passive: true能救滚动:它让第 ② 步可以直接发生,不必等第 ③④ 步。 - 为什么
touchmove里做重活会「拖住」页面:浏览器在等你的 JS 决定是否阻止默认行为。 - 为什么点击有「输入延迟」:事件要先排队到主线程,主线程正忙就只能等。INP 的「输入延迟」段就是这里。
几个相关 API:
// 只允许纵向平移,把横向手势交给浏览器/父容器
// CSS: touch-action: pan-y;
// 阻止滚动链(内层滚到底后不要带动外层)
// CSS: overscroll-behavior: contain;
// 指针事件:比 mouse/touch 更统一,支持笔/触摸/鼠标
element.addEventListener('pointerdown', (event) => {
element.setPointerCapture(event.pointerId);
});
3.1.6 一帧的跨线程流水线
vsync 到来
│
├─ 合成线程:决定要不要新帧(滚动/合成动画可以只更新偏移)
│
├─ 主线程(仅当需要):rAF 回调 → 样式 → 布局 → 预绘制 → 绘制指令 → 提交
│
├─ 光栅线程:把新增/失效的瓦片画成像素
│
├─ 合成线程:激活新瓦片、按属性树拼图层
│
└─ Display Compositor / GPU:输出到屏幕
两个工程含义:
- 没有主线程变更时,合成线程可以复用上一帧的瓦片,所以「只滚动」比「滚动 + 改 DOM」便宜得多。
- 提交(commit)是分界线:提交之后主线程就可以开始下一帧的工作,不必等光栅和上屏完成。这也是为什么「主线程很忙但滚动还行」是可能的。
3.1.7 滚动卡的四种归因
| 归因 | 现象 | 证据 | 修复 |
|---|---|---|---|
| 主线程长任务 | 滚动时 JS 动画停、点击无响应 | Main 轨道有 >50ms 任务 | 拆任务、移 Worker、减少滚动回调里的计算 |
| 非 passive 监听 | 手指移动了页面才动,或明显滞后 | Event Listeners 看 passive;Console 有干预警告 | 加 { passive: true },或改用 CSS |
| 光栅/解码慢 | 快速滚动出现白块、图片区域晚出现 | GPU/Raster 轨道长、Layers 层过大 | 缩小层、压缩图片、content-visibility、减少滤镜 |
| 合成成本高 | 滚动整体发涩、内存高 | Layers 面板层数多、每层面积大 | 移除无用的 will-change、合并层、减少固定背景 |
补充两种「看起来像滚动卡,其实不是」的情况:
- 滚动回调里读布局:
scroll监听里调getBoundingClientRect()/offsetTop,每帧强制同步布局,表现和掉帧一样。用rAF合并读取,或改用IntersectionObserver。 - 滚动容器嵌套 +
overscroll-behavior缺失:内层滚到底后把滚动传给外层,用户感觉「页面乱跳」。
3.1.8 合成层:什么时候建、什么时候「炸」
元素被提升为独立合成层的常见原因:
.layer {
will-change: transform; /* 显式提示 */
transform: translateZ(0); /* 经典 hack */
opacity: 0.99; /* 有动画/半透明时可能建层 */
filter: blur(0); /* 滤镜通常建层 */
position: fixed; /* 部分场景 */
/* 以及:video、canvas、跨站 iframe、3D transform */
}
层的收益是「隔离」:这一层的内容变化,不必让整页重绘;层上的 transform/opacity 动画可以交给合成线程。
层的代价是「资源」:
- 每层都要独立的光栅存储与瓦片管理;
- 层越多,合成与内存开销越大;
- 一个巨大的层(比如整页容器加了
will-change)会把「小修小补」变成「整层重光栅」。
判断方法:DevTools → Layers 面板看层数与每层内存;Performance 里看 Update Layer Tree / Composite Layers 的耗时。不要凭感觉加 will-change,它是「用内存换动画流畅度」的交易,不是免费加速。
3.1.9 实战:想做某个效果,该改哪个属性
| 目标 | 首选 | 代价 | 备注 |
|---|---|---|---|
| 位移、旋转、缩放 | transform | 合成(已建层时) | 不要用 top/left |
| 淡入淡出 | opacity | 合成 | 注意它会创建层叠上下文 |
| 改颜色 | background-color / color | 重绘 | 面积大时仍可能贵 |
| 改尺寸 | width / height | 布局 + 重绘 | 能用 scale 替代就替代(会变形) |
| 展开/收起 | grid-template-rows / height | 布局 | 或 clip-path(重绘) |
| 阴影 | box-shadow | 重绘,面积大时很贵 | 可用伪元素 + opacity 模拟 |
| 模糊 | filter: blur() | 光栅,每帧可能重算 | 移动端慎用大面积模糊 |
| 列表插入 | DocumentFragment / 批量更新 | 布局一次 | 避免循环里逐条插入 |
FLIP 技巧(First, Last, Invert, Play):先记录首尾位置,用 transform 做「反直觉的位移」,再过渡回 0。它把「改布局」变成「改合成」,适合列表重排、弹窗展开这类动画。
3.1.10 自检
- 主线程被长任务占满,为什么滚动还能动?——滚动偏移由合成线程维护,瓦片已光栅,不需要主线程重新布局。
- 为什么 JS 每帧改
transform仍会卡?——值由主线程计算,主线程没空就产不出新值;CSS/WAAPI 才能完全交给合成线程。 - 快速滚动出现白块,查哪里?——GPU/Raster 轨道与 Layers 面板,重点看层面积、图片解码、滤镜。
will-change: transform加在列表每一项上会怎样?——可能创建成百上千个层,显存暴涨、合成变慢,移动端容易 OOM。
3.2 调度:优先级与长任务
3.2.1 主线程不是「一个队列」
更准确的说法是:主线程有多个任务源(task source),浏览器按优先级和公平性挑选下一个任务。大致的优先级关系是:
用户输入(点击、键盘、指针)
> 渲染相关(rAF、样式/布局)
> 默认任务(setTimeout、网络回调、大部分 JS)
> 空闲任务(requestIdleCallback)
但这不是「绝对抢占」:一个已经开始的 JS 任务不会被中途打断,输入只能等它结束。所以长任务仍然是输入延迟的头号原因。
3.2.2 任务、微任务、渲染机会
执行一个宏任务
→ 清空微任务队列(可能被无限追加)
→ 浏览器判断是否渲染(rAF → style → layout → paint)
→ 下一个宏任务
| 让出方式 | 让出到 | 会被 4ms 钳制 | 渲染能否插入 | 适用 |
|---|---|---|---|---|
queueMicrotask / Promise.then | 微任务 | — | 不能 | 同一轮内的异步收尾 |
setTimeout(fn, 0) | 宏任务 | 嵌套 5 层后是 | 能 | 简单兼容写法 |
MessageChannel | 宏任务 | 否 | 能 | 手写调度器 |
scheduler.postTask | 宏任务(带优先级) | 否 | 能 | 需要分级 |
scheduler.yield() | 宏任务(延续优先) | 否 | 能 | 长任务分片(新 API) |
requestIdleCallback | 空闲 | — | 能(帧末) | 低优先级后台工作 |
核心结论:想让渲染发生,必须让出到宏任务;微任务再多也不会给渲染机会。
3.2.3 长任务:50ms 是条红线
- 超过 50ms 的任务会被标记为 Long Task;
- 长任务会推迟输入响应与下一帧,直接拉高 INP/TBT;
- Performance 面板里带红色角标;也可以用
PerformanceObserver采集。
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.log('长任务', entry.duration, entry.startTime);
}
});
observer.observe({ type: 'longtask', buffered: true });
拆分原则:每 50ms 主动让出一次,把大循环切成小片;每片之间给浏览器处理输入和渲染的机会。
/**
* 分片执行大数组任务,避免长时间占用主线程。
* @template T
* @param {T[]} items 待处理数据
* @param {(item: T) => void} work 单项处理逻辑
* @param {number} [budget=40] 每片的时间预算(ms)
* @returns {Promise<void>}
*/
async function runInChunks(items, work, budget = 40) {
let sliceStart = performance.now();
for (const item of items) {
work(item);
if (performance.now() - sliceStart >= budget) {
await yieldToMain();
sliceStart = performance.now();
}
}
}
/**
* 让出主线程,给输入与渲染一个机会。
* 优先用 scheduler.yield,其次 MessageChannel,最后 setTimeout。
* @returns {Promise<void>}
*/
function yieldToMain() {
if (globalThis.scheduler?.yield) {
return scheduler.yield();
}
return new Promise((resolve) => {
const channel = new MessageChannel();
channel.port1.onmessage = () => {
channel.port1.close();
resolve();
};
channel.port2.postMessage(null);
});
}
3.2.4 scheduler.postTask 与优先级
// 三个优先级:user-blocking > user-visible > background
scheduler.postTask(() => handleInput(), { priority: 'user-blocking' });
scheduler.postTask(() => renderList(), { priority: 'user-visible' });
scheduler.postTask(() => precompute(), { priority: 'background' });
// 可中止、可延迟
const controller = new AbortController();
scheduler.postTask(() => doWork(), {
priority: 'background',
signal: controller.signal,
delay: 1000,
});
controller.abort();
注意:
priority影响的是任务排队顺序,不会把已开始的任务打断;background任务在页面繁忙时可能被长期推迟,适合「不做也不影响功能」的工作;- Safari 对 Scheduler API 的支持较弱,生产要做降级。
3.2.5 isInputPending:按「有没有输入」决定是否让出
// Chrome 专有;includeContinuous 把 pointermove/scroll 也算进来
if (navigator.scheduling?.isInputPending({ includeContinuous: true })) {
await yieldToMain();
}
它解决的是「我不知道现在该不该让出」的问题:如果队列里已经有用户输入,就优先让出;没有就继续算。比「固定每 50ms 让一次」更自适应。
3.2.6 拆任务的四个层次
| 层次 | 做法 | 适合 |
|---|---|---|
| ① 减少工作量 | 虚拟列表、按需计算、缓存 | 大部分场景优先做 |
| ② 分片让出 | yieldToMain / postTask | 大循环、批量 DOM 更新 |
| ③ 移出主线程 | Web Worker、WASM | 解析、加密、图像处理 |
| ④ 移到服务端 | BFF、预计算、SSR | 首屏不需要客户端算的数据 |
不要做的事:
- 用
queueMicrotask做「让出」——它清空后仍在同一轮,渲染轮不到; - 在
setTimeout(0)里递归处理大任务——4ms 钳制会让总时长变长; - 把「必须按顺序完成」的任务拆到多个 Worker 并行——顺序与依赖会变得难维护。
自检:为什么「每 50ms 让出一次」不是固定规则?——50ms 是长任务的判定线,不是魔法数字;实际应按设备性能、任务紧急度和
isInputPending动态调整。
3.3 进程与安全模型
1.1 讲了「进程怎么隔离」,这一节讲「隔离之后,页面之间还剩哪些通道,以及怎么把它们关掉」。
3.3.1 三层边界:进程、源、站点
| 边界 | 由什么决定 | 隔离什么 | 前端能感知的后果 |
|---|---|---|---|
| 进程 | 站点实例(site instance) | 地址空间、崩溃、内存 | 跨站 iframe 独立进程,只能 postMessage |
| 源(origin) | scheme + host + port | DOM 读写、Cookie 之外的存储、CORS | 跨源读不到 DOM/响应,要 CORS |
| 站点(site) | scheme + eTLD+1 | Cookie 的 SameSite、存储分区、进程复用 | 子域同站但跨源;a.example.com ≠ b.example.com |
**沙箱(sandbox)**是进程层的兜底:渲染进程不能直接读文件系统、不能随意创建进程、系统调用受限(Linux seccomp、Windows AppContainer、macOS Seatbelt)。所以文件访问必须走 File System Access API 这类「用户授权 + 浏览器代理」的通道。
3.3.2 COOP:把「窗口引用」也隔离掉
即使两个页面不同源,window.opener 仍然可能让它们互相操作(比如把对方导航到钓鱼页)。COOP 用来切断这种引用:
Cross-Origin-Opener-Policy: same-origin
| 取值 | 效果 | 适用 |
|---|---|---|
unsafe-none(默认) | 允许与跨源窗口共享 browsing context group | 普通页面 |
same-origin-allow-popups | 自己隔离,但允许自己打开的弹窗保持引用 | OAuth 登录弹窗 |
same-origin | 只与同源窗口共享;跨源 opener 变为 null | 需要强隔离的页面 |
代价:
- 用
window.open打开的跨源页面,window.opener拿不到; - OAuth 回调依赖
opener的场景会断,需要改用postMessage+ 轮询/回调页; - 与 COEP 一起设置才进入「跨源隔离」。
3.3.3 COEP + CORP:把「跨源资源」也管起来
COEP 要求页面里的跨源子资源显式声明可被嵌入:
Cross-Origin-Embedder-Policy: require-corp
| 取值 | 效果 |
|---|---|
unsafe-none(默认) | 不限制跨源子资源 |
require-corp | 跨源资源必须带 CORP 头或通过 CORS 授权 |
credentialless | 跨源 no-cors 请求不带凭证,换取无需 CORP(部分浏览器) |
资源侧用 CORP 回应:
Cross-Origin-Resource-Policy: same-origin # 只允许同源嵌入
Cross-Origin-Resource-Policy: same-site # 允许同站
Cross-Origin-Resource-Policy: cross-origin # 允许任意站点嵌入(谨慎)
三者的关系:
COOP: same-origin ─┐
├─► 跨源隔离(cross-origin isolated)
COEP: require-corp ─┘
│
└─► 跨源资源必须:CORP 头 / CORS 头 / 同源
进入跨源隔离后,才能使用:
SharedArrayBuffer;performance.measureUserAgentSpecificMemory();- 更高分辨率的
performance.now(); - 部分高精度计时/内存 API。
上线注意:开 COEP 最容易炸的是第三方图片、字体、脚本、iframe。上线前要逐一确认它们带 CORP/CORS,或者改用 credentialless、代理到同源。
3.3.4 iframe 的 sandbox:比 X-Frame-Options 更细
<iframe
src="https://widget.example.com"
sandbox="allow-scripts allow-forms"
referrerpolicy="no-referrer"
></iframe>
| 令牌 | 放开什么 | 风险 |
|---|---|---|
allow-scripts | 允许执行 JS | 与 allow-same-origin 同开时,iframe 可移除自己的 sandbox |
allow-same-origin | 保留源,可访问同源存储 | 与 allow-scripts 同开非常危险 |
allow-forms | 允许提交表单 | 可用于钓鱼提交 |
allow-popups | 允许 window.open | 可能被滥用弹窗 |
allow-top-navigation | 允许导航顶层页面 | 可把整页跳走 |
经验:第三方内容默认全禁,只按需加;不要同时给 allow-scripts 和 allow-same-origin,除非你完全信任对方。
配套响应头:
Content-Security-Policy: frame-ancestors 'self'
X-Frame-Options: SAMEORIGIN
前者是标准做法,后者是兼容老浏览器;两者不冲突,可以同时设。
3.3.5 其它隔离开关
| 机制 | 作用 | 注意 |
|---|---|---|
Origin-Agent-Cluster: ?1 | 让 agent cluster 按源而不是按站划分 | 与 document.domain 互斥 |
Cross-Origin-Resource-Policy | 资源能否被跨源加载 | 不替代 CORS,只控制「能不能加载」 |
window.opener = null / rel="noopener" | 切断 opener 引用 | 新窗口默认已是 noopener(部分浏览器) |
postMessage(targetOrigin) | 限制消息接收方 | 接收方必须校验 event.origin |
document.domain | 放宽同源(已废弃) | 与站点隔离/Origin-Agent-Cluster 冲突,不要再用 |
3.3.6 部署检查清单
- 需要
SharedArrayBuffer吗?→ 配 COOPsame-origin+ COEPrequire-corp,并逐项验证第三方资源。 - 有 OAuth 弹窗吗?→ 用
same-origin-allow-popups,或改成回调页 +postMessage。 - 嵌入了第三方 iframe 吗?→ 加
sandbox,只开必要令牌;确认对方允许被嵌入。 - 允许被别的站点嵌入吗?→ 用
frame-ancestors精确控制,不要图省事写*。 - 还在用
document.domain吗?→ 尽快移除,改用postMessage/ CORS。 - 跨源图片/字体被 COEP 拦了吗?→ 让对方加 CORP,或走同源代理。
自检:为什么「设了 COOP/COEP 之后页面图片全挂了」?——
require-corp要求每个跨源子资源显式授权;没带 CORP/CORS 的图片会被拦。
3.4 存储体系与配额
- 配额按源分配,通常为磁盘剩余空间的一定比例。
- 同一源下 IndexedDB、Cache Storage、OPFS 共享配额。
- 隐私模式/无痕模式下配额更小,且关闭即清空。
- 存储压力下会触发
StorageManager驱逐,持久化存储(persist)不会被驱逐。
离线优先应用的设计要点:
- 启动时
persist()申请持久化; - 用 IndexedDB 存业务数据,Cache Storage 存静态资源;
- 每次读取都处理「数据可能被清除」的情况(不能假设一定在);
- 版本迁移要有
onupgradeneeded的兼容逻辑。
3.5 资源优先级
浏览器按资源类型给默认优先级(大致):
| 优先级 | 资源 |
|---|---|
| 最高 | HTML、CSS、字体(首屏) |
| 高 | 首屏图片(fetchpriority="high")、<script defer> |
| 中 | 普通图片、XHR/fetch |
| 低 | async 脚本、prefetch、屏外图片(loading="lazy") |
<!-- 明确提升 LCP 图片优先级 -->
<img src="/hero.jpg" fetchpriority="high" alt="..." />
<!-- 预连接:第三方域名 -->
<link rel="preconnect" href="https://cdn.example.com" crossorigin />
<!-- 预加载:当前页马上要用的字体 -->
<link rel="preload" href="/font.woff2" as="font" type="font/woff2" crossorigin />
⚠️ preload 没效果的四个原因:
as类型不对、缺少crossorigin、资源实际没被使用(浏览器警告「preloaded but not used」)、服务端返回的Content-Type不匹配。
3.6 Worker 架构:从「搬计算」到「架构级并发」
什么时候该用 Worker:
- 计算超过 50ms 且不依赖 DOM;
- 大批量数据解析(CSV/JSON/图片/音视频);
- 需要持续运行的逻辑(心跳、同步)。
Worker 池设计要点:
/**
* 简易 Worker 池:复用 Worker,限制并发。
* @param {string} url worker 脚本地址
* @param {number} size 池大小
*/
class WorkerPool {
constructor(url, size = navigator.hardwareConcurrency || 4) {
this.idle = [];
this.queue = [];
this.size = size;
this.url = url;
this.workers = Array.from({ length: size }, () => {
const w = new Worker(url, { type: 'module' });
w.onmessage = (e) => this._onDone(w, e.data);
return w;
});
this.idle = [...this.workers];
}
/**
* 提交任务。
* @param {unknown} payload 传给 worker 的数据
* @returns {Promise<unknown>} worker 返回结果
*/
run(payload) {
return new Promise((resolve) => {
this.queue.push({ payload, resolve });
this._next();
});
}
_next() {
if (!this.idle.length || !this.queue.length) return;
const worker = this.idle.pop();
const task = this.queue.shift();
worker._current = task;
worker.postMessage(task.payload);
}
_onDone(worker, data) {
worker._current.resolve(data);
this.idle.push(worker);
this._next();
}
}
SharedArrayBuffer + Atomics 可以做无锁共享内存,但需要 COOP/COEP 头,且调试困难。大多数场景用消息传递 + Transferable 就够了。
3.7 实时通信:WebSocket / SSE / WebTransport
| 方案 | 方向 | 优点 | 缺点 |
|---|---|---|---|
| WebSocket | 双向 | 全双工、低延迟 | 需要心跳、断线重连、无法利用 HTTP 缓存 |
| SSE(EventSource) | 服务端→客户端 | 自动重连、文本流简单 | 只能 GET、不能自定义头(需用 fetch 流替代) |
| WebTransport | 双向(QUIC) | 多流、低延迟、无序可靠可选 | 支持度较新 |
SSE 的三个实战坑:代理缓冲导致「不流式」、超时断连需重连、EventSource 不能带 Authorization(改用 fetch + ReadableStream)。
3.8 DevTools 深挖:怎么读火焰图
Performance 面板录制的读法:
- 看 FPS 条:红色表示掉帧。
- 看 Main 轨道:哪些任务超过 50ms(红色角标)。
- 切到 Bottom-Up:按「自身耗时」排序,找到最贵的函数。
- 看 Call Tree:从入口往下追调用链。
- 看 Timings:LCP、DCL、FP 标记位置。
- 看 Warnings:
Forced reflow、Layout thrashing、Long task。
Memory 面板:
- Heap snapshot:对比两次快照的
# Delta,找新增对象。 - Allocation instrumentation on timeline:看内存分配速率与 GC 频率。
- Detached elements:查找游离 DOM。
3.9 容器能力边界
| 容器 | 差异点 |
|---|---|
| WebView(iOS/Android) | 内核版本随系统,Safari 系更新慢;无部分 API |
| 小程序 | 双线程,无 DOM,setData 通信 |
| Electron | Chromium 版本锁定,可用 Node API |
| 自研容器 | API 子集,需特性检测 |
原则:特性检测优先于 UA 判断。
if ('showModal' in HTMLDialogElement.prototype) {
dialog.showModal();
} else {
fallbackOpen(dialog);
}
3.10 L3 自检
- 滚动掉帧如何区分主线程、合成、光栅?——Performance 看 Main 是否有长任务;Layers 看层数;Rendering 看 paint flashing;GPU/Raster 轨道看是否缺瓦片。
SharedArrayBuffer为什么需要额外响应头?——跨源隔离要求(COOP/COEP),防 Spectre 类攻击。- 首屏资源顺序乱,怎么用优先级 API 干预?——
fetchpriority+preload+ 移除不必要的同步脚本。 - 为什么 CSS 动画比 JS 每帧改
transform更稳?——CSS 动画可由合成线程驱动;JS 动画的值仍要主线程计算。 isInputPending/scheduler.yield解决什么问题?——让长任务在「有输入/该渲染」时主动让出,降低 INP。- 为什么一个巨大的合成层比多个小层更糟?——大层每次变化都可能整层重光栅,显存占用也按面积增长。
4. L4 专家:治理与量化
4.1 规范到实现的差异治理
建立「能力矩阵」文档:
| 能力 | 规范状态 | Chrome | Safari | Firefox | 降级方案 |
|---|---|---|---|---|---|
dialog | 标准 | ✅ | ✅ 15.4+ | ✅ | 手写模态 |
content-visibility | 标准 | ✅ 85+ | ✅ 18+ | ✅ 125+ | 虚拟列表 |
WebGPU | 标准 | ✅ 113+ | ✅ 26+ | 部分 | WebGL |
SharedArrayBuffer | 需隔离头 | ✅ | ✅ | ✅ | 消息传递 |
关键判断:不是「能不能用」,而是「不支持时的体验是否可接受」。
4.2 性能量化模型
一帧 16.7ms 的预算分配(60fps):
输入处理 ≤ 4ms
JS 执行 ≤ 8ms
样式+布局 ≤ 3ms
绘制+合成 ≤ 2ms
(留 2ms 缓冲)
120Hz 屏幕预算减半(8.3ms),这解释了「为什么高刷屏上同样的代码更容易掉帧」。
掉帧归因流程:
掉帧
├─ Main 有长任务?→ 拆任务 / 移 Worker
├─ Forced reflow?→ 批量读写
├─ 光栅化慢?→ 减少层、降低层面积、避免大图缩放
├─ 合成层爆炸?→ 移除不必要的 will-change/transform
└─ GC 抖动?→ 减少临时对象、对象池
4.3 RUM 与 Lab:两套数据不能互相替代
| 维度 | Lab(Lighthouse) | RUM(真实用户) |
|---|---|---|
| 环境 | 固定设备、模拟网络 | 真实设备/网络/地域 |
| 优点 | 可复现、可对比 | 反映真实分布 |
| 缺点 | 不代表真实用户 | 噪声大、成本高 |
| 用途 | 开发期回归、定位具体问题 | 评估真实体验、发现长尾问题 |
// 现场采集核心指标
import { onLCP, onINP, onCLS } from 'web-vitals';
onLCP((metric) => report('LCP', metric.value));
onINP((metric) => report('INP', metric.value));
onCLS((metric) => report('CLS', metric.value));
/**
* 统一上报(页面卸载时也要能发出去)。
* @param {string} name 指标名
* @param {number} value 指标值
*/
function report(name, value) {
navigator.sendBeacon('/api/metrics', JSON.stringify({ name, value }));
}
LoAF(Long Animation Frames) 能告诉你「这一帧慢,是因为哪个脚本、哪段样式」,比笼统的长任务更有用。
4.4 版本策略与灰度
- 企业环境固定浏览器版本(政企、银行网点),必须考虑旧内核。
- 渐进增强:核心功能用最稳定的 API,增强功能用新 API。
- Baseline 作为决策依据:进入 Baseline 后再作为默认方案。
- 灰度:新特性按用户比例放量,配合错误率与性能指标监控。
4.5 稳定性信号
前端能拿到的「浏览器侧」稳定性指标:
| 信号 | 采集方式 | 含义 |
|---|---|---|
| 白屏 | 渲染根节点为空 + 超时 | 资源失败/JS 崩溃 |
| JS 错误 | window.onerror / unhandledrejection | 代码缺陷 |
| 资源错误 | 捕获阶段监听 error | CDN/网络问题 |
| 崩溃 | document.wasDiscarded、visibilitychange 异常 | 内存/OOM |
| 卡死 | 心跳上报中断 | 主线程死循环 |
4.6 L4 自检
- 某特性 20% 用户设备不支持,如何设计「渐进增强 + 降级 + 度量」?——用特性检测而不是 UA 判断,核心功能保留可用降级路径(polyfill、替代交互或服务端兜底),非核心增强直接隐藏;度量降级命中率、功能完成率与错误率,并按支持/不支持分桶对比。
- 如何用现场数据证明优化有效,而不是随机波动?——用 RUM 分桶(版本、机型、网络、地域),做灰度或 A/B 对照,比较 p75/p95 与置信区间,排除活动与发布日影响;单次 Lighthouse 分数不能作为证据。
- 自研容器要裁剪标准 API,如何评审裁剪清单?——按「使用率 × 业务影响 × 替代成本」逐项打分,标注兼容性与降级方案;安全相关能力(CSP、CORS、存储隔离)不裁;先灰度收集线上报错并公告弃用周期再定稿。
5. 综合实战:排查一次「白屏 3 秒」
现象:用户反馈首屏空白 3 秒后才出现内容,部分机型永久白屏。
步骤 1 · 先确定是「未渲染」还是「渲染了但看不见」
// 在控制台检查根节点是否有内容
document.getElementById('root').childElementCount; // 0 表示没渲染
getComputedStyle(document.getElementById('root')).opacity; // 0 可能是动画没结束
步骤 2 · 分链路排查
| 可能原因 | 验证方式 | 修复 |
|---|---|---|
| HTML 未返回 | Network 看 TTFB | 服务端/网关优化 |
| JS 加载失败 | Network 看 404/超时 | CDN、重试、fallback |
| JS 执行报错 | Console / error 监控 | 修 bug、加错误边界 |
| 资源阻塞 | Performance 看长任务 | 拆包、延后非关键 JS |
| 样式未加载 | 页面无样式闪烁 | 关键 CSS 内联 |
| SSR 水合失败 | 控制台 hydration 警告 | 排查不一致 |
| 低端机太慢 | RUM 按机型切片 | 降级、减少首屏 JS |
步骤 3 · 用数据定位长尾
// 上报「首屏可见时间」,按机型/网络/版本切片
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.name === 'first-contentful-paint') {
report('FCP', entry.startTime);
}
}
}).observe({ type: 'paint', buffered: true });
步骤 4 · 加固
- JS 加载失败重试(带指数退避);
- 骨架屏 + 超时兜底文案;
- 错误边界 + 白屏检测(根节点为空 3s 后上报并展示重试按钮);
- 关键资源
preload,非关键资源延后。
6. 常见误区对照表
| 误区 | 事实 |
|---|---|
「transform 一定不触发重排」 | 它不触发布局,但未被合成时仍要重绘;还会改变 fixed 后代的包含块 |
「localStorage 可以当数据库」 | 同步 API 阻塞主线程,容量小且无索引 |
| 「CORS 是前端要解决的问题」 | 浏览器是执行者,服务端响应头才是根因;CORS 也不防 CSRF |
| 「同站就是同源」 | 同站只看 scheme + eTLD+1;端口/子域不同仍是跨源,仍要 CORS |
「HttpOnly 能防 CSRF」 | 它只阻止 JS 读 Cookie,请求照样会带;CSRF 要靠 SameSite + Token/Origin 校验 |
「SameSite=Lax 能防所有 CSRF」 | 顶层 GET 导航仍会带 Cookie;状态变更不能用 GET |
「fetch 遇到 500 会 reject」 | 只有网络层失败才 reject;4xx/5xx 要自己看 response.ok |
| 「每个标签页一定是一个进程」 | 同站点实例可能复用进程;跨站 iframe 通常独立进程 |
| 「Service Worker 能拦截所有请求」 | 只作用于其作用域内的请求;首次注册前不生效 |
| 「UA 判断是可靠的兼容手段」 | UA 可伪造且不断冻结,应使用特性检测 |
| 「Lighthouse 分数高就等于快」 | 实验室模拟,真实分布要看 RUM |
「will-change 加上就更快」 | 长期挂着占显存,动画结束要移除;层爆炸反而更卡 |
「unload 里做清理是好习惯」 | 它会破坏 BFCache,已废弃 |
7. 面试高频题(附答题思路)
Q1:从输入 URL 到页面展示发生了什么? 分四段答:网络(DNS/TCP/TLS/HTTP)、解析(HTML→DOM,CSS→CSSOM)、渲染(Style/Layout/Paint/Composite)、交互(JS 执行与水合)。每段给一个可观测点。
Q2:重排和重绘的区别?如何减少? 管线阶段差异 → 触发属性列表 → 优化手段(transform/opacity、批量读写、contain、content-visibility)。
Q3:为什么 JS 会阻塞渲染?
主线程是单线程,JS 执行时无法同时做样式计算与布局;<script> 还会阻塞 HTML 解析。解法:defer/async、拆任务、移 Worker。
Q4:CORS 的预检请求什么时候触发?
非简单请求(自定义头、非安全方法、application/json);预检结果可缓存(Max-Age);带 credentials 时不能用通配符。
Q5:Service Worker 的更新流程? install → waiting → activate;旧页面关闭后新 SW 才接管;用版本化缓存 + 提示刷新解决「看到旧版本」。
Q6:Cookie 的 SameSite / HttpOnly / Secure 各防什么?
Secure 防明文传输;HttpOnly 防 JS 读取(挡 XSS 偷 Token,不挡 CSRF);SameSite 限制跨站发送(挡大部分 CSRF,但顶层 GET 仍会带)。再补一句:跨域带 Cookie 还需要 CORS 的 Allow-Credentials + 具体 Origin。
Q7:为什么浏览器要多进程?站点隔离解决了什么?
稳定性(崩溃隔离)、安全(沙箱 + 不同站点不同地址空间,降低 Spectre 收益)、可回收(冻结/丢弃标签页)。代价是内存与 IPC;跨站 iframe 变 OOPIF,只能 postMessage,SharedArrayBuffer 需要 COOP/COEP。
Q8:为什么滚动可以不受主线程阻塞影响? 滚动偏移由合成线程维护,内容已光栅成瓦片,合成线程直接移动瓦片;前提是没有非 passive 的滚动监听、没有依赖主线程的滚动效果、瓦片已经准备好。
Q9:fetch 的错误模型是怎样的?
只有网络层失败(断网、CORS、DNS)才 reject;4xx/5xx 是成功的响应,必须检查 response.ok。CORS 错误只给 TypeError,细节在 Console;流式读取用 response.body,注意代理缓冲。
Q10:localStorage、IndexedDB、Cookie 怎么选?
Cookie 给服务端读、量小、随请求发送;localStorage 放少量同步键值;IndexedDB 放结构化大数据、异步、有索引;静态资源用 Cache Storage;大文件用 OPFS。登录态优先 HttpOnly Cookie,不放 localStorage。
Q11:页面从 BFCache 恢复时要注意什么?
监听 pageshow 的 persisted,重新拉数据、重连、校正计时;在 pagehide 清理连接与保存状态;不要监听 unload,HTML 不要用 no-store。
Q12:怎么把一个 500ms 的长任务优化到不阻塞交互?
先量(Performance/LoAF)→ 减少工作量(虚拟列表/缓存)→ 分片让出(scheduler.yield / MessageChannel / isInputPending)→ 移 Worker/WASM → 服务端预计算;不要用微任务让出。
8. 面试深挖
8.1 面试官在考察什么
| 维度 | 看什么 | 加分回答 | 减分回答 |
|---|---|---|---|
| 渲染管线 | 能否说清一次改动的代价落在哪一阶段 | 区分 layout / paint / composite 的触发条件 | 「改样式就会重排」 |
| 线程模型 | 主线程、合成线程、光栅线程的分工 | 解释为什么 transform 动画更稳 | 「浏览器多线程所以不卡」 |
| 安全边界 | 同源策略与进程隔离的关系 | 说出 COOP/COEP 与跨域隔离的目的 | 「跨域就是后端加个头」 |
| 观测能力 | 会不会用面板取证 | 用 Layers + Performance 交叉定位 | 只会看 Network 瀑布图 |
| 兼容判断 | 新 API 的降级路径 | 给 @supports / 特性检测方案 | 「现在都支持了」 |
8.2 追问链
链 1 · 渲染管线 → 合成层 → 动画该改哪个属性
- 面试官:从改一个 class 到屏幕变化,中间发生了什么?
- 要点:计算样式 → 布局(layout/reflow)→ 绘制(paint,生成绘制指令)→ 分层与合成(composite)。改
width触发前三步;改background跳过布局;改transform/opacity可以只走合成,因为合成线程能独立处理。
- 要点:计算样式 → 布局(layout/reflow)→ 绘制(paint,生成绘制指令)→ 分层与合成(composite)。改
- 追问:为什么
transform动画通常更流畅?- 要点:元素被提升为合成层后,动画由合成线程驱动,不占用主线程;主线程被长任务阻塞时动画仍能推进。代价是每层都要独立显存,层数过多会带来内存与合成开销。
- 再追问:
will-change: transform为什么不能随便加?- 要点:它会提前创建合成层并常驻,元素多时显存暴涨,移动端可能直接触发 OOM 或掉帧;正确用法是「交互前加、结束后移除」,或用
transform: translateZ(0)之外更语义化的方式,并通过 Layers 面板确认层数。
- 要点:它会提前创建合成层并常驻,元素多时显存暴涨,移动端可能直接触发 OOM 或掉帧;正确用法是「交互前加、结束后移除」,或用
链 2 · 进程模型 → 同源策略 → 跨域隔离
- 面试官:为什么一个标签页崩溃不影响其他标签页?
- 要点:Chrome 采用多进程架构,默认按站点隔离(Site Isolation),每个站点实例独立进程(同站标签页可能复用);GPU 进程、网络进程、浏览器主进程各自负责合成、请求与窗口管理。
- 追问:同源策略到底限制了什么?
- 要点:限制的是「读取」而不是「发送」。跨源请求默认可以发出(简单请求),但响应被浏览器拦截不给 JS 读;
img、script、form等标签不受同一限制,这也是 JSONP 与 CSRF 存在的土壤。
- 要点:限制的是「读取」而不是「发送」。跨源请求默认可以发出(简单请求),但响应被浏览器拦截不给 JS 读;
- 再追问:
Cross-Origin-Opener-Policy与Cross-Origin-Embedder-Policy一起设有什么用?- 要点:两者同时设为
same-origin/require-corp后,页面进入「跨源隔离」状态,才能使用SharedArrayBuffer、高精度计时器等能力;代价是嵌入的跨源资源必须显式声明 CORP 或 CORS,否则加载失败。
- 要点:两者同时设为
链 3 · 观察者时机 → 输入优先级 → INP 归因
- 面试官:
requestAnimationFrame、ResizeObserver、IntersectionObserver的回调分别在什么时机?- 要点:
rAF在每帧渲染前执行,适合动画;ResizeObserver在布局完成后、绘制前触发,避免「读写交替」造成的循环布局;IntersectionObserver由浏览器在计算交叉区域后异步回调,不阻塞主线程,天然适合懒加载。
- 要点:
- 追问:为什么点击后视觉反馈慢,Performance 里却看不到长任务?
- 要点:INP 拆成输入延迟、处理时长、呈现延迟三段。输入延迟可能是主线程被上一个任务占用;呈现延迟可能是下一帧被其他工作挤占,或触发了同步布局。要看 Experience 区的 INP 分解,而不是只看总时长。
- 再追问:怎么降低输入延迟?
- 要点:把非关键工作移出事件处理(
postTask的background优先级);避免在pointerdown里做重计算;用passive: true让滚动不被preventDefault阻塞;对高频输入做合并(一次渲染处理多个事件)。
- 要点:把非关键工作移出事件处理(
链 4 · Cookie 作用域 → SameSite → CSRF/CORS
- 面试官:为什么
a.example.com的 Cookie 会发给b.example.com?- 要点:Cookie 的作用域是
Domain+Path,不是同源。Domain=example.com会覆盖所有子域;同站只影响SameSite与进程复用,不改变同源策略。
- 要点:Cookie 的作用域是
- 追问:
SameSite怎么判断「跨站」?Lax有什么例外?- 要点:站点 = scheme + eTLD+1;
a.example.com与b.example.com同站,http与https跨站。Lax允许顶层 GET 导航带 Cookie,跨站 POST/iframe/fetch 不带;Chrome 还有「新设置 2 分钟内允许 Lax+POST」的兼容例外。
- 要点:站点 = scheme + eTLD+1;
- 再追问:跨域带 Cookie 的请求为什么失败?
- 要点:三件事要同时满足——前端
credentials: 'include'、服务端具体 Origin +Allow-Credentials: true、Cookie 本身SameSite=None; Secure且未被第三方 Cookie 策略拦截。预检请求不带 Cookie,别在预检里做登录校验。
- 要点:三件事要同时满足——前端
- 再追问:
HttpOnly能防 CSRF 吗?- 要点:不能。它只阻止 JS 读 Cookie,浏览器仍会自动带上;CSRF 要靠
SameSite、CSRF Token、校验Origin/Referer,以及不用 GET 做状态变更。
- 要点:不能。它只阻止 JS 读 Cookie,浏览器仍会自动带上;CSRF 要靠
链 5 · 页面生命周期 → BFCache → 卸载上报
- 面试官:用户切到后台、再点后退,页面分别发生了什么?
- 要点:切后台进入
hidden,定时器被节流;长时间后台可能frozen,内存压力下discarded。后退时若命中 BFCache,整页(含 JS 堆)被恢复,触发pageshow且persisted = true。
- 要点:切后台进入
- 追问:为什么说
unload监听器有害?- 要点:Chrome 中带
unload的页面无法进入 BFCache,后退要重新加载;unload在移动端还经常不触发,既不可靠又有代价。清理逻辑放pagehide/visibilitychange。
- 要点:Chrome 中带
- 再追问:怎么保证卸载前的埋点不丢?
- 要点:用
navigator.sendBeacon或fetch(..., { keepalive: true });在visibilitychange的hidden与pagehide都尝试上报;普通fetch可能在卸载时被取消。
- 要点:用
8.3 手写题
题 1 · 图片懒加载(含降级)
/**
* 图片懒加载:进入视口前 200px 时替换 data-src。
* @param {string} [selector='img[data-src]'] 目标选择器
* @returns {() => void} 停止观察并清理
*/
function lazyLoad(selector = 'img[data-src]') {
const images = [...document.querySelectorAll(selector)];
/** 真正加载图片,并移除占位状态 */
const load = (img) => {
img.src = img.dataset.src;
img.removeAttribute('data-src');
img.classList.add('is-loaded');
};
if (!('IntersectionObserver' in window)) {
images.forEach(load);
return () => {};
}
const io = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
load(entry.target);
io.unobserve(entry.target);
});
},
{ rootMargin: '200px 0px' }
);
images.forEach((img) => io.observe(img));
return () => io.disconnect();
}
追问:
- 为什么用
rootMargin而不是等完全进入视口?→ 提前预取,减少「滚到才白一下」;值过大会浪费带宽,需要按图片大小与网络状况调。 - 图片加载失败怎么办?→ 监听
error换占位图,并做一次重试上限,避免死循环。 - 为什么不在
scroll里手写判断?→ 滚动回调频繁触发强制布局(读getBoundingClientRect),而 IO 由浏览器批量计算,主线程成本更低。
题 2 · requestIdleCallback 的兼容实现
/**
* 空闲回调:不支持原生 API 时用 MessageChannel 模拟。
* @param {(deadline: {didTimeout: boolean, timeRemaining: () => number}) => void} cb
* @param {{timeout?: number}} [options]
* @returns {number} 句柄
*/
const idle = (() => {
if (typeof requestIdleCallback === 'function') {
return (cb, options) => requestIdleCallback(cb, options);
}
let id = 0;
const channel = new MessageChannel();
const queue = new Map();
channel.port1.onmessage = () => {
const start = performance.now();
queue.forEach((item, key) => {
queue.delete(key);
item.cb({ didTimeout: false, timeRemaining: () => Math.max(0, 50 - (performance.now() - start)) });
});
};
return (cb, options = {}) => {
id += 1;
queue.set(id, { cb });
channel.port2.postMessage(null);
if (options.timeout) {
setTimeout(() => {
const item = queue.get(id);
if (!item) return;
queue.delete(id);
item.cb({ didTimeout: true, timeRemaining: () => 0 });
}, options.timeout);
}
return id;
};
})();
追问:
- 为什么用
MessageChannel而不是setTimeout?→postMessage属于宏任务但没有 4ms 嵌套钳制,调度更及时。 - 模拟版的
timeRemaining为什么给 50ms?→ 这是原生实现的帧内预算量级,模拟值只需保证「一次别做太久」。 - 空闲回调适合做什么、不适合做什么?→ 适合埋点上报、预计算、缓存预热;不适合动画、用户输入响应与有时序依赖的任务。
8.4 场景设计题
题:一个商品列表页在低端安卓机上滚动掉帧,同时用户反馈「用久了越来越卡」。你怎么联合排查?
评估点:是否把「掉帧」和「内存增长」当成两个问题、是否用面板取证。
参考方案:
- 分现象:滚动掉帧看 Performance(FPS、Long Task、Layout 次数);越用越卡看 Memory(快照对比、Detached DOM)。
- 掉帧归因:若 Layout 次数与滚动次数同阶,说明在滚动回调里读写布局(强制同步布局);若主线程被长任务占满,把图片解码、数据格式化移出主线程。
- 内存归因:快照对比找 Detached DOM,沿 Retainers 定位持有者,常见是全局缓存、未解绑的
scroll监听、闭包持有的列表项。 - 修复:滚动监听
passive: true+rAF合并;列表项复用(回收 DOM);图片按视口解码(loading="lazy"+decoding="async");缓存设上限。 - 验证:同一台低端机跑 10 分钟,记录 FPS 分布、JS 堆大小曲线、Detached 节点数。
加分:指出低端机应降级效果(减少阴影与模糊,prefers-reduced-motion);指出「越用越卡」也可能是合成层累积,用 Layers 面板确认层数。
减分:只答「加 will-change」;把内存问题当成渲染问题;不做前后对比。
8.5 最容易答错的点
| 常见说法 | 纠正 |
|---|---|
| 「改样式一定触发重排」 | 只有几何属性触发 layout,颜色等只触发 paint,transform 可只合成 |
「用了 transform 动画就一定在合成线程」 | JS 每帧改 style.transform 仍由主线程算值;CSS/WAAPI 才可能完全交给合成线程 |
「requestAnimationFrame 是定时器」 | 它跟随刷新率,后台标签页会暂停,不能当计时用 |
「localStorage 是同步的所以更快」 | 同步会阻塞主线程,大数据量应放 IndexedDB |
| 「跨域是浏览器不让发请求」 | 请求通常已发出,被拦的是响应读取 |
「Cookie 的 HttpOnly 能防 XSS」 | 它只阻止 JS 读取,不能阻止利用登录态发起请求 |
「SameSite=Lax 能防所有 CSRF」 | 顶层 GET 导航仍会带 Cookie;状态变更必须用非 GET + Token/Origin 校验 |
| 「同站就是同源」 | 同站看 scheme + eTLD+1;子域/端口不同仍是跨源,仍要 CORS |
「fetch 遇到 500 会进 catch」 | 只有网络层失败才 reject;4xx/5xx 要检查 response.ok |
| 「每个标签页都是一个进程」 | 同站点实例可能复用进程;跨站 iframe 通常独立进程 |
「Service Worker 能拦截所有请求」 | 只作用于其作用域内的同源请求,首次注册前不生效 |
| 「Service Worker 里可以操作 DOM / localStorage」 | 它没有 DOM 与 localStorage,状态要放 IndexedDB |
「BFCache 与 HTTP 缓存是一回事」 | 前者是整页内存快照,后者是资源缓存,unload 会破坏 BFCache |
| 「BFCache 恢复后定时器还在正常走」 | 页面被冻结期间定时器暂停,恢复后时间可能跳变,要用时间戳校正 |
「IntersectionObserver 回调是同步的」 | 异步批量回调,且不保证每帧都触发 |
9. 延伸资源
- web.dev:Rendering performance、Service Worker、Storage 系列。
- Chrome Developers「Inside look at modern web browser」:多进程架构四篇,图文极清晰。
- MDN Web APIs:每个 API 的兼容性与限制。
web-vitals源码:现场指标采集的标准实现。- Chrome DevTools 官方文档:Performance/Memory/Layers 面板教程。
- 工具:
chrome://net-export(网络日志)、chrome://tracing、Lighthouse CI。
下一步练习:打开一个你常用的网站,用 Performance 面板录一次「点击 → 内容出现」,然后回答三个问题:主线程最长任务是多少毫秒?有没有强制同步布局?掉帧发生在哪一帧?