深入了解现代 Web 浏览器
Category(分类): Browser Status: 已更新
这篇文章从 CPU、GPU、进程和线程开始,沿着“输入 URL 到页面可交互”的路径,了解现代浏览器如何把 HTML、CSS、JavaScript 和资源转换成屏幕上的像素。
文中的架构图主要参考 Chromium,但浏览器架构属于实现细节,不是 Web 标准。Chrome、Firefox、Safari、Android WebView 以及不同版本的 Chromium,在进程拆分、线程数量、缓存和渲染调度上都可能不同。
一、CPU、GPU、进程和线程
1. CPU 和 GPU
CPU 擅长执行通用指令和复杂的分支逻辑;GPU 擅长并行处理大量相似的图形计算。浏览器会根据平台和驱动把部分栅格化、合成、视频解码或 WebGL 工作交给 GPU,但“使用 GPU”并不意味着 JavaScript、样式计算和布局都在 GPU 上运行。

应用程序通常通过操作系统提供的接口使用 CPU、GPU、文件和网络。浏览器还要在性能、隔离、安全和功耗之间做取舍:桌面设备可能使用更多进程和线程,移动设备则可能合并服务以降低内存占用。
2. 进程和线程
进程是操作系统提供的资源和隔离边界之一,通常拥有独立的虚拟地址空间、文件描述符和权限上下文。线程是进程中的执行单元,同一进程的线程可以共享堆和大部分进程资源,但每个线程仍有自己的栈、寄存器和执行状态。

进程之间不能直接读写彼此的普通内存,需要通过 IPC、共享内存、管道、消息队列或操作系统对象通信。进程隔离可以限制崩溃和恶意代码的影响,但不是绝对隔离:操作系统、驱动、共享文件、GPU 和 IPC 本身仍是复杂的安全边界。
并发和并行也需要区分:
- 并发:多个任务在一段时间内交替推进,单核 CPU 也可以通过调度实现;
- 并行:多个任务在同一时刻由不同的 CPU 核心或执行资源同时运行。
原文把 async/await 直接等同于“协程”并不严谨。async/await 是语言和运行时对 Promise 异步控制流的语法支持,会把后续工作安排到任务/微任务调度中;它不是一个保证独立操作系统线程或独立进程的通用协程实现。不同语言的 coroutine、fiber、goroutine 也有不同的调度模型。
二、Chrome/Chromium 的多进程架构
1. 浏览器架构是实现选择
浏览器可以使用一个进程配合多个线程,也可以使用多个进程通过 IPC 协作。网页平台并没有规定浏览器必须采用哪一种进程模型。

Chromium 的高层结构通常包括:
- Browser process:浏览器 UI、导航协调、权限、会话、部分网络和存储服务;
- Renderer process:运行 Blink、V8,处理某个站点/标签页/框架中的网页内容;
- GPU/Viz process:处理 GPU 资源、栅格化、显示合成等工作,现代 RenderingNG 中 Viz 是重要的合成聚合组件;
- Utility process:按需运行网络、音视频、数据解码、打印等服务;
- Extension/plugin process:扩展或历史插件的隔离环境,Flash 等旧插件已经不属于现代网页的常规路径。

“一个标签页一个渲染进程”是便于入门的旧模型,不能当作固定规则。现代 Chromium 会结合站点隔离、站点实例、标签页关系、内存压力、设备能力和 WebView 环境决定进程归属:
- 不同站点通常会放在不同渲染进程,跨站 iframe 也可能使用独立进程;
- 同一站点的多个标签页不一定总是共享或总是独立;
- 强内存压力下,浏览器可能合并部分工作;
- 浏览器进程、GPU/Viz 和实用程序进程也不一定只有一个;
- DevTools 任务管理器展示的是某个版本和设备上的实际结果。
2. 站点隔离和沙盒
同源策略是 Web 数据安全的核心规则,站点隔离则进一步把不同站点放到不同的渲染进程中,降低 Spectre 等侧信道和渲染器漏洞跨站窃取数据的风险。渲染器进程通常运行在沙盒中,不能任意访问本地文件或系统资源,特权操作由浏览器进程和其他服务代为执行。
多进程的好处包括:
- 某个页面卡死时,其他页面有机会继续工作;
- 通过操作系统权限和沙盒隔离不可信网页;
- 不同站点的内存和任务可以分开调度;
- 网络、GPU、媒体等服务可以独立演进或重启。
代价是:每个进程有额外内存、IPC 和调度成本,浏览器必须在稳定性和内存占用之间动态平衡。
三、从地址栏输入 URL 到导航提交
1. 处理输入
地址栏既可以接受 URL,也可以接受搜索词。浏览器 UI 线程会进行规范化、搜索引擎判断、安全策略和历史记录处理。用户也可以通过链接、表单、脚本、历史记录、推送通知或恢复会话发起导航。
2. 开始导航
按下回车后,浏览器可能先检查:
- HTTP 缓存和内存缓存;
- Service Worker 是否控制该 URL;
- 前进/后退缓存(BFCache)是否可以恢复页面;
- 已有的 HTTP/2 或 HTTP/3 连接是否可以复用。
没有短路时,网络栈会进行 DNS、连接、TLS 和 HTTP 协商。HTTP/1.1、HTTP/2 over TCP 与 HTTP/3 over QUIC 的握手路径不同,不能把固定的“几次握手”当作所有访问的事实。
3. 重定向和响应
服务端可能返回 301、302、307 或 308,浏览器会按照重定向规则发起新的导航,并检查重定向次数、协议降级和安全策略。Location 不只可能指向另一个站点,也可能指向同站点的规范 URL、登录页或语言版本。
网络栈会依据响应头、响应体和导航上下文处理 MIME 类型。HTML 导航会交给渲染流程,下载、PDF、图片或其他类型可能交给下载管理器、内置查看器或外部应用。MIME 嗅探、安全浏览、CORB/ORB 等是浏览器实现中的额外安全检查,不应简化为“只要 Content-Type: text/html 就一定渲染”。
4. 查找或启动渲染进程
浏览器已经知道目标站点后,可以在网络请求的同时预热或寻找合适的渲染进程。若请求发生跨站重定向,预先准备的进程可能不会被使用。

5. 提交导航
当响应和渲染进程准备好后,浏览器进程通过 IPC 向渲染进程提交导航,并把文档数据流交给它。渲染进程随后解析 HTML、加载资源和生成页面。地址栏、安全指示器和会话历史也会更新。
“导航提交完成”不等于“用户已经看到完整页面”。DOMContentLoaded、load、首次绘制、LCP 和页面真正可交互是不同时间点;客户端 JavaScript 在 load 后仍然可以请求资源、渲染视图或注册事件。
6. 离开当前页面
导航离开前,浏览器可能询问页面是否注册了 beforeunload。该事件只能在有限条件下触发离开确认,不能自定义对话框文本,也不适合用来保存关键数据。
不要依赖 unload 做清理或数据保存:它可能阻止 BFCache,移动端也可能根本不触发。更可靠的做法是使用 visibilitychange、pagehide、freeze 等页面生命周期信号,并把关键数据及时写入服务端或本地存储。
四、Service Worker 和 Navigation Preload
Service Worker 是受源和作用域限制的事件驱动 Worker,拥有独立的全局执行环境。它可以拦截受控页面的请求,在网络可用时访问网络,也可以从 Cache API 或其他本地数据返回响应。
旧资料把 Service Worker 说成“运行在渲染进程中”过于绝对。它由浏览器调度,通常不会和某个页面的主线程共享执行环境,也不保证固定的操作系统进程;页面关闭后它仍可能被短暂唤醒处理事件,空闲时则会被终止。
// sw.js
self.addEventListener('fetch', event => {
if (event.request.mode !== 'navigate') return
event.respondWith((async () => {
const cached = await caches.match(event.request)
if (cached) return cached
const response = await fetch(event.request)
return response
})())
})
首次注册后,Service Worker 还要经历安装、激活和控制页面的生命周期;注册成功不代表当前页面立刻由它控制。更新和缓存策略必须处理版本、失败回退、敏感响应以及旧 Worker 的清理。
如果 Service Worker 启动本身增加了导航延迟,可以启用 Navigation Preload,让浏览器在启动 Worker 的同时发起导航请求:
// sw.js
self.addEventListener('activate', event => {
event.waitUntil(self.registration.navigationPreload.enable())
})
self.addEventListener('fetch', event => {
if (event.request.mode !== 'navigate') return
event.respondWith((async () => {
const preload = await event.preloadResponse
return preload || fetch(event.request)
})())
})
五、渲染进程中的主要工作
渲染进程负责把 HTML、CSS、JavaScript 和资源转换为可交互页面。现代 Chromium 中常见的角色包括:
- 主线程:HTML/CSS 解析、脚本、DOM、样式、布局、事件分发和文档生命周期;
- 合成线程:输入路由、滚动、合成动画和图层组织;
- 栅格工作线程:把绘制列表栅格化为纹理图块;
- Worker 线程:运行 Web Worker、SharedWorker、部分 OffscreenCanvas 工作;
- 媒体、解码和其他辅助线程:处理图片、音视频和特定 API。
这不是“一个页面只有四五个固定线程”。线程数量会随设备、内容和浏览器版本变化。
1. 构建 DOM
浏览器从字节解码为字符,再经过 HTML tokenizer 和 tree builder 构建 DOM。HTML 解析具有标准规定的错误恢复和插入模式,并不是简单的 XML 树构建。

预加载扫描器会在主解析器之外尽早发现脚本、样式、图片、字体等资源,但它是浏览器的优化实现,不会改变 HTML 解析的语义。

2. 脚本加载和执行
经典脚本默认会在解析器遇到它时暂停解析,下载并执行脚本。常用写法如下:
<script src="classic.js" defer></script>
<script type="module" src="main.js"></script>
<script src="analytics.js" async></script>
defer经典脚本在文档解析完成后执行,并保持多个 defer 脚本的文档顺序;- 模块脚本默认具有类似延迟执行的行为,并按模块依赖图加载;
async脚本下载完成后尽快执行,多个 async 脚本之间没有执行顺序保证;- 动态创建的经典脚本默认
async为真,除非显式调整; DOMContentLoaded会等待 defer 和模块脚本,但不等待 async 脚本和所有图片;- 脚本执行可能读取和修改 DOM、样式及布局,因此已发现的 CSS 可能影响脚本执行时机。
preload 只是下载提示,必须正确声明 as、type、crossorigin 等属性;过多 preload 反而会和关键资源争抢带宽。
3. CSS、样式和布局
CSS 会被解析为规则,并通过层叠、继承、媒体查询、容器查询和自定义属性得到计算样式。浏览器还会应用用户代理默认样式。

在教学中常把 DOM 与 CSSOM 合成“Render Tree”。这个模型仍然有帮助,但现代 Chromium 内部还会维护布局片段树、属性树、绘制块和合成结构;不同浏览器不能共用一套内部类名。
布局阶段会计算参与布局内容的尺寸和位置。display: none 通常不参加布局,visibility: hidden 通常仍占据空间;伪元素、匿名盒、表格、滚动容器和 iframe 会让布局结构比 DOM 更复杂。

4. Paint、Raster 和 Composite
布局之后,浏览器记录背景、边框、文本、图片、阴影和滤镜等绘制指令,形成显示列表或类似结构;栅格化阶段执行这些绘制指令,生成 GPU/CPU 可以使用的图块;合成阶段再按照变换、裁剪、透明度、滚动和层叠关系组成最终画面。

RenderingNG 将渲染拆成 Animate、Style、Layout、Pre-paint、Scroll、Paint、Commit、Layerize、Raster、Activate、Aggregate、Draw 等阶段。每一帧不一定完整执行所有阶段:合成属性动画和滚动有时可以跳过 Layout、Paint,直接在合成线程更新。


transform、opacity 动画可能只更新合成属性,但不代表“所有 transform 都使用 GPU”或“加 translateZ(0) 就一定更快”。will-change 是提示浏览器提前准备资源,长期给大量元素设置会增加内存和栅格化成本。

合成线程和栅格线程可以减少主线程工作,但如果动画内容本身需要重新布局、绘制或解码,主线程仍然可能成为瓶颈。应通过 Performance、Layers 和 Rendering 面板观察真实更新路径。
六、输入事件与滚动
鼠标、触摸、键盘和滚轮都属于输入。浏览器进程先接收设备事件,再把事件路由到合适的渲染进程;渲染进程可以进行命中测试并触发 DOM 事件。

如果合成线程能够确定滚动不需要等待主线程,它可能直接处理滚动,从而避免主线程长任务阻塞视觉滚动。若页面的非被动监听器可能调用 preventDefault(),浏览器就需要在执行默认滚动前询问主线程。
const scroller = document.querySelector('.scroller')
// 不需要阻止默认滚动时,明确声明 passive
scroller?.addEventListener('touchmove', handleTouchMove, {
passive: true
})
function handleTouchMove(event) {
// 这里只做轻量工作,不调用 preventDefault()
console.log(event.touches.length)
}
确实需要阻止默认滚动时,必须使用 passive: false,但应尽量把监听范围缩小:
const canvas = document.querySelector('canvas')
canvas?.addEventListener('touchmove', event => {
if (event.cancelable) event.preventDefault()
}, { passive: false })
在 passive: true 监听器中调用 preventDefault() 不会达到预期,浏览器通常会忽略它并发出警告。passive 也不是“让回调在后台线程执行”,回调仍然在对应的 JavaScript 执行环境中运行。

浏览器可能合并或延迟部分高频指针事件,也可能提供 PointerEvent.getCoalescedEvents() 获取更细粒度数据;具体事件合并策略由浏览器和设备决定。视觉更新可以批量安排到 requestAnimationFrame,但不能把它当作固定 60Hz 的计时器。
let scheduled = false
let latestX = 0
window.addEventListener('pointermove', event => {
latestX = event.clientX
if (scheduled) return
scheduled = true
requestAnimationFrame(() => {
scheduled = false
document.documentElement.style.setProperty('--pointer-x', `${latestX}px`)
})
})
七、如何从浏览器原理指导性能优化
- 减少阻塞首屏的脚本,合理使用
defer、模块脚本和代码分割; - 为图片、视频和 iframe 提供尺寸,减少布局偏移;
- 使用缓存、CDN、压缩、HTTP/2/3 和连接复用降低网络延迟;
- 将大计算、数据处理和合适的 Canvas 工作迁移到 Worker,但不要把 DOM 操作直接放进普通 Worker;
- 动画优先评估
transform、opacity,但必须用 trace 验证是否真的降低了 Layout/Paint; - 使用
content-visibility、CSS containment 和懒加载时,验证尺寸、焦点、可访问性和搜索行为; - 以 LCP、INP、CLS、Long Task、Layout、Paint、Raster 和 Composite 数据为依据,而不是背诵固定的“14KB”“6 个连接”或“每个标签页一个进程”。