【浏览器工作原理与实践】3. 宏观视角下的浏览器 - 从输入 URL 到页面展示,这中间发生了什么?

从输入 URL 到页面展示完整流程示意图:

从输入 URL 到页面展示完整流程示意图
从输入 URL 到页面展示完整流程示意图

回顾浏览器进程、渲染进程和网络进程的主要职责:

  • 浏览器进程主要负责用户交互、子进程管理和文件储存等功能。
  • 网络进程是面向渲染进程和浏览器进程等提供网络下载功能。
  • 渲染进程的主要职责是把从网络下载的 HTML、JavaScript、CSS、图片等资源解析为可以显示和交互的页面。因为渲染进程所有的内容都是通过网络获取的,会存在一些恶意代码利用浏览器漏洞对系统进行攻击,所以运行在渲染进程里面的代码是不被信任的。这也是为什么 Chrome 会让渲染进程运行在安全沙箱里,就是为了保证系统的安全。

总结:从输入 URL 到页面展示,这中间发生了什么

导航流程:

  • 用户输入 url 并回车

  • 浏览器进程检查 url,组装协议,构成完整的 url

  • 浏览器进程通过进程间通信(IPC)把 url 请求发送给网络进程

  • 网络进程接收到 url 请求后检查本地缓存是否缓存了该请求资源,如果有则将该资源返回给浏览器进程

  • 如果没有,网络进程向 web 服务器发起 http 请求(网络请求),请求流程如下

    • 进行 DNS 解析,获取请求域名的服务器 IP 地址,构建请求

    • 等待 TCP 队列,利用 IP 地址和服务器建立 TCP 连接(3 次握手),获取 TCP 连接时附加的端口

      端口:在建立 TCP 连接的时候附加在报文段头部字段中的。头部字段包括了源端口和目的端口两个部分。服务器端在接收到 TCP 请求后就知道了其中的数据要往哪个端口发送。如果,没有标明端口,则采用默认端口,http 默认端口是 80,https 默认端口是 443。

      TCP 协议属于运输层,HTTP 协议属于应用层。

    • 发送 HTTP 请求向服务器发送请求行、请求体和请求头

    • 服务器响应后,网络进程接收响应头和响应信息,并解析响应内容

    • 关闭 TCP 连接

  • 网络进程解析响应流程

    • 检查状态码,如果是 301/302,则需要重定向,从 Location 自动中读取地址,重新进行第 4 步(查找缓存),如果是 200,则继续处理请求。

      • 301 重定向

        当浏览器接收到服务端 301(永久)重定向返回码时,会将 original_url 和 redirect_url1 存储在浏览器缓存中,当再次请求 original_url 时,浏览器会从本地缓存中读取 redirect_url1 直接进行跳转,不再请求服务端。

        在浏览器未清理缓存或缓存未失效的情况下,即使服务端将重定向地址修改为 redirect_url2,浏览器依然会跳转到 redirect_url1。

      • 302 重定向

        当浏览器接收到服务端 302(临时)重定向返回码时,不会进行缓存。每次请求 original_url 时,都会请求一下服务端。

    • 200 响应处理:检查响应类型 Content-Type,如果是字节流类型,则将该请求提交给下载管理器,该导航流程结束,不再进行后续的渲染,如果是 html 则通知浏览器进程准备渲染进程准备进行渲染。

  • 准备渲染进程

    浏览器进程检查当前 url 是否和之前打开的页面是同一站点

    “同一站点”:根域名(例如,geekbang.org)加上协议(例如,https:// 或者 http://)相同,还包含了该根域名下的所有子域名和不同的端口

    • 相同:复用原来的进程
    • 不同:开启新的渲染进程
  • 传输数据、更新状态

    • 渲染进程准备好后,浏览器向渲染进程发起 “提交文档” 的消息,渲染进程接收到消息和网络进程建立传输数据的 “管道”
    • 渲染进程接收完数据后,向浏览器发送 “确认提交”
    • 浏览器进程接收到确认消息后更新浏览器界面状态:安全、地址栏 url、前进后退的历史状态、更新 web 页面

渲染流程

  • 浏览器获取 HTML 文档并将其解析成 DOM 树
  • 处理 CSS 标记,构成 CSSOM (CSS Object Model,样式表声明模型)
  • 将 DOM 和 CSSOM 合并为渲染树 (rendering tree)
  • 遍历生成的渲染树,得到节点的几何信息(位置,大小),开始建立 layout (布局)
  • 根据渲染树以及重排得到的几何信息,得到节点的绝对像素,此过程称为 Painting (重绘)
  • Display: 将像素发送给 GPU,最后通过调用操作系统 Native GUI 的 API 绘制,展示在页面上。

这其中,用户发出 URL 请求到页面开始解析的这个过程,就叫做导航。

导航流程

用户输入

浏览器进程接收到用户输入的 URL 请求,浏览器进程便将该 URL 转发给网络进程

当用户在地址栏中输入一个查询关键字时,地址栏会判断输入的关键字是搜索内容,还是请求的 URL。

  • 如果是搜索内容,地址栏会使用浏览器默认的搜索引擎,来合成新的带搜索关键字的 URL
  • 如果判断输入内容符合 URL 规则,那么地址栏会根据规则,把这段内容加上协议,合成为完整的 URL

浏览器开始加载一个地址后,标签页的图表也变成加载状态,等待提交文档阶段后,页面内容才会被替换。

URL 请求过程

浏览器进程通过进程间通信(IPC)把 URL 请求发送至网络进程

  1. 查找本地缓存:如果本地缓存中有该缓存资源,那么就直接使用缓存,不再发起网络请求

  2. 进入网络请求流程:

    (1) 进行 DNS 解析,获取请求域名的服务器 IP 地址
    (2) 发起 TCP 连接,利用 IP 地址和服务器建立 TCP 连接
    (3) 发送 HTTP 请求,获取响应头
    (4) 接收响应头,解析响应头

    • 响应行的状态码是 301 或者 302:进行重定向,浏览器重新导航到新的地址。

      • 301 重定向

        当浏览器接收到服务端 301(永久)重定向返回码时,会将 original_url 和 redirect_url1 存储在浏览器缓存中,当再次请求 original_url 时,浏览器会从本地缓存中读取 redirect_url1 直接进行跳转,不再请求服务端。

        在浏览器未清理缓存或缓存未失效的情况下,即使服务端修改重定向地址,浏览器依然会跳转到缓存中的 url 地址

      • 302 重定向

        当浏览器接收到服务端 302(临时)重定向返回码时,不会进行缓存。每次请求 original_url 时,都会请求一下服务端。

    • 响应行的状态码是 200:那么表示浏览器可以继续处理该请求。

    (5) 根据响应头的 Content-Type 字段判断后续处理流程

    • Content-Type 字段是下载类型:该请求会被提交给浏览器的下载管理器进行下载,结束导航流程。
    • Content-Type 字段是 HTML:继续进行导航流程。

    思否参考文章:Http 请求中的 Content-Type

准备渲染进程

总结来说,打开一个新页面采用的渲染进程策略就是:

  • 通常情况下,打开新的页面都会使用单独的渲染进程;
  • 如果从 A 页面打开 B 页面,且 A 和 B 都属于同一站点的话,那么 B 页面复用 A 页面的渲染进程;如果是其他情况,浏览器进程则会为 B 创建一个新的渲染进程。

官方把这个默认策略叫 process-per-site-instance

“同一站点” 定义为根域名(例如,geekbang.org)加上协议(例如,https:// 或者 http://),还包含了该根域名下的所有子域名和不同的端口。

渲染进程准备好之后,还不能立即进入文档解析状态,因为此时的文档数据还在网络进程中,并没有提交给渲染进程,所以下一步就进入了提交文档阶段。

提交文档

首先要明确一点,这里的 “文档” 是指 URL 请求的响应体数据。

  • 首先当浏览器进程接收到网络进程的响应头数据之后,便向渲染进程发起 “提交文档” 的消息
  • “提交文档” 的消息是由浏览器进程发出的,渲染进程接收到 “提交文档” 的消息后,会和网络进程建立传输数据的 “管道”
  • 等文档数据传输完成之后,渲染进程会返回 “确认提交” 的消息给浏览器进程
  • 浏览器进程在收到 “确认提交” 的消息后,会更新浏览器界面状态,包括了安全状态、地址栏的 URL、前进后退的历史状态,并更新 Web 页面

其中,当渲染进程确认提交之后,更新内容如下图所示:

导航完成状态
导航完成状态

渲染阶段

HTML CSS JavaScript关系图
HTML CSS JavaScript 关系图

HTML、CSS 和 JavaScript 的含义:

  • HTML 的内容是由标记和文本组成
  • CSS 又称为层叠样式表,是由选择器和属性组成
  • JavaScript(简称为 JS),用于实现页面的动态效果

由于渲染机制过于复杂,所以渲染模块在执行过程中会被划分为很多子阶段,输入的 HTML 经过这些子阶段,最后输出像素。我们把这样的一个处理流程叫做渲染流水线,其大致流程如下图所示:

渲染流水线示意图
渲染流水线示意图

按照渲染的时间顺序,流水线可分为如下几个子阶段:构建 DOM 树、样式计算、布局阶段、分层、绘制、分块、光栅化和合成。

每个阶段的过程中:

  • 开始每个子阶段都有其输入的内容
  • 然后每个子阶段有其处理过程
  • 最终每个子阶段会生成输出内容

解析 HTML

DOM 树构建过程示意图
DOM 树构建过程示意图

从图中可以看出,构建 DOM 树的输入内容是一个非常简单的 HTML 文件,然后经由 HTML 解析器解析,最终输出树状结构的 DOM。DOM 和 HTML 内容几乎是一样的,但是和 HTML 不同的是,DOM 是保存在内存中树状结构,可以通过 JavaScript 来查询或修改其内容。

样式计算

HTML 加载 CSS 的三种方式
HTML 加载 CSS 的三种方式

从图中可以看出,CSS 样式来源主要有三种:

  • 通过 link 引用的外部 CSS 文件
  • <style> 标记内的 CSS
  • 元素的 style 属性内嵌的 CSS

将 CSS 文本转换为浏览器可以理解的结构

和 HTML 文件一样,浏览器也是无法直接理解这些纯文本的 CSS 样式,所以当渲染引擎接收到 CSS 文本时,会执行一个转换操作,将 CSS 文本转换为浏览器可以理解的结构 —— styleSheets,并且该结构同时具备了查询和修改功能,这会为后面的样式操作提供基础。

转换样式表中的属性值,使其标准化

标准化属性值
标准化属性值

将所有值转换为渲染引擎容易理解的、标准化的计算值

计算出 DOM 树中每个节点的具体样式

样式计算阶段的目的是为了计算出 DOM 节点中每个元素的具体样式,在计算过程中需要遵守 CSS 的继承和层叠两个规则。这个阶段最终输出的内容是每个 DOM 节点的样式,并被保存在 ComputedStyle 的结构内。

规则一 —— CSS 继承:就是每个 DOM 节点都包含有父节点的样式。

1
2
3
4
5
body { font-size: 20px; }
p { color:blue; }
span { display: none }
div { font-weight: bold; color:red }
div p { color:green; }

这张样式表最终应用到 DOM 节点的效果如下图所示:

计算后 DOM 的样式
计算后 DOM 的样式

样式的继承过程界面
样式的继承过程界面

规则二 —— 样式层叠:层叠是 CSS 的一个基本特征,它是一个定义了如何合并来自多个源的属性值的算法。它在 CSS 处于核心地位,CSS 的全称 “层叠样式表” 正是强调了这一点。

了解每个 DOM 元素最终的计算样式:

  打开 Chrome 的“开发者工具” —> 选“Element”标签 —> 选择“Computed”子标签

获取节点信息

现在,我们有 DOM 树和 DOM 树中元素的样式,但这还不足以显示页面,因为我们还不知道 DOM 元素的几何位置信息。那么接下来就需要计算出 DOM 树中可见元素的几何位置和大小,我们把这个计算过程叫做布局(自动重排)。

Chrome 在布局阶段需要完成两个任务:创建布局树布局计算

布局流程的输出是一个 “盒模型”,它会精确地捕获每个元素在视口内的确切位置和尺寸,所有相对测量值都将转换为屏幕上的绝对像素。

布局完成后,浏览器会立即发出 “Paint Setup” 和 “Paint” 事件,将渲染树转换成屏幕上的像素。

创建布局树

当我们生成 DOM 树和 CSSOM 树以后,就需要将这两棵树组合为布局树。

布局树构造过程示意图
布局树构造过程示意图

从上图可以看出,DOM 树中所有不可见的节点都没有包含到布局树中。

为了构建布局树,浏览器大体上完成了下面这些工作:

  • 遍历 DOM 树中的所有可见节点,并把这些节点加到布局树中
  • 而不可见的节点会被布局树忽略

布局计算

在执行布局操作的时候,会把布局运算的结果重新写回布局树中,所以布局树既是输入内容也是输出内容,这是布局阶段一个不合理的地方,因为在布局阶段并没有清晰地将输入内容和输出内容区分开来。针对这个问题,Chrome 团队正在重构布局代码,下一代布局系统叫 LayoutNG,试图更清晰地分离输入和输出,从而让新设计的布局算法更加简单。

重排与重绘

重排(回流):通过 JavaScript 或者 CSS 修改元素的几何位置属性,使浏览器触发重新布局、重新生成渲染树等一系列子阶段。

更新元素的几何属性
更新元素的几何属性

重绘:在浏览器重新生成渲染树之后,对渲染树的每个节点进行重新绘制

更新元素背景
更新元素背景

重排和重绘的区别:相较于重排操作,重绘省去了布局和分层阶段,重排一定会触发重绘,而重绘不一定会重排,所以执行效率会比重排操作要高一些。

何时发生重排
  • 页面一开始渲染的时候(自动重排,无法避免)
  • 浏览器的窗口尺寸变化(因为重排是根据视口的大小来计算元素的位置和大小的)
  • 添加或删除可见的 DOM 元素
  • 元素的位置发生变化
  • 元素的尺寸发生变化(包括外边距、内边框、边框大小、高度和宽度等)
  • 内容发生变化,比如文本变化或图片被另一个不同尺寸的图片所替代。
  • 元素字体大小变化
  • 激活 CSS 伪类(例如::hover)

不是所有操作都会造成浏览器重排,例如:改变字体颜色、改变背景色等不会影响元素几何属性(在文档流中的位置)的操作只会导致重绘。

浏览器优化机制

现代浏览器会对频繁的回流或重绘操作进行优化:

浏览器会维护一个队列,把所有引起回流和重绘的操作放入队列中,如果队列中的任务数量或者时间间隔达到一个阈值的,浏览器就会将队列清空,进行一次批处理,这样可以把多次回流和重绘变成一次。

当你访问以下属性或方法时,浏览器会立刻清空队列:

1
2
3
4
5
6
clientWidth、clientHeight、clientTop、clientLeft
offsetWidth、offsetHeight、offsetTop、offsetLeft
scrollWidth、scrollHeight、scrollTop、scrollLeft
width、height
getComputedStyle()
getBoundingClientRect()

因为队列中可能会有影响到这些属性或方法返回值的操作,即使你希望获取的信息与队列中操作引发的改变无关,浏览器也会强行清空队列,确保你拿到的值是最精确的。

以上属性和方法都需要返回最新的布局信息,因此浏览器不得不清空队列,触发回流重绘来返回正确的值。因此,我们在修改样式的时候,最好避免使用上面列出的属性,他们都会刷新渲染队列。如果要使用它们,最好将值缓存起来。

如何减少重排重绘
  • 使用 class 操作样式,而不是频繁操作 style

  • 使用 transform 实现动画效果,可以避开重排重绘,直接在非主线程上执行合成动画操作,没有占用主线程的资源,另外也避开了布局和绘制两个子阶段,所以相对于重绘和重排,合成能大大提升绘制效率。

    避开重排和重绘
    避开重排和重绘

  • 使用 visibility 替换 display: none ,因为前者只会引起重绘,后者会引发重排(改变了布局)

  • 不要把节点的属性值放在一个循环里当成循环里的变量。

  • 避免使用 table 布局

  • 动画实现的速度的选择,动画速度越快,回流次数越多,也可以选择使用 requestAnimationFrame

  • 批量 dom 操作,例如 createDocumentFragment,或者使用框架,例如 React

  • Debounce window resize 事件

  • CSS 选择符从右往左匹配查找,避免节点层级过多

  • will-change: transform 做优化

  • 将频繁重绘或者回流的节点设置为图层,图层能够阻止该节点的渲染行为影响别的节点。比如对于 video 标签来说,浏览器会自动将该节点变为图层。

最小化回流和重绘

由于回流和重绘可能代价比较昂贵,因此最好就是可以减少它的发生次数。为了减少发生次数,我们可以合并多次对 DOM 和样式的修改,然后一次处理掉。

考虑这个例子:

1
2
3
4
const el = document.getElementById('test');
el.style.padding = '5px';
el.style.borderLeft = '1px';
el.style.borderRight = '2px';

例子中,有三个样式属性被修改了,每一个都会影响元素的几何结构,引起回流。当然,大部分现代浏览器都对其做了优化,因此,只会触发一次重排。但是如果在旧版的浏览器或者在上面代码执行的时候,有其他代码访问了布局信息 (上文中的会触发回流的布局信息),那么就会导致三次重排。

因此,我们可以合并所有的改变然后依次处理,比如我们可以采取以下的方式:

    1. 使用 cssText:
1
2
const el = document.getElementById('test');
el.style.cssText += 'border-left: 1px; border-right: 2px; padding: 5px;';
    1. 使用 class, 把 css 样式用个 class 包住,修改 CSS 的 class
1
2
3
4
5
.active{
border-left: 1px;
border-right: 2px;
padding: 5px;
}
1
2
const el = document.getElementById('test');
el.className += ' active';

批量修改 DOM

当我们需要对 DOM 对一系列修改的时候,可以通过以下步骤减少回流重绘次数:

  • 使元素脱离文档流
  • 对其进行多次修改
  • 将元素带回到文档中

该过程的第一步和第三步可能会引起回流,但是经过第一步之后,对 DOM 的所有修改都不会引起回流,因为它已经不在渲染树了。

有三种方式可以让 DOM 脱离文档流:

  • 隐藏元素,应用修改,重新显示
  • 使用文档片段 (document fragment) 在当前 DOM 之外构建一个子树,再把它拷贝回文档
  • 将原始元素拷贝到一个脱离文档的节点中,修改节点后,再替换原始的元素

性能优化策略

基于上面介绍的浏览器渲染原理,DOM 和 CSSOM 结构构建顺序,初始化可以对页面渲染做些优化,提升页面性能。

JS 优化:<script> 标签加上 defer 属性 和 async 属性 用于在不阻塞页面文档解析的前提下,控制脚本的下载和执行。 defer 属性: 用于开启新的线程下载脚本文件,并使脚本在文档解析完成后执行。 async 属性: HTML5 新增属性,用于异步下载脚本文件,下载完毕立即解释执行代码。

CSS 优化:<link> 标签的 rel 属性 中的属性值设置为 preload 能够让你在你的 HTML 页面中可以指明哪些资源是在页面加载完成后即刻需要的,最优的配置加载顺序,提高渲染性能

分层

因为页面中有很多复杂的效果,如一些复杂的 3D 变换、页面滚动,或者使用 z-indexing 做 z 轴排序等,为了更加方便地实现这些效果,渲染引擎还需要为特定的节点生成专用的图层,并生成一棵对应的图层树(LayerTree)

查看页面的分层情况:

  打开 Chrome 的“开发者工具” —> 选择“Layers”标签
布局树和图层树关系示意图
布局树和图层树关系示意图

通常情况下,并不是布局树的每个节点都包含一个图层,如果一个节点没有对应的层,那么这个节点就从属于父节点的图层。

通常满足下面两点中任意一点的元素就可以被提升为单独的一个图层:

  • 拥有层叠上下文属性的元素会被提升为单独的一层
  • 需要剪裁(clip)的地方也会被创建为图层,比如内容超出显示区域,需要裁剪,会单独创建一个层,如果出现滚动条,滚动条也会被提升为单独的层

图层绘制

渲染引擎实现图层的绘制与之类似,会把一个图层的绘制拆分成很多小的绘制指令,然后再把这些指令按照顺序组成一个待绘制列表,如下图所示:

绘制列表
绘制列表

查看绘制列表:

  打开“开发者工具”的“Layers”标签 —> 选择“document”层
一个图层的绘制列表
一个图层的绘制列表

在该图中,区域 1 就是 document 的绘制列表,拖动区域 2 中的进度条可以重现列表的绘制过程。

栅格化(raster)操作

绘制列表只是用来记录绘制顺序和绘制指令的列表,而实际上绘制操作是由渲染引擎中的合成线程来完成的。你可以结合下图来看下渲染主线程和合成线程之间的关系:

渲染进程中的合成线程和主线程
渲染进程中的合成线程和主线程

如上图所示,当图层的绘制列表准备好之后,主线程会把该绘制列表提交(commit)给合成线程。

视口
视口

视口(viewport):指页面上用户可以看到的部分。

图层被划分为图块示意图
图层被划分为图块示意图

合成线程会将图层划分为图块(tile),这些图块的大小通常是 256x256 或者 512x512,如下图所示:

然后合成线程会按照视口附近的图块来优先生成位图,实际生成位图的操作是由栅格化来执行的。

栅格化(raster):指将图块转换为位图。

而图块是栅格化执行的最小单位。渲染进程维护了一个栅格化的线程池,所有的图块栅格化都是在线程池内执行的,运行方式如下图所示:

线程池:java 中的线程池旨在解决线程新建和丢弃造成的内存消耗问题。

合成线程提交图块给栅格化线程池
合成线程提交图块给栅格化线程池

通常,栅格化过程都会使用 GPU 来加速生成,使用 GPU 生成位图的过程叫快速栅格化,或者 GPU 栅格化,生成的位图被保存在 GPU 内存中。

GPU 操作是运行在 GPU 进程中,如果栅格化操作使用了 GPU,那么最终生成位图的操作是在 GPU 中完成的,这就涉及到了跨进程操作。

GPU 栅格化
GPU 栅格化

从图中可以看出,渲染进程把生成图块的指令发送给 GPU,然后在 GPU 中执行生成图块的位图,并保存在 GPU 的内存中。

合成和显示

一旦所有图块都被光栅化,合成线程就会生成一个绘制图块的命令 ——“DrawQuad”,然后将该命令提交给浏览器进程。

浏览器进程里面有一个叫 viz 的组件,用来接收合成线程发过来的 DrawQuad 命令,然后根据 DrawQuad 命令,将其页面内容绘制到内存中,最后再将内存显示在屏幕上。

到这里,经过这一系列的阶段,编写好的 HTML、CSS、JavaScript 等文件,经过浏览器就会显示出漂亮的页面了。

渲染流水线大总结

完整的渲染流水线示意图
完整的渲染流水线示意图

结合上图,一个完整的渲染流程大致可总结为如下:

  • 渲染进程将 HTML 内容转换为能够读懂的 DOM 树结构
  • 渲染引擎将 CSS 样式表转化为浏览器可以理解的 styleSheets,计算出 DOM 节点的样式
  • 创建布局树,并计算元素的布局信息
  • 对布局树进行分层,并生成分层树
  • 为每个图层生成绘制列表,并将其提交到合成线程
  • 合成线程将图层分成图块,并在光栅化线程池中将图块转换成位图
  • 合成线程发送绘制图块命令 DrawQuad 给浏览器进程
  • 浏览器进程根据 DrawQuad 消息生成页面,并显示到显示器上

补充

async 和 defer

async 和 defer 属性的区别
async 和 defer 属性的区别

其中:
蓝色线代表 JavaScript 加载
红色线代表 JavaScript 执行
绿色线代表 HTML 解析

情况一:script 标签没有 没有 defer 或 async

浏览器会立即加载并执行指定的脚本,也就是说不等待后续载入的文档元素,读到就加载并执行。

情况二:script 标签添加 defer (延迟执行)

defer 属性表示延迟执行引入的 JavaScript,即这段 JavaScript 加载时 HTML 并未停止解析,这两个过程是并行的。整个 document 解析完毕且 defer-script 也加载完成之后(这两件事情的顺序无关),会执行所有由 defer-script 加载的 JavaScript 代码,然后触发 DOMContentLoaded 事件。

情况三:script 标签添加 async (延迟执行)

async 属性表示异步执行引入的 JavaScript,与 defer 的区别在于,如果已经加载好,就会开始执行 —— 无论此刻是 HTML 解析阶段还是 DOMContentLoaded 触发之后。需要注意的是,这种方式加载的 JavaScript 依然会阻塞 load 事件。换句话说,async-script 可能在 DOMContentLoaded 触发之前或之后执行,但一定在 load 触发之前执行。

defer 与相比普通 script 区别:载入 JavaScript 文件时不阻塞 HTML 的解析,执行阶段被放到 HTML 标签解析完成之后。

async 和 defer 区别:在加载多个 JS 脚本的时候,async 是无顺序的加载,而 defer 是有顺序的加载。

为什么操作 DOM 慢

把 DOM 和 JavaScript 各自想象成一个岛屿,它们之间用收费桥梁连接。——《高性能 JavaScript》

JS 是很快的,在 JS 中修改 DOM 对象也是很快的。在 JS 的世界里,一切是简单的、迅速的。但 DOM 操作并非 JS 一个人的独舞,而是两个模块之间的协作。

因为 DOM 是属于渲染引擎中的东西,而 JS 又是 JS 引擎中的东西。当我们用 JS 去操作 DOM 时,本质上是 JS 引擎和渲染引擎之间进行了 “跨界交流”。这个 “跨界交流” 的实现并不简单,它依赖了桥接接口作为 “桥梁”。

过 “桥” 要收费 —— 这个开销本身就是不可忽略的。我们每操作一次 DOM(不管是为了修改还是仅仅为了访问其值),都要过一次 “桥”。过 “桥” 的次数一多,就会产生比较明显的性能问题。因此 “减少 DOM 操作” 的建议,并非空穴来风。

如果下载 CSS 文件阻塞了,会阻塞 DOM 树的合成吗?会阻塞页面的显示吗?

CSSOM 会阻塞渲染,只有当 CSSOM 构建完毕后才会进入下一个阶段构建渲染树。

通常情况下 DOM 和 CSSOM 是并行构建的,但是当浏览器遇到一个不带 defer 或 async 属性的 script 标签时,DOM 构建将暂停,如果此时又恰巧浏览器尚未完成 CSSOM 的下载和构建,由于 JavaScript 可以修改 CSSOM,所以需要等 CSSOM 构建完毕后再执行 JS,最后才重新 DOM 构建。