我们从三个部分来了解浏览器加载与渲染网页的整体流程:先看 Chrome 导航到网址时发生了什么,再看 Chrome 解析页面(DOM 构建与资源加载)的关键机制,最后再看 Chrome 如何将页面渲染到屏幕上。
Chrome 导航到网址的过程发生了什么
本节简要了解当用户输入网址或页面发生跳转时,Chrome 在浏览器端做了哪些工作。
参考:
新 tab 的默认主页
打开一个新的 tab,Chrome 会显示默认主页。它也是网页,但不属于任何站点。理论上它需要一个渲染进程(因为要渲染页面)。多个默认的 tab 页面可能共用同一个渲染进程,并且与网站页面的渲染进程并不相同。
步骤一:在地址栏输入内容
此时由 UI 进程(或 UI 线程)处理输入事件。它会判断这是站点导航还是搜索,并据此决定下一步。这里假设是站点导航。
步骤二:加载访问的站点数据
开始加载
UI 线程判断为站点导航(例如访问 https://baidu.com
),会与专门处理网络请求的网络线程交互,通知其加载该站点的初始资源(通常是 HTML)。网络线程会解析 DNS,并建立 TCP 连接(或根据协议完成 TLS 等握手)。
在加载过程中可能出现 301 重定向。若发生重定向,网络线程会与 UI 线程通信,UI 线程更新地址栏显示,随后网络线程继续加载重定向后的站点资源。
处理加载的资源
当资源返回后,网络线程会根据 MIME 类型判断资源类型。例如是 zip 包则通知下载管理器,如果是 HTML,则需要把数据传递给渲染进程。
同时,这里也会进行内容安全检查:若域名与响应数据疑似匹配已知恶意站点,网络线程会通知 UI 线程显示警告页面。发生跨源数据读取阻止(CORB)的跨站资源也不会进入渲染进程。
步骤三:准备渲染进程
当站点 HTML 数据准备好(加载完毕且通过安全检查)后,网络线程会通知 UI 线程。UI 线程会为该页面准备渲染进程(可能是新的,也可能复用已有的)。为降低等待开销,UI 线程通常会在网络加载期间就预先为该站点准备好渲染进程:一切顺利的话,当网络线程接收完数据,渲染进程已就绪。(若中途发生重定向,则会重复上一步,已准备的渲染进程可能不会被使用。)
步骤四:提交导航
当数据与渲染进程都就绪时,UI 线程会将站点数据交给渲染进程并请求其渲染,此步骤被称为“提交导航”。此时,站点信息与前进/后退(历史会话)已可用,并会被持久化到磁盘。
渲染进程接收到 HTML 数据后,开始解析该 HTML 并通过网络线程加载页面中的其他资源,进入文档加载阶段。渲染进程如何渲染页面的细节,见后文。
当渲染进程完成首次渲染,并执行完所有 onload
回调后,会通知 UI 线程“初始加载完成”,UI 线程相应更新页面的 loading 状态。需要注意,这只是“初始加载完成”,后续渲染进程仍可能继续加载资源并更新页面内容。
此时,整个页面的导航和显示基本上已经完成了。
在已渲染页面中再次通过地址栏进行导航
如果在已渲染的页面中再次通过地址栏访问其他站点,整体仍按上述流程执行,只是有几点需要注意。
页面beforeunload
如果页面注册了 beforeunload
,UI 线程会在重新导航前,通过渲染进程触发并检查该事件(用于“是否离开此页”的提示),根据结果决定留在当前页还是继续导航。
导航到其他站点的渲染进程
如果导航到其他站点(跨站点),通常会为新站点创建新的渲染进程。原有渲染进程会保留并触发 unload
等事件,以完成卸载;详见前述“页面的生命周期”。
从页面内进入其他站点
如果从页面内(而非地址栏)进入其他站点,仍会检查 beforeunload
,并复用上面的导航流程;唯一区别在于,这次导航请求由渲染进程发起。
service worker
对这套流程影响较大的一个因素是 Service Worker。它可以决定资源是否从缓存读取或重新加载。注意:Service Worker 是运行在渲染进程中的 JavaScript 代码,因此 UI 线程需要准备渲染进程来执行它。UI 线程如何知道某站点是否注册了 Service Worker?注册时网络线程会维护一张“站点域名到 Service Worker”的映射表,用于查询。
Chrome 导航流程小结
通过上述过程,我们可以了解 Chrome 在一次导航中做了哪些工作。从中也能感受到其多进程(甚至服务化)的架构设计,从整体上看非常符合高内聚、低耦合的思想。
Chrome 解析页面
本节更侧重浏览器解析页面 DOM 时的机制与行为,包括解析过程中的资源加载、哪些情况下会阻塞 DOM 解析等。
参考资源:
从解析 HTML 到显示页面的大致步骤(WebKit)
渲染进程得到 HTML 资源后,大致会经历以下步骤来渲染页面:
- 解析 HTML 为 DOM 树(期间有预加载扫描器提前分析并加载所需资源)。
- 遇到资源即发起加载,资源分为阻塞型与非阻塞型。
- 构建 DOM 树的同时,若遇到样式标签或外联样式,会并行构建样式树(CSSOM)。
- 遇到阻塞型资源则等待其加载与执行后再继续解析(浏览器仍会尽量尽早展示内容,即使样式尚未最终定型)。
- DOM 与 CSSOM 就绪后,合并并计算样式,构建渲染树并进行布局计算。
- 绘制与显示。
其中:
- DOM“解析”完成后会触发
DOMContentLoaded
事件,需要满足:- DOM 树完全构建;
- 同步 JavaScript 执行完毕。
- DOM 解析完成且所有资源加载完成(包括带
async
的脚本被加载并执行)后,会触发load
事件。
接下来一步一步分析
ps:注意上面的说明,解析 DOM 与渲染是两个步骤。
开始解析 DOM
浏览器依照 HTML 规范将文本解析为 DOM,并构建 DOM 树。这里有个优化:预加载扫描器会提前分析页面所需资源并尽早发起加载。
预加载扫描器的原因在于:解析 DOM 可能被阻塞,而资源加载可以异步并行。若等解析器遇到资源再加载,部分资源可能很晚才开始请求,不利于首屏。因此提前加载可在解析器走到相应位置前就将资源准备就绪,从而缩短总体渲染时间。
遇到阻塞型内容
当解析遇到阻塞型内容时(如内联 script
、不带 async
/defer
的外联脚本),解析器会等待这些资源加载并执行完毕后才继续向下解析(通常会等待同步 JavaScript 执行完成)。阻塞型内容包括:
- 内联 JavaScript;
- 普通外联
script
(不带async
、defer
); - JavaScript 之前的外联 CSS 资源(特殊情况)。
之所以阻塞,是出于安全与一致性考虑:脚本可能会修改 DOM,甚至执行 document.write
。因此解析器会等待 JavaScript 加载并执行完成后再继续。需要注意,DOM 解析与 JavaScript 执行都在渲染进程主线程上完成;若在解析后执行的脚本修改了 DOM,页面会再次变化(浏览器仍会尽力尽早显示可见内容)。
需要注意执行顺序:通常情况下,脚本按“解析顺序”执行,而非“加载完成顺序”(带 async
的脚本除外)。
例如:js执行阻塞dom解析的简单示例:
1 |
|
可以看到,上面的 long task 是 Parse HTML,其中耗时最长的是 JavaScript 执行。该长任务执行完成后,无论是否显式修改 DOM,浏览器都会触发一次“轻量布局检查”(Layout 任务),以确认 DOM 树结构完整性并为 “DOMContentLoaded 后的首次渲染” 做准备,然后继续 Parse HTML,最终触发 DOMContentLoaded
事件。
再说“JavaScript 之前的外联 CSS 资源”。按理 CSS 解析与 DOM 解析并行进行,不会阻塞解析。但为符合 HTML5 解析规范并保证脚本可获得正确样式,浏览器会在执行某个脚本前阻塞它,等待该脚本之前的 CSS 资源加载并解析完毕。由于脚本被阻塞且脚本又会阻塞 DOM 解析,这种情况下 CSS 等于“间接阻塞”了 DOM 解析。参考:CSS 到底会不会阻塞页面渲染。
遇到非阻塞型内容
当解析遇到非阻塞型内容(如图像、外联 CSS),解析器不会被阻塞:遇到 CSS 外联标签时,会立即发起下载请求,并继续向下解析。非阻塞型内容包括:
- 内联 CSS;
- 外联 CSS(不位于将要执行的脚本之前);
- 带
defer
或async
的外联脚本; - 图片(
img
标签); iframe
。
一般情况下,CSS 的解析与 DOM 的解析并行进行,互不阻塞(除上文特殊情况)。DOM 解析器遇到 CSS 会交由样式解析器生成样式树,然后继续解析。
图像与 iframe
不会阻塞 DOM 的解析。现代 Chrome 中,iframe
往往使用独立渲染进程渲染,自然不会阻塞当前页面解析。
关于带 defer
的脚本见下文。需要注意的是,defer
不会阻止 DOM 解析,但会在 DOMContentLoaded
触发前执行。
事件触发
- DOM 解析完毕且需要执行的脚本执行完成后(含
defer
的脚本),触发DOMContentLoaded
。 - DOM 解析完毕且所有资源加载完成后(如图片),触发
load
。
至此,页面的“解析”阶段算是完成了。但页面的真正显示并不完全由这两个事件来界定,相关优化见后文。
一些注意事项:如果没有脚本阻止 DOM 解析,页面可能很快显示出来,但此时可能尚不可交互,因为事件绑定等逻辑可能仍在加载(例如带 async
的脚本尚未执行)。
脚本的 async、defer 与动态脚本
背景:普通外联 script
会阻塞 DOM 解析,而且脚本无法访问其后的 DOM 元素。为此引入了 async
与 defer
来控制脚本执行时机。
defer脚本特性
- 不阻塞 DOM 解析;
- 在“DOM 解析完毕后、
DOMContentLoaded
触发之前”执行; - 多个
defer
脚本按其在文档中的“解析顺序”执行(注意:内联script
总是先执行,因为脚本解析位于“当前 DOM 解析位置之后”)。
如果 script 脚本没有 src,则会忽略 defer 特性。
async脚本特性
与 defer
不同:
- 不阻塞 DOM 解析;
- 每个
async
脚本互相独立,只要脚本加载完成就立即执行(若 HTML 尚未解析完成,会暂时暂停解析去执行该脚本); - 谁先加载完毕谁先执行;
DOMContentLoaded
与异步脚本互不等待。
如果 script 脚本没有 src,则会忽略 async 特性。
动态脚本
可以使用 JavaScript 动态创建脚本并附加到文档中。默认情况下,动态脚本的行为是“异步/async”的:
- 它们不会等待任何东西,也没有其它任务会等待它们;
- 先加载完成的先执行(“加载优先”顺序)。
如果显式设置 script.async = false
,则按脚本在文档中的顺序执行,类似 defer
。
注意:动态脚本无论如何都不会阻止 DOM 解析。
一些思考
如何优化页面显示
从上面的流程看,可以采用的优化手段:
- 使用
async
与defer
; - 将脚本放在
body
底部,避免阻塞 DOM 解析(通用 HTML 场景); - 优化 CSS,使其尽快加载与解析(如预加载),减少阻塞脚本执行的时间。
js是否会阻止页面显示
我们常见的优化是将脚本放在页面底部,以便页面更快显示。原理是什么?按上文所述,不带 defer/async
的脚本会阻塞 DOM 解析;在脚本执行完成之前,DOM 解析仍未完成,那为何仍能看到页面呢?
这里涉及到两个点:
- 浏览器解析的渐进性:浏览器会“边解析边渲染”,只要出现部分“可见内容”,就会尽快显示。
- GUI 渲染与 JavaScript 执行互斥:若正在执行内联脚本,渐进式渲染会被阻塞,页面 UI 无法更新。
参考如下代码:
1 |
|
上面代码会有如下结果:
- 初始仅显示第一个灰底的 div,并触发 FCP(虽然后续 DOM 解析被外联脚本阻塞)。
- 约 1 秒后外联脚本加载并执行,解析继续。
- 外联脚本执行完成后,页面仍无变化;虽然第二个 div 已被解析,但由于内联脚本正在执行
while(true)
,而脚本执行与渲染互斥,页面无法更新(第二个 div 未被渲染)。 - 待内联脚本执行完毕后,解析继续,页面更新(渲染第二个 div),并解析到新的
style
,第一个 div 字体变为 20px。 - 最终触发
DOMContentLoaded
与load
。
可以通过 Performance 面板记录:页面在很早就触发首屏渲染(First Paint),显示出第一个 div 及其样式。但最后的 style
标签尚未解析到,因此 20px 的字体还未应用。中间大部分时间用于脚本的加载与执行;待脚本加载执行完成后,才触发 DOMContentLoaded
与 load
。
Chrome 解析 HTML 小结
本节重点梳理了浏览器解析阶段的主要流程,对我们优化网页加载具有一定启发。
Chrome 渲染页面
参考资源:
- 构建对象模型
- 深入了解现代网络浏览器(第 3 部分) 这篇文章来自chrome的博客
当浏览器加载好站点数据,会通知渲染进程解析并渲染该页面内容。下面从渲染进程收到 HTML 数据开始,概述渲染页面时在内部做了哪些工作。
关于整体的导航流程,可参考前文。
渲染进程的内部
渲染进程负责浏览器标签页内的几乎所有工作。主线程处理大部分页面逻辑;Web Worker、Service Worker 运行在渲染进程的其他线程中;页面光栅化与合成也由渲染进程的其他线程负责。
现代浏览器仍在持续演进并引入优化,所以下文并非严格的最终流程,更多用于帮助理解整体渲染机理。
下面从解析 HTML 开始,描述渲染进程的主要工作。
DOM 的构建
当接收到导航消息与 HTML 数据时,渲染进程主线程将文本 HTML 解析成 DOM 树
。DOM 是浏览器对页面的内部表示,并提供给 JavaScript 的 API。HTML 的解析由HTML 标准定义。
在解析 HTML 的过程中,会遇到需要加载的资源(CSS/JS/图片等)。当解析到这些资源时,会通过“浏览器网络进程”请求它们。这里也有一个优化:preload scanner
(预加载扫描器)会扫描页面中需要加载的资源,并在解析 DOM 的同时尽早发起加载。
注意:如果解析到 script
,渲染进程会暂停 DOM 解析,等待脚本加载与执行完成后再继续。更具体的解析过程可参考上文:Chrome 解析页面。
解析 CSS
DOM 是页面的骨架,CSS 描述其外观。在解析 HTML 构建 DOM 的同时,若遇到 CSS 或外联样式,会并行解析为样式树(CSSOM:CSS 对象模型)。CSSOM 与 DOM 是两个独立的数据结构,记录了各元素的样式规则。
当需要计算某个元素的最终样式时,会从通用规则开始,递归匹配并合并适用该元素的样式。即使页面没有提供任何 CSS,浏览器也有一套默认样式表。
注意:CSS 与 DOM 的解析是并行的,因此“通常意义”上 CSS 解析不会阻塞 DOM 解析。
渲染树
关于接下来的几个阶段,不同资料的表述可能有所差异。
这里以 深入了解现代网络浏览器(第 3 部分) 为准。
当 DOM 与 CSSOM 均已就绪,需要将二者信息合并,这一步是渲染树(有的资料也称“样式计算”)。
该步骤的目标是计算出每个节点的最终样式。
注:部分文章认为此阶段会移除不可见元素,但 Chrome 的博客将这部分工作放到“构建布局树”阶段。
当通过 DOM API 获取不可见元素(如 head
或 display: none
元素)的样式与几何信息时,通常宽高与位置为 0,可理解为“在窗口左上角的一个 0×0 元素”。
布局树
渲染树确定了页面结构与样式,但仍不足以确定页面的几何形态。需要根据渲染树与布局规则确定元素位置与尺寸,这一步是布局。
布局树中仅保留可见的 DOM 节点,去除不可见节点(如 link
、head
、script
)以及 display: none
的元素。
注意:visibility: hidden
不同于 display: none
。前者不可见但仍占据布局空间,因此仍在布局树中;后者则完全不参与布局,位置与尺寸为 0。
布局计算非常复杂:需要确定元素在页面中的位置、元素间的相互影响(流式布局等)、定位规则(position)、甚至元素高度可能因文本行数不同而变化。
此处会应用 CSS 盒模型确定元素大小与占据空间;位置信息则依据不同布局规则(文档流、脱离文档流的定位、Flex、Grid 等)。
最终得到包含 x/y 坐标与边界等信息的布局树。
重排/回流也发生在此阶段。
绘制记录(paint)
已经知道页面最终样子,但绘制时还需记录绘制顺序。若存在 z-index
等层叠关系,绘制顺序不当会导致显示错误(例如被错误覆盖)。
因此浏览器会遍历布局树,创建绘制记录,并考虑 z-index
等属性,以确认正确的绘制顺序。
此步骤仅确认绘制顺序与页面内容,尚未转换为像素。随后会进行光栅化并显示。
显示页面:直接光栅化?
当渲染树、布局树与绘制记录就绪后,便可将其转换为屏幕像素,即光栅化。
那么需要光栅化哪些内容?全部光栅化成本过高且没有必要,因为视口只显示页面的一小部分。
是否只光栅化视口内的内容,并在滚动时继续光栅化新出现的区域?这曾是早期方案,如今 Chrome 采用了更高效的合成方案。
显示页面:合成与页面分层
合成是一种技术:Chrome 将页面内容分成多个层,分别光栅化,然后由合成器线程将各层合并为一帧。滚动或动画时,仅需移动层并合成新帧即可。
这是当前 Chrome 使用的页面显示方式。
分层
此阶段确定哪些元素进入哪些图层。主线程遍历布局树,生成层树(layer tree)。可以通过 CSS 的 will-change
提示浏览器(请勿滥用,通常浏览器能很好地做出分层决策)。
创建合成帧
图层树与绘制记录就绪后,主线程将其交给合成器线程,由合成器线程对各层进行光栅化。
- 合成器将每一层切分为多个小块(tiles),交由光栅线程光栅化并缓存到 GPU 内存;
- 优先光栅化视口内的瓦片以提升可见区域的渲染速度;
- 当瓦片光栅化完成后,创建合成帧并提交至 GPU 显示;页面变化时则创建新的合成帧。
合成器的工作不涉及主线程;而 DOM 解析、样式计算、布局等在主线程上,会被 JavaScript 执行所阻塞。因此,只涉及“合成”的动画通常效率最高(如 transform 动画仅在合成阶段调整层位置,无需重新布局或绘制)。参考:如何创建高性能 CSS 动画。若涉及布局或样式计算,仍需主线程参与。
渲染管道
上文的“DOM/CSS → 样式计算(渲染树)→ 布局树 → 绘制(paint)→ 合成”被称为渲染管道。后续步骤依赖前述步骤的结果,若其中任一步骤的数据变化,受影响的部分需从该步骤开始重新计算。
绘制与页面合成的边界并非总是泾渭分明,可粗略理解为:
- 绘制:确定绘制顺序、元素分层、确定每层显示的内容
- 页面合成:光栅化每层页面、创建合成帧
只要元素外观改变(如背景色变化),就一定涉及重绘;若尺寸或位置变化,还会触发布局与重绘。
一个修改样式,导致位置页面位置变化的示例:
js操作dom和页面渲染的关联
在主线程执行 JavaScript 并操作 DOM 时,样式与属性的修改会同步更新到 DOM/CSSOM。有些操作会触发样式计算与布局。
案例:页面jank
例如:若先修改元素 top
,随后立刻读取该元素的 top
,第二步会强制触发样式计算与回流(同步)。在火焰图中可以看到,在一个 JS 任务中可能多次同步触发样式计算与回流。即便回流影响范围很小(如绝对定位元素),也仍然会触发。这里仅表示触发样式计算/回流,不一定会立刻导致绘制或合成。
防止触发强制回流
浏览器会通过队列化与批处理优化重排/回流:将多次修改合并后再统一执行。但当你“读取布局信息”时,会强制刷新队列并执行回流(不一定触发重绘),以确保读取到的几何信息是最新的。
比如获取以下内容时:
- 元素宽高
- 所在的位置信息,相对于父元素、窗口等
更新渲染时的代价
当页面更新(DOM 或样式变化)时,会导致重新计算:更新 DOM/CSSOM,随后重建渲染树与布局树,详见:渲染管道。
例如:若页面有动画,为保证流畅需要达到 60fps,即每帧约 16ms 内完成上述步骤。若无法在一帧内完成,就会出现卡顿(jank)。
此外,这些计算操作运行在主线程上,而 JavaScript 也运行在主线程上。若脚本运行时间过长,会阻塞这些计算,导致错帧。
通常我们会优化 JavaScript,以避免阻塞:
- 将长任务放入 Worker;
- 将任务拆分为更小的片段,并使用
requestAnimationFrame
在下一次重绘前执行(适合逐帧动画); - 使用
requestIdleCallback
在浏览器空闲时执行低优先级任务(参考:MDN requestIdleCallback)。
如果你使用 JavaScript 处理动画(如 Canvas 动画),需要在 60fps 的每一帧渲染前运行脚本更新内容,此时应选择 requestAnimationFrame
而非 requestIdleCallback
(后者不保证与每帧对齐)。若只是避免长任务阻塞页面响应,可将任务切分并使用 requestIdleCallback
。
不建议用 setTimeout
驱动动画:其回调不与帧同步,且受宏任务阻塞,容易错过下一帧。
浏览器重绘和回流
参考:
结合上文可知:回流发生在布局阶段——当 DOM 或样式变化导致布局需要重新计算时,会触发布局树更新,即回流。重绘可粗略理解为“需要绘制新的页面内容”。
结语
本文从三个阶段梳理了 Chrome 的页面生命周期:
- 导航:UI 进程协调网络线程与渲染进程,经历资源请求、重定向、安全检查、准备渲染进程与“提交导航”等步骤,完成从地址栏到文档加载的切换。
- 解析:在预加载扫描器的配合下,DOM 与 CSSOM 并行构建;同步脚本会阻塞解析,而
async
/defer
可降低阻塞;DOMContentLoaded
与load
分别标识解析与所有资源完成的时间点。 - 渲染/合成:样式计算→布局树→绘制记录→分层与光栅化→合成器线程提交帧到 GPU;仅涉及合成的动画性能最佳,布局与绘制会增加主线程压力。