浏览器原理与实践系列文章内容来自极客时间李兵老师《浏览器工作原理与实践》,主要是记录自己的学习过程,基于自己的理解对内容做的一些总结,包括《宏观视角下的浏览器:地址栏键入URL后会发生什么?HTML,CSS,JS又是怎么变成页面显示出来的》《JS中内存机制、数据类型、V8引擎垃圾回收原理、V8怎样执行JS代码,据此可以做哪些性能优化》《V8引擎工作原理》《事件循环系统:宏任务微任务如何有条不紊的执行?》《浏览器中的页面:重绘重排及合成?如何提高页面渲染性能》《网络协议:http1、http2、http3都有些什么优缺点?》《浏览器安全:xss及CSRF攻击有何特点?如何防御》共七篇,此为第五篇
从网络传给渲染引擎的HTML文件字节流是无法直接被渲染引擎理解的,所以要将其转化为渲染引擎能够理解的内部结构,这个结构就是DOM。DOM提供了对HTML文档结构化的表述。在渲染引擎中,DOM有三个层面的作用。
简言之,DOM是表述HTML的内部数据结构,它会将Web页面和JavaScript脚本连接起来,并过滤一些不安全的内容。
在渲染引擎内部,有一个叫HTML解析器(HTMLParser) 的模块,它的职责就是负责将HTML字节流转换为DOM结构。
HTML解析器并不是等整个文档加载完成之后再解析的,而是网络进程加载了多少数据,HTML解析器便解析多少数据:网络进程接收到响应头之后,会根据响应头中的content-type字段来判断文件的类型,比如content-type的值是“text/html”,浏览器就会判断这是一个HTML类型的文件,然后为该请求选择或者创建一个渲染进程。渲染进程准备好之后,网络进程和渲染进程之间会建立一个共享数据的管道,网络进程接收到数据后就往这个管道里面放,而渲染进程则从管道的另外一端不断地读取数据,并同时将读取的数据传递给HTML解析器,它会动态接收字节流,并将其解析为DOM。
当执行到JavaScript标签时,解析器会暂停整个DOM的解析,执行JavaScript代码,不过执行JavaScript时,需要先下载JavaScript代码。JavaScript文件的下载过程会阻塞DOM解析,而通常下载又是非常耗时的,会受到网络环境、JavaScript文件大小等因素的影响。
不过Chrome浏览器做了很多优化,其中一个主要的优化是预解析操作。当渲染引擎收到字节流之后,会开启一个预解析线程,用来分析HTML文件中包含的JavaScript、CSS等相关文件,解析到相关文件之后,预解析线程会提前下载这些文件。
有一些相关的策略来规避JavaScript线程阻塞DOM,比如使用CDN来加速JavaScript文件的加载,压缩JavaScript文件的体积。另外,如果JavaScript文件中没有操作DOM相关代码,就可以将该JavaScript脚本设置为异步加载,通过async 或defer来标记代码。async和defer虽然都是异步的,不过还有一些差异,使用async标志的脚本文件一旦加载完成,会立即执行,与在页面中出现的顺序无关;而使用了defer标记的脚本文件,需要在DOMContentLoaded事件之前执行,与出现顺序有关,dom加载完毕后按出现顺序依次执行。
如果JavaScript代码出现了类似 div.style.color = ‘red' 的语句,在执行JavaScript之前,就需要先解析JavaScript语句之上所有的CSS样式,因为它是用来操纵CSSOM的。所以如果代码里引用了外部的CSS文件,在执行JavaScript之前,还需要等待外部的CSS文件下载完成,并解析生成CSSOM对象之后,才能执行JavaScript脚本。
而JavaScript引擎在解析JavaScript之前,是不知道JavaScript是否操纵了CSSOM的,所以渲染引擎在遇到JavaScript脚本时,不管该脚本是否操纵了CSSOM,都会执行CSS文件下载,解析操作,再执行JavaScript脚本。
所以说JavaScript脚本是依赖样式表的,这又多了一个阻塞过程。JavaScript会阻塞DOM生成,而样式文件又会阻塞JavaScript的执行,所以在实际的工程中需要重点关注JavaScript文件和样式表文件,使用不当会影响到页面性能的。
<link href="theme.css" rel="stylesheet">
<div>geekbang com</div>
<script src="foo.js"></script>
<div>geekbang com</div>
以上是页面HTML代码以及渲染流程图,借此分析渲染流水线:
首先是发起主页面的请求,这个发起请求方可能是渲染进程,也有可能是浏览器进程,发起的请求被送到网络进程中去执行。网络进程接收到返回的HTML数据之后,将其发送给渲染进程,渲染进程会解析HTML数据并构建DOM。需要特别注意下,请求HTML数据和构建DOM中间有一段空闲时间,这个空闲时间有可能成为页面渲染的瓶颈。当渲染进程接收HTML文件字节流时,会先开启一个预解析线程,如果遇到JavaScript文件或者CSS文件,预解析线程会提前下载这些数据。对于上面的代码,HTML预解析器识别出来了有CSS文件和JavaScript文件需要下载,然后就同时发起这两个文件的下载请求,这两个文件的下载过程是重叠的,所以下载时间按照最久的那个文件来算。不管CSS文件和JavaScript文件谁先到达,都要先等到CSS文件下载完成并生成CSSOM,然后再执行JavaScript脚本,最后再继续构建DOM,构建布局树,绘制页面。还有一个空闲时间需要注意,就是在DOM构建结束之后、theme.css文件还未下载完成的这段时间内,渲染流水线无事可做,因为下一步是合成布局树,而合成布局树需要CSSOM和DOM,所以这里需要等待CSS加载结束并解析成CSSOM。
和HTML一样,渲染引擎也是无法直接理解CSS文件内容的,所以需要将其解析成渲染引擎能够理解的结构,这个结构就是CSSOM。和DOM一样,CSSOM也具有两个作用,第一个是提供给JavaScript操作样式表的能力,第二个是为布局树的合成提供基础的样式信息。 因此渲染流水线也需要等待CSSOM。等DOM和CSSOM都构建好之后,渲染引擎就会构造布局树。布局树的结构基本上就是复制DOM树的结构,不同之处在于DOM树中那些不需要显示的元素会被过滤掉,如display:none属性的元素、head标签、script标签等。复制好基本的布局树结构之后,渲染引擎会为对应的DOM元素选择对应的样式信息,这个过程就是样式计算。样式计算完成之后,渲染引擎还需要计算布局树中每个元素对应的几何位置,这个过程就是计算布局。通过样式计算和计算布局就完成了最终布局树的构建。
渲染流水线影响到了首次页面展示的速度,而首次页面展示的速度又直接影响到了用户体验,所以分析渲染流水线的目的就是为了找出一些影响到首屏展示的因素,然后再基于这些因素做一些针对性的调整。
从发起URL请求开始,到首次显示页面的内容,在视觉上经历三个阶段。
影响第一个阶段的因素主要是网络或者是服务器处理。第二个阶段主要问题是白屏时间,如果白屏时间过久,就会影响到用户体验。为了缩短白屏时间,需要挨个分析这个阶段的主要任务,包括了解析HTML、下载CSS、下载JavaScript、生成CSSOM、执行JavaScript、生成布局树、绘制页面一系列操作。
通常情况下的瓶颈主要体现在下载CSS文件、下载JavaScript文件和执行JavaScript。
所以要想缩短白屏时长,可以有以下策略:
每个显示器都有固定的刷新频率,通常是60HZ,也就是每秒更新60张图片,更新的图片都来自于显卡中的前缓冲区,显示器所做的任务很简单,就是每秒固定读取60次前缓冲区中的图像,并将读取的图像显示到显示器上。那么这里显卡做什么呢? 显卡的职责就是合成新的图像,并将图像保存到后缓冲区中,一旦显卡把合成的图像写到后缓冲区,系统就会让后缓冲区和前缓冲区互换,这样就能保证显示器能读取到最新显卡合成的图像。通常情况下,显卡的更新频率和显示器的刷新频率是一致的。但有时候,在一些复杂的场景中,显卡处理一张图片的速度会变慢,这样就会造成视觉上的卡顿。
大多数设备屏幕的更新频率是60次/秒,这也就意味着正常情况下要实现流畅的动画效果,渲染引擎需要每秒更新60张图片到显卡的后缓冲区。
渲染流水线生成的每一副图片称为一帧,渲染流水线每秒更新了多少帧称为帧率,比如滚动过程中1秒更新了60帧,那么帧率就是60Hz(或者60FPS)。
任意一帧的生成方式,有重排、重绘和合成三种方式。
这三种方式的渲染路径是不同的,通常渲染路径越长,生成图像花费的时间就越多。
Chrome浏览器是怎么实现合成操作的,可以用三个词来概括总结:分层、分块和合成:
如果没有采用分层机制,从布局树直接生成目标图片的话,那么每次页面有很小的变化时,都会触发重排或者重绘机制,这种“牵一发而动全身”的绘制策略会严重影响页面的渲染效率。和PhotoShop的图层类似,PhotoShop中一个项目是由很多图层构成的,每个图层都可以是一张单独图片,可以设置透明度、边框阴影,可以旋转或者设置图层的上下位置,将这些图层叠加在一起后,就能呈现出最终的图片了。将素材分解为多个图层的操作就称为分层,最后将这些图层合并到一起的操作就称为合成。合成操作是在合成线程上完成的,这就意味着在执行合成操作时,是不会影响到主线程执行的。 所以有时候主线程卡住了,但是CSS动画依然能执行。
如果说分层是从宏观上提升了渲染效率,那么分块则是从微观层面提升了渲染效率。
通常情况下,页面的内容都要比屏幕大得多,显示一个页面时,如果等待所有的图层都生成完毕,再进行合成的话,会产生一些不必要的开销,也会让合成图片的时间变得更久。
因此,合成线程会将每个图层分割为大小固定的图块,然后优先绘制靠近视口的图块,这样就可以大大加速页面的显示速度。不过有时候, 即使只绘制那些优先级最高的图块,也要耗费不少的时间,因为涉及到一个很关键的因素——纹理上传,这是因为从计算机内存上传到GPU内存的操作会比较慢。
开发中经常需要对某个元素做几何形状变换、透明度变换或者一些缩放操作,如果使用JavaScript来写这些效果,会牵涉到整个渲染流水线,所以JavaScript的绘制效率会非常低下。
这时可以使用 will-change来告诉渲染引擎会对该元素做一些特效变换,CSS代码如下:
.box {
will-change: transform, opacity;
}
以上代码就是提前告诉渲染引擎box元素将要做几何变换和透明度变换操作,这时候渲染引擎会将该元素单独实现一帧,等这些变换发生时,渲染引擎会通过合成线程直接去处理变换,这些变换并没有涉及到主线程,这样就大大提升了渲染的效率。这也是CSS动画比JavaScript动画高效的原因。
所以,如果涉及到一些可以使用合成线程来处理CSS特效或者动画的情况,就尽量使用will-change来提前告诉渲染引擎,让它为该元素准备独立的层。但是凡事都有两面性,每当渲染引擎为一个元素准备一个独立层的时候,它占用的内存也会大大增加,因为从层树开始,后续每个阶段都会多一个层结构,这些都需要额外的内存,所以需要恰当地使用 will-change。
所谓页面优化,其实就是指如何让页面更快地显示和响应。一个页面在它不同的阶段,所侧重的关注点是不一样的,所以页面优化,就要分析一个页面生存周期的不同阶段。
通常一个页面有三个阶段:加载阶段、交互阶段和关闭阶段。
重点关注加载阶段和交互阶段,因为影响到体验的因素主要都在这两个阶段
上图是一个典型的渲染流水线。并非所有的资源都会阻塞页面的首次绘制,比如图片、音频、视频等文件就不会阻塞页面的首次渲染;而JavaScript、首次请求的HTML资源文件、CSS文件是会阻塞首次渲染,因为在构建DOM的过程中需要HTML和JavaScript文件,在构造渲染树的过程中需要用到CSS文件。
能阻塞网页首次渲染的资源称为关键资源。基于关键资源,可以细化出来三个影响页面首次渲染的核心因素。
第一个是关键资源个数。关键资源个数越多,首次页面的加载时间就会越长。比如上图中的关键资源个数就是3个,1个HTML文件、1个JavaScript和1个CSS文件。
第二个是关键资源大小。通常情况下,所有关键资源的内容越小,其整个资源的下载时间也就越短,那么阻塞渲染的时间也就越短。上图中关键资源的大小分别是6KB、8KB和9KB,那么整个关键资源大小就是23KB。
第三个是请求关键资源需要多少个RTT(Round Trip Time) 。当使用TCP协议传输一个文件时,比如文件大小是0.1M,由于TCP的特性,这个数据并不是一次传输到服务端的,而是需要拆分成一个个数据包来回多次进行传输的。RTT就是这里的往返时延,表示从发送端发送数据开始,到发送端收到来自接收端的确认,总共经历的时延。通常1个HTTP的数据包在14KB左右,所以1个0.1M的页面就需要拆分成8个包来传输,也就是说需要8个RTT。
结合上图来看看它的关键资源请求需要多少个RTT。首先是请求HTML资源,大小是6KB,小于14KB,所以1个RTT就可以解决了。至于JavaScript和CSS文件,这里需要注意的是,由于渲染引擎有一个预解析的线程,在接收到HTML数据之后,预解析线程会快速扫描HTML数据中的关键资源,一旦扫描到了,会立马发起请求,可以认为JavaScript和CSS是同时发起请求的,所以它们的请求是重叠的,那么计算它们的RTT时,只需要计算体积最大的那个数据就可以了。这里最大的是CSS文件(9KB),所以按照9KB来计算,同样由于9KB小于14KB,所以JavaScript和CSS资源也就可以算成1个RTT。也就是说,上图中关键资源请求共花费了2个RTT。
了解了影响加载过程中的几个核心因素之后,就可以系统性地考虑优化方案了。总的优化原则就是减少关键资源个数,降低关键资源大小,降低关键资源的RTT次数。
上图为交互渲染流水线示意图。交互阶段的优化,其实就是渲染进程渲染帧的速度,因为在交互阶段,帧的渲染速度决定了交互的流畅度。
大部分情况下,生成一个新的帧都是由JavaScript通过修改DOM或者CSSOM来触发的。还有另外一部分帧是由CSS来触发的。
如果在计算样式阶段发现有布局信息的修改,就会触发重排操作,然后触发后续渲染流水线的一系列操作,这个代价是非常大的。
若在计算样式阶段没有发现有布局信息的修改,只是修改了颜色一类的信息,就不会涉及到布局相关的调整,则跳过布局阶段,直接进入绘制阶段,这个过程叫重绘。不过重绘阶段的代价也是不小的。
还有另外一种情况,通过CSS实现一些变形、渐变、动画等特效,这是由CSS触发的,并且是在合成线程上执行的,这个过程称为合成。它不会触发重排或重绘,而且合成操作本身的速度就非常快,所以执行合成是效率最高的方式。
有时JavaScript函数的一次执行时间可能有几百毫秒,这就严重霸占了主线程执行其他渲染任务的时间。针对这种情况可以采用以下两种策略:
总之,在交互阶段,对JavaScript脚本总的原则就是不要一次霸占太久主线程。
正常情况下的布局操作,通过DOM接口执行添加元素或者删除元素等操作后,是需要重新计算样式和布局的,这些操作都是在另外的任务中异步完成的,这样做是为了避免当前的任务占用太长的主线程时间。而强制同步布局,是指JavaScript强制将计算样式和布局操作提前到当前的任务中。 如下代码所示就会造成强制同步布局:
function foo() {
let main_div = document.getElementById("mian_div")
let new_node = document.createElement("li")
let textnode = document.createTextNode("time.geekbang")
new_node.appendChild(textnode);
document.getElementById("mian_div").appendChild(new_node);
//由于要获取到offsetHeight,
//但是此时的offsetHeight还是老的数据,
//所以需要立即执行布局操作
console.log(main_div.offsetHeight)
}
将新的元素添加到DOM之后,又调用了main_div.offsetHeight来获取新main_div的高度信息。如果要获取到main_div的高度,就需要重新布局,所以这里在获取到main_div的高度之前,JavaScript还需要强制让渲染引擎默认执行一次布局操作。
布局抖动,是指在一次JavaScript执行过程中,多次执行强制布局和抖动操作。如下代码所示,在一个for循环语句里面不断读取属性值,每次读取属性值之前都要进行计算样式和布局,这会大大影响当前函数的执行效率。这种情况的避免方式和强制同步布局一样,都是尽量不要在修改DOM结构时再去查询一些相关值。
function foo() {
let time_li = document.getElementById("time_li")
for (let i = 0; i < 100; i++) {
let main_div = document.getElementById("mian_div")
let new_node = document.createElement("li")
let textnode = document.createTextNode("time.geekbang")
new_node.appendChild(textnode);
new_node.offsetHeight = time_li.offsetHeight;
document.getElementById("mian_div").appendChild(new_node);
}
}
合成动画是直接在合成线程上执行的,这和在主线程上执行的布局、绘制等操作不同,如果主线程被JavaScript或者一些布局任务占用,CSS动画依然能继续执行。所以要尽量利用好CSS合成动画,如果能让CSS处理动画,就尽量交给CSS来操作。另外,如果能提前知道对某个元素执行动画操作,那就最好将其标记为will-change,这是告诉渲染引擎需要将该元素单独生成一个图层。
JavaScript使用了自动垃圾回收机制,如果在一些函数中频繁创建临时对象,那么垃圾回收器也会频繁地去执行垃圾回收策略。这样当垃圾回收操作发生时,就会占用主线程,从而影响到其他任务的执行,严重的话还会让用户产生掉帧、不流畅的感觉。所以要尽量避免产生那些临时垃圾数据,可以尽可能优化储存结构,尽可能避免小颗粒对象的产生。
DOM提供了一组JavaScript接口用来遍历或者修改节点,这套接口包含了getElementById、removeChild、appendChild等方法。通过JavaScript操纵DOM是会影响到整个渲染流水线的,比如常见的重绘、重排、合成等,对于DOM的不当操作还有可能引发强制同步布局和布局抖动的问题,这些操作都会大大降低渲染效率。
以react中的虚拟DOM有两个执行阶段:
在开发游戏或者处理其他图像的过程中,屏幕从前缓冲区读取数据然后显示。但是很多图形操作都很复杂且需要大量的运算,比如一幅完整的画面,可能需要计算多次才能完成,如果每次计算完一部分图像,就将其写入缓冲区,那么显示一个稍微复杂点的图像的过程中,看到的页面效果可能是一部分一部分地显示出来,因此在刷新页面的过程中,会让用户感受到界面的闪烁。
而使用双缓存,可以让你先将计算的中间结果存放在另一个缓冲区中,等全部的计算结束,该缓冲区已经存储了完整的图形之后,再将该缓冲区的图形数据一次性复制到显示缓冲区,这样就使得整个图像的输出非常稳定。在这里,可以把虚拟DOM看成是DOM的一个buffer,和图形显示一样,它会在完成一次完整的操作之后,再把结果应用到DOM上,这样就能减少一些不必要的更新,同时还能保证DOM的稳定输出。
MVC的整体结构比较简单,由模型、视图和控制器组成,其核心思想就是将数据和视图分离,视图和模型之间是不允许直接通信的,它们之间的通信都是通过控制器来完成的。通常情况下的通信路径是视图发生了改变,然后通知控制器,控制器再根据情况判断是否需要更新模型数据。当然还可以根据不同的通信路径和控制器不同的实现方式,基于MVC又能衍生出很多其他的模式,如MVP、MVVM等,不过万变不离其宗,它们的基础骨架都是基于MVC而来。
上图为基于React和Redux构建MVC模型。在该图中,可以把虚拟DOM看成是MVC的视图部分,其控制器和模型都是由Redux提供的。其具体实现过程如下:
阅读量:804
点赞量:0
收藏量:0