主题
第 23 章 · 浏览器原理
一句话开篇:浏览器是前端的「家」——不理解它,写出来的代码就像不知道厨房布局的厨师做菜,永远只能照菜谱,做不出意外。
0. 生活类比(先建立直觉)
把浏览器想象成一家外卖中央厨房:
| 浏览器组件 | 中央厨房 |
|---|---|
| 浏览器进程 | 总经理(管前台、调度) |
| 网络进程 | 采购员(去网上下单买原料) |
| 渲染进程 | 厨师(把原料做成成品 = 把 HTML 渲染成像素) |
| GPU 进程 | 摆盘师(把菜摆得漂亮、上桌) |
| 插件进程 | 外聘的临时帮厨 |
| 标签页 | 一个独立厨房(每个 tab 一个渲染进程) |
把"打开一个网页"想象成"下单一份外卖":
你下单(输入 URL)
↓
总经理(浏览器进程)记录订单
↓
采购员(网络进程)跑去市场(DNS → TCP → HTTP)
↓
厨师(渲染进程)拿到原料(HTML/CSS/JS)开始做菜
↓
摆盘师(GPU 进程)摆好端给你(屏幕显示)多进程的好处:一个厨房着火(一个标签页崩溃),不会烧掉整个外卖店(其他标签页)。这就是 Chrome 的核心设计。
1. 浏览器架构(多进程模型)
现代浏览器(以 Chrome 为代表)是多进程架构:
┌─────────────────────────────────────────────────────────────┐
│ Chrome 多进程架构 │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────┐ 管理标签、地址栏、菜单 │
│ │ 浏览器主进程 │ 唯一一个 │
│ └──────────────────┘ │
│ │ │
│ ├──────────────────┐ │
│ ↓ ↓ │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ 渲染进程 1 │ │ 渲染进程 2 │ 每个标签一个 │
│ │ (taobao.com) │ │ (zhihu.com) │ (沙箱隔离) │
│ └──────────────────┘ └──────────────────┘ │
│ │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ 网络进程 │ │ GPU 进程 │ │
│ │ (HTTP/Cookie) │ │ (合成、画图) │ │
│ └──────────────────┘ └──────────────────┘ │
│ │
│ ┌──────────────────┐ │
│ │ 插件进程 / 扩展 │ │
│ └──────────────────┘ │
└─────────────────────────────────────────────────────────────┘各进程职责
| 进程 | 职责 |
|---|---|
| 浏览器 | 主进程,管 UI、菜单、tab、子进程调度 |
| 渲染 | 解析 HTML/CSS、跑 JS、合成图层(每个 tab 一个) |
| 网络 | 处理网络请求(HTTP/HTTPS/DNS/Cookie) |
| GPU | 处理 3D / 合成 / 部分 2D 加速 |
| 插件 | 浏览器插件、扩展 |
| 存储 | IndexedDB、Cache 等持久化(独立进程) |
⚠️ Site Isolation:从 2018 年起,不同站点(不只是不同 tab)会被隔离到不同渲染进程,防止 Spectre 漏洞利用。
渲染进程内部
渲染进程内部又有多个线程:
┌──── 渲染进程 ────────────────────────┐
│ ① GUI 渲染线程 ─ 排版、绘制 │
│ ② JS 引擎线程 ─ V8 跑 JS(与 ① │
│ 互斥) │
│ ③ 事件触发线程 ─ 把事件塞进任务队列 │
│ ④ 定时器线程 ─ setTimeout/Interval│
│ ⑤ 网络异步线程 ─ XHR/fetch 回调 │
│ ⑥ 合成线程 ─ 把图层合成 │
└─────────────────────────────────────┘关键点:JS 引擎线程和 GUI 渲染线程互斥——JS 跑得久了,页面就卡。
2. 从输入 URL 到页面渲染(必背!)
这是面试必考题。完整答案要分 5 个阶段:
┌──────────────────────────────────────────────────────────┐
│ 浏览器地址栏输入 https://www.example.com │
└──────────────────────────────────────────────────────────┘
↓
┌─── 阶段一:URL 解析 + 缓存检查 ─────────────────────────┐
│ • 检查是否非法 URL(按搜索处理) │
│ • 检查 HSTS 缓存(强制 HTTPS) │
│ • 查浏览器缓存:strong cache 命中 → 直接渲染 │
└──────────────────────────────────────────────────────────┘
↓
┌─── 阶段二:DNS 解析 ────────────────────────────────────┐
│ 浏览器 DNS 缓存 │
│ ↓ miss │
│ 操作系统 DNS 缓存(hosts → /etc/hosts) │
│ ↓ miss │
│ 本地 DNS 服务器(运营商) │
│ ↓ miss │
│ 根域名服务器 → .com 顶级 → example.com 权威 │
│ ↓ │
│ 得到 IP(如 93.184.216.34) │
└──────────────────────────────────────────────────────────┘
↓
┌─── 阶段三:建立连接 ────────────────────────────────────┐
│ • TCP 三次握手 │
│ Client → SYN → Server │
│ Client ← SYN+ACK ← Server │
│ Client → ACK → Server │
│ • TLS 握手(HTTPS) │
│ Client Hello → Server Hello → 证书验证 → 密钥协商 │
│ • HTTP/2 / HTTP/3 还涉及多路复用、QUIC │
└──────────────────────────────────────────────────────────┘
↓
┌─── 阶段四:发送请求 + 接收响应 ─────────────────────────┐
│ • 发 HTTP 请求(Request Line + Headers + Body) │
│ • 服务器处理(路由、业务、查 DB……) │
│ • 返回 Response(状态行 + Headers + Body) │
│ • 浏览器收到 HTML 流(边收边解析) │
└──────────────────────────────────────────────────────────┘
↓
┌─── 阶段五:浏览器渲染 ──────────────────────────────────┐
│ 1. 解析 HTML → DOM 树 │
│ 2. 解析 CSS → CSSOM 树 │
│ 3. 合成 Render Tree(DOM + CSSOM,去掉 display:none) │
│ 4. Layout:计算每个节点的几何信息(位置、尺寸) │
│ 5. Paint:把每个节点画到对应图层 │
│ 6. Composite:GPU 合成图层 → 显示到屏幕 │
│ 7. JS 执行(解析 → 编译 → 执行;可能阻塞渲染) │
│ 8. 触发 DOMContentLoaded / load 事件 │
└──────────────────────────────────────────────────────────┘
↓
用户看到页面
↓
四次挥手关闭 TCP 连接几个考点细节
TCP 三次握手为什么是三次? 两次不够:服务端无法确认客户端的接收能力;四次浪费。三次刚好双向确认。
为什么挥手要四次? TCP 是全双工的,关闭时双方各发一次 FIN+ACK。
HTTPS 握手简化版:
1. ClientHello:客户端发支持的 TLS 版本、密码套件、随机数 R1
2. ServerHello:服务端选定 TLS 版本/套件,发随机数 R2 + 证书 + 公钥
3. 客户端验证证书 → 用公钥加密"预主密钥 R3"发回
4. 双方用 R1 + R2 + R3 计算出对称密钥
5. 后续通信用对称密钥加密详见第 24 章。
3. 渲染流水线详解
HTML ─────► Tokens ─────► DOM Tree
╲
CSS ─────► Tokens ─────► CSSOM Tree ╲
▶ Render Tree
▶ Layout(几何)
▶ Paint(绘制指令)
▶ Composite(合成)
▶ 屏幕显示① 解析 HTML → DOM
边下边解析(流式),生成树形结构:
<html>
<head><title>Hi</title></head>
<body>
<h1>Hello</h1>
</body>
</html>
↓
document
└─ html
├─ head
│ └─ title — "Hi"
└─ body
└─ h1 — "Hello"② 解析 CSS → CSSOM
body { font-size: 16px; }
h1 { color: red; }
↓
CSSOM
└─ body
└─ font-size: 16px
└─ h1
└─ color: red
└─ font-size: 16px (继承)③ 构建 Render Tree
DOM + CSSOM 合并,去掉不显示的节点(display: none 的、<head> 等)。
⚠️
visibility: hidden仍然在 Render Tree 里(占位置但不可见),display: none不在。
④ Layout(布局 / 回流)
计算每个节点的精确位置和大小(盒模型 + 文档流 + 浮动 + flex/grid)。
⑤ Paint(绘制 / 重绘)
把每个节点画到一个或多个图层上(绘制指令列表,还没真画到屏幕)。
⑥ Composite(合成)
把多个图层送到 GPU,由合成线程合成最终位图,输出到屏幕。
4. 回流 vs 重绘 vs 合成
| 阶段 | 触发 | 代价 |
|---|---|---|
| 回流 | 几何属性变化(width/height/position/display) | 最贵,整树重新计算 |
| 重绘 | 仅外观变化(color/background/visibility) | 中等,跳过 Layout |
| 合成 | 仅 transform / opacity | 最便宜,仅 GPU 工作 |
变更属性
│
├── width/height/font-size/display ──► Layout → Paint → Composite【回流】
│
├── color/background/box-shadow ─────► Paint → Composite 【重绘】
│
└── transform/opacity ───────────────► Composite 【合成】强制同步布局(Layout Thrashing)
js
// ❌ 每次循环都触发回流
for (let i = 0; i < items.length; i++) {
items[i].style.width = items[i].offsetWidth + 10 + 'px';
// 读 offsetWidth → 强制 Layout → 写入 → 又一次 Layout
}
// ✅ 先批读,再批写
const widths = items.map(it => it.offsetWidth);
items.forEach((it, i) => it.style.width = widths[i] + 10 + 'px');5. 合成层(Composite Layer)与 GPU 加速
什么是合成层
浏览器会把"动起来更频繁"的元素单独提为一个图层,让 GPU 单独合成,避免动一下就回流整个页面。
触发合成层的条件
| 条件 | 说明 |
|---|---|
transform: translateZ(0) / translate3d(0,0,0) | 经典的"hack",强制 GPU |
will-change: transform / opacity | 现代标准方法 |
<video> / <canvas> | 自带合成层 |
position: fixed | 通常会 |
opacity 动画期间 | 临时层 |
filter / backdrop-filter | 触发 |
收益
- 动画只在合成阶段做,不进入 Layout/Paint,60fps 丝滑。
- 这一层重绘不影响其他层。
代价
- 吃显存:每个层的位图都要存到 GPU 内存。
- 滥用
will-change会让首屏更慢(创建层有开销)。
⚠️ 经典反模式:给上千个元素都加
will-change: transform,浏览器卡到崩。用了就要在动画结束后will-change: auto。
6. 事件循环(浏览器版)
JavaScript 是单线程的,靠"事件循环"实现异步。
主循环
┌────────────────────────────────────────┐
│ 事件循环(一圈) │
├────────────────────────────────────────┤
│ 1. 取一个宏任务执行 │
│ 2. 执行完后:清空整个微任务队列 │
│ 3. requestAnimationFrame 回调 │
│ 4. 渲染(如果有变化) │
│ 5. requestIdleCallback(空闲时) │
│ 6. 回到 1 │
└────────────────────────────────────────┘宏任务 vs 微任务
| 类型 | 例子 | 执行时机 |
|---|---|---|
| 宏任务 | setTimeout、setInterval、MessageChannel、I/O、UI 事件 | 每轮取一个 |
| 微任务 | Promise.then、MutationObserver、queueMicrotask | 每个宏任务后全清空 |
经典面试题
js
console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
console.log('4');
// 输出:1 → 4 → 3 → 2
// 同步代码 → 微任务 → 下一轮宏任务requestAnimationFrame vs requestIdleCallback
| API | 何时执行 | 用途 |
|---|---|---|
requestAnimationFrame | 下一帧渲染前 | 流畅动画、读 DOM 测量 |
requestIdleCallback | 浏览器空闲时(一帧渲染完后剩余时间) | 非紧急任务(埋点、预取) |
js
// 60fps 动画
function tick() {
element.style.transform = `translateX(${x}px)`;
x += 1;
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
// 空闲时上报埋点
requestIdleCallback(deadline => {
while (deadline.timeRemaining() > 0 && queue.length) {
sendOne(queue.shift());
}
});7. 跨标签页通信
| 方式 | 同源限制 | 持久性 | 适用场景 |
|---|---|---|---|
localStorage 事件 | 同源 | 持久 | 简单跨 tab 通知 |
BroadcastChannel | 同源 | 临时 | 现代首选,多 tab 群聊 |
postMessage | 跨源 ✅ | 临时 | iframe / 弹出窗口通信 |
SharedWorker | 同源 | 临时 | 多 tab 共享一份后台任务/状态 |
Service Worker | 同源 | 持久 | 离线、推送、消息中转 |
1. localStorage 事件(最古老)
js
// Tab A
localStorage.setItem('msg', 'hello' + Date.now());
// Tab B
window.addEventListener('storage', e => {
if (e.key === 'msg') console.log('收到:', e.newValue);
});
// 注意:只在"其他 tab"触发,发送方自己不会收到2. BroadcastChannel(推荐 ✅)
js
// 任意 tab
const ch = new BroadcastChannel('app');
ch.postMessage({ type: 'login', user: 'alice' });
ch.onmessage = e => console.log('收到:', e.data);3. postMessage(跨源)
js
// 父页面 → iframe
iframe.contentWindow.postMessage({ hi: 1 }, 'https://other.com');
// iframe 接收
window.addEventListener('message', e => {
if (e.origin !== 'https://parent.com') return; // ⚠️ 一定要校验来源!
console.log(e.data);
});4. SharedWorker
js
// shared.js
const ports = [];
onconnect = e => {
const port = e.ports[0];
ports.push(port);
port.onmessage = ev => ports.forEach(p => p.postMessage(ev.data));
};
// 各 tab
const w = new SharedWorker('shared.js');
w.port.start();
w.port.postMessage('hi');
w.port.onmessage = e => console.log(e.data);详见 examples/tab-communication.html。
8. ⚠️ 易踩的坑
以为
<script>不阻塞 HTML 解析——默认是阻塞的!加defer(DOM 解析完后按序执行)或async(下载完立即执行,不保证顺序)。DOMContentLoaded和load区别:DOMContentLoaded:DOM 树构建完成(不等图片、样式表完成)load:所有资源(图、CSS、iframe)都加载完- 一般注册事件用前者更快。
requestAnimationFrame在 tab 后台时会暂停——切换标签时动画会"冻住"。这其实是省电特性。postMessage不校验e.origin——存在 XSS 入口。永远校验来源。强制同步布局陷阱——读
offsetWidth后立刻写,再读再写……FPS 直接掉到 5。以为微任务会"暂停"宏任务——错。微任务是"清空",如果一个微任务里再 push 微任务,会一直清下去,可能导致页面卡死。
跨 tab 通信用
localStorage同 tab 触发——同 tab 不触发 storage 事件,只在其他 tab 触发。GPU 层数过多:DevTools → Layers 面板能看到层。每层都要显存,几百层就崩。
JS 长任务把所有事件都积压:单次 JS > 50ms 就会让页面无响应(INP 飙高)。要拆任务、用 Worker。
以为
setTimeout(fn, 0)是真的 0ms——HTML5 规范最小 4ms,且要等整个事件循环排到。
9. 一句话总结
浏览器是「多进程的画家」:网络进程拿原料,渲染进程把 HTML/CSS/JS 解析成 DOM/CSSOM,组成 Render Tree,经过 Layout → Paint → Composite 三步在 GPU 上画到屏幕;中间所有事件、定时器、Promise,都靠事件循环调度——理解了这个流程,你就能解释一切前端性能、渲染、异步问题。
10. 延伸阅读
- Inside look at modern web browser — Chrome 团队 4 篇博客
- HTML 规范 · Event loop
- What forces layout/reflow(Gist)
- JavaScript Visualized: Event Loop(图解)
- 《WebKit 技术内幕》(朱永盛)