-
Notifications
You must be signed in to change notification settings - Fork 51
前端性能优化之浏览器渲染优化 —— 打造60FPS页面 #9
Comments
灰常nice的文章 |
感谢楼主雄文,学习了~ |
学习了>__< |
js 执行过程中,所有涉及到 GUI 更新的操作都会被放置在一个队列,等待 js 线程空闲后,再执行渲染线程。但是如果在 js 执行过程中触发了重排,则会清空队列,强制挂起 js 线程,使用渲染线程做一次回流后再回到 js 线程,好计算出正确的布局相关值。因为回流、线程间通信会有一定的代价,所以才有所谓的 dom 操作性能问题。 不知道这样子理解是否合理? |
@cyh-93 我也是这么理解的😎 |
@cyh-93 我是这样理解的: let boxes = document.getElmentsByClassName('.box')
for(let i = 0; i < boxes.length; i++) {
let width = document.getElementById('table')
boxes[i].style.color = 'red'
} 2、现在,每次循环一开始就会执行一次重排,换句话说,执行一次对dom的操作就会引起一次重排(对应原文关键词: let boxes = document.getElmentsByClassName('.box')
for(let i = 0; i < boxes.length; i++) {
let width = document.getElementById('table').width
boxes[i].style.width = width
} 3、如果非得这么做那也没办法,只能用rAF,把上面的一次读写操作放到它的回调里,然后一帧执行一次读写,再经过这一帧正常的渲染后,下一帧就能拿到正确的值(对应原文关键词: requestAnimationFrame(logBoxHeight);
function logBoxHeight() {
box.classList.add('super-big');
// Gets the height of the box in pixels
// and logs it out.
console.log(box.offsetHeight);
} |
文章很受用,精彩。 想问一下您,现在的一些 editor 插件,比如左边写右边预览的 markdown 插件或者集成这些插件的编辑器比如 Typora, HexoCleint (貌似都是基于 Electron),在输出字数不多的情况下,很流畅,一旦书写的比较多了,那么左边的录入框会异常卡顿,反应超级慢。 请问这方面的问题可以从哪方面拯救一下么?(个人猜测可能是集成的chrome渲染处理逻辑问题) 您对这方面的见解是?还请指点一下。 |
彩彩彩! |
受教了 |
感觉楼主说rAF的时机有问题,requestAnimationFrame其实是在每帧绘制之前执行的,也就是js event loop -> rAF -> style -> layout -> paint -> composite,以前edge和Safari把rAF放在了paint后面是不符合标准的,但是现在也改回来了。 |
看完收获很大 |
前言
本文整理自:Google Developers 及 高性能 JavaScript,再加上了一些个人对其中提到的知识点的理解与补充。前端性能优化涉及很多方面,本文仅针对高性能渲染进行分析。
单个帧的渲染流程 —— 像素管道
目前,大多数设备的刷新率都是 60 FPS,如果浏览器在交互的过程中能够时刻保持在 60FPS 左右,用户就不会感到卡顿,否则,就会影响用户的体验。
下图为浏览器运行的单个帧的渲染流水线,称为像素管道,如果其中的一个或多个环节执行时间过长就会导致卡顿。像素管道是作为开发者能够掌握的对帧性能有影响的部分,其他部分由浏览器掌握,我们无法控制。我们的目标就是就是尽快完成这些环节,以达到 60 FPS 的目标。
.headline
或.nav > .nav__item
)计算出哪些元素应用哪些 CSS 规则的过程,这个过程不仅包括计算层叠样式表中的权重来确定样式,也包括内联的样式,来计算每个元素的最终样式。采用更好的 CSS 方法进行优化
上节渲染管道的每个环节都有可能引起卡顿,所以要尽可能减少通过的管道步骤。
修改不同的样式属性会有以下几种不同的帧流程,在这里就直接贴 Google Developers 的图了:
我们可以看到 JS,Style 和 Composite 是不可避免的,因为需要 JS 来引发样式的改变,Style 来计算更改后最终的样式,Composite 来合成各个层最终进行显示。Layout 和 Paint 这两个步骤不一定会被触发,所以在优化的过程中,如果是需要频繁触发的改变,我们应该尽可能避免 Layout 和 Paint。
尽量使用 transform 和 opacity 属性更改来实现动画
性能最佳的像素管道版本会避免 Layout 和 Paint:
为了实现此目标,需要坚持更改可以由合成器单独处理的属性。常用的两个属性符合条件:transform 和 opacity。
想知道每种 CSS 属性的更改是否会触发 Layout,Paint,Composite,可以通过 csstriggers.com 查看。
除了 transform 和 opacity,只会触发 composite 的 CSS 属性还有:pointer-events(是否响应鼠标事件)、perspective (透视效果)、perspective-origin(perspective 的灭点)、cursor(指针样式)、orphans(设置当元素内部发生分页时必须在页面底部保留的最少行数(用于打印或打印预览))、widows(设置当元素内部发生分页时必须在页面顶部保留的最少行数(用于打印或打印预览))。
减小选择器匹配的难度
通过上面的几种不同流程的管道图可以发现,只要是修改样式那么必不可少会经过 Style,计算样式的第一步是创建一组匹配选择器,这实质上是浏览器计算出给指定元素应用哪些类、伪选择器和 ID 。第二步是从对应的匹配选择器中获取所有样式规则,并计算出此元素的最终样式,简单的来说就是第一步先确定选择器都匹配哪些元素,第二步根据每个元素所匹配的选择器,通过权重计算出最终的样式。
对于要匹配相同的元素,
.final-box-title
比.box:nth-last-child(-n+1) .title
明显复杂度要来的小得多,浏览器不需要去判断要查找的元素是不是最后一个元素即可根据类名快速找到.final-box-title
对应的元素,相比复杂的选择器,简单地将选择器与元素匹配开销要小得多,而且嵌套过深的 CSS 选择器依赖了过多的类名,很容易在改动依赖的类名时不小心被影响到。这里推荐使用 BEM(块、元素、修饰符) 编码规则简化选择器规则,该方法实际上纳入了上述选择器匹配的性能优势,因为它建议所有元素都有单个类,并且在需要层次结构时也纳入了类的名称。
提升元素到新的层
有一种能有效减小 Layout 和 Paint 的方法是将元素提升,像 Photoshop 中层的概念一样,样式也有层的概念,不同的层根据不同顺序叠加起来,通过 Composite 最终显示出来。在每个层中对这个层进行 Layout 或者 Paint 是不会影响其他层的,一般会根据整个页面的语义将页面分为几个层。
但是不要滥用层,将每个元素都单独提升到一层, Composite 这个环节有两步,
Update Layer Tree
和Composite Layer Tree
,前者负责计算页面中有多少个层,哪些层应该出现并应该按什么顺序叠加起来,后者负责将 layers 合成到屏幕上。层越多,这两个步骤花的时间越长,同时也会占用更多的内存,所以要在适当的地方提升元素而不是对所有元素都进行提升。提升元素还有一个好处就是会将动画从 CPU 转移到 GPU 来完成,来实现硬件加速。
提升元素的两个方法:
有些浏览器对
will-change
的支持还不够好,所以一般两个都写上。参考:How (not) to trigger a layout in WebKit
使用 flexbox 而不是较早的布局模型
经过测试,flex 布局在现代浏览器上相比早期的浮动或者定位布局性能更好,而且到现在 flex 布局已经很好的得到了浏览器的支持(IE10- 手动再见)。
尽量避免 Layout
强制同步重排 - FSL (forced synchronous layout)
浏览器的工作原理:新式网络浏览器幕后揭秘 将布局分为异步布局和同步布局:
除了影响所有呈现器的全局样式更改,例如字体大小更改和屏幕大小调整的更改都是增量修改,增量修改是异步的也就给了我们用 thunk 修改的机会。
再来看下单个帧的流程图
如果我们在 js 中这样写
这种情况下,这一帧相比上一帧没有布局没有发生改变,那么直接用旧的 Layout 去赋值 width 就可以,也不需要对页面进行重排。
但是如果这样写:
当下一次循环到来时浏览器还没进重排(因为一直处于 JS 阶段) ,为了获取正确的 width ,浏览器就不得不立刻重新 Layout 获取一个最新值,从而失去了浏览器自身的批量更新的优化,这就是强制同步布局。
为什么叫强制呢,大多数浏览器通过队列化修改并批量执行来优化重排过程(就是上面说的异步布局),但是如果触发了强制同步布局 ,每经过一次循环,都会要求浏览器强制刷新队列并要求计划任务立刻执行,这就失去了浏览器对重排的优化。
什么操作会触发强制同步布局 呢,这个 gist 里列出了对应的操作。
避免强制同步布局
使用
requestAnimationFrame
(后面有介绍),将获取 width 的操作推迟到下一帧,在经过浏览器正常的 Layout 之后,下一帧可以直接拿到 Layout 值。缓存不动变量,对上面的那个强制同步布局的例子,避免在循环中进行可能会导致强制同步布局的操作
FLIP策略
在做某些动画时,有可能会有连续触发 Layout 步骤的属性,如下图的动画
如果凭直觉来做,很可能就是 click 之后加上一个类似于
这样的类。但是,可以看到下图中用 Chrome devTools 打开显示 Paint 区域的功能,发现重绘的区域很大,并且肯定伴随着重排,帧数也很低,出现了卡顿的现象。
这时候,就用 transform 来代替对 width 和 height 的改变。
其实到这里,就已经可以满足 60 FPS 的效果了,但是为了做到内容与样式分离,将起始于终结的样式全部由 CSS 管理,而中间通过 transform 动画的行为有 CSS 控制,则需要使用 FLIP 方法:
接下来,介绍一下 Paul Lewis 发明的 FLIP 方法,FLIP 就是 F (first) L (last) I (invert) P (play) 的缩写。
transform: translateX(-10px) scale(0.5)
,再给他一个left: 10px; width: 200px; height: 200px;
(假设原来是left: 0; width: 100px; height: 100px;
),这两个属性视觉效果上抵消,好像元素从来没有改变过。transition
效果,再移除元素的 transform 属性,因为此时元素已经是终止状态了,所以就会 transition 到 0,整个过程只有 transform ,可以轻松达到 60FPS。核心思想就是 pre-calculation,用代码来表示就是这样,直接贴一下原作者的代码,已经很详细了:
实际上,FLIP 是将复杂的计算放在了一开始(包括一次强制同步),根据 RAIL 规则,触发后 100ms 的反应时间是可以接受的,所以在 100ms 内完成为止的计算,之后的动画用
transform
来达到 60FPS。附上一个自己写的小 demo,大家可以感受一下。
参考:
高性能 JavaScript
昂贵的 DOM 操作
其实,JS 的执行速度是很快的,尤其是发展到了现在这个年代,像 V8 这样的解释器性能已经十分强悍了(吊打 Python),真正慢的是操作 DOM。浏览器请通常会将 DOM 和 JS 独立实现,DOM 是个与语言无关的 API,但是在浏览器中的接口却是用 JS 来实现的,这意味着通过 JS 去访问另一个模块实现提供的 API 时,会造成很大的开销,这就是造成操作 DOM 慢的原因。
小心 live HTMLCollection使用document.getElementsByName()
,document.getElementsByClassName()
,document.getElementsByTagName()
时,返回值是一个实时的的 HTMLCollection,也就是所谓的 live,这些函数返回的集合是以一种 “假定实时态”,这意味着底层文档对象更新时,它也会自动更新,所以每次你获取这个集合中的信息时,这个集合都会重复执行查询的过程。所以,在不需要满足实时更新的情况下,推荐使用document.querySelectorAll()
,它将返回一个非 live 的静态列表。批量修改 DOM
在 JS 同步代码中操作(比如添加、删除或者修改尺寸等)DOM 会让浏览器进行重排,包括
其中 Layout 分为全局布局及增量布局,全局布局是指触发了整个呈现树范围的布局,触发原因可能包括:
布局可以采用增量方式,也就是只对 dirty 呈现器进行布局(这样可能存在需要进行额外布局的弊端)。当呈现器为 dirty 时,会异步触发增量布局。例如,当来自网络的额外内容添加到 DOM 树之后,新的呈现器附加到了呈现树中。
解决方法是让 DOM 脱离文档流再对其进行操作,所有操作完成后添加进文档流,这样可以将重排及重绘的次数降低到一次或两次(脱离文档流及回归文档流的时候),以下方法可以让元素脱离文档流:
display: none;
(事实上display:none
不会让元素出现在 layout tree 中)。事件委托
利用事件冒泡的机制来处理子元素事件的绑定,将子元素的 DOM 事件,交由它们的父元素来进行处理,可以有效降低页面的开销 —— 由于事件的冒泡特性,只需要给父组件添加一个监听事件,就能够捕获到冒泡自子元素的事件,再通过
e.target
来获取真正被操作的子元素。避免微优化
现在浏览器都有 JIT(just in time)即时编译的引擎,所以会在运行中编译代码
附上一段 知乎 上对 JIT 带来的优化的解释:
所以我们没有必要再去手工的做一些优化,比如在 for 循环中缓存 length,或者像 《高性能JavaScript》 (这已经是2010年的书了,好多结论都是拿 IE 来说的)中介绍的
for (var i=items.length; i--; )
来减少每次迭代经过的步骤,我们无法知道这样的代码在经过 JIT 后,是否会带来任何好处,甚至是否会给 JIT 带来一个负面效果,并且这样做肯定会在一定程度上降低代码的可读性。举个例子,Redux中,在执行 subscribe 的函数时,用的是
for (let i = 0; i < listeners.length; i++)
,listeners.length
本身是可以缓存的(不存在运行过程中 length 改变的情况),但是作者给出的理由是 V8 足够智能来做更好的优化,具体可以看我写的 通过GitHub Blame深入分析Redux源码 。Web Worker
Web Worker 还暂时没研究过,按照MDN的解释
Web Worker 是提供一种在主线程之外的多线程能力,我们可以将耗时的、阻塞的js操作放在 Web Worker 中,PWA 也是基于 Web Worker 来实现,并已经成为了前端的未来趋势之一。
使用 requestAnimationFrame
在某个单个帧中,有可能发生这种情况,在某一帧中会被多次触发某个事件(比如 scroll),这个事件又会频繁的触发样式的修改,导致可能需要多次 Layout 或者 Paint,这是一种浪费,过于频繁的 Layout 和 Paint 会造成卡顿,而且实际上一帧中并不需要重复 Layout 或者 Paint 那么多次。
这个时候就可以用到 rAF 了,先放上一段 MDN 上对 rAF 的解释:
简单来说,rAF 的作用就是将传给 rAF 的回调函数,安排在下一帧的一开始执行。这样就能保证这个回调函数最先执行,并且因为绝大多数浏览器都是 60FPS,所以 rAF 自带节流效果。
这里要提一下浏览器的事件循环,在浏览器的一轮事件循环中,会有 task -> microtask -> UI render,这么的一个循序,rAF 将回调函数放在下一帧的开头,就是已经让其所在的那一轮的 UI 先 render,然后再在下一帧的最开始去执行(关于 event loop 的更多介绍,可以看我写的 这篇文章)。
rAF 的一般调用方法为:
在调用 rAF 时,有一点切记:不要在 rAF 的回调函数中先修改样式,再查询样式,这样就失去了 rAF 的作用。可以将对样式的查询提前到回调函数中或者 rAF 中尽量靠前的位置。
举个例子:
JS 连续执行,rAF 还没能等到下一帧在同一个流水线里被触发强制同步布局了,解决方法也很简单:将 1 和 2 换一下即可,直接用上一帧的样式,再去修改样式。
内存管理
JavaScript 是自动管理内存的,浏览器引擎会自动 GC,作为开发者我们无需去操心内存(只需要别泄漏内存)。但是 GC 同样是需要消耗时间的(可以从 Chrome devTools 里的 Performance 里看到,GC 需要一段很短的时间),如果数据结构使用不当,造成了内存泄漏或者导致频繁的 GC,也是会对页面流畅度造成影响的。
下面是一些写出 GC 友好的代码的教程:
总结
前端性能优化是个大话题,渲染部分的内容也远不止文章中写出的这些,就拿 Composite 来说,就有 无线性能优化:Composite | Taobao FED | 淘宝前端团队) 这样深入的文章,先在这里挖个坑,以后遇到可以补充的再继续更新,欢迎留言👏
The text was updated successfully, but these errors were encountered: