05

浏览器原理与 Web API

3,960 行约 243 分钟123 个代码块92 张表格更新于 2026-11-03

05 · 浏览器原理与 Web API(详解版)

一句话定位:浏览器是前端代码唯一的运行环境。所有「为什么会卡 / 为什么会白屏 / 为什么这个 API 只在 Safari 出问题」的答案,最终都落在这台机器的内部机制上。

读完本篇你应该能做到

  1. 说出输入 URL 到首屏渲染的完整链路,并在每个环节知道用什么工具观测;
  2. 看到掉帧、白屏、内存增长,能判断该查主线程、合成、网络还是存储;
  3. 解释同源策略、CORS、Cookie 属性、Service Worker 缓存这些「配置项」背后的机制;
  4. 说清进程/线程、站点隔离、合成层与滚动为什么互不阻塞,并能用面板取证;
  5. 在 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 调度的单位,共享所属进程的内存与资源弱,同进程线程可直接读写同一块内存小:创建快、切换快办公室里的员工

浏览器选择多进程,主要不是为了「快」,而是为了三件事:

  1. 稳定性:一个页面崩溃 / OOM,不会带走整个浏览器;
  2. 安全:渲染进程跑在沙箱里,且不同站点不共享地址空间,降低 Spectre 这类侧信道攻击的收益;
  3. 可回收:后台标签页可以整进程冻结或丢弃,内存能真正还给系统。

代价也很直接:每个渲染进程都有一份 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 单独占一个进程;
  • 同站点的多个标签页 → 可能复用同一个进程(受浏览器启发式、内存压力、站点隔离策略影响),不要假设它们一定隔离。

这就解释了几件常见的事:

  1. 一个页面可能对应多个进程:主文档一个,跨站 iframe 各自一个;任务管理器里能看到多行。
  2. 同站点的另一个标签页 OOM,可能连累你:如果它们共用了进程。
  3. 跨站 iframe 里不能直接拿父页面的 DOM:不仅是同源策略,很多时候连内存都不在一起,只能 postMessage。
  4. 站点隔离不是「同源隔离」: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/超时,展示兜底

两个关键认知:

  1. window.onerror / try...catch 抓不到进程崩溃和 OOM。它们只能抓当前 JS 上下文里的异常;进程没了,代码也不在了。要靠外部心跳(定时上报 + 服务端判断中断)和 document.wasDiscarded 这类信号。
  2. OOM 往往不是「一次性申请太大」,而是「长期不释放」。常见来源:全局缓存无上限、Detached DOM、未解绑的监听器、不断增长的数组/Map、闭包持有大对象。排查用 Memory 面板的堆快照对比,见 3.8。

1.1.7 对前端的 10 条实践推论

  1. 不要假设两个标签页共享内存:跨标签通信走 BroadcastChannel、storage 事件或服务端;SharedWorker 也不是所有容器都支持。
  2. 跨站 iframe 当独立应用对待:通信只走 postMessage,必须校验 origin,不要传函数或 DOM 节点。
  3. 需要 SharedArrayBuffer / 高精度计时:先确认 COOP/COEP 是否配好,否则直接降级为消息传递。
  4. localStorage 只放小配置:它是同步的,且可能走 IPC;大数据、二进制、需要索引的放 IndexedDB / OPFS。
  5. performance.memory 只代表当前渲染进程:多进程页面的总内存要看任务管理器或 measureUserAgentSpecificMemory()(需跨源隔离)。
  6. 生产环境不要用 alert/confirm/prompt:它们会阻塞整个渲染进程,移动端体验尤其差。
  7. 进程崩溃要有兜底:心跳中断 → 提示刷新;iframe 加超时与占位;不要指望错误边界能接住一切。
  8. 减少不必要的跨站嵌入:每个跨站 iframe 可能是一个进程,内存与 IPC 成本都会上升。
  9. 别再用 document.domain 放宽同源:它已被弃用,且与站点隔离冲突;用 postMessage、CORS 或同源网关。
  10. 看到「同站不同源」的请求别混淆:同站只影响 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网络进程域名IPTiming → DNS Lookup
TCP/TLS网络进程IP + 端口加密连接Timing → Initial connection / SSL
HTTP网络进程请求头响应头 + 流式 bodyTiming → TTFB
解析渲染进程主线程HTML 字节DOMPerformance → 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 线程把导航请求交给网络进程之前,会先做三件事:

  1. 查 Service Worker:如果当前源注册过 SW 且作用域匹配,请求会先走 SW 的 fetch 事件,可能直接由缓存返回,一个字节都不上网;
  2. 处理 beforeunload:如果旧页面注册了它,可能弹确认框(这也是它破坏 BFCache 的原因);
  3. 决定是否复用渲染进程:同站点同实例可以复用,跨站点要新建进程。

观测: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 下载完。提交后:

  1. 浏览器选中或新建渲染进程(site instance);
  2. 把响应 body 以流的方式交给渲染进程;
  3. 地址栏更新、历史记录写入;
  4. 旧页面触发 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_NXDOMAINNetwork → Timing;chrome://net-export换 DNS、预解析、等 TTL
TCP端口不通、被防火墙拦ERR_CONNECTION_REFUSED / 超时Network → Timing查服务、安全组、代理
TLS证书过期、域名不匹配、链不完整安全警告页Security 面板;openssl s_client续期、补中间证书、校时
HTTP404/401/403/429/5xx空白、报错、跳登录Network → Status / Response修路由、鉴权、限流、重试
缓存304 但内容错、CDN 缓存了旧 HTML部分用户旧页面Response Headers、Age、ETag改缓存键、清 CDN、HTML 不缓存
重定向循环、跨域丢 Cookie、POST 变 GET一直转圈 / 登录态丢Network 多条 3xx修规则;用 307/308 保方法
Content-TypeHTML 被当文件下载、JS 被当文本下载框 / Unexpected token '<'Response Headers设正确的 Content-Type + nosniff
解析同步脚本太久、document.write白屏很久Performance → Parse HTML / Long Taskdefer/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: 里根节点为空查看源代码 / curlSSR 报错、模板异常、构建产物缺失
③ 有 DOM 但看不见Elements 里有节点,屏幕空白看 opacity、visibility、z-index、高度CSS 动画未结束、遮罩、height: 0
④ 有内容但样式错乱文字裸奔、布局堆叠Network 看 CSS 状态与 MIMECSS 404、CORS、Content-Type 错误
⑤ 有内容但不能点视觉正常,交互无反应Console 报错、Event ListenersJS 崩溃、水合失败、长任务、透明遮罩

一段可以直接粘进控制台的「三分钟体检」:

/**
 * 白屏快速体检:输出根节点、可见性、关键资源与错误信息。
 */
(() => {
  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_ERRORTLS 握手失败协议/加密套件不匹配、HTTP 打 HTTPS 端口查 TLS 配置与端口
ERR_HTTP2_PROTOCOL_ERRORHTTP/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,也可能是请求被 abortConsole 是否报 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% 的问题:

  1. Network:文档请求的状态码、Timing 分解、Response Headers(尤其 Content-Type、Cache-Control、CORS 头)、Size 是否 (ServiceWorker)。
  2. Console:JS 异常、CORS、CSP、混合内容、水合警告、ResizeObserver loop。
  3. Performance:FCP/LCP 标记位置、长任务、Recalculate Style 与 Layout 的次数。
  4. Application:Service Worker 是否在控制页面、缓存名与版本、Cookie 是否符合预期。
  5. Rendering:Paint flashing 看重绘范围;Frame Rendering Stats 看 FPS。
  6. 外部证据:chrome://net-export(完整网络日志)、服务端访问日志、CDN 日志、RUM 分桶数据。

一句话:错误码告诉你「哪一层断了」,面板告诉你「为什么断」,RUM 告诉你「影响多少人」。三者缺一不可。

1.2.13 耗时量级参考

以下只是量级,真实值随网络/设备变化,用来判断「这段是不是异常」:

阶段正常量级异常信号
DNS(缓存命中)< 1ms> 100ms 说明没缓存或链路差
DNS(未命中)20–120ms> 300ms 考虑换 DNS/预解析
TCP + TLS(同城)30–100ms> 300ms 看证书链、跨地域
TTFB50–500ms> 1s 查服务端与网关
HTML 下载与体积线性首屏 HTML > 100KB 要警惕
JS 执行与体积/设备线性主线程长任务 > 50ms 就影响交互

一句话总结:这条链路上每一段都有对应的 DevTools 证据;排查时不要猜,先定位「慢在哪一段」,再决定是优化网络、减少阻塞资源,还是拆主线程任务。

1.3 DevTools 五大面板:各自解决什么问题

面板解决什么必会操作
ElementsDOM 与样式盒模型高亮、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;

三个高频问题:

  1. querySelectorAll 返回的是静态集合,DOM 变化后不会更新;getElementsByClassName 返回动态集合(有性能与「遍历时变化」的坑)。
  2. 在循环里 appendChild 会触发 N 次布局,必须用 DocumentFragment 或先 display: none。
  3. 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 基础:四种存储怎么选

存储容量同步/异步生命周期适用
Cookie4KB/个同步(随请求发送)可设过期登录态(HttpOnly)
localStorage5-10MB同步永久(除非清除)轻量偏好设置
sessionStorage5-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);

四个必须知道的事实:

  1. 最小延迟钳制:规范规定嵌套超过 5 层的定时器,最小延迟为 4ms。所以「setTimeout(fn, 0) 循环」并不快,反而会被钳。
  2. 后台节流:页面不可见时,定时器通常被压到 约 1 秒一次;Chrome 对隐藏超过 5 分钟的页面启用「intensive throttling」,进一步压到 约 1 分钟一次。
  3. setInterval 会漂移与堆积:回调执行时间不计入间隔;如果回调比间隔长,任务会排队。需要稳定节奏时用「递归 setTimeout + 校正」。
  4. 定时器不保证准时:主线程忙时,回调只能排队;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);
特性setTimeoutrequestAnimationFrame
触发时机任意任务队列每帧渲染前,跟随刷新率
隐藏标签页被节流到约 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元素尺寸变化布局之后、绘制之前图表自适应、容器查询替代
MutationObserverDOM 树变化微任务时机监听第三方脚本改 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 的区别:

机制页面状态恢复速度触发
BFCacheJS 堆、DOM 全部保留几乎瞬时前进/后退
discarded状态丢失,重新加载取决于加载速度内存压力/省内存模式

自检:为什么「在 unload 里上报」既不准又有害?——移动端常不触发(不准),且会让页面无法进入 BFCache(有害)。

1.10 HTML 解析、脚本执行与资源加载

1.2 讲的是「链路」,这一节把其中最容易出问题的两步——解析与资源加载——拆开讲。首屏白屏、脚本顺序错乱、DOMContentLoaded 时机不对,根因几乎都在这里。

1.10.1 解析器怎么把字节变成 DOM

字节流
  → 解码(charset:BOM > HTTP header > <meta> > 默认)
  → 分词(tokenizer):开始标签、结束标签、文本、注释、DOCTYPE
  → 树构建(tree construction):按标签语义建节点、处理隐式闭合
  → DOM

三个容易忽略的点:

  1. 容错是规范的一部分:<p><div> 会自动闭合 <p>,<table><div> 会把 div「寄养」到表格前面(foster parenting)。浏览器不会因为标签写错就停止解析,它按规范「猜」你的意图。
  2. 解析是流式的:HTML 还在下载时 DOM 就在增长,所以用户能更早看到内容;这也是服务端流式渲染能工作的前提。
  3. 解析会让出主线程:浏览器分块解析,中间可以处理输入与渲染;但同步脚本一旦开始执行,解析就完全停住。

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、Headers4xx/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()分片上传要处理顺序/重试/合并
内存 URLURL.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 很慢
3DWebGL / WebGPU需要 GPU 支持;上下文丢失要恢复
离屏渲染OffscreenCanvasWorker 中可用,但字体/图片加载要另处理
音频Web Audio API通常要用户手势后才能 resume()
录制MediaRecorder编码格式因浏览器而异
视频<video>、Picture-in-Picture、Fullscreen自动播放受策略限制(muted 才常允许)
动画Web Animations API、ScrollTimeline可合成属性才能脱离主线程

1.11.5 并发与后台

能力核心 API关键限制/坑
专用 Workernew Worker()无 DOM;通信靠消息,注意结构化克隆成本
共享 WorkerSharedWorker兼容性一般;生命周期由浏览器管理
Service Workernavigator.serviceWorker仅 HTTPS/localhost;会被随时终止
WorkletCSS.paintWorklet、AudioWorklet运行在独立线程,API 受限
WASMWebAssembly需要编译/实例化;与 JS 边界有调用成本
跨上下文消息BroadcastChannel、MessageChannel同源限制;消息要可序列化

1.11.6 观测与调试

能力核心 API关键限制/坑
性能条目PerformanceObserver不同 entry 字段不同;buffered 很关键
长任务/长帧longtask、long-animation-frameLoAF 信息更细,但兼容性较新
布局偏移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-visiblefocus 不冒泡;用 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 的五条原则

  1. 安全上下文:绝大多数设备/文件/剪贴板 API 要求 HTTPS(localhost 例外)。上线前先确认协议。
  2. 用户手势:弹窗、全屏、剪贴板写、文件选择、摄像头、音频播放,通常必须由点击/按键触发;在 setTimeout 里调用会被拦。
  3. 权限分离:permissions.query() 只查状态,申请要走具体 API;用户拒绝后不要反复弹。
  4. 特性检测优先:用 'showModal' in HTMLDialogElement.prototype 这类检测,而不是 UA 判断;不支持的路径要有降级。
  5. 清理资源: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设备/文件/剪贴板要安全上下文 + 用户手势;特性检测优先

自检题

  1. 为什么 getElementsByClassName 和 querySelectorAll 遍历时行为不同?——前者动态、后者静态。
  2. localStorage 存 10MB 会怎样?——解析与反序列化阻塞主线程。
  3. 为什么建议用 IntersectionObserver 做懒加载?——避免滚动事件 + getBoundingClientRect 造成的强制同步布局。
  4. 同一个浏览器的两个标签页一定是两个进程吗?——不一定;同站点实例可能复用进程,跨站 iframe 通常独立进程。
  5. 为什么 setTimeout(fn, 0) 不能用来做「让出主线程」的最佳方案?——有 4ms 嵌套钳制;MessageChannel / scheduler.yield 更合适。
  6. 页面被丢弃后怎么知道?——document.wasDiscarded(恢复后为 true),配合 pagehide 保存状态。

2. L2 进阶:渲染管线与网络边界

2.1 渲染管线五阶段

JS/样式修改
   ↓
① Style(样式计算):把 CSS 规则匹配到 DOM 节点,算出每个元素的最终样式
   ↓
② Layout(布局/重排):计算每个盒子的位置与大小
   ↓
③ Paint(绘制):生成绘制指令(先画什么、后画什么)
   ↓
④ Raster(光栅化):把指令变成像素(GPU/CPU 分块执行)
   ↓
⑤ Composite(合成):把多个图层按顺序叠起来,输出到屏幕

每一阶段内部在做什么:

阶段输入内部动作关键概念观测
StyleDOM + CSSOM选择器匹配(从右往左)、层叠与继承、算出计算值失效集、:has() 会反向影响祖先、CSS 变量改根节点影响全树Recalculate Style
Layout样式后的盒树计算位置、尺寸、换行、对齐包含块、格式化上下文、布局边界、contain: layoutLayout
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)。

改动 → 触发阶段速查:

改动StyleLayoutPaintComposite说明
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❌❌❌✅只是提示,可能提前建层

两个必须记住的细节:

  1. transform 不触发布局,但没被合成时仍要重绘;它还会让后代的 position: fixed 变成相对它定位(包含块改变)。
  2. 这张表是「典型情况」,真假以 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.eventPhase1 捕获 / 2 目标 / 3 冒泡目标阶段既不算捕获也不算冒泡
event.stopPropagation()阻止继续传播到其他节点同一节点上的其他监听器仍会执行
event.stopImmediatePropagation()连同一节点的后续监听器也阻止慎用,容易让排查变困难
event.preventDefault()阻止浏览器默认行为不等于阻止冒泡

2.5.2 哪些事件会冒泡

不是所有事件都冒泡,这直接决定「事件委托」能不能用:

会冒泡不冒泡
click、dblclick、mousedown/up/movefocus、blur(用 focusin/focusout 代替)
input、change、keydown/upmouseenter、mouseleave(用 mouseover/out 代替)
pointerdown/up/move、touchstart/move/endload、error、scroll(元素上的 scroll 不冒泡)
submit、resetDOMContentLoaded、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]?.();
});

优点:动态节点自动生效、监听器数量少、内存占用低。

四个坑:

  1. 不冒泡的事件不能委托(见上表);
  2. 中间层调用了 stopPropagation(),委托就收不到;
  3. Shadow DOM 会重定向 event.target:需要在宿主上用 event.composedPath() 找真实路径;
  4. 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 简单请求与非简单请求

浏览器把跨源请求分成两类:

简单请求(不触发预检)必须同时满足:

  1. 方法是 GET、HEAD、POST 之一;
  2. 只带 CORS 安全列表内的请求头:Accept、Accept-Language、Content-Language、Content-Type(且值受限)、Range(单段范围)等;
  3. 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 暴露。

三件事必须同时成立:

  1. 前端 fetch 写 credentials: 'include'(或 XHR withCredentials = true);
  2. 服务端返回具体 Origin + Access-Control-Allow-Credentials: true;
  3. 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 靠:SameSite Cookie、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)。

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

三个关键认知:

  1. Cookie 由浏览器附加,不由 JS 附加。你写 fetch 时不需要手动拼 Cookie 头(也拼不了,禁止设置)。
  2. Cookie 的作用域不是「源」,而是 domain + path。所以 a.example.com 和 b.example.com 可以共享 Cookie,即使它们是跨源。
  3. 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:

写法类型发送范围
不写 Domainhost-only只发给当前 host,a.example.com 不会发给 b.example.com
Domain=example.comdomain 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 是公共后缀)

三种取值的行为:

场景StrictLaxNone
从外站点链接跳转进来(顶层 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
HttpOnlyJS 无法通过 document.cookie 读取XSS 直接偷 CookieCSRF;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 被全面拦截后,嵌入组件如何维持状态」,不是「跨站追踪」的许可。

这是最容易混淆的一组关系,记住三句话:

  1. Cookie 是否发送,先由浏览器的 Cookie 规则决定;对 fetch/XHR 这类受控请求,credentials 模式还能进一步要求「不带」或「允许跨源携带」。
  2. CORS 决定的是「跨源响应能不能被 JS 读」。
  3. 带 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,但后续接口 401credentials 没写 include,Cookie 没带
浏览器报 Allow-Origin: * 与 credentials 冲突服务端用了通配符
本地 localhost 正常,线上跨域失败线上是跨站,Cookie 缺 SameSite=None; Secure
Safari 上登录态丢失ITP 拦截第三方 Cookie,或 SameSite 规则更严
预检 401预检不带 Cookie,服务端却要求登录

Cookie 会自动附加到所有匹配的请求上,包括 HTML、JS、CSS、图片、接口。后果:

  1. 请求头变大:4KB 的 Cookie × 50 个请求 = 200KB 额外上行流量,移动弱网下非常明显。
  2. CDN 缓存命中率下降:带 Cookie 的请求默认更容易被当作私有内容;如果 CDN 缓存键包含 Cookie,缓存会被打散。
  3. 首屏变慢:HTML 请求带一堆业务 Cookie,TTFB 可能变长(尤其上行受限时)。

实践建议:

  • 静态资源放独立域名(cookie-free domain),或至少把 Path 限制到接口路径;
  • Cookie 只放会话标识,用户信息、权限列表、埋点参数不要塞进去;
  • CDN 配置 Vary: Cookie 要谨慎(会显著降低命中率),更常见的是「HTML 不缓存 / 接口按需缓存」;
  • 定期清理无用 Cookie,注意同名不同 path 的「僵尸 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 标签能看到「这次实际带了哪些」。

场景更合适的选择原因
大量结构化数据、离线缓存IndexedDB / OPFSCookie 只有 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 自检

  1. 为什么 a.example.com 的 Cookie 会发给 b.example.com?——同站 + Domain=example.com,与同源策略无关。
  2. SameSite=Lax 能完全防 CSRF 吗?——不能,顶层 GET 导航仍会带 Cookie;状态变更必须用非 GET + Token/Origin 校验。
  3. HttpOnly 能防 XSS 吗?——不能,它只阻止 JS 读 Cookie;XSS 仍可发起请求、篡改页面。
  4. 跨域带 Cookie 为什么必须写 credentials: 'include'?——默认 same-origin,跨源请求不会带凭证;同时服务端不能用 *。
  5. 删不掉 Cookie 的常见原因?——Domain / Path 与创建时不一致,或 HttpOnly Cookie 只能由服务端删。

2.8 存储进阶:配额、驱逐与离线

2.8.1 六种存储的完整对照

存储容量量级同步/异步作用域生命周期适合
Cookie4KB/条,约 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);
});

四个硬限制:

  1. 同步 API:读写都在主线程,读一个几 MB 的字符串会卡住页面;部分实现还可能走同步 IPC。
  2. 只能存字符串:二进制要转 Base64,体积膨胀 33%。
  3. 没有索引/查询:只能全量读出再过滤。
  4. 不是永久存储:浏览器在存储压力、隐私模式、用户清理时都可能删除;Safari 对长期不访问的站点也会清理脚本可写存储。

localStorage 与 sessionStorage 的区别:

维度localStoragesessionStorage
共享范围同源的所有标签页仅当前标签页
刷新保留保留
新标签打开同源页面共享不共享(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 对「长期不访问的站点」有额外清理策略。

工程结论:

  1. 启动时申请 persist();
  2. 每次读取都处理「数据不在了」的情况,不要假设一定存在;
  3. 关键数据要有服务端兜底或可重建;
  4. 版本迁移要有回滚/兼容逻辑。

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、密钥放进去;登录态优先 HttpOnly Cookie。
  • 不要用 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 小时检查一次)。

发现字节变化后:

  1. 下载新脚本 → install;
  2. 进入 waiting,不立即接管(因为旧页面可能还在用旧资源);
  3. 所有旧页面关闭后 → activate;
  4. 新页面才由新 SW 控制。

「一直看到旧版本」的三个根因:

  1. 旧 SW 还在控制页面(waiting 没结束);
  2. 缓存策略是 cache-first,且缓存名没变,永远命中旧缓存;
  3. 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、onmessagewindow、document、DOM 操作
fetch、XMLHttpRequest、WebSocketlocalStorage、sessionStorage
indexedDB、cachesalert / 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
监听了 beforeunloadChrome 中会直接失去 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 的正确姿势

  1. 移除 unload,把清理逻辑放到 pagehide / visibilitychange;
  2. beforeunload 只用于「真有未保存内容」,不要用它做埋点或清理;
  3. 在 pagehide 关闭长连接、暂停媒体、保存草稿;
  4. 在 pageshow(persisted) 重新拉数据、重连、恢复计时器;
  5. HTML 不要用 no-store,用 no-cache + ETag/Last-Modified;
  6. 不要假设定时器在冻结期间继续走:恢复后 Date.now() 可能跳变,用时间戳校正;
  7. 埋点注意:从 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 的分工

维度fetchXMLHttpRequest
返回值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 上最常用的字段:

字段说明
statusHTTP 状态码(CORS 失败时拿不到)
okstatus 在 200–299 之间
headers响应头(受 Expose-Headers 限制)
bodyReadableStream,只能读一次
typebasic / cors / opaque / opaqueredirect
redirected是否发生过重定向
url最终 URL

2.12.3 七个高频误区

  1. 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();
  1. CORS 失败只给 TypeError:拿不到状态码、响应头、响应体;真正原因在 Console。
  2. mode: 'no-cors' 不是「绕过 CORS」:它返回 opaque 响应,JS 读不到任何内容,只适合 <img> 类场景。
  3. 默认不带跨源 Cookie:跨源登录态要 credentials: 'include',服务端还要 Allow-Credentials: true + 具体 Origin。
  4. response.body 只能消费一次:需要读两次时先 response.clone()(会占内存)。
  5. Content-Type 不会自动猜:发 JSON 要手动设 application/json;用 FormData 时不要手动设 Content-Type,浏览器要自动加 boundary。
  6. 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 没有上传进度

需要进度条时,只能:

  1. 用 XMLHttpRequest 的 upload.onprogress;
  2. 或者把文件分片,按「已上传分片数」估算进度。
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 为 false4xx/5xx看 status 与响应体
Unexpected token '<'返回了 HTML(登录页/网关页)看 Content-Type 与响应前 200 字符
body used already同一个 Response 读了两次先 clone()
413 Payload Too Large请求体超限调大限制或分片
415 Unsupported Media TypeContent-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
CookieDomain/Path 决定作用域,SameSite 决定跨站,HttpOnly 防读取
存储按「同步/异步、容量、是否随请求发送」选型;配额会被驱逐
SW版本化缓存 + 更新提示;HTML 不要 cache-first
BFCache别监听 unload;在 pagehide 清理、pageshow 恢复
fetch4xx/5xx 不 reject;流式读取要看代理缓冲;上传进度用 XHR

自检题

  1. 为什么 position: fixed 在某个容器里会「跟着滚动」?——祖先有 transform,包含块变了。
  2. 带 Cookie 的跨域请求为什么被打回?——Allow-Origin: * 与 credentials 互斥。
  3. Service Worker 更新后为什么用户还是旧页面?——旧 SW 控制 + cache-first + 等待激活。
  4. SameSite=Lax 能防住所有 CSRF 吗?——不能,顶层 GET 导航仍带 Cookie。
  5. fetch 返回 500 会进 catch 吗?——不会,只有网络层失败才 reject。
  6. 为什么第三方 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 把已经光栅好的瓦片往上挪,不需要主线程重新计算样式或布局。

但这有三个前提:

  1. 内容已经光栅化过:新进入视口的区域如果还没瓦片,就要现场光栅,来不及就出现白块(checkerboarding)。
  2. 没有「非 passive」的滚动/触摸监听:如果页面在 wheel / touchmove 上挂了非 passive 监听,浏览器必须先把事件送给主线程执行 JS,等它决定要不要 preventDefault,滚动才能开始。
  3. 没有依赖主线程的滚动效果: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。光栅线程按「视口附近优先」把瓦片画出来,合成线程再从瓦片拼出画面。

由此产生四个可观测现象:

  1. 快速滚动出现白块:新区域还没光栅完。低端机、大图、复杂滤镜时最明显。
  2. 层越大,内存越高:一个层的显存约等于 宽 × 高 × 设备像素比² × 4 字节。一个 2000×10000 的层,在 2x 屏上约 320MB——足够让移动端直接 OOM。
  3. 滤镜/阴影/大渐变很贵:它们不是「贴一张图」,而是每帧可能都要重新光栅。
  4. 图片解码也占资源:大图缩小、频繁换图、滚动中解码,都会抢光栅线程的时间。
<!-- 屏外内容跳过布局/绘制,滚进视口再算 -->
<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 自检

  1. 主线程被长任务占满,为什么滚动还能动?——滚动偏移由合成线程维护,瓦片已光栅,不需要主线程重新布局。
  2. 为什么 JS 每帧改 transform 仍会卡?——值由主线程计算,主线程没空就产不出新值;CSS/WAAPI 才能完全交给合成线程。
  3. 快速滚动出现白块,查哪里?——GPU/Raster 轨道与 Layers 面板,重点看层面积、图片解码、滤镜。
  4. 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 + portDOM 读写、Cookie 之外的存储、CORS跨源读不到 DOM/响应,要 CORS
站点(site)scheme + eTLD+1Cookie 的 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 部署检查清单

  1. 需要 SharedArrayBuffer 吗?→ 配 COOP same-origin + COEP require-corp,并逐项验证第三方资源。
  2. 有 OAuth 弹窗吗?→ 用 same-origin-allow-popups,或改成回调页 + postMessage。
  3. 嵌入了第三方 iframe 吗?→ 加 sandbox,只开必要令牌;确认对方允许被嵌入。
  4. 允许被别的站点嵌入吗?→ 用 frame-ancestors 精确控制,不要图省事写 *。
  5. 还在用 document.domain 吗?→ 尽快移除,改用 postMessage / CORS。
  6. 跨源图片/字体被 COEP 拦了吗?→ 让对方加 CORP,或走同源代理。

自检:为什么「设了 COOP/COEP 之后页面图片全挂了」?——require-corp 要求每个跨源子资源显式授权;没带 CORP/CORS 的图片会被拦。

3.4 存储体系与配额

  • 配额按源分配,通常为磁盘剩余空间的一定比例。
  • 同一源下 IndexedDB、Cache Storage、OPFS 共享配额。
  • 隐私模式/无痕模式下配额更小,且关闭即清空。
  • 存储压力下会触发 StorageManager 驱逐,持久化存储(persist)不会被驱逐。

离线优先应用的设计要点:

  1. 启动时 persist() 申请持久化;
  2. 用 IndexedDB 存业务数据,Cache Storage 存静态资源;
  3. 每次读取都处理「数据可能被清除」的情况(不能假设一定在);
  4. 版本迁移要有 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 面板录制的读法:

  1. 看 FPS 条:红色表示掉帧。
  2. 看 Main 轨道:哪些任务超过 50ms(红色角标)。
  3. 切到 Bottom-Up:按「自身耗时」排序,找到最贵的函数。
  4. 看 Call Tree:从入口往下追调用链。
  5. 看 Timings:LCP、DCL、FP 标记位置。
  6. 看 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 通信
ElectronChromium 版本锁定,可用 Node API
自研容器API 子集,需特性检测

原则:特性检测优先于 UA 判断。

if ('showModal' in HTMLDialogElement.prototype) {
  dialog.showModal();
} else {
  fallbackOpen(dialog);
}

3.10 L3 自检

  1. 滚动掉帧如何区分主线程、合成、光栅?——Performance 看 Main 是否有长任务;Layers 看层数;Rendering 看 paint flashing;GPU/Raster 轨道看是否缺瓦片。
  2. SharedArrayBuffer 为什么需要额外响应头?——跨源隔离要求(COOP/COEP),防 Spectre 类攻击。
  3. 首屏资源顺序乱,怎么用优先级 API 干预?——fetchpriority + preload + 移除不必要的同步脚本。
  4. 为什么 CSS 动画比 JS 每帧改 transform 更稳?——CSS 动画可由合成线程驱动;JS 动画的值仍要主线程计算。
  5. isInputPending / scheduler.yield 解决什么问题?——让长任务在「有输入/该渲染」时主动让出,降低 INP。
  6. 为什么一个巨大的合成层比多个小层更糟?——大层每次变化都可能整层重光栅,显存占用也按面积增长。

4. L4 专家:治理与量化

4.1 规范到实现的差异治理

建立「能力矩阵」文档:

能力规范状态ChromeSafariFirefox降级方案
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代码缺陷
资源错误捕获阶段监听 errorCDN/网络问题
崩溃document.wasDiscarded、visibilitychange 异常内存/OOM
卡死心跳上报中断主线程死循环

4.6 L4 自检

  1. 某特性 20% 用户设备不支持,如何设计「渐进增强 + 降级 + 度量」?——用特性检测而不是 UA 判断,核心功能保留可用降级路径(polyfill、替代交互或服务端兜底),非核心增强直接隐藏;度量降级命中率、功能完成率与错误率,并按支持/不支持分桶对比。
  2. 如何用现场数据证明优化有效,而不是随机波动?——用 RUM 分桶(版本、机型、网络、地域),做灰度或 A/B 对照,比较 p75/p95 与置信区间,排除活动与发布日影响;单次 Lighthouse 分数不能作为证据。
  3. 自研容器要裁剪标准 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 可以只走合成,因为合成线程能独立处理。
  • 追问:为什么 transform 动画通常更流畅?
    • 要点:元素被提升为合成层后,动画由合成线程驱动,不占用主线程;主线程被长任务阻塞时动画仍能推进。代价是每层都要独立显存,层数过多会带来内存与合成开销。
  • 再追问:will-change: transform 为什么不能随便加?
    • 要点:它会提前创建合成层并常驻,元素多时显存暴涨,移动端可能直接触发 OOM 或掉帧;正确用法是「交互前加、结束后移除」,或用 transform: translateZ(0) 之外更语义化的方式,并通过 Layers 面板确认层数。

链 2 · 进程模型 → 同源策略 → 跨域隔离

  • 面试官:为什么一个标签页崩溃不影响其他标签页?
    • 要点:Chrome 采用多进程架构,默认按站点隔离(Site Isolation),每个站点实例独立进程(同站标签页可能复用);GPU 进程、网络进程、浏览器主进程各自负责合成、请求与窗口管理。
  • 追问:同源策略到底限制了什么?
    • 要点:限制的是「读取」而不是「发送」。跨源请求默认可以发出(简单请求),但响应被浏览器拦截不给 JS 读;img、script、form 等标签不受同一限制,这也是 JSONP 与 CSRF 存在的土壤。
  • 再追问: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 与进程复用,不改变同源策略。
  • 追问:SameSite 怎么判断「跨站」?Lax 有什么例外?
    • 要点:站点 = scheme + eTLD+1;a.example.com 与 b.example.com 同站,http 与 https 跨站。Lax 允许顶层 GET 导航带 Cookie,跨站 POST/iframe/fetch 不带;Chrome 还有「新设置 2 分钟内允许 Lax+POST」的兼容例外。
  • 再追问:跨域带 Cookie 的请求为什么失败?
    • 要点:三件事要同时满足——前端 credentials: 'include'、服务端具体 Origin + Allow-Credentials: true、Cookie 本身 SameSite=None; Secure 且未被第三方 Cookie 策略拦截。预检请求不带 Cookie,别在预检里做登录校验。
  • 再追问:HttpOnly 能防 CSRF 吗?
    • 要点:不能。它只阻止 JS 读 Cookie,浏览器仍会自动带上;CSRF 要靠 SameSite、CSRF Token、校验 Origin/Referer,以及不用 GET 做状态变更。

链 5 · 页面生命周期 → BFCache → 卸载上报

  • 面试官:用户切到后台、再点后退,页面分别发生了什么?
    • 要点:切后台进入 hidden,定时器被节流;长时间后台可能 frozen,内存压力下 discarded。后退时若命中 BFCache,整页(含 JS 堆)被恢复,触发 pageshow 且 persisted = true。
  • 追问:为什么说 unload 监听器有害?
    • 要点:Chrome 中带 unload 的页面无法进入 BFCache,后退要重新加载;unload 在移动端还经常不触发,既不可靠又有代价。清理逻辑放 pagehide / visibilitychange。
  • 再追问:怎么保证卸载前的埋点不丢?
    • 要点:用 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 场景设计题

题:一个商品列表页在低端安卓机上滚动掉帧,同时用户反馈「用久了越来越卡」。你怎么联合排查?

评估点:是否把「掉帧」和「内存增长」当成两个问题、是否用面板取证。

参考方案:

  1. 分现象:滚动掉帧看 Performance(FPS、Long Task、Layout 次数);越用越卡看 Memory(快照对比、Detached DOM)。
  2. 掉帧归因:若 Layout 次数与滚动次数同阶,说明在滚动回调里读写布局(强制同步布局);若主线程被长任务占满,把图片解码、数据格式化移出主线程。
  3. 内存归因:快照对比找 Detached DOM,沿 Retainers 定位持有者,常见是全局缓存、未解绑的 scroll 监听、闭包持有的列表项。
  4. 修复:滚动监听 passive: true + rAF 合并;列表项复用(回收 DOM);图片按视口解码(loading="lazy" + decoding="async");缓存设上限。
  5. 验证:同一台低端机跑 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 面板录一次「点击 → 内容出现」,然后回答三个问题:主线程最长任务是多少毫秒?有没有强制同步布局?掉帧发生在哪一帧?