浏览器层面优化前端性能 (2):Reader 引擎线程与模块分析优化点
Reader 引擎线程与模块分析
首先是网页内容,加载完输入到 HTML 解释器,解释后构成 DOM 树,这期间如果遇到 JavaScript 代码就交给 JavaScript 引擎去处理,如果网页中包含 CSS,就交给 CSS 解释器;DOM 树简历的时候,渲染引擎接收来自 CSS 解释器的样式信息,构建一个新的你日不会吐模型,该模型由布局模块计算模型内部各个元素的位置和大小信息
渲染流程有四个主要步骤:
解析 HTML 生成 DOM 树 - 渲染引擎首先解析 HTML 文档,生成 DOM 树
构建 Render 树 - 接下来不管是内联式,外联式还是嵌入式引入的 CSS 样式会被解析生成 CSSOM 树,根据 DOM 树与 CSSOM 树生成另外一棵用于渲染的树-渲染树(Render tree),
布局 Render 树 - 然后对渲染树的每个节点进行布局处理,确定其在屏幕上的显示位置
绘制 Render 树 - 最后遍历渲染树并用 UI 后端层将每一个节点绘制出来
DOM 树与 Render 树
renderer 与 DOM 元素是相对应的,但并不是一一对应,有些 DOM 元素没有对应的 renderer,而有些 DOM 元素却对应了好几个 renderer,对应多个 renderer 的情况是普遍存在的,就是为了解决一个 renderer 描述不清楚如何显示出来的问题,譬如有下拉列表的 select 元素,我们就需要三个 renderer:一个用于显示区域,一个用于下拉列表框,还有一个用于按钮。
另外,renderer 与 DOM 元素的位置也可能是不一样的。那些添加了 float 或者 position:absolute 的元素,因为它们脱离了正常的文档流,构造 Render 树的时候会针对它们实际的位置进行构造。
布局与绘制
上面确定了 renderer 的样式规则后,然后就是重要的显示元素布局了。当 renderer 构造出来并添加到 Render 树上之后,它并没有位置跟大小信息,为它确定这些信息的过程,接下来是布局(layout)。
浏览器进行页面布局基本过程是以浏览器可见区域为画布,左上角为(0,0)基础坐标,从左到右,从上到下从 DOM 的根节点开始画,首先确定显示元素的大小跟位置,此过程是通过浏览器计算出来的,用户 CSS 中定义的量未必就是浏览器实际采用的量。如果显示元素有子元素得先去确定子元素的显示信息。
布局阶段输出的结果称为 box 盒模型(width,height,margin,padding,border,left,top,…),盒模型精确表示了每一个元素的位置和大小,并且所有相对度量单位此时都转化为了绝对单位。
在绘制(painting)阶段,渲染引擎会遍历 Render 树,并调用 renderer 的 paint() 方法,将 renderer 的内容显示在屏幕上。绘制工作是使用 UI 后端组件完成的。
回流与重绘
回流(reflow):当浏览器发现某个部分发生了点变化影响了布局,需要倒回去重新渲染。reflow 会从<html>这个 root frame 开始递归往下,依次计算所有的结点几何尺寸和位置。reflow 几乎是无法避免的。现在界面上流行的一些效果,比如树状目录的折叠、展开(实质上是元素的显示与隐藏)等,都将引起浏览器的 reflow。鼠标滑过、点击……只要这些行为引起了页面上某些元素的占位面积、定位方式、边距等属性的变化,都会引起它内部、周围甚至整个页面的重新渲染。通常我们都无法预估浏览器到底会 reflow 哪一部分的代码,它们都彼此相互影响着。
重绘(repaint):改变某个元素的背景色、文字颜色、边框颜色等等不影响它周围或内部布局的属性时,屏幕的一部分要重画,但是元素的几何尺寸没有变。
关键渲染路径与阻塞渲染
在浏览器拿到 HTML、CSS、JS 等外部资源到渲染出页面的过程,有一个重要的概念关键渲染路径(Critical Rendering Path)。
例如为了保障首屏内容的最快速显示,通常会提到一个渐进式页面渲染,但是为了渐进式页面渲染,就需要做资源的拆分,那么以什么粒度拆分、要不要拆分,不同页面、不同场景策略不同。
现代浏览器总是并行加载资源,例如,当 HTML 解析器(HTML Parser)被脚本阻塞时,解析器虽然会停止构建 DOM,但仍会识别该脚本后面的资源,并进行预加载。
CSS 被视为渲染阻塞资源(包括 JS),这意味着浏览器将不会渲染任何已处理的内容,直至 CSSOM 构建完毕,才会进行下一阶段。
存在阻塞的 CSS 资源时,浏览器会延迟 JavaScript 的执行和 DOM 构建
css 加载不会阻塞 DOM 树的解析
css 加载会阻塞 DOM 树的渲染
css 不会阻塞 JS 的加载
css 加载会阻塞后面 js 语句的执行
JavaScript 被认为是解释器阻塞资源,HTML 解析会被 JS 阻塞,它不仅可以读取和修改 DOM 属性,还可以读取和修改 CSSOM 属性。
当浏览器遇到一个 script 标记时,DOM 构建将暂停,直至脚本完成执行。
JavaScript 可以查询和修改 DOM 与 CSSOM。
CSSOM 构建时,JavaScript 执行将暂停,直至 CSSOM 就绪。
没有 js 的理想情况下,html 与 css 会并行解析,分别生成 DOM 与 CSSOM,然后合并成 Render Tree,进入 Rendering Pipeline;但如果有 js,css 加载会阻塞后面 js 语句的执行,而(同步)js 脚本执行会阻塞其后的 DOM 解析(所以通常会把 css 放在头部,js 放在 body 尾)
CSS 优先:引入顺序上,CSS 资源先于 JavaScript 资源。JavaScript 应尽量少影响 DOM 的构建。
改变脚本加载次序 defer/async/document.createElement
defer
defer 属性表示延迟执行引入 JavaScript,即 JavaScript 加载时 HTML 并未停止解析,这两个过程是并行的。整个 document 解析完毕且 defer-script 也加载完成之后(这两件事情的顺序无关),会执行所有由 defer-script 加载的 JavaScript 代码,再触发 DOMContentLoaded(初始的 HTML 文档被完全加载和解析完成之后触发,无需等待样式表图像和子框架的完成加载) 事件。
defer 不会改变 script 中代码的执行顺序,示例代码会按照 1、2、3 的顺序执行。所以,defer 与相比普通 script,有两点区别:载入 JavaScript 文件时不阻塞 HTML 的解析,执行阶段被放到 HTML 标签解析完成之后。
async
async 属性表示异步执行引入的 JavaScript,与 defer 的区别在于,如果已经加载好,就会开始执行,无论此刻是 HTML 解析阶段还是 DOMContentLoaded 触发(HTML 解析完成事件)之后。需要注意的是,这种方式加载的 JavaScript 依然会阻塞 load 事件。换句话说,async-script 可能在 DOMContentLoaded 触发之前或之后执行,但一定在 load 触发之前执行。
从上一段也能推出,多个 async-script 的执行顺序是不确定的,谁先加载完谁执行。值得注意的是,向 document 动态添加 script 标签时,async 属性默认是 true。
document.createElement
使用 document.createElement 创建的 script 默认是异步的
通过动态添加 script 标签引入 JavaScript 文件默认是不会阻塞页面的。如果想同步执行,需要将 async 属性人为设置为 false。
优化渲染性能
chrome 官方文档:https://developers.google.com/web/fundamentals/performance/?hl=en
翻译:https://x5.tencent.com/tbs/document/doc-chrome.html
优化 JS 的执行效率
动画实现使用 requestAnimationFrame
setTimeout(callback)和 setInterval(callback)无法保证 callback 函数的执行时机,很可能在帧结束的时候执行,从而导致丢帧。
requestAnimationFrame(callback)可以保证 callback 函数在每帧动画开始的时候执行。拓展阅读《频率史—从电源频率到音频采样频率与视频帧率:29.97/44.1》、《弄懂javascript的执行机制:事件轮询|微任务和宏任务》
长耗时的 JS 代码放到 Web Workers 中执行
JS 代码运行在浏览器的主线程上,与此同时,浏览器的主线程还负责样式计算、布局、绘制的工作,如果 JavaScript 代码运行时间过长,就会阻塞其他渲染工作,很可能会导致丢帧。
前面提到每帧的渲染应该在 16ms 内完成,但在动画过程中,由于已经被占用了不少时间,所以 JavaScript 代码运行耗时应该控制在 3-4 毫秒。
如果真的有特别耗时且不操作 DOM 元素的纯计算工作,可以考虑放到 Web Workers 中执行。
CSS 渲染与布局优化
添加或移除一个 DOM 元素、修改元素属性和样式类、应用动画效果等操作,都会引起 DOM 结构的改变,从而导致浏览器要 repaint 或者 reflow。
降低样式选择器的复杂度
尽量保持 class 的简短,或者使用Web Components框架(如:Omi)。
降低样式选择器的复杂度;使用基于 class 的方式,比如 BEM(Block, Element, Modifier)。
减少 css 嵌套,如 sass 使用 @at-root
减少需要执行样式计算的元素的个数
对于样式计算来说,范围越小、规则越简单的话,处理效率越高。
在过去,如果你修改了 body 元素的 class 属性,那么页面里所有元素都要重新计算样式。现代的浏览器中不再这样做了,浏览器不会检查所有受到样式变化影响的元素。因为会对每个 DOM 元素维护一个独有的样式规则小集合,如果这个集合发生改变,才重新计算该元素的样式。所以,样式计算一般是直接对那些目标元素执行。因此我们应该尽可能减少需要执行样式计算的元素的个数。
一般来说在最坏的情况下,样式计算量 = 元素个数 x 样式选择器个数。因为对每个元素最少需要检查一次所有的样式,以确认是否
Web Components 中的样式计算不会跨越 Shadow DOM 范围,仅在单个的 Web Component 中进行,而不是在整个页面的 DOM 树上进行
避免大规模、复杂的布局
布局,就是浏览器计算 DOM 元素的几何信息的过程:元素大小和在页面中的位置。每个元素都有一个显式或隐式的大小信息,决定于其 CSS 属性的设置、或是元素本身内容的大小、抑或是其父元素的大小。在 Blink/WebKit 内核的浏览器和 IE 中,这个过程称为布局。在基于 Gecko 的浏览器(比如 Firefox)中,这个过程称为 Reflow。
尽可能避免触发布局
布局的时间消耗主要在于:
需要布局的 DOM 元素的数量
布局过程的复杂程度
一份详细的能触发布局、绘制或渲染层合并的CSS属性清单:CSS Triggers
使用 flexbox 替代老的布局模型
新的 Flexbox 比旧的 Flexbox 和基于浮动的布局模型更高效。
在任何情况下,不管是是否使用 Flexbox,你都应该努力避免同时触发所有布局,特别在页面对性能敏感的时候(比如执行动画效果或页面滚动时)。
避免强制同步布局事件的发生
将一帧画面渲染到屏幕上的处理顺序如下所示:
在 JavaScript 脚本运行的时候,它能获取到的元素样式属性值都是上一帧画面的,都是旧的值。
如果想在这一帧开始的时候,读取一个元素属性值,就需要修改当前元素的某个属性(可能触发重绘与回流)。
为了避免触发不必要的布局过程,你应该首先批量读取元素样式属性,然后再对样式属性进行写操作。
大多数情况下,都不需要先修改然后再读取元素的样式属性值,使用上一帧的值就足够了。过早地同步执行样式计算和布局是潜在的页面性能的瓶颈之一
避免快速连续的布局
比强制同步布局更糟:连续快速的多次执行它。如:
FastDom 是一个轻量的库,它提供一个公共接口,能让 DOM 的读/写操作捆绑在一起。
https://github.com/wilsonpage/fastdom
简化绘制的复杂度、减小绘制区域
绘制并非总是在内存中的单层画面里完成的。实际上,浏览器在必要时将会把一帧画面绘制成多层画面,然后将这若干层画面合并成一张图片显示到屏幕上。
这种绘制方式的好处是,使用 tranforms 来实现移动效果的元素将会被正常绘制,同时不会触发对其他元素的绘制。这种处理方式和思想跟图像处理软件(比如 Sketch/GIMP/Photoshop)是一致的,它们都是可以在图像中的某个单个图层上做操作,最后合并所有图层得到最终的图像。
提升移动或渐变元素的绘制层
在页面中创建一个新的渲染层的最好方式就是使用CSS属性will-change,同时再与 transform 属性一起使用,就会创建一个新的组合层:will-change: transform;
对于那些目前还不支持 will-change 属性、但支持创建渲染层的浏览器,以使用一个 3D transform 属性来强制浏览器创建一个新的渲染层:transform: translateZ(0);
减少绘制区域
有时候尽管把元素提升到了一个单独的渲染层,渲染工作依然是必须的。渲染过程中一个比较有挑战的问题是,浏览器会把两个相邻区域的渲染任务合并在一起进行,这将导致整个屏幕区域都会被绘制。比如,你的页面顶部有一个固定位置的 header,而此时屏幕底部有某个区域正在发生绘制的话,整个屏幕都将会被绘制。
注意:在 DPI 较高的屏幕上,固定定位的元素会自动地被提升到一个它自有的渲染层中。但在 DPI 较低的设备上却并非如此,因为这个渲染层的提升会使得字体渲染方式由子像素变为灰阶(详细内容请参考:Text Rendering),我们需要手动实现渲染层的提升。
减少绘制区域通常需要对动画效果进行精密设计,以保证各自的绘制区域之间不会有太多重叠,或者想办法避免对页面中某些区域执行动画效果。
简化绘制的复杂度
比如 js 获取元素的 offsetTop ffsetTop 比如 getBoundingClientRect 消耗更少。
在 css 里面,重绘 backgroun 比如 box-shadow 消耗更好。
那些能性能更加耗资源,我也不知道,道友若知,请留言赐教,多谢。手工就 paint profiler 分析对比咯
优先使用渲染层合并属性、控制层数量
只使用 transform/opacity 来实现动画效果
应用了 transforms/opacity 属性的元素必须独占一个渲染层。为了对这个元素创建一个自有的渲染层,你必须提升该元素。在合成层上面的元素,也会合并到此图层中。
用 will-change/translateZ 属性把动画元素提升到单独的渲染层中
避免滥用渲染层提升:更多的渲染层需要更多的内存和更复杂的管理
过多的渲染层来带的开销而对页面渲染性能产生的影响,甚至远远超过了它在性能改善上带来的好处。由于每个渲染层的纹理都需要上传到 GPU 处理,因此我们还需要考虑 CPU 和 GPU 之间的带宽问题、以及有多大内存供 GPU 处理这些纹理的问题。
从性能方面考虑,最理想的渲染流水线是没有布局和绘制环节的,只需要做渲染层的合并即可:
之前也参看:《关于css3之transform一些坑的总结-transform对普通元素的N多渲染》
对用户输入事件的处理去抖动
避免使用运行时间过长的输入事件处理函数,它们会阻塞页面的滚动
避免在输入事件处理函数中修改样式属性
对输入事件处理函数去抖动,存储事件对象的值,然后在 requestAnimationFrame 回调函数中修改样式属性
具体参看《Debounce 和 Throttle 的原理及实现》
参考文章:
从浏览器多进程到 JS 单线程,JS 运行机制最全面的一次梳理 https://www.cnblogs.com/cangqinglang/p/8963557.html
Chrome 源码剖析、上--多线程模型、进程通信、进程模型https://www.cnblogs.com/v-July-v/archive/2011/04/02/2036008.html
Chrome 源代码分析之进程和线程模型(三) https://blog.csdn.net/namelcx/article/details/6582730
http://dev.chromium.org/developers/design-documents/multi-process-architecture
chrome 渲染机制浅析 https://www.jianshu.com/p/99e450fc04a5
浅析浏览器渲染原理 https://segmentfault.com/a/1190000012960187
javascript 宏任务和微任务 https://www.cnblogs.com/fangdongdemao/p/10262209.html
浏览器与 Node 的事件循环(Event Loop)有何区别? https://blog.csdn.net/Fundebug/article/details/86487117
转载本站文章《浏览器层面优化前端性能(2):Reader引擎线程与模块分析优化点》,请注明出处:https://www.zhoulujun.cn/html/webfront/browser/webkit/2020_0615_8464.html
评论