谈浏览器和性能,话页面渲染知多少

第一次渲染时都发生了什么

浏览器在渲染一个页面时都发生了什么,在这个过程中资源的加载会对页面造成什么影响呢?

页面初次渲染的最小渲染路径

浏览器从加载到渲染一个页面需要经过的最小的渲染路径如下

生成DOM树

当浏览器开始接收到HTML的数据时,主线程就开始解析HTML并且转换成DOM(Document Object Model)树。解析(Parse)HTML的过程和解析其他编程语言的过程相似;这个解析过程是流式的,浏览器在接收到一定量的HTML时解析就会开始。

DOM树是面向JavaScript的数据结构和一组API,开发人员可以通过JavaScript来访问和操作页面内容

资源加载

在解析HTML的同时,浏览器会开始请求页面中能够解析出的资源,如图片、CSS、JavaScript等。 包括在内的等标签也是在这个时候被解析出来的,并且触发响应的提前加载等

阻塞解析的JavaScript

解析HTML的过程中并不是一帆风顺的,如果浏览器在这个时候遇到内联的JavaScript或没有defer/async的

如果不需要JavaScript使用document.write(),那么尽可能(将

生成CSSOM

仅仅有了DOM,渲染并不会开始,因为如果给用户看一个没有CSS的页面其实意义并不大。所以,接下来浏览器需要解析样式并且生成对应的CSSOM,这个过程同样由主线程完成,浏览器在解析CSS后会根据选择器和规则生成对应的CSSOM

CSSOM同样具有树结构,使用这种数据结构,浏览器能够应用一些规则(如标签的font-size默认适用于标签的所有子元素)。除了用户定义的CSS,浏览器还会提供一套默认样式(User Agent Stylesheet),如h1默认具有比h2更大的font-size等

生成渲染树

浏览器从DOM树开始遍历节点,并在CSSOM中找到每个节点对应的样式规则,将两者合并成一棵树用于渲染,即渲染树

计算布局

计算元素具体的几何信息的过程就是布局(Layout) 现在有了包含样式和内容的渲染树,然而浏览器仍然无法开始绘制,因为浏览器并不知道应该把元素放在什么位置及具体的大小信息,CSSOM(可以理解为computed style)并不包含元素的具体位置。事实上,无法根据单独的元素和CSS计算出它的几何信息,因为不同元素之间的位置是相互影响的,A元素的margin可能就会导致B元素位置向下移动。而计算元素具体位置的几何信息的过程就是布局(Layout)

为了计算具体的几何信息,主线程开始遍历渲染树,把相对的位置布局和大小等都转换成屏幕上的绝对像素。CSS盒模型、flex布局等就是在这个时候完成的,字体规则也是在这个时候应用的,浏览器还需要考虑字体间距和连写(ligature)等支持

CSS支持的布局能力非常丰富,因此布局的工作非常复杂,仅仅是一行文字的断行发生变化可能就会导致大面积的重排。

至此,浏览器生成了和DOM树对应的布局树

分层

有了完整的渲染树和布局树,以及页面的内容、样式和布局,但是我们不知道应该按照什么顺序绘制元素,最后才不会导致一个元素错误地覆盖另外一个元素。这种覆盖可能会发生在z-index、3D变换场景下,因此需要针对这些元素生成对应的图层树(Layer Tree)

有了图层划分之后,就能知道按照什么顺序进行绘制。

绘制

有了各个图层后就可以开始绘制内容。现在要把这些几何信息真正输出成像素点,这个过程称为光栅化(Rasterize),并且将各个层进行合并,这个过程称为合成(Copositing)。 前面介绍的过程都是在主线程完成的,这是因为从多个线程访问DOM是一件非常复杂的事情,JavaScript在操作DOM时可能会依赖布局的结果(如获取元素位置)。而到了光栅化和合成阶段,就可以把主线程从繁忙的工作中释放出来,接下来的事情就可以交给合成线程和光栅化线程。

一个页面一般是非常大的,而需要显示的只是页面的一部分,所以合成线程会把得到的层分割成块,并把需要渲染的块交给光栅化线程,由光栅化线程完成分块的光栅化并且存储在GPU的内存中

分块完成光栅化后,合成线程会据此创建一个合成帧,直接发送给GPU完成屏幕的显示;

当页面滚动和纯合成动画发生时,合成器只需要重新生成一个合成帧,并发送给GPU即可。整个过程完全不需要经过主线程,这也是合成动画具有更好的性能的原因。另外,纯合成动画仅依靠合成线程就能完整地显示动画,这意味着解析、执行JavaScript、重新布局等依赖主线程完成的动画都不是纯合成动画,在性能上会更差一些

至此,浏览器成功地把页面渲染出来

浏览器从接收到页面到绘制到屏幕上是一个非常复杂的过程,渲染流程如下:
  • 【主线程】解析HTML生成对应的DOM树,同时下载解析到的资源
  • 【主线程】解析CSS生成对应的CSSOM,并且和DOM树合并成渲染树
  • 【主线程】通过布局计算,生成对应的布局树
  • 【主线程】拆分出对应的层,交给合成线程
  • 【合成线程】得到对应的层后进行分割,根据优先级交给光栅化线程池进行光栅化
  • 【光栅化线程池】得到对应的分块进行光栅化,把结果存储在GPU中
  • 【合成线程】完成光栅化后生成合成帧,发送给GPU完成显示

尽快返回HTML

由于浏览器对于HTML的加载和解析都是流式的,而后续的资源加载、解析、执行等逻辑都依赖从HTML中解析的信息,因此应该尽早把HTML返回给浏览器,如通过流式渲染等方式让浏览器展示首屏及提前加载必要的内容。

减少资源阻塞

在浏览器的最小渲染路径中,最常见的耗时原因是资源的阻塞,如页面头部的JavaScript代码和CSS资源会阻塞后续内容的解析与执行。

CSS会阻塞页面的解析和渲染,但必要的CSS对页面来说是必不可少的,所以,应该把必要的CSS放在页面头部尽早加载

JavaScript代码默认按顺序执行,虽然不阻塞后续资源的加载,但是会阻塞后续的解析、执行和渲染。在可行的情况下,应该尽可能把JavaScript代码放在页面内容之后,并且适当使用defer和async标记不需要阻塞执行的JavaScript代码

为什么DOM操作很慢

我们常说DOM操作是昂贵的,要避免或减少DOM操作,很多前端框架(如React或Vue)在介绍VirturalDOM等特性时都会强调这一点。那么,DOM操作到底慢在哪里? 和普通的JavaScript操作又有什么不同? 下面介绍执行DOM操作时发生了什么以及如何优化DOM操作的耗时

上面介绍了浏览器渲染出一个页面要经过的过程,这只是完成了一帧。而页面在呈现的过程中,其实在不断地渲染新的帧。在DeveTools的Performance面板中录制一段性能后,就可以通过Frames看到页面渲染的帧,同时会显示每一帧总体的消耗时间。一般来说,为了达到60fps(非常流畅),每秒要渲染60帧,也就是说,在理想的情况下每一帧需要在16ms内完成

要保持页面流畅,就是要保证这些帧能够在有限的时间内被渲染出来。各种DOM操作对于浏览器来说意味着什么呢?之前介绍的渲染过程只是其中的一帧,而每一帧的处理耗时在很大程度上取决于这一帧是否要完全重新执行一遍所有的操作

重排

当DOM本身或位置、大小等信息发生变化时,就需要从布局开始重新走完所有的流程,并且同时触发后面的绘制相关的工作,这个过程称为重排(Reflow)

可能导致重排的操作有以下几个:

  • 修改DOM或样式
  • 移动DOM元素
  • 一些需要重排的用户动作,如调整窗口大小、滚动

而计算样式、布局等信息都必须在主线程中才能进行,必须在主线程中执行的还有JavaScript,主线程的资源是相当宝贵的。一些不适当的动画就是常见的频繁触发重排的例子。 例如:假设通过定时改动一个相对定位对象的位置来实现动画,这意味着这个对象移动的过程中,浏览器在不停地重新计算受其影响的其他DOM对象的布局

没有设置初始宽高的图片在加载完成之前是不占空间的,而加载完成后又会把其他元素撑开,这个过程其实就发生了重排。更糟糕的是,重排不仅意味着耗时上的性能损耗,还意味着对用户体验的损伤,CLS就用户衡量布局抖动对用户体验造成的影响

重排不一定意味着布局抖动,但一般来说异常的大龄重排往往和布局抖动有关

重绘

重排必然引起重绘。但在有些情况下,大小、位置信息并未改变,只是改变了一些元素的样式信息,如颜色,这样就仅触发重新绘制的动作

重排的性能损伤总是高于重绘的,在大部分情况下,避免重排、重绘主要是指避免大规模重排

访问DOM属性

和我们的直觉可能不同,并不是改变DOM或在页面中增加新的元素时才慢,还因为访问DOM属性的成本可能会非常高

跨线程通信

DOM的英文全称是Document Object Model,中文全称为文档对象模型。事实上,DOM并不是JavaScript的一部分,而是JavaScript中访问HTML/SVG/XML等文档对象的接口。DOM对象和JavaScript引擎在两个不同的线程中

这意味着,当从JavaScript中访问和操作DOM对象时,都需要跨线程通信,导致简单地属性访问也比普通的JavaScript对象要慢很多。与我们直觉不同的是,部分属性的访问还会引起重排

强制重排

由于重排的性能损耗很大,一个元素的变动往往会触发大量相关元素的布局重新计算,因此浏览器一般会等待一段时间再进行批量处理。然而,当从JavaScript中获取一些和排版有关的信息时,为了保证信息的正确性,浏览器不得不放弃这项优化,同步计算样式和排版信息并且返回给JavaScript,这个过程称为强制重排(Force Reflow)

会导致重排的方法包括以下几种:
  • 获取元素、窗口的大小、位置信息的方法或属性,如offsetLeft
  • 进行滚动,如scrollTop
  • 获取鼠标指针的位置信息,如mouseEvent.offsetX
  • 进行样式计算,如getComputedStyle 这里并没有列举全部方法,事实上,哪些方法会触发重排也取决于浏览器的具体实现。总之,我们需要了解JavaScript访问大小、位置等和排版相关的信息会触发强制重排,从而让浏览器放弃重排的批处理优化

如何优化DOM操作

了解了DOM操作的成本及这些成本的真正来源,就可以从以下几个方面来优化DOM操作

批量操作

浏览器对DOM操作其实也做了大量优化,最典型的优化就是浏览器会把同一帧的DOM操作产生的影响聚合在一起。例如:在一帧内执行10次导致重排的DOM操作,其实浏览器只会进行一次DOM操作

于是对前端来说,可以把批量的DOM操作聚合到一次执行,React的batchUpdate也是类似的技巧

纯合成动画

重排和重绘是影响性能的,但在使用董浩时需要频繁甚至流畅地改变一个物体的位置、大小等,针对这种需求,浏览器也做了大量的优化,合成(compositing)过程对这种场景的性能有很大帮助。 浏览器渲染的过程中,和页面上其他元素不属于同一坐标空间的元素会被提到其他层(称为合成层),最后由GPU合成输出最后的显示内容,并且正确处理元素的透明和层级关系

这么做除了可以保证多层渲染的逻辑正确,还有一些性能方面的优势,纯合成动画就是一个典型优化手段。当动画同时满足以下条件时,GPU只经过重新合成的过程就可以渲染出动画的每一帧。

  • 不会造成重排: 如用transform而不是position改变位置
  • 不会造成重绘
  • 动画对象处在合成层

由于不依赖主线程,因此这个过程非常迅速和流畅,即使JavaScript繁忙也不受影响。这就是CSS动画中的GPU加速,其实GPU加速是有限的,只是纯合成动画能够被GPU加速

告知浏览器把对象放进合成层可以通过will-change来实现,需要注意的是,层也并不是越多越好,合成层会带来额外的内存消耗

结语

  • 上面内容抄自《前端性能揭秘》一书,致敬作者
  • 中年大叔依然是学了忘-忘了学-学了再忘,在没有改行之前,还是苟且着吧
相关推荐
神夜大侠1 小时前
VUE 实现公告无缝循环滚动
前端·javascript·vue.js
明辉光焱1 小时前
【Electron】Electron Forge如何支持Element plus?
前端·javascript·vue.js·electron·node.js
柯南二号2 小时前
HarmonyOS ArkTS 下拉列表组件
前端·javascript·数据库·harmonyos·arkts
wyy72932 小时前
v-html 富文本中图片使用element-ui image-viewer组件实现预览,并且阻止滚动条
前端·ui·html
前端郭德纲2 小时前
ES6的Iterator 和 for...of 循环
前端·ecmascript·es6
王解2 小时前
【模块化大作战】Webpack如何搞定CommonJS与ES6混战(3)
前端·webpack·es6
欲游山河十万里2 小时前
(02)ES6教程——Map、Set、Reflect、Proxy、字符串、数值、对象、数组、函数
前端·ecmascript·es6
明辉光焱2 小时前
【ES6】ES6中,如何实现桥接模式?
前端·javascript·es6·桥接模式
PyAIGCMaster2 小时前
python环境中,敏感数据的存储与读取问题解决方案
服务器·前端·python
baozhengw2 小时前
UniAPP快速入门教程(一)
前端·uni-app