浏览器原理
按一次页面打开的顺序,把浏览器做了什么、哪一步容易慢、工程上怎么优化串起来。先看主线:
浏览器多进程架构(以 Chrome 为例):
- 浏览器进程:负责地址栏、标签页、导航管理、权限、历史记录和进程协调。
- 网络进程:负责缓存、DNS、连接、请求、响应、代理和证书校验。
- 渲染进程:负责 HTML、CSS、JavaScript、DOM、样式、布局和绘制。
- GPU 进程:负责合成、纹理上传和最终显示。
其中渲染进程内部最关键的是主线程、合成线程和栅格线程池。主线程在大多数场景下最容易成为性能瓶颈,因为 HTML 解析、样式计算、布局、脚本执行大多都要经过它。
URL 导航
导航的入口在浏览器进程的 UI 线程。
用户在地址栏输入内容后,浏览器先判断它是搜索词还是 URL。搜索词会交给默认搜索引擎;URL 会补全协议,解析协议、域名、端口、路径、查询参数和哈希。
如果当前页面注册了 beforeunload,浏览器进程会通知旧页面所在的渲染进程执行这个事件。旧页面可以阻止跳转;确认可以离开后,浏览器才进入加载状态。
如果命中前进后退缓存,页面可能直接从内存中恢复,不再完整走后面的网络请求和渲染流程。
容易慢在这里:
beforeunload会影响前进后退缓存命中率,也会拖慢导航。- 重定向链太长时,用户会长时间停留在加载状态。
常见优化:
- 只在确实需要时才使用
beforeunload,不要把它当成默认配置。 - 减少 HTTP 重定向,能直达最终地址就不要多跳一层。
- 重要页面尽量满足前进后退缓存条件,避免页面离开时还挂着不必要的长连接或清理逻辑。
网络请求阶段
导航确认后,请求交给网络进程。网络进程负责缓存判断、DNS 解析、连接建立、请求发送和响应接收。
缓存策略
缓存判断发生在真正发请求之前。
网络进程发请求前,先判断浏览器缓存是否可用。缓存判断不只发生在 HTML 主文档,也发生在 CSS、JavaScript、图片、字体等子资源上。
先抓住几条主线:
- 强缓存:缓存没过期,浏览器直接用本地副本,请求不会发出去。
- 协商缓存:浏览器会带上缓存标识去问服务器,没变就继续用本地副本。
- 强缓存常看
Cache-Control、Expires,其中Cache-Control优先级更高。 - 协商缓存常看
ETag、Last-Modified,其中ETag一般更优先。 - 强缓存命中没有新的状态码;协商缓存命中通常是
304;没命中通常返回200。
容易出问题的地方:
- HTML 被强缓存,可能导致用户拿到旧入口文件。
- 静态资源不缓存,会导致重复下载。
- 缓存粒度太粗,资源变化后无法精确更新。
常见优化:
- HTML 使用协商缓存或较短强缓存,避免用户长期拿到旧入口。
- 带 hash 的 CSS、JavaScript、图片使用长期强缓存。
- 构建时让文件名带内容 hash,内容变化时文件名自然变化。
- 用户相关接口使用
Cache-Control: no-store,避免被浏览器或中间缓存保存。
DNS 解析
DNS 解析由网络进程发起,必要时会调用系统 DNS 能力。
缓存未命中时,网络进程把域名解析成 IP 地址。实际顺序受浏览器、系统和网络配置影响,但通常会经过浏览器缓存、系统能力、hosts 文件、本地 DNS 服务和权威 DNS 等环节。
DNS 解析完成后,网络进程才知道要连接哪台服务器。如果一个域名背后有多个 IP,浏览器或 DNS 服务还可能按可用性、地理位置和负载情况选择其中一个。
容易慢在这里:
- 首次访问域名时,DNS 查询会增加首包延迟。
- 页面里第三方域名太多,会产生多次 DNS 查询和连接建立。
- DNS 解析不稳定会直接拖慢所有资源请求。
常见优化:
- 对只需要提前解析域名的第三方源使用
dns-prefetch。
- 对首屏一定会请求的源使用
preconnect,提前完成 DNS、TCP 和 TLS,比如:
- 合并低价值第三方域名,避免一个页面同时连接过多源。
HTTP 连接和握手
连接建立由网络进程调度。
普通 HTTP 下,网络进程先建立 TCP 连接。TCP 三次握手可以简化理解为:
三次握手完成后,HTTP 请求才能通过这条 TCP 连接发送出去。
HTTP/1.1 可以复用 TCP 连接,但同一连接上的请求容易受队头阻塞影响。HTTP/2 仍基于 TCP,在一个连接上做多路复用,可以同时承载多个请求。HTTP/3 基于 QUIC,运行在 UDP 之上,把传输和加密握手整合得更紧,弱网下恢复能力也更好。
容易慢在这里:
- TCP 握手需要至少一次往返时间。
- HTTP/1.1 并发能力有限,请求多时容易排队。
- HTTP/2 仍可能受 TCP 层丢包影响。
常见优化:
- 在服务端开启 HTTP/2。
- 在支持的网关或 CDN 上开启 HTTP/3。
- 保持连接复用,不要给同一类静态资源拆太多域名,否则每个域名都要重新建连。
- 对关键源使用
preconnect。
HTTPS 握手
HTTPS 握手也在网络进程这一层完成,证书校验属于这条链路的一部分。
HTTPS 在 HTTP/1.1 和 HTTP/2 下,通常先建立 TCP 连接,再进行 TLS 握手。TLS 握手主要做四件事:
- 协商 TLS 版本和加密套件。
- 服务端发送证书。
- 浏览器校验证书是否合法、是否过期、域名是否匹配。
- 双方协商会话密钥,后续 HTTP 数据都用密钥加密传输。
简化流程:
命中 TLS 会话复用时,浏览器可以减少握手成本。HTTP/3 使用 QUIC,不再走 TCP 三次握手,而是把连接建立和 TLS 1.3 加密协商整合在一起。在服务端、客户端和网络路径都支持时,它通常能降低建连成本。
容易慢在这里:
- TLS 握手会增加额外往返时间。
- 证书链太长或 OCSP 校验慢,会拖慢连接建立。
- 没有启用会话复用时,重复访问成本更高。
常见优化:
- 只启用 TLS 1.2、TLS 1.3 这类现代协议。
- 开启 TLS 会话复用,降低重复访问的握手成本。
- 开启 OCSP Stapling,让服务端代替浏览器查询证书状态。
- 对首屏必需的 HTTPS 源使用
preconnect。
发送 HTTP 请求
连接建好后,网络进程组装 HTTP 请求,包括请求行、请求头、Cookie、缓存控制信息和可选请求体。请求可能经过代理、网关、CDN 或负载均衡,最终到达目标服务器。
服务端收到请求后,执行路由匹配、鉴权、业务逻辑、数据查询、模板渲染或接口响应,然后返回 HTTP 响应。
容易慢在这里:
- Cookie 太大会增加每个请求的上行体积。
- 请求头过大,会放大慢网和高并发下的传输成本。
- POST 请求体过大,会推迟服务端处理开始时间。
常见优化:
- 控制 Cookie 作用域,不让静态资源请求携带业务 Cookie。
- 静态资源尽量放到独立的无 Cookie 域名上。
- 删除无意义自定义请求头,避免触发 CORS 预检或增大请求体积。
- 大文件上传使用分片,避免一次请求占用太久。
网络阶段的选择建议
首屏链路上,优先减少不必要请求、减少建连次数、让关键资源更早到达。可以按这个顺序排查:
-
资源是否能命中缓存。
-
关键域名是否已 DNS 预解析或预连接。
-
协议是否使用 HTTP/2 或 HTTP/3。
-
首屏关键资源是否过多。
-
请求头和 Cookie 是否过大。
-
CDN 是否离用户足够近。
-
用
curl看缓存头是否符合预期。
- 用浏览器 Performance 面板看是否有过多 DNS、Initial connection、SSL 耗时。确认是关键源后,再加资源提示。
- 用 Network 面板看协议列。如果静态资源还在 HTTP/1.1,可以在 CDN 或网关开启 HTTP/2、HTTP/3。
- 用 Network 面板看请求头大小。如果 Cookie 过大,把静态资源迁到无 Cookie 域名。
接收响应
网络进程收到响应后,先检查状态码、响应头和内容类型:
- 301、302、307、308:按重定向地址重新请求。
- 304:使用本地缓存。
- 200:读取响应体并继续解析。
- 4xx、5xx:进入错误处理或展示错误页面。
如果响应是下载类型,就交给下载管理器。如果是 HTML,网络进程会把响应数据转交给渲染进程。如果响应体被压缩,浏览器会在数据流进入后续解析前进行解压,很多情况下可以边接收边解压。
容易慢在这里:
- 服务端首字节时间过长会推迟所有后续渲染。
- HTML 太大会让解析、传输和首屏都变慢。
- 重定向过多会反复回到网络请求阶段。
常见优化:
- 对 HTML、CSS、JavaScript 启用 gzip 或 Brotli。
- 服务端尽早返回 HTML 头部,让浏览器先发现关键资源。
- 降低服务端首字节时间,把慢查询、慢接口放到缓存或异步流程。
提交文档
浏览器进程会为新页面选择或创建渲染进程。跨站点导航通常会创建新的渲染进程,或复用合适的已有进程,这是站点隔离的一部分。
渲染进程准备好后,会和网络进程建立数据传输通道。浏览器进程把这次导航确认为提交文档,更新地址栏、历史记录和标签页状态,并开始替换旧页面。
这一步之后,HTML 数据会持续流入渲染进程,页面解析和资源加载可以并行推进。
容易慢在这里:
- 频繁跨站导航会增加进程切换成本。
- 新建渲染进程需要初始化运行环境。
常见优化:
- 同站内页面使用前端路由,避免整页重新导航。
- 对用户很可能点击的同站页面做预取。
- 登录、鉴权等流程避免多层跨域中转,服务端直接返回最终跳转地址。
解析 HTML
HTML 解析主要发生在渲染进程主线程,预加载扫描器会在后台帮忙提前发现资源。
渲染进程主线程把 HTML 字节流解码成字符,再经过词法解析和语法解析生成 DOM 树。解析是增量进行的,不是等 HTML 全部下载完才开始。
解析过程中遇到外部资源,会按资源类型触发不同处理:
- CSS:下载并解析成 CSSOM,通常会阻塞渲染。
- JavaScript:默认会阻塞 HTML 解析,除非使用
defer、async或模块脚本策略。 - 图片和字体:通常异步加载,但会影响后续绘制效果。
预加载扫描器会提前扫描 HTML,尽早发现 CSS、JavaScript、图片和字体,把下载任务交给网络进程。
容易慢在这里:
- 大 HTML 会占用主线程解析时间。
- 同步脚本会阻塞 HTML 解析。
- CSS 会阻塞首次渲染,因为浏览器需要知道元素最终样式。
常见优化:
- HTML 优先输出首屏结构,把非首屏内容延后挂载。
- 非关键脚本使用
defer,不阻塞 HTML 解析。
-
不依赖 DOM 顺序的第三方脚本使用
async,例如统计 SDK。 -
内联首屏关键 CSS,非关键 CSS 延后加载。
- 使用
preload提前加载关键字体、关键图片或关键脚本。
子资源加载
解析过程中发现的 CSS、JavaScript、图片、字体、视频等资源,由网络进程下载。网络进程会根据优先级、缓存状态、协议能力和连接复用情况调度下载。
资源优先级通常大致是:主文档最高,关键 CSS 和阻塞脚本较高,首屏图片较高,普通图片和懒加载图片较低。
容易慢在这里:
- 关键资源太多会竞争带宽。
- 字体加载慢会导致文字闪烁或不可见。
- 大图片会拖慢下载和解码。
常见优化:
- 首屏只加载必要脚本,非首屏模块动态导入。
- 图片使用合适尺寸和现代格式。
- 非首屏图片使用懒加载。
- 字体使用
font-display,避免字体下载阻塞文字展示太久。 - 首屏核心图片设置高优先级。
执行脚本
脚本执行主要占用渲染进程主线程,JavaScript 引擎执行页面脚本也在这一条线程里。
普通脚本下载完成后,会暂停 HTML 解析,先执行 JavaScript。脚本可以读取和修改 DOM,也可以读取样式和布局信息。
如果脚本要读取布局信息,比如 offsetWidth、getBoundingClientRect,而前面又修改过样式,浏览器可能被迫提前执行样式计算和布局,这就是强制同步布局。
容易慢在这里:
- 长任务会阻塞主线程,导致页面无法响应输入。
- 频繁读写 DOM 会触发布局抖动。
- 大体积 JavaScript 会增加下载、解析、编译和执行成本。
常见优化:
- 按路由或功能拆分代码,只在需要时加载。
- 把非关键逻辑延后到空闲时间执行,比如埋点、预计算、预取数据。
- 批量读取 DOM,再批量写入 DOM,避免读写交错触发布局抖动。
- 重计算放到 Web Worker,避免阻塞主线程。
样式计算
CSS 解析后生成 CSSOM。浏览器结合 DOM 和 CSSOM,为每个 DOM 节点计算最终样式,并把相对单位和颜色等值标准化,比如把 em 转成 px,把颜色名转成具体颜色值。
每个节点都会得到一份计算后的样式(computed style)。样式计算会处理选择器匹配、继承、层叠优先级和默认样式。
容易慢在这里:
- DOM 节点太多会放大样式计算成本。
- 复杂选择器会增加匹配成本。
- 频繁修改 class 或内联样式会触发重复样式计算。
常见优化:
- 控制 DOM 规模,长列表只渲染视口附近节点。
- 选择器尽量简单稳定,少写过深的后代选择器链。
- 批量修改样式,用 class 切换替代逐个写内联样式。
布局
布局阶段会基于 DOM 和 computed style 创建布局树,只包含可见且参与布局的节点。比如 display: none 的元素不会进入布局树,但 visibility: hidden 的元素仍然占据布局空间。
然后浏览器会计算每个元素的几何信息,包括位置、宽高、边距和层级关系。如果后续 JavaScript 修改 DOM 或样式,导致几何信息变化,就可能触发重新布局,也就是常说的 reflow。
容易慢在这里:
- DOM 规模大时,全局布局成本高。
- 修改影响文档流的属性容易触发布局。
- 读写布局信息交错会导致强制同步布局。
常见优化:
- 动画优先使用
transform和opacity,避免修改width、height、left等布局属性。
- 对独立组件使用
contain限制布局影响范围,避免一个区域的小变化影响整页。 - 给图片、广告位、异步内容预留尺寸,减少布局偏移。
- 先批量读取布局信息,再批量写入 DOM。
分层
图层树由渲染进程主线程生成
布局完成后,浏览器会生成图层树。普通元素会归入父节点所在图层;拥有复杂效果或合成收益的元素,可能被提升为单独图层。
常见的分层原因包括 3D 变换、页面滚动、视频、canvas、特定裁剪区域,以及某些复杂堆叠或合成效果。分层可以让后续合成更高效,但图层过多也会增加内存和合成成本。
容易慢在这里:
- 图层过少,局部变化可能导致大面积重绘。
- 图层过多,会增加内存、纹理上传和合成压力。
- 滥用
will-change容易制造无意义图层。
常见优化:
- 只给即将发生动画的元素临时添加
will-change。 - CSS 中只对少量高频动画元素声明
will-change。
- 避免给列表里的每一项都加
will-change,只给当前正在动画的项加。
绘制
绘制阶段不会立刻把像素画到屏幕上,而是为每个图层生成绘制指令列表。指令会描述文字、背景、边框、阴影、图片等内容应该如何画。
如果只是颜色、背景、阴影这类视觉变化,可能只需要重新绘制,也就是 repaint,不一定需要重新布局。
容易慢在这里:
- 复杂阴影、滤镜、渐变和大面积透明会增加绘制成本。
- 频繁变化的背景、边框、阴影会导致重复绘制。
常见优化:
- 高频动画避免改变
box-shadow、filter、background-color这类容易触发绘制的属性。
- 复杂阴影可以用静态伪元素承载,只动画透明度。
光栅化
光栅化主要由渲染进程的合成线程和栅格线程池处理,必要时会借助 GPU 进程。
合成线程会把图层切成图块,并把图块交给栅格线程转换成位图。靠近视口的图块通常会优先光栅化,必要时还会使用 GPU 加速。
光栅化完成后,合成线程收集 draw quads
容易慢在这里:
- 大图片解码和大图层光栅化会占用大量时间和内存。
- 快速滚动时,如果图块来不及光栅化,可能出现白屏或掉帧。
常见优化:
- 使用响应式图片,通过
srcset和sizes让小屏拿小图。 - 长列表做虚拟滚动,只渲染可见范围。
- canvas 按设备像素比设置尺寸,但限制最大像素面积。
合成与显示
渲染进程合成线程和 GPU 进程接手最终显示。
合成线程把多个图层和图块按正确顺序合成一帧,提交给 GPU 进程,最终显示到屏幕上。这个阶段很多情况下不需要主线程参与。
像 transform、opacity 这类属性,很多情况下可以只触发合成,不必重新布局和重新绘制,所以动画性能通常更好。
容易慢在这里:
- 合成图层过多会增加 GPU 内存和合成成本。
- 主线程长任务会让新帧提交不及时,即使合成线程能工作,页面交互仍可能卡顿。
- 低端设备 GPU 压力大时,复杂合成也会掉帧。
常见优化:
- 控制图层数量,只让真正需要独立合成的元素走合成路径,别把普通元素都抬成单独图层。
- 动画只改
transform和opacity。 - 长任务切片执行,给浏览器处理输入和渲染的机会。
页面完成
首屏显示出来不等于页面完全完成。常见节点有:
- DOMContentLoaded:HTML 已解析完成,defer 脚本已执行,但图片等资源不一定加载完。
- load:页面主资源和依赖资源基本加载完成。
- 可交互:关键 JavaScript 执行完,用户操作能被及时响应。
性能上更重要的不是单个 load 时间,而是这些指标:
- FCP:用户第一次看到内容。
- LCP:最大核心内容渲染完成。
- INP:用户交互能否快速得到响应。
- CLS:页面加载过程中布局是否稳定。
所以一次页面打开,可以理解成三条线同时推进:浏览器进程负责导航和页面管理,网络进程负责拿资源,渲染进程负责把资源变成像素。排查性能问题时,也要先判断瓶颈落在哪条线上:是资源来得慢,还是主线程太忙,或者合成和 GPU 压力太高。定位清楚之后,优化才不会只是在页面上到处加缓存、加预加载、加 will-change。