浏览器渲染HTML过程

浏览器资源加载过程

首先抛出两个问题:

  • 浏览器如何知道应该加载哪些资源?
  • 浏览器是什么顺序来加载这些资源?

当浏览器截获到一个页面,将会按顺序做下面四件事

  1. 首先会将所有需要加载的资源进行分类。
  2. 然后根据浏览器相关的安全策略,来决定资源的加载权限。
  3. 接着对各个资源的加载优先级进行计算和排序。
  4. 最后一步,根据加载优先级顺序来加载资源。

资源分类

chrome浏览器会将资源分为14类,如下表所示。

类型介绍
kMainResource即主资源,html页面文件资源就属于该类型
kImage各种图片资源
kCSSStyleSheet顾名思义,就是层叠样式表css资源
kScript脚本资源,例如js资源
kFont字体资源,例如网页中常用的字体集.woff资源
kRaw混合类型资源,最常见的ajax请求就属于这类资源
kSVGDocumentSVG可缩放矢量图形文件资源
kXSLStyleSheet扩展样式表语言XSLT,是一种转换语言,关于该类型可以查阅w3c XSL来了解
kLinkPrefetchHTML5页面的预读取资源(Link prefetch),例如dns-prefetch。下面会有详细介绍
kTextTrackvideo的字幕资源,- 即``标签
kImportResourceHTML Imports,将一个HTML文件导入到其他HTML文档中,例如``。详细了解请查阅相关文档。
kMedia多媒体资源,video or audio都属于该类资源
kManifestHTML5 应用程序缓存资源
kMock预留的测试类型

安全策略检查

网页安全政策(Content Security Policy,缩写 CSP)是由浏览器提供的一种白名单制度。开发者通过配置,来告诉浏览器各类外部资源的加载和执行限制,来提高网页的安全性。一种最常用的应用就是通过限制非信任域名脚本的加载来预防XSS攻击。 可以通过两种方式来配置CSP。 第一种,就是通过页面HTTP请求头的Content-Security-Policy字段来限制。如下图所示,这是www.google.com页面的请求头:

流程流程

第二种是,通过标签来设置。是以key-value的方式来进行配置的。下面以几个具体的应用例子来介绍。

  1. 用于预防XSS:
1
2
<meta http-equiv="Content-Security-Policy" content="script-src 'self'; style-src nos.netease.com kaola.com;">
复制代码

上面的script-src代表脚本资源;style-src代表样式资源;'self'代表只信任当前域名下的外来资源,其他域下的资源全部会被拦截;nos.netease.com kaola.com代表信任nos.netease.com和kaola.com这两个域名下的资源。 所以上面的标签的意义就是:对于脚本资源只信任本域下的,对于样式资源,除了本域还会加载nos.netease.com和kaola.com这两个域名下的。

  1. 用于站点请求协议升级过渡(http转https):
1
2
<meta http-equiv="Content-Secur****ity-Policy" content="upgrade-insecure-requests">
复制代码

上面的upgrade-insecure-requests的意义,就如同字面意思一样:升级所有非安全请求。当加了这个meta标签以后,浏览器会将https页面中的所有htttp请自动升级到https。例如,当我们需要进行全站http转https改造时,对于原有的大量http资源会直接强制以https或wss等SSL加密形式发送请求而不会报错。当然如果资源服务器不支持https等SSL加密,那么该资源还是不会载入。

  1. 用于阻止Mixed Content:
1
2
<meta http-equiv="Content-Security-Policy" content="block-all-mixed-content">
复制代码

混合内容(Mixed Content)就是第2个例子所说的,在https站点中,进行的http请求。这类在安全链接中混合了非安全请求内容就叫混合内容。出现这类请求时,我们可以在浏览器控制台中找到对应的警告信息,如下图所示。

流程流程

混合内容会降低HTTPS网站的安全性和用户体验。不过让人略感放心的是,浏览器对于可能对安全性造成较大威胁的资源类型的混合模式请求都会直接拦截报错,例如脚本资源,如下图所示。但对于图片、音频、视频等资源只会警告,但不会阻止其加载。

流程流程

对于安全性要求极高的网站,可以通过上面的标签来阻止所以类型的非安全链接请求,这样包括图片、音频、视频等资源也将会被拦截报错。 当然对于Content-Security-Policy的设置还有很多其他作用,大家可以通过MDN来做进一步了解。

资源优先级计算

资源的优先级被分为5级。不同资料上,对这5级的命名描述上可能有所不同。主要是因为资料本身可能是从网络层面浏览器内核或者用户端控制台显示这三个方向中的某一个来说的。这三个方向虽然对这5级的命名不同,但都是一一对应的。 网络层面,5级分别为:Highest、Medium、Low、Lowest、Idle; 浏览器内核,5级分别为:VeryHigh、High、Medium、Low、VeryLow; 用户端控制台显示,5级分别为:Highest、High、Medium、Low、Lowest;

下面是以浏览器内核作为研究方向,来介绍浏览器的资源优先级计算过程:

  • 第一步,根据资源的类型来设定默认优先级。 对于每一类资源浏览器都有一个默认的加载优先级规则:
  1. html、css、font这三种类型的资源优先级最高;
  2. 然后是preload资源(通过``标签预加载)、script、xhr请求;
  3. 接着是图片、语音、视频;
  4. 最低的是prefetch预读取的资源。
  • 第二步,根据一定的实际规则,对优先级进行调整。 初始优先级设置好以后,浏览器会根据资源的实际属性和位于文档中的位置等方面,对优先级进行调整,来确定出最终的加载优先级顺序。对于几个常见资源类型的调整规则如下:
  1. 对于XHR请求资源:将同步XHR请求的优先级调整为最高。 XHR请求可以分为同步请求和异步请求,浏览器会把同步请求的优先级提升到最高级,以便尽早获取数据、加快页面的显示。
  2. 对于图片资源:会根据图片是否在可见视图之内来改变优先级。 图片资源的默认优先级为Low。现代浏览器为了提高用户首屏的体验,在渲染时会计算图片资源是否在首屏可见视图之内,在的话,会将这部分视口可见图片(Image in viewport)资源的优先级提升为High。
  3. 对于脚本资源:浏览器会将根据脚本所处的位置和属性标签分为三类,分别设置优先级。 首先,对于添加了defer/async属性标签的脚本的优先级会全部降为Low。 然后,对于没有添加该属性的脚本,根据该脚本在文档中的位置是在浏览器展示的第一张图片之前还是之后,又可分为两类。在之前的(标记early**)它会被定为High优先级,在之后的(标记late**)会被设置为Medium优先级。

按照上面计算的安全策略和优先级来加载或阻塞资源。

浏览器渲染过程

关键渲染路径

提到页面渲染,有几个相关度非常高的概念,最重要的是关键渲染路径,其他几个概念都可以从它展开,下面稍作说明。

**关键渲染路径(Critical Rendering Path)**是指与当前用户操作有关的内容。例如用户刚刚打开一个页面,首屏的显示就是当前用户操作相关的内容,具体就是浏览器收到 HTML、CSS 和 JavaScript 等资源并对其进行处理从而渲染出 Web 页面。

了解浏览器渲染的过程与原理,很大程度上是为了优化关键渲染路径,但优化应该是针对具体问题的解决方案,所以优化没有一定之规。例如为了保障首屏内容的最快速显示,通常会提到渐进式页面渲染,但是为了渐进式页面渲染,就需要做资源的拆分,那么以什么粒度拆分、要不要拆分,不同页面、不同场景策略不同。具体方案的确定既要考虑体验问题,也要考虑工程问题。

从耗时的角度,浏览器请求、加载、渲染一个页面,时间花在下面五件事情上:

  1. DNS 查询
  2. TCP 连接
  3. HTTP 请求即响应
  4. 服务器响应
  5. 客户端渲染

本文讨论第五个部分,即浏览器对内容的渲染,这一部分(渲染树构建、布局及绘制),又可以分为下面五个步骤

  1. 处理 HTML 标记并构建 DOM 树。
  2. 处理 CSS 标记并构建 CSSOM 树。
  3. 将 DOM 与 CSSOM 合并成一个渲染树。
  4. 根据渲染树来布局,以计算每个节点的几何信息。
  5. 将各个节点绘制到屏幕上。

需要明白,这五个步骤并不一定一次性顺序完成。如果 DOM 或 CSSOM 被修改,以上过程需要重复执行,这样才能计算出哪些像素需要在屏幕上进行重新渲染。实际页面中,CSS 与 JavaScript 往往会多次修改 DOM 和 CSSOM,下面就来看看它们的影响方式。

阻塞渲染:CSS 与 JavaScript

谈论资源的阻塞时,我们要清楚,现代浏览器总是并行加载资源。例如,当 HTML 解析器(HTML Parser)被脚本阻塞时,解析器虽然会停止构建 DOM,但仍会识别该脚本后面的资源,并进行预加载。

同时,由于下面两点:

  1. 默认情况下,CSS 被视为阻塞渲染的资源,这意味着浏览器将不会渲染任何已处理的内容,直至 CSSOM 构建完毕。
  2. JavaScript 不仅可以读取和修改 DOM 属性,还可以读取和修改 CSSOM 属性。

存在阻塞的 CSS 资源时,浏览器会延迟 JavaScript 的执行和 DOM 构建。另外:

  1. 当浏览器遇到一个 script 标记时,DOM 构建将暂停,直至脚本完成执行。
  2. JavaScript 可以查询和修改 DOM 与 CSSOM。
  3. CSSOM 构建时,JavaScript 执行将暂停,直至 CSSOM 就绪。

所以,script 标签的位置很重要。实际使用时,可以遵循下面两个原则:

  1. CSS 优先:引入顺序上,CSS 资源先于 JavaScript 资源。
  2. JavaScript 应尽量少影响 DOM 的构建。

浏览器的发展日益加快(目前的 Chrome 官方稳定版是 61),具体的渲染策略会不断进化,但了解这些原理后,就能想通它进化的逻辑。下面来看看 CSS 与 JavaScript 具体会怎样阻塞资源。

CSS

1
2
<style> p { color: red; }</style>
<link rel="stylesheet" href="index.css">

这样的 link 标签(无论是否 inline)会被视为阻塞渲染的资源,浏览器会优先处理这些 CSS 资源,直至 CSSOM 构建完毕。

渲染树(Render-Tree)的关键渲染路径中,要求同时具有 DOM 和 CSSOM,之后才会构建渲染树。即,HTML 和 CSS 都是阻塞渲染的资源。HTML 显然是必需的,因为包括我们希望显示的文本在内的内容,都在 DOM 中存放,那么可以从 CSS 上想办法。

最容易想到的当然是精简 CSS 并尽快提供它。除此之外,还可以用媒体类型(media type)和媒体查询(media query)来解除对渲染的阻塞。

1
2
3
<link href="index.css" rel="stylesheet">
<link href="print.css" rel="stylesheet" media="print">
<link href="other.css" rel="stylesheet" media="(min-width: 30em) and (orientation: landscape)">

第一个资源会加载并阻塞。
第二个资源设置了媒体类型,会加载但不会阻塞,print 声明只在打印网页时使用。
第三个资源提供了媒体查询,会在符合条件时阻塞渲染。

JavaScript

JavaScript 的情况比 CSS 要更复杂一些。观察下面的代码:

1
2
3
4
5
6
7
8
9
10
11
<p>Do not go gentle into that good night,</p>
<script>console.log("inline")</script>
<p>Old age should burn and rave at close of day;</p>
<script src="app.js"></script>
<p>Rage, rage against the dying of the light.</p>

<p>Do not go gentle into that good night,</p>
<script src="app.js"></script>
<p>Old age should burn and rave at close of day;</p>
<script>console.log("inline")</script>
<p>Rage, rage against the dying of the light.</p>

这样的 script 标签会阻塞 HTML 解析,无论是不是 inline-script。上面的 P 标签会从上到下解析,这个过程会被两段 JavaScript 分别打算一次(加载、执行)。

所以实际工程中,我们常常将资源放到文档底部。

改变阻塞模式:defer 与 async

为什么要将 script 加载的 defer 与 async 方式放到后面呢?因为这两种方式是的出现,全是由于前面讲的那些阻塞条件的存在。换句话说,defer 与 async 方式可以改变之前的那些阻塞情形。

首先,注意 async 与 defer 属性对于 inline-script 都是无效的,所以下面这个示例中三个 script 标签的代码会从上到下依次执行。

1
2
3
4
5
6
7
8
9
10
<!-- 按照从上到下的顺序输出 1 2 3 -->
<script async>
console.log("1");
</script>
<script defer>
console.log("2");
</script>
<script>
console.log("3");
</script>

故,下面两节讨论的内容都是针对设置了 src 属性的 script 标签。

defer

1
2
3
<script src="app1.js" defer></script>
<script src="app2.js" defer></script>
<script src="app3.js" defer></script>

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

defer 不会改变 script 中代码的执行顺序,示例代码会按照 1、2、3 的顺序执行。所以,defer 与相比普通 script,有两点区别:载入 JavaScript 文件时不阻塞 HTML 的解析,执行阶段被放到 HTML 标签解析完成之后。

async

1
2
3
<script src="app.js" async></script>
<script src="ad.js" async></script>
<script src="statistics.js" async></script>

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

从上一段也能推出,多个 async-script 的执行顺序是不确定的。值得注意的是,向 document 动态添加 script 标签时,async 属性默认是 true,下一节会继续这个话题。

document.createElement

使用 document.createElement 创建的 script 默认是异步的,示例如下。

1
console.log(document.createElement("script").async); // true

所以,通过动态添加 script 标签引入 JavaScript 文件默认是不会阻塞页面的。如果想同步执行,需要将 async 属性人为设置为 false。

如果使用 document.createElement 创建 link 标签会怎样呢?

1
2
3
4
const style = document.createElement("link");
style.rel = "stylesheet";
style.href = "index.css";
document.head.appendChild(style); // 阻塞?

其实这只能通过试验确定,已知的是,Chrome 中已经不会阻塞渲染,Firefox、IE 在以前是阻塞的,现在会怎样我没有试验。

document.write 与 innerHTML

通过 document.write 添加的 link 或 script 标签都相当于添加在 document 中的标签,因为它操作的是 document stream(所以对于 loaded 状态的页面使用 document.write 会自动调用 document.open,这会覆盖原有文档内容)。即正常情况下, link 会阻塞渲染,script 会同步执行。不过这是不推荐的方式,Chrome 已经会显示警告,提示未来有可能禁止这样引入。如果给这种方式引入的 script 添加 async 属性,Chrome 会检查是否同源,对于非同源的 async-script 是不允许这么引入的。

如果使用 innerHTML 引入 script 标签,其中的 JavaScript 不会执行。当然,可以通过 eval() 来手工处理,不过不推荐。如果引入 link 标签,我试验过在 Chrome 中是可以起作用的。另外,outerHTML、insertAdjacentHTML() 应该也是相同的行为,我并没有试验。这三者应该用于文本的操作,即只使用它们添加 text 或普通 HTML Element。

重排与重绘

无论何时总会有一个初始化的页面布局伴随着一次绘制。(除非你希望你的页面是空白的:))之后,每一次改变用于构建渲染树的信息都会导致以下至少一个的行为:

  1. 部分渲染树(或者整个渲染树)需要重新分析并且节点尺寸需要重新计算。这被称为重排。注意这里至少会有一次重排-初始化页面布局。
  2. 由于节点的几何属性发生改变或者由于样式发生改变,例如改变元素背景色时,屏幕上的部分内容需要更新。这样的更新被称为重绘

重排和重绘代价是高昂的,它们会破坏用户体验,并且让UI展示非常迟缓。

什么情况会触发重排和重绘?

任何改变用来构建渲染树的信息都会导致一次重排或重绘。

  • 添加、删除、更新DOM节点
  • 通过display: none隐藏一个DOM节点-触发重排和重绘
  • 通过visibility: hidden隐藏一个DOM节点-只触发重绘,因为没有几何变化
  • 移动或者给页面中的DOM节点添加动画
  • 添加一个样式表,调整样式属性
  • 用户行为,例如调整窗口大小,改变字号,或者滚动。

参考链接:

浏览器渲染过程

浏览器资源加载

https://juejin.im/entry/582f16fca22b9d006b7afd89