漫漫前端路之浏览器基础——页面渲染阻塞原因分析篇

412 阅读6分钟

DOM树:JavaScript是如何影响DOM树构建的?

DOM的作用

从网络进程传给渲染进程的html文件字节流无法直接被渲染引擎理解,需要将其转换为dom结构。因此,我们说dom提供了对html文档的结构化表述。

  • 对页面来说,DOM是生成页面的基础数据结构;
  • 对JS来说,DOM给JS脚本提供接口,使得JS可以通过这套接口对DOM结构进行访问,从而改变文档的结构,样式及内容;
  • 从安全视角来看,一些不安全的内容在DOM解析的时候即可避免;

DOM树如何生成

渲染引擎中有个html解析器(HTMLParser)模块,其责任失去将HTML字节流转为DOM结构。 HTML 解析器并不是等整个文档加载完成之后再解析的,而是网络进程加载了多少数据,HTML 解析器便解析多少数据: 在网络进程收到响应头后,若响应头中的content-type是'text/html'的话,浏览器会为该请求选择或创建一个渲染进程,并向渲染进程发送“提交文档”的消息,在渲染进程接收到此消息后,将和网络进程建立一个数据共享的管道,这个管道类似一个水管,网络进程不断放数据,渲染进程不断读取数据并喂给html解析器。

具体过程

image.png

  • 通过分词器将字节流转换为 Token: image.png
  • 第二个和第三个阶段是同步进行的,需要将 Token 解析为 DOM 节点,并将 DOM 节点添加到 DOM 树中。HTML 解析器维护了一个 Token 栈结构,该 Token 栈主要用来计算节点之间的父子关系,在第一个阶段中生成的 Token 会被按照顺序压到这个栈中。HTML 解析器开始工作时,会默认创建了一个根为 document 的空 DOM 结构。具体的处理规则如下所示:
  1. 如果压入到栈中的是 StartTag Token,HTML 解析器会为该 Token 创建一个 DOM 节点,然后将该节点加入到 DOM 树中,它的父节点就是栈中相邻的那个元素生成的节点。
  2. 如果分词器解析出来是文本 Token,那么会生成一个文本节点,然后将该节点加入到 DOM 树中,文本 Token 是不需要压入到栈中,它的父节点就是当前栈顶 Token 所对应的 DOM 节点。
  3. 如果分词器解析出来的是 EndTag 标签,比如是 EndTag div,HTML 解析器会查看 Token 栈顶的元素是否是 StarTag div,如果是,就将 StartTag div 从栈中弹出,表示该 div 元素解析完成。 image.png

JS如何影响DOM生成

  • <script>标签
<html>
<body>
    <div>1</div>
    <script>
    let div1 = document.getElementsByTagName('div')[0]
    div1.innerText = 'time.geekbang'
    </script>
    <div>test</div>
</body>
</html>

当解析到<script>标签时,渲染引擎判断是脚本,此时html解析器会暂停dom解析,因为js可能会修改当前生成的dom结构等。 image.png

  • 引入js文件
<html>
<body>
    <div>1</div>
    <script type="text/javascript" src='foo.js'></script>
    <div>test</div>
</body>
</html>

整体流程还是一样,遇到不同的是<script>标签先暂停dom解析,需要先下载js文件在执行,因为js下载会堵塞dom的解析且受到网络,文件大小等多方面因素的影响。这方面Chrome浏览器也做了一些优化,比如预解析操作,当渲染引擎接收到字节流,会开启预解析线程,用来分析html中js、css文件,解析到相关文件,预解析线程会提前加载这些文件。除此之外,我们也可以通过CDN加速,压缩js文件大小,若js中没有涉及操作DOM,可以将脚本设置为异步加载,将代码标记为async/defer。其中,async标记的代码一旦加载完成会立即执行,而defer需要在DOMContentLoaded事件完成之前执行

  • js、css共存
//theme.css
div {color:blue}
<html>
    <head>
        <style src='theme.css'></style>
    </head>
<body>
    <div>1</div>
    <script>
            let div1 = document.getElementsByTagName('div')[0]
            div1.innerText = 'time.geekbang' //需要DOM
            div1.style.color = 'red'  //需要CSSOM
        </script>
    <div>test</div>
</body>
</html>

js脚本含有操作CSSOM的代码,在js执行之前,需要解析js语句之上的css样式。因为代码又引用了外部的css文件,所在执行脚本之前,还需要等待外部的css文件下载完成,并解析生成CSSOM对象,在接着执行脚本。但是在渲染引擎解析脚本之前,对脚本是否操作CSSOM是未知的,所以渲染引擎在遇到脚本时,无论其是否涉及操作CSSOM的代码,都会先执行css文件的下载解析再执行脚本

CSS如何影响首次加载时的白屏时间?

CSSOM的作用

  1. 提供给脚本操作样式表的能力
  2. 为布局树的合成提供基础样式信息

  • 只有css下的渲染流水线
<html>
<head>
    <link href="theme.css" rel="stylesheet">
</head>
<body>
    <div>geekbang com</div>
</body>
</html>

image.png

  1. 请求 HTML 数据和构建 DOM 中间有一段空闲时间,这个空闲时间有可能成为页面渲染的瓶颈
  2. 当渲染进程接收 HTML 文件字节流时,会先开启一个预解析线程,预解析线程会解析出来一个外部的 theme.css 文件,并发起 theme.css 的下载。这里也有一个空闲时间就是在 DOM 构建结束之后、theme.css 文件还未下载完成的这段时间内,渲染流水线无事可做,因为下一步是合成布局树,而合成布局树需要 CSSOM 和 DOM,所以这里需要等待 CSS 加载结束并解析成 CSSOM。
  • <script>标签脚本、CSS共存的渲染流水线
//theme.css
div{ 
    color : coral;
    background-color:black
}
<html>
<head>
    <link href="theme.css" rel="stylesheet">
</head>
<body>
    <div>geekbang com</div>
    <script>
        console.log('time.geekbang.org')
    </script>
    <div>geekbang com</div>
</body>
</html>

image.png 在执行脚本之前,渲染引擎需要先将外部css文件和<style>标签的css内容转换为CSSOM,因为JS有操作CSSOM的能力,也就是说CSS在部分情况下也会阻塞DOM的生成

  • 外部JS文件、CSS共存 image.png 预解析线程解析出有CSS、JS文件需要下载,将同时发起两个文件的下载请求,虽然下载过程是重叠,下载时间取决于长的那个。无论谁先到达,脚本都需要等css文件解析完并生成CSSOM后执行,在脚本执行后继续构建DOM,再构建布局树,绘制页面。

白屏时间及优化

白屏时间

在用户输入到首次显示页面内容的视觉变化主要分为三个阶段:

  1. 等请求发出去之后,到提交数据阶段,这段时间内页面仍然展示的是之前的页面内容
  2. 提交数据后渲染进程会创建一个空白页面,这段时间称之为解析白屏(白屏时间),并等待CSS和JS文件加载文成,并生成DOM和CSSOM,然后合成布局树,最后经过一系列步骤准备首次渲染
  3. 等首次渲染完成之后,就进入完整页面的生成阶段,然后页面会被一点点绘制。

优化

白屏时间的瓶颈主要体现在下载 CSS 文件、下载 JavaScript 文件和执行 JavaScript; 所以要想缩短白屏时长,可以有以下策略:

  • 内联 JavaScript、内联 CSS 来移除这两种类型的文件下载,这样获取到 HTML 文件之后就可以直接开始渲染流程了,但并不是所有的场合都适合内联。
  • 尽量减少文件大小,比如通过 webpack 等工具移除一些不必要的注释,并压缩 JavaScript 文件。
  • 将一些不需要在解析 HTML 阶段使用的 JavaScript 标记上 async 或者 defer。
  • 利用媒体查询属性,在特定的场景下加载特定的 CSS 文件。

学习例子

1:<script src="foo.js" type="text/javascript"></script>
2:<script defer src="foo.js" type="text/javascript"></script>
3:<script sync src="foo.js" type="text/javascript"></script>
4:<link rel="stylesheet" type="text/css" href="foo.css" />
5:<link rel="stylesheet" type="text/css" href="foo.css" media="screen"/>
6:<link rel="stylesheet" type="text/css" href="foo.css" media="print" />
7:<link rel="stylesheet" type="text/css" href="foo.css" media="orientation:landscape" />
8:<link rel="stylesheet" type="text/css" href="foo.css" media="orientation:portrait" />
  1. 下载JavaScript文件并执行同步代码,会阻塞页面渲染
  2. defer异步下载JavaScript文件,会在HTML解析完成之后执行,在DOMContentLoaded之前执行,不会阻塞DOM构建,会阻塞页面渲染
  3. async异步下载JavaScript文件,下载完成之后会立即执行,有可能会阻塞页面渲染
  4. 下载CSS文件,可能阻塞页面渲染
  5. media属性用于区分设备,screen表示用于有屏幕的设备,无法用于打印机、3D眼镜、盲文阅读机等,在题设手机条件下,会加载
  6. print用于打印预览模式或打印页面,这里不会加载,不会阻塞页面渲染
  7. orientation:landscape表示横屏,与题设条件一致,会加载
  8. orientation:portrait表示竖屏,这里不会加载,不会阻塞页面渲染

资料来源

time.geekbang.org/column/arti…