New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

前端性能优化相关 #34

Open
wy-ei opened this Issue Sep 24, 2016 · 5 comments

Comments

Projects
None yet
1 participant

@wy-ei wy-ei added the 读书笔记 label Sep 24, 2016

@wy-ei

This comment has been minimized.

Show comment
Hide comment
@wy-ei

wy-ei Dec 14, 2016

Owner

前端性能优化

最近工作中一个项目在运行时有一些性能问题,为此我看了很多与性能优化相关的内容,下面做个简单的分享。

前端性能优化,这包括 CSS/JS 性能优化、网络性能优化等等内容,这方面的内容 《高性能网站建设指南》《高性能网站建设进阶指南》《高性能JavaScript》 等等书都做了很多讲解,强烈推荐阅读。

下面的内容,上面提到的书中大都包含了,因此可以考虑转而去读这些书,做一个完完全全的了解,对于本文,也就不要再读下去了。

如果你坚持看到了这里,那就来谈谈我遇到的一些前端性能问题,并聊聊解决方案。

优先优化对性能影响大的部分

当应用有了性能问题后,不要一股脑扎到代码中去,首先要想想那部分对性能影响最大。优先优化那些对性能影响大的部分,可以起到立杆见影的效果。
使用 Chrome DevTools ,可以很快地找到导致性能变差的最主要因素,关于 Chrome DevTools 的使用强烈推荐阅读 Google Developers 上面的系列教程 - Chrome DevTools

另外在对代码进行优化的时候,也首先要关注那些存在循环或者高频调用的地方。有的时候我们可能不知道某个地方是否会高频执行,比如某些事件的回调。这个时候可以使用 console.count 来对执行次数进行统计。当这部分高频执行的代码已经足够优化的时候,就要考虑是否能够减少执行次数。比如一个时间复杂度为 O(n*n*n) 的算法,再怎么优化也不如将其变为 O(n*n) 来的快。

对高频触发的事件进行节流或消抖

对于 Scroll 和 Touchmove 这类事件,永远不要低估了它们的执行频率,处理这类事件的时候可以考虑是否要给它们添加一个节流或者消抖过的回调。节流和消抖,可能其他人不这么翻译,其实也就是 debouncethrottle 这两个函数。

debouncethrottle 是两个相似(但不相同)的用于控制函数在某段事件内的执行频率的技术。你可以在 underscore 或者 lodash 中找到这两个函数。

使用 debounce 进行消抖

多次连续的调用,最终实际上只会调用一次。想象自己在电梯里面,门将要关上,这个时候另外一个人来了,取消了关门的操作,过了一会儿门又要关上,又来了一个人,再次取消了关门的操作。电梯会一直延迟关门的操作,直到某段时间里没人再来。

所以 debounce 适合用在比如对用户输入内容进行校验的这种场景下,多次触发只需要响应最后一次触发就好了。

使用 throttle 进行节流

将频繁调用的函数限定在一个给定的调用频率内。它保证某个函数频率再高,也只能在给定的事件内调用一次。比如在滚动的时候要检查当前滚动的位置,来显示或隐藏回到顶部按钮,这个时候可以使用 throttle 来将滚动回调函数限定在每 300ms 执行一次。

需要提到的是,这两个函数常常被误用,且很多时候当事人并没有意识到自己误用了。我曾经用错过,也见过别人用错。这两个函数都接受一个函数作为参数,然后返回一个节流/去抖后的函数,下面第二种用法才是正确的用法:

// 错误的用法,每次事件触发都得到一个新的函数
$(window).on('scroll', function() {
   _.throttle(doSomething, 300);
});

// 正确的用法,将节流后的函数作为回调
$(window).on('scroll', _.throttle(doSomething, 200));

JavaScript 很快,DOM 很慢

JavaScript 如今已经很快了,真正慢的是 DOM。因此避免使用一些不易读但据说能提高速度的写法。不久前,一位朋友对我说使用 '+' 号将字符串转为数字比使用 parseInt 快。对此我并没有怀疑,因为直觉上 parseInt 进行了函数调用,很可能会慢一些,我们一起在 在 node v6.3.0 上进行了一些验证,结果的确如我们所预计的那样,但是差别有多大呢,进行了 5 亿次迭代,使用 '+' 号的方法仅仅快了2秒。但实际中将字符转为数字的操作可能只会进行几次,这种写法只会导致代码质量降低。

plus: 1694.392ms
parseInt: 3661.403ms

真正慢的是 DOM,DOM 对外提供了 API,而 JavaScript 可以调用这些 API,它们两者就像是使用一座桥梁相连,每次过桥都要被收取大量费用,因此应该尽量让减少过桥的次数。

为什么 DOM 很慢

谈到这里需要对浏览器利用 HTML/CSS/JavaScript 等资源呈现出精彩的页面的过程进行简单说明。浏览器在收到 HTML 文档之后会对文档进行解析开始构建 DOM (Document Object Model) 树,进而在文档中发现样式表,开始解析 CSS 来构建 CSSOM(CSS Object Model)树,这两者都构建完成后,开始构建渲染树。整个过程如下:

text-渲染树的构建过程

在每次修改了 DOM 或者其样式之后都要进行 DOM树的构建,CSSOM 的重新计算,进而得到新的渲染树。浏览器会利用新的渲染树对页面进行重排和重绘,以及图层的合并。通常浏览器会批量进行重排和重绘,以提高性能。但当我们试图通过 JavaScript 获取某个节点的尺寸信息的时候,为了获得当前真实的信息,浏览器会立刻进行一次重排。

避免强制性同步布局

在 JavaScript 中读取到的布局信息都是上一帧的信息,如果在 JavaScript 中修改了页面的布局,比如给某个元素添加了一个类,然后再读取布局信息。这个时候为了获得真实的布局信息,浏览器需要强制性对页面进行布局。因此应该避免这样做。

批量操作 DOM

在必须要进行频繁的 DOM 操作时,可以使用 fastdom 这样的工具,它的思路是将对页面的读取和改写放进队列,在页面重绘的时候批量执行,先进行读取后改写。因为如果将读取与改写交织在一起可能引起多次页面的重排。而利用 fastdom 就可以避免这样的情况发生。

虽然有了 fastdom 这样的工具,但有的时候还是不能从根本上解决问题,比如我最近遇到的一个情况,与页面简单的一次交互(轻轻滚动页面)就执行了几千次 DOM 操作,这个时候核心要解决的是减少 DOM 操作的次数。这个时候就要从代码层面考虑,看看是否有不必要的读取。

另外一些关于高效操作 DOM 的方法,可以参见《高性能 JavaScript》相关章节,也可以先参考一下我的读书笔记 《高性能 JavaScript》

优化渲染性能

浏览器通常每秒更新页面 60 次,每一帧的时间就是 16.6ms,为了能让浏览器保持 60帧 的帧率,为了让动画看起来流畅,需要保证帧率达到 60fps,因此每一帧的逻辑需要在 16.6ms 内完成。

每一帧实际上都包含下列步骤:

因此,通常 JavaScript 的执行时间不能超过 10ms。

  • JavaScript:改变元素样式,添加元素到 DOM 中等等
  • Style:元素的类或者style改变了,这个时候需要重新计算元素的样式
  • Layout:需要重新计算元素的具体尺寸
  • Paint:将元素的绘制的图层上
  • Composite:合并多个图层

当然也不是说每一帧都会进行这些操作。当你的 JavaScript 改变了某个 layout 属性,比如元素的 widthheight 或者 top 等等,浏览器就会重新计算布局,并对整个页面进行重排。

如果修改了 backgroundcolor 这样的仅仅会让页面重绘的属性,这不会影响页面的布局,浏览器会跳过计算布局(layout)的过程,只进行重绘(paint)。

如果修改了一个不需要计算布局也不需要重绘的属性,那就只会进行图层的合并,这是代价最小的修改。从 https://csstriggers.com/ 上你可以知道修改那些样式属性会触发(Layout,Paint,Composite)中的那些操作。

将渐变或者会动画元素放到单独的绘制层中

绘制并非在一个单独的画布上进行的,而是多层。因此将那些会变动的元素提升至单独的图层,可以让他的改变影响到的元素更少。

可以使用 CSS 中的 will-change: transform; 或者 transform: translateZ(0); 这样来将元素提升至单独的图层中。

text-使用 Chrome DevTools 来审查图层

在调试的时候你可以在 Chrome DevTools 的 timeline 面板来观察绘制图层。当然也不是说图层越多越好,因为新增加一个图层可能会耗费额外的内存。且新增加一个图层的目的是为了避免某个元素的变动影响其他元素。

降低绘制复杂度

某些属性的重绘相对而言更加复杂,比如 filter、box-shadow 等滤镜或渐变效果。因此不要滥用这类效果。

优化 JavaScript 的执行

下面提到的 JavaScript 优化,并不是说如何让 JavaScript 执行的更快,而是如何让 JavaScript 更高效地与 DOM 配合。

使用 requestAnimationFrame 来更新页面

我们希望在每一帧刚开始的时候对页面进行更改,目前只有使用 requestAnimationFrame 能够保证这一点。使用 setTimeout 或者 setInterval 来触发更新页面的函数,该函数可能在一帧的中间或者结束的时间点上调用,进而导致该帧后面需要进行的事情没有完成,引发丢帧。

text-使用 setTimeout 可能导致丢帧

requestAnimationFrame 会将任务安排在页面重绘之前,这保证动画能有足够的时间来执行 JavaScript 。

使用 Web Worker 来处理复杂的计算

JavaScript 是在单线程的,并且可能会一直这样,因此 JavaScript 在执行复杂计算的时候很可能会阻塞线程,导致页面假死。但 Web Worker 的出现,以另外一种方式给了我们多线程的能力,可以将复杂计算放在 worker 中进行,当计算完成后,以 postMessage 的形式将结果传回来。

对于单个函数,因为 Web Worker 接受一个脚本的 url 作为参数,使用 URL.createObjectURL 方法,我们可以将一个函数的内容转换为 url,利用它创建一个 worker。

var workerContent = `
self.onmessage = function(evt){
    // ...
    // 在这里进行复杂计算
    var result = complexFunc();

    // 将结果传回
    self.postMessage(result);
};`

// 得到 url
var blob = new Blob([workerContent]);
var url = window.URL.createObjectURL(blob);

// 创建 worker
var worker = new Worker(url);

使用 transform 和 opacity 来完成动画

如今只有对这两个属性的修改不需要经历 layout 和 paint 过程。

避免在 scroll 或 touchmove 这类事件的回调中修改样式

因为 scrolltouchmove 事件的回调会在 requestAnimationFrame 之前触发,如果在这里面修改了样式,在 requestAnimationFrame 中若有读取样式的操作,就会强制重新计算样式。但是如果一定要修改样式该怎么办呢?可以将对样式的修改放在一个函数中,并使用 requestAnimationFrame 来触发。在 requestAnimationFrame 执行序列中,就算有对样式的修改,也不会触发重排,重排会在回调序列执行完成后进行。

更多关于使用 requestAnimationFrame 来提升性能的细节可以阅读这篇文章:Better Performance With requestAnimationFrame

优化 CSS

CSS 选择器在匹配的时候是由右至左进行的,因此最后一个选择器常被称为关键选择器,因为最后一个选择越特殊,需要进行匹配的次数越少。要千万避免使用 *(通用选择器)作为关键选择器。因为它能匹配到所有元素,进而倒数第二个选择器还会和所有元素进行一次匹配。这导致效率很低下。

/* 不要这样做 */
div p * {}

另外 first-child 这类伪类选择器也不够特殊,也要避免将它们作为关键选择器。关键选择器越特殊,浏览器就能用较少的匹配次数找到待匹配元素,选择器性能也就越好。

还有一个老生常谈的注意事项,不要使用太多的选择器。如果还有同学很悲剧地要兼容低版本 IE,要避免使用 CSS 表达式,它的性能很差,详细内容可参见我之前记录的一篇笔记 《高性能网站建设指南》笔记

合理处理脚本和样式表

如今有了 requirejs,webpack 等工具,可能很少会在页面中加载很多 JavaScript/CSS 代码了。尽管如此,还是有必要谈谈如何合理处理脚本和样式表。

大多数人已经知道通常要把 JavaScript 放在文档底部,把 CSS 放在文档顶部。为什么呢?因为 JavaScript 会阻塞页面的解析,而外部样式表会阻塞页面的呈现和 JavaScript 的执行。

CSS阻塞渲染

通常情况下 CSS 被认为是阻塞渲染的资源,在CSSOM 构建完成之前,页面不会被渲染,放在顶部让样式表能够尽早开始加载。但如果把引入样式表的 link 放在文档底部,页面虽然能立刻呈现出来,但是页面加载出来的时候会是没有样式的,是混乱的。当后来样式表加载进来后,页面会立即进行重绘,这也就是通常所说的闪烁了。

JavaScript 阻塞文档解析

当在 HTML 文档中遇到 script 标签后控制权将交给 JavaScript,在 JavaScript 下载并执行完成之前,都不会解析 HTML。因此如果将 JavaScript 放在文档顶部,恰好这个时候 JavaScript 脚本加载的特别慢,用户将会等待很长一段时间,这段个时候 HTML 文档还没有解析到 body 部分,页面会是空白的。

另外常常被忽略的事实是:在浏览器没有下载并解析完成使用 link 引入的 CSS 文件之前,JavaScript 是不会执行的,因为 JavaScript 中可能需要读取样式,而此时样式表还没有加载回来,因此浏览器不会执行 JavaScript。可以给 JavaScript 加上 async 标记,表示 JavaScript 的执行不会读取 DOM ,JavaScript 可以不被 CSS 阻塞,可以在空闲时间立刻执行。

综上所述,你更要保证 CSS 文件加载的足够快。

关于这部分内容, 《高性能网站建设指南》 上有很精彩的讲解,墙裂推荐。《高性能网站建设指南》我在读的时候记录了笔记,可以在这里看到。

最后强烈推荐阅读 Google Developers 中关于性能优化的系列文章

参考资料

Owner

wy-ei commented Dec 14, 2016

前端性能优化

最近工作中一个项目在运行时有一些性能问题,为此我看了很多与性能优化相关的内容,下面做个简单的分享。

前端性能优化,这包括 CSS/JS 性能优化、网络性能优化等等内容,这方面的内容 《高性能网站建设指南》《高性能网站建设进阶指南》《高性能JavaScript》 等等书都做了很多讲解,强烈推荐阅读。

下面的内容,上面提到的书中大都包含了,因此可以考虑转而去读这些书,做一个完完全全的了解,对于本文,也就不要再读下去了。

如果你坚持看到了这里,那就来谈谈我遇到的一些前端性能问题,并聊聊解决方案。

优先优化对性能影响大的部分

当应用有了性能问题后,不要一股脑扎到代码中去,首先要想想那部分对性能影响最大。优先优化那些对性能影响大的部分,可以起到立杆见影的效果。
使用 Chrome DevTools ,可以很快地找到导致性能变差的最主要因素,关于 Chrome DevTools 的使用强烈推荐阅读 Google Developers 上面的系列教程 - Chrome DevTools

另外在对代码进行优化的时候,也首先要关注那些存在循环或者高频调用的地方。有的时候我们可能不知道某个地方是否会高频执行,比如某些事件的回调。这个时候可以使用 console.count 来对执行次数进行统计。当这部分高频执行的代码已经足够优化的时候,就要考虑是否能够减少执行次数。比如一个时间复杂度为 O(n*n*n) 的算法,再怎么优化也不如将其变为 O(n*n) 来的快。

对高频触发的事件进行节流或消抖

对于 Scroll 和 Touchmove 这类事件,永远不要低估了它们的执行频率,处理这类事件的时候可以考虑是否要给它们添加一个节流或者消抖过的回调。节流和消抖,可能其他人不这么翻译,其实也就是 debouncethrottle 这两个函数。

debouncethrottle 是两个相似(但不相同)的用于控制函数在某段事件内的执行频率的技术。你可以在 underscore 或者 lodash 中找到这两个函数。

使用 debounce 进行消抖

多次连续的调用,最终实际上只会调用一次。想象自己在电梯里面,门将要关上,这个时候另外一个人来了,取消了关门的操作,过了一会儿门又要关上,又来了一个人,再次取消了关门的操作。电梯会一直延迟关门的操作,直到某段时间里没人再来。

所以 debounce 适合用在比如对用户输入内容进行校验的这种场景下,多次触发只需要响应最后一次触发就好了。

使用 throttle 进行节流

将频繁调用的函数限定在一个给定的调用频率内。它保证某个函数频率再高,也只能在给定的事件内调用一次。比如在滚动的时候要检查当前滚动的位置,来显示或隐藏回到顶部按钮,这个时候可以使用 throttle 来将滚动回调函数限定在每 300ms 执行一次。

需要提到的是,这两个函数常常被误用,且很多时候当事人并没有意识到自己误用了。我曾经用错过,也见过别人用错。这两个函数都接受一个函数作为参数,然后返回一个节流/去抖后的函数,下面第二种用法才是正确的用法:

// 错误的用法,每次事件触发都得到一个新的函数
$(window).on('scroll', function() {
   _.throttle(doSomething, 300);
});

// 正确的用法,将节流后的函数作为回调
$(window).on('scroll', _.throttle(doSomething, 200));

JavaScript 很快,DOM 很慢

JavaScript 如今已经很快了,真正慢的是 DOM。因此避免使用一些不易读但据说能提高速度的写法。不久前,一位朋友对我说使用 '+' 号将字符串转为数字比使用 parseInt 快。对此我并没有怀疑,因为直觉上 parseInt 进行了函数调用,很可能会慢一些,我们一起在 在 node v6.3.0 上进行了一些验证,结果的确如我们所预计的那样,但是差别有多大呢,进行了 5 亿次迭代,使用 '+' 号的方法仅仅快了2秒。但实际中将字符转为数字的操作可能只会进行几次,这种写法只会导致代码质量降低。

plus: 1694.392ms
parseInt: 3661.403ms

真正慢的是 DOM,DOM 对外提供了 API,而 JavaScript 可以调用这些 API,它们两者就像是使用一座桥梁相连,每次过桥都要被收取大量费用,因此应该尽量让减少过桥的次数。

为什么 DOM 很慢

谈到这里需要对浏览器利用 HTML/CSS/JavaScript 等资源呈现出精彩的页面的过程进行简单说明。浏览器在收到 HTML 文档之后会对文档进行解析开始构建 DOM (Document Object Model) 树,进而在文档中发现样式表,开始解析 CSS 来构建 CSSOM(CSS Object Model)树,这两者都构建完成后,开始构建渲染树。整个过程如下:

text-渲染树的构建过程

在每次修改了 DOM 或者其样式之后都要进行 DOM树的构建,CSSOM 的重新计算,进而得到新的渲染树。浏览器会利用新的渲染树对页面进行重排和重绘,以及图层的合并。通常浏览器会批量进行重排和重绘,以提高性能。但当我们试图通过 JavaScript 获取某个节点的尺寸信息的时候,为了获得当前真实的信息,浏览器会立刻进行一次重排。

避免强制性同步布局

在 JavaScript 中读取到的布局信息都是上一帧的信息,如果在 JavaScript 中修改了页面的布局,比如给某个元素添加了一个类,然后再读取布局信息。这个时候为了获得真实的布局信息,浏览器需要强制性对页面进行布局。因此应该避免这样做。

批量操作 DOM

在必须要进行频繁的 DOM 操作时,可以使用 fastdom 这样的工具,它的思路是将对页面的读取和改写放进队列,在页面重绘的时候批量执行,先进行读取后改写。因为如果将读取与改写交织在一起可能引起多次页面的重排。而利用 fastdom 就可以避免这样的情况发生。

虽然有了 fastdom 这样的工具,但有的时候还是不能从根本上解决问题,比如我最近遇到的一个情况,与页面简单的一次交互(轻轻滚动页面)就执行了几千次 DOM 操作,这个时候核心要解决的是减少 DOM 操作的次数。这个时候就要从代码层面考虑,看看是否有不必要的读取。

另外一些关于高效操作 DOM 的方法,可以参见《高性能 JavaScript》相关章节,也可以先参考一下我的读书笔记 《高性能 JavaScript》

优化渲染性能

浏览器通常每秒更新页面 60 次,每一帧的时间就是 16.6ms,为了能让浏览器保持 60帧 的帧率,为了让动画看起来流畅,需要保证帧率达到 60fps,因此每一帧的逻辑需要在 16.6ms 内完成。

每一帧实际上都包含下列步骤:

因此,通常 JavaScript 的执行时间不能超过 10ms。

  • JavaScript:改变元素样式,添加元素到 DOM 中等等
  • Style:元素的类或者style改变了,这个时候需要重新计算元素的样式
  • Layout:需要重新计算元素的具体尺寸
  • Paint:将元素的绘制的图层上
  • Composite:合并多个图层

当然也不是说每一帧都会进行这些操作。当你的 JavaScript 改变了某个 layout 属性,比如元素的 widthheight 或者 top 等等,浏览器就会重新计算布局,并对整个页面进行重排。

如果修改了 backgroundcolor 这样的仅仅会让页面重绘的属性,这不会影响页面的布局,浏览器会跳过计算布局(layout)的过程,只进行重绘(paint)。

如果修改了一个不需要计算布局也不需要重绘的属性,那就只会进行图层的合并,这是代价最小的修改。从 https://csstriggers.com/ 上你可以知道修改那些样式属性会触发(Layout,Paint,Composite)中的那些操作。

将渐变或者会动画元素放到单独的绘制层中

绘制并非在一个单独的画布上进行的,而是多层。因此将那些会变动的元素提升至单独的图层,可以让他的改变影响到的元素更少。

可以使用 CSS 中的 will-change: transform; 或者 transform: translateZ(0); 这样来将元素提升至单独的图层中。

text-使用 Chrome DevTools 来审查图层

在调试的时候你可以在 Chrome DevTools 的 timeline 面板来观察绘制图层。当然也不是说图层越多越好,因为新增加一个图层可能会耗费额外的内存。且新增加一个图层的目的是为了避免某个元素的变动影响其他元素。

降低绘制复杂度

某些属性的重绘相对而言更加复杂,比如 filter、box-shadow 等滤镜或渐变效果。因此不要滥用这类效果。

优化 JavaScript 的执行

下面提到的 JavaScript 优化,并不是说如何让 JavaScript 执行的更快,而是如何让 JavaScript 更高效地与 DOM 配合。

使用 requestAnimationFrame 来更新页面

我们希望在每一帧刚开始的时候对页面进行更改,目前只有使用 requestAnimationFrame 能够保证这一点。使用 setTimeout 或者 setInterval 来触发更新页面的函数,该函数可能在一帧的中间或者结束的时间点上调用,进而导致该帧后面需要进行的事情没有完成,引发丢帧。

text-使用 setTimeout 可能导致丢帧

requestAnimationFrame 会将任务安排在页面重绘之前,这保证动画能有足够的时间来执行 JavaScript 。

使用 Web Worker 来处理复杂的计算

JavaScript 是在单线程的,并且可能会一直这样,因此 JavaScript 在执行复杂计算的时候很可能会阻塞线程,导致页面假死。但 Web Worker 的出现,以另外一种方式给了我们多线程的能力,可以将复杂计算放在 worker 中进行,当计算完成后,以 postMessage 的形式将结果传回来。

对于单个函数,因为 Web Worker 接受一个脚本的 url 作为参数,使用 URL.createObjectURL 方法,我们可以将一个函数的内容转换为 url,利用它创建一个 worker。

var workerContent = `
self.onmessage = function(evt){
    // ...
    // 在这里进行复杂计算
    var result = complexFunc();

    // 将结果传回
    self.postMessage(result);
};`

// 得到 url
var blob = new Blob([workerContent]);
var url = window.URL.createObjectURL(blob);

// 创建 worker
var worker = new Worker(url);

使用 transform 和 opacity 来完成动画

如今只有对这两个属性的修改不需要经历 layout 和 paint 过程。

避免在 scroll 或 touchmove 这类事件的回调中修改样式

因为 scrolltouchmove 事件的回调会在 requestAnimationFrame 之前触发,如果在这里面修改了样式,在 requestAnimationFrame 中若有读取样式的操作,就会强制重新计算样式。但是如果一定要修改样式该怎么办呢?可以将对样式的修改放在一个函数中,并使用 requestAnimationFrame 来触发。在 requestAnimationFrame 执行序列中,就算有对样式的修改,也不会触发重排,重排会在回调序列执行完成后进行。

更多关于使用 requestAnimationFrame 来提升性能的细节可以阅读这篇文章:Better Performance With requestAnimationFrame

优化 CSS

CSS 选择器在匹配的时候是由右至左进行的,因此最后一个选择器常被称为关键选择器,因为最后一个选择越特殊,需要进行匹配的次数越少。要千万避免使用 *(通用选择器)作为关键选择器。因为它能匹配到所有元素,进而倒数第二个选择器还会和所有元素进行一次匹配。这导致效率很低下。

/* 不要这样做 */
div p * {}

另外 first-child 这类伪类选择器也不够特殊,也要避免将它们作为关键选择器。关键选择器越特殊,浏览器就能用较少的匹配次数找到待匹配元素,选择器性能也就越好。

还有一个老生常谈的注意事项,不要使用太多的选择器。如果还有同学很悲剧地要兼容低版本 IE,要避免使用 CSS 表达式,它的性能很差,详细内容可参见我之前记录的一篇笔记 《高性能网站建设指南》笔记

合理处理脚本和样式表

如今有了 requirejs,webpack 等工具,可能很少会在页面中加载很多 JavaScript/CSS 代码了。尽管如此,还是有必要谈谈如何合理处理脚本和样式表。

大多数人已经知道通常要把 JavaScript 放在文档底部,把 CSS 放在文档顶部。为什么呢?因为 JavaScript 会阻塞页面的解析,而外部样式表会阻塞页面的呈现和 JavaScript 的执行。

CSS阻塞渲染

通常情况下 CSS 被认为是阻塞渲染的资源,在CSSOM 构建完成之前,页面不会被渲染,放在顶部让样式表能够尽早开始加载。但如果把引入样式表的 link 放在文档底部,页面虽然能立刻呈现出来,但是页面加载出来的时候会是没有样式的,是混乱的。当后来样式表加载进来后,页面会立即进行重绘,这也就是通常所说的闪烁了。

JavaScript 阻塞文档解析

当在 HTML 文档中遇到 script 标签后控制权将交给 JavaScript,在 JavaScript 下载并执行完成之前,都不会解析 HTML。因此如果将 JavaScript 放在文档顶部,恰好这个时候 JavaScript 脚本加载的特别慢,用户将会等待很长一段时间,这段个时候 HTML 文档还没有解析到 body 部分,页面会是空白的。

另外常常被忽略的事实是:在浏览器没有下载并解析完成使用 link 引入的 CSS 文件之前,JavaScript 是不会执行的,因为 JavaScript 中可能需要读取样式,而此时样式表还没有加载回来,因此浏览器不会执行 JavaScript。可以给 JavaScript 加上 async 标记,表示 JavaScript 的执行不会读取 DOM ,JavaScript 可以不被 CSS 阻塞,可以在空闲时间立刻执行。

综上所述,你更要保证 CSS 文件加载的足够快。

关于这部分内容, 《高性能网站建设指南》 上有很精彩的讲解,墙裂推荐。《高性能网站建设指南》我在读的时候记录了笔记,可以在这里看到。

最后强烈推荐阅读 Google Developers 中关于性能优化的系列文章

参考资料

@wy-ei

This comment has been minimized.

Show comment
Hide comment
@wy-ei

wy-ei Dec 14, 2016

Owner

读书笔记 --《高性能 JavaScript》

加载和执行

javascript 是单线程的。且多数浏览器是使用单一进程来处理用户界面的刷新和javascript代码的执行的,所以 javascript 执行时间越久,页面等待时间就会越久。

在页面中每次出现 script 标签的时候,就意味着浏览器需要去执行这些代码,页面也就会处于暂停状况。为什么 javascript 的执行不能和页面的渲染并行呢,因为 javascript 的执行有时候决定了页面如何进行渲染。因为脚本可能在执行的过程中向页面中添加内容。

比如使用 document.write

使用 src 属性加载外部 javascript 文件的时候也是如此,浏览器必须等待文件加载完毕,并执行该文件,执行完毕后再继续解析 HTML 代码。在这个阶段用户的交互和页面渲染是完全被阻塞的。

脚本位置:

浏览器在解析到 body 之前是不会开始渲染页面的,所以将大量的脚本或者样式表放在 head 部分会导致页面长时间处于空白。

file1.js —> load —> exec —> file2.js —> load —> exec —> file3.js —> load —> exec

ok,虽然浏览器支持并行下载,但是执行上一定是串行的,所以尽可能将脚本放在最接近 body 底部的地方。

将内嵌脚本放在 head 中的情况

为了确保脚本执行时候得到的是正确的样式,所以这会阻塞页面等到样式表的加载完毕。

脚本数量

考虑到 http 连接的开销,可以将小的脚本文件进行压缩。

无阻塞的脚本

javascript 倾向于阻塞用户界面和 HTTP 请求。减小 javascript 文件的大小,并减少http请求数量只是第一步。

延迟脚本

defer ,async 属性

作用:异步加载脚本,不阻塞页面的渲染。保证脚本不会修改 DOM
区别:async 是 HTML5 标准中提出的,在脚本加载完毕后立刻执行,defer 是早 HTML4 中提出的,是在 dom 加载完成后执行。

目前所有主流浏览器都实现了以上属性的支持,有一点需要注意的是 defer 只作用于使用 src 加载的脚本

动态脚本

  • AMD:require.js
  • CMD:sea.js

解决方法

  • 压缩代码
  • 合并脚本
  • lazyload

DOM 编程

使用脚本对 DOM 进行操作的代价很昂贵。

天生就慢

因为 DOM 是独立于语言的,它提供了操作文档的 API,它和 ECMAScript 是分开的,它们两者就像是使用一座桥梁链接起来的一样,每当需要跨越这座桥梁的时候就要收取一些费用,所以要尽量减少过桥的次数。

  • 减少DOM的访问,把事情尽量留在 javascript 这边进行
  • 缓存DOM集合的长度,因为DOM集合是动态的,每次访问其中的属性都要进行一次查询。

元素集合

使用 firstElementChild 等只获得元素节点的方法,而不是使用 firstChild 并判断其 nodeType 是否为 3

选择器API

querySelectorAll 返回一个 NodeList 其中保存了所有符合选择器的元素的引用,这个集合不是动态的,不对随着文档的改变而改变。

重绘与重排

浏览器在下载完成所有的资源之后,会建立两个内部的数据结构:

  • DOM🌲:表示页面结构
  • 渲染🌲:表示DOM节点如何显示

每当DOM元素的宽高,颜色等改变,都会触发渲染树的更新,对于尺寸变化会进行一次重新排版,然后在重新绘制。并不是任何操作都会触发重排,比如背景色的变化就只会触发一次重绘,因为布局并没有改变。

重排何时发生

  • 添加或者删除可见元素
  • 元素位置改变
  • 元素尺寸改变
  • 内容改变
  • 页面渲染初始化
  • 浏览器尺寸变化

渲染树变化的排队和刷新

由于每次重排都会产生计算消耗,所以浏览器会通过队列来批量执行重排过程。然而在读取元素的 offset_。scroll_,client* 等属性的时候,由于要获取到当前准确的信息,这个时候会强制进行重排以返回正确值。

最小化重绘与重排

  • 批量改变样式

使用 el.style.cssText 来批量添加,或者在后面追加。

  • 批量修改 DOM

当需要对 DOM 进行一系列操作的时候,可以采用下面的方式:

  • 隐藏DOM
  • 对其应用多次修改
  • 重新显示该元素

其他一些方案:使用文档片段

  • 缓存布局信息

因为在每次获取布局信息的时候都要进行渲染树的刷新,所以可以缓存下布局信息,避免频繁获取这些信息。

  • 让元素脱离动画流

有的时候页面的顶部有一个动画,这个元素的高度不断变大,然后将下面的内容撑到下面,这个时候会导致整个页面的重绘,使得整个页面看起来一顿一顿的。解决方法是将这个元素在动画开始前设置为 绝对定位,让其脱离文档流,然后在应用动画,这个时候就只会对这个元素以及这个元素遮挡的元素进行重绘,在动画结束后再恢复定位让他回到标准流中。

  • IE:hover

对于元素很多的时候,不要对大量元素使用 hover 选择器。在 IE8 中性能很差。

  • 事件委托

利用事件冒泡的机制来处理大量的事件的绑定。

Owner

wy-ei commented Dec 14, 2016

读书笔记 --《高性能 JavaScript》

加载和执行

javascript 是单线程的。且多数浏览器是使用单一进程来处理用户界面的刷新和javascript代码的执行的,所以 javascript 执行时间越久,页面等待时间就会越久。

在页面中每次出现 script 标签的时候,就意味着浏览器需要去执行这些代码,页面也就会处于暂停状况。为什么 javascript 的执行不能和页面的渲染并行呢,因为 javascript 的执行有时候决定了页面如何进行渲染。因为脚本可能在执行的过程中向页面中添加内容。

比如使用 document.write

使用 src 属性加载外部 javascript 文件的时候也是如此,浏览器必须等待文件加载完毕,并执行该文件,执行完毕后再继续解析 HTML 代码。在这个阶段用户的交互和页面渲染是完全被阻塞的。

脚本位置:

浏览器在解析到 body 之前是不会开始渲染页面的,所以将大量的脚本或者样式表放在 head 部分会导致页面长时间处于空白。

file1.js —> load —> exec —> file2.js —> load —> exec —> file3.js —> load —> exec

ok,虽然浏览器支持并行下载,但是执行上一定是串行的,所以尽可能将脚本放在最接近 body 底部的地方。

将内嵌脚本放在 head 中的情况

为了确保脚本执行时候得到的是正确的样式,所以这会阻塞页面等到样式表的加载完毕。

脚本数量

考虑到 http 连接的开销,可以将小的脚本文件进行压缩。

无阻塞的脚本

javascript 倾向于阻塞用户界面和 HTTP 请求。减小 javascript 文件的大小,并减少http请求数量只是第一步。

延迟脚本

defer ,async 属性

作用:异步加载脚本,不阻塞页面的渲染。保证脚本不会修改 DOM
区别:async 是 HTML5 标准中提出的,在脚本加载完毕后立刻执行,defer 是早 HTML4 中提出的,是在 dom 加载完成后执行。

目前所有主流浏览器都实现了以上属性的支持,有一点需要注意的是 defer 只作用于使用 src 加载的脚本

动态脚本

  • AMD:require.js
  • CMD:sea.js

解决方法

  • 压缩代码
  • 合并脚本
  • lazyload

DOM 编程

使用脚本对 DOM 进行操作的代价很昂贵。

天生就慢

因为 DOM 是独立于语言的,它提供了操作文档的 API,它和 ECMAScript 是分开的,它们两者就像是使用一座桥梁链接起来的一样,每当需要跨越这座桥梁的时候就要收取一些费用,所以要尽量减少过桥的次数。

  • 减少DOM的访问,把事情尽量留在 javascript 这边进行
  • 缓存DOM集合的长度,因为DOM集合是动态的,每次访问其中的属性都要进行一次查询。

元素集合

使用 firstElementChild 等只获得元素节点的方法,而不是使用 firstChild 并判断其 nodeType 是否为 3

选择器API

querySelectorAll 返回一个 NodeList 其中保存了所有符合选择器的元素的引用,这个集合不是动态的,不对随着文档的改变而改变。

重绘与重排

浏览器在下载完成所有的资源之后,会建立两个内部的数据结构:

  • DOM🌲:表示页面结构
  • 渲染🌲:表示DOM节点如何显示

每当DOM元素的宽高,颜色等改变,都会触发渲染树的更新,对于尺寸变化会进行一次重新排版,然后在重新绘制。并不是任何操作都会触发重排,比如背景色的变化就只会触发一次重绘,因为布局并没有改变。

重排何时发生

  • 添加或者删除可见元素
  • 元素位置改变
  • 元素尺寸改变
  • 内容改变
  • 页面渲染初始化
  • 浏览器尺寸变化

渲染树变化的排队和刷新

由于每次重排都会产生计算消耗,所以浏览器会通过队列来批量执行重排过程。然而在读取元素的 offset_。scroll_,client* 等属性的时候,由于要获取到当前准确的信息,这个时候会强制进行重排以返回正确值。

最小化重绘与重排

  • 批量改变样式

使用 el.style.cssText 来批量添加,或者在后面追加。

  • 批量修改 DOM

当需要对 DOM 进行一系列操作的时候,可以采用下面的方式:

  • 隐藏DOM
  • 对其应用多次修改
  • 重新显示该元素

其他一些方案:使用文档片段

  • 缓存布局信息

因为在每次获取布局信息的时候都要进行渲染树的刷新,所以可以缓存下布局信息,避免频繁获取这些信息。

  • 让元素脱离动画流

有的时候页面的顶部有一个动画,这个元素的高度不断变大,然后将下面的内容撑到下面,这个时候会导致整个页面的重绘,使得整个页面看起来一顿一顿的。解决方法是将这个元素在动画开始前设置为 绝对定位,让其脱离文档流,然后在应用动画,这个时候就只会对这个元素以及这个元素遮挡的元素进行重绘,在动画结束后再恢复定位让他回到标准流中。

  • IE:hover

对于元素很多的时候,不要对大量元素使用 hover 选择器。在 IE8 中性能很差。

  • 事件委托

利用事件冒泡的机制来处理大量的事件的绑定。

@wy-ei wy-ei changed the title from 《高性能 JavaScript》笔记 to 前端性能优化相关 Dec 14, 2016

@wy-ei wy-ei removed the 读书笔记 label Dec 14, 2016

@wy-ei

This comment has been minimized.

Show comment
Hide comment
@wy-ei

wy-ei Dec 14, 2016

Owner

GPU 是如何加速网页渲染的

前端工程师应该都听说过硬件加速,通常它是指利用 GPU 来加速页面的渲染。那么 GPU 目前在web页面的渲染过程中起到什么作用呢?

GPU 的作用

早期浏览器完全依赖 CPU 来进行页面渲染。现在随着 GPU 的能力增强和普及,且目前绝大多数运行浏览器的设备上都集成了 GPU。浏览器可以利用 GPU 来加速网页渲染。

GPU 包含几百上千个核心,但每个核心的结构都相对简单, GPU 的结构也决定了它适合用来进行大规模并行计算。进行图层合并需要操作大量的像素,这方面 GPU 能比 CPU 更高效的完成。这里有个视频,很清楚地说明 CPU 与 GPU 的差别。

页面渲染过程

浏览器利用 HTML 构建出 DOM 树,利用 CSS 构建 CSSOM 树,最终得到 Render 树。

text-渲染树的构建过程

然而这只是很宏观的描述,浏览器为了将 DOM 元素高效地绘制且正确地出来,将多个元素安排在一个图层中,使用 PaintLayer 来描述,在每个 PaintLayer 中又存在 GraphicsLayers。当某个元素的样式改变后,不需要去重绘某个图层就好了。

浏览器的每一帧都可能会经过以下几个步骤:

JavaScript 的执行可能修改 DOM 树和 CSSOM 树,随后浏览器需要重新计算样式,并根据新的样式计算出元素的实际属性(比如 CSS 中 width 是 50%,这里就要利用父元素的宽度得出自己真实的 width 值),重绘有变动的图层,随后将各图层传递给 GPU ,由 GPU 来进行图层的合并。

上面 5 个步骤中,Layout 和 Paint 是可以省略的,当修改后的样式不会改变元素的尺寸、位置等涉及布局的属性时候,就没有必要进行 Layout(计算布局),比如修改了 color 属性,这个时候就只需要进行重绘(Paint)步骤。同样的道理,修改某些属性也不需要进行 Paint 步骤,只需要 Composite 就可以。

因此,我们希望所做的操作能尽可能地避免 Layout 和 Paint 这两个步骤,这样一帧所需的时间也就会大大缩短,可以明显避免卡顿。

目前有三个属性的改变只需要进行 Composite 过程,分别是:

  • filter
  • transform
  • opacity

这几个属性的改变,GPU 只需要在合并图层之前对图层进行一些变换,比如 opacity 属性的改变,GPU 只需要在合并之前改变图层的 alpha 通道。transform 和 filter 的改变 GPU 也可以利用矩阵变换很快地得到变化后的图层。

正确地利用 GPU

使用 transform, filter 和 opacity 来完成动画

使用以上 3 个属性来完成动画,可以避免在动画的每一帧进行重绘。如果在动画中改变了其他属性,那也不能避免重新绘制。

避免不合理地强制开启硬件加速

常常看到有文章指出使用 transform:translateZ(0); 这样的 hark 可以强制开启硬件加速来提高性能,这是错误的说法,要知道所谓的硬件加速就是利用 GPU 来将本就存在于 GPU 中的图层进行一些变换得到新的图层。如果改变的属性必须要要进行重绘,比如改变了 background 属性,那么图层还是要进行重绘然后重新加载至 GPU 中。这个时候就算强制开启硬件加速也没有什么用。

使用 transform:translateZ(0); 这样的 CSS hark 写法会将元素提升至单独的图层。在这么做之前要考虑为什么要这样做,创建新的图层的目的应该是,避免某个元素的改变导致大面积重绘,比如某个小标签的颜色的改变,导致大面积重绘,因此将其提升至单独的图层中。这里有个例子,小标签背景色的改变会导致大面积的重绘,但是如果将其提升至单独的图层后,改变它的背景色将只会重绘它自身。你可以代码 Chrome 调试工具,通过 Timeline 观察每次闪烁重绘的内容。

而如果整个图层的都要被重绘,那么再将其中的部分元素提升至单独的图层,会导致重绘的时候会分多个图层来进行绘制,然后在进行多个图层的合并,这个时候不如将所有元素放置在单个图层中,重绘整个大的图层。

总结

所谓硬件加速,早起浏览器是使用纯软件来渲染页面的,如今现代浏览器利用了 GPU 来进行页面的渲染,在合适的时候浏览器就会自动去使用 GPU 而不是开发者自己去指定。GPU 的功能是在合并图层阶段,它可以在进行图层合并之前来对原图层进行一些变换,合理地使用这个变换可以避免页面重绘,使得每一帧消耗的时间最少,避免卡顿。

Owner

wy-ei commented Dec 14, 2016

GPU 是如何加速网页渲染的

前端工程师应该都听说过硬件加速,通常它是指利用 GPU 来加速页面的渲染。那么 GPU 目前在web页面的渲染过程中起到什么作用呢?

GPU 的作用

早期浏览器完全依赖 CPU 来进行页面渲染。现在随着 GPU 的能力增强和普及,且目前绝大多数运行浏览器的设备上都集成了 GPU。浏览器可以利用 GPU 来加速网页渲染。

GPU 包含几百上千个核心,但每个核心的结构都相对简单, GPU 的结构也决定了它适合用来进行大规模并行计算。进行图层合并需要操作大量的像素,这方面 GPU 能比 CPU 更高效的完成。这里有个视频,很清楚地说明 CPU 与 GPU 的差别。

页面渲染过程

浏览器利用 HTML 构建出 DOM 树,利用 CSS 构建 CSSOM 树,最终得到 Render 树。

text-渲染树的构建过程

然而这只是很宏观的描述,浏览器为了将 DOM 元素高效地绘制且正确地出来,将多个元素安排在一个图层中,使用 PaintLayer 来描述,在每个 PaintLayer 中又存在 GraphicsLayers。当某个元素的样式改变后,不需要去重绘某个图层就好了。

浏览器的每一帧都可能会经过以下几个步骤:

JavaScript 的执行可能修改 DOM 树和 CSSOM 树,随后浏览器需要重新计算样式,并根据新的样式计算出元素的实际属性(比如 CSS 中 width 是 50%,这里就要利用父元素的宽度得出自己真实的 width 值),重绘有变动的图层,随后将各图层传递给 GPU ,由 GPU 来进行图层的合并。

上面 5 个步骤中,Layout 和 Paint 是可以省略的,当修改后的样式不会改变元素的尺寸、位置等涉及布局的属性时候,就没有必要进行 Layout(计算布局),比如修改了 color 属性,这个时候就只需要进行重绘(Paint)步骤。同样的道理,修改某些属性也不需要进行 Paint 步骤,只需要 Composite 就可以。

因此,我们希望所做的操作能尽可能地避免 Layout 和 Paint 这两个步骤,这样一帧所需的时间也就会大大缩短,可以明显避免卡顿。

目前有三个属性的改变只需要进行 Composite 过程,分别是:

  • filter
  • transform
  • opacity

这几个属性的改变,GPU 只需要在合并图层之前对图层进行一些变换,比如 opacity 属性的改变,GPU 只需要在合并之前改变图层的 alpha 通道。transform 和 filter 的改变 GPU 也可以利用矩阵变换很快地得到变化后的图层。

正确地利用 GPU

使用 transform, filter 和 opacity 来完成动画

使用以上 3 个属性来完成动画,可以避免在动画的每一帧进行重绘。如果在动画中改变了其他属性,那也不能避免重新绘制。

避免不合理地强制开启硬件加速

常常看到有文章指出使用 transform:translateZ(0); 这样的 hark 可以强制开启硬件加速来提高性能,这是错误的说法,要知道所谓的硬件加速就是利用 GPU 来将本就存在于 GPU 中的图层进行一些变换得到新的图层。如果改变的属性必须要要进行重绘,比如改变了 background 属性,那么图层还是要进行重绘然后重新加载至 GPU 中。这个时候就算强制开启硬件加速也没有什么用。

使用 transform:translateZ(0); 这样的 CSS hark 写法会将元素提升至单独的图层。在这么做之前要考虑为什么要这样做,创建新的图层的目的应该是,避免某个元素的改变导致大面积重绘,比如某个小标签的颜色的改变,导致大面积重绘,因此将其提升至单独的图层中。这里有个例子,小标签背景色的改变会导致大面积的重绘,但是如果将其提升至单独的图层后,改变它的背景色将只会重绘它自身。你可以代码 Chrome 调试工具,通过 Timeline 观察每次闪烁重绘的内容。

而如果整个图层的都要被重绘,那么再将其中的部分元素提升至单独的图层,会导致重绘的时候会分多个图层来进行绘制,然后在进行多个图层的合并,这个时候不如将所有元素放置在单个图层中,重绘整个大的图层。

总结

所谓硬件加速,早起浏览器是使用纯软件来渲染页面的,如今现代浏览器利用了 GPU 来进行页面的渲染,在合适的时候浏览器就会自动去使用 GPU 而不是开发者自己去指定。GPU 的功能是在合并图层阶段,它可以在进行图层合并之前来对原图层进行一些变换,合理地使用这个变换可以避免页面重绘,使得每一帧消耗的时间最少,避免卡顿。

@wy-ei

This comment has been minimized.

Show comment
Hide comment
@wy-ei

wy-ei Dec 14, 2016

Owner

避免强制性同步布局

强制性同步布局,发生在使用 JavaScript 改变了 DOM 元素的属性,而后又读取 DOM 元素的属性。比如改变了 DOM 元素的宽度,而后又使用 clientWidth 读取 DOM 元素的宽度。这个时候由于为了获取到 DOM 元素真实的宽度,需要重新计算样式。

案例

想象一下,如果有一组 DOM 元素,我们需要读取它们的宽度,并设置其高度与宽度一致。

解决方案

1. 新手解决方法

for(var i = 0,len = divs.length; i<len; i++){
    var width = divs[i].clientWidth;
    divs[i].style.height = width + 'px';
}

执行这段代码就引起了强制性同步布局(forced synchonous layout),在每次迭代开始的时候都会进行重新计算布局,这是很昂贵的操作,千万要避免。

2. 分离读和写

以上场景下,我们可以使用两次循环,在第一次循环中只进行读取 DOM 元素宽度的操作,并将结果保存起来,在第二个循环中修改 DOM 元素的高度。

var widthArray = [];
for(var i = 0,len = divs.length; i<len; i++){
    var width = divs[i].clientWidth;
    widthArray.push(width);
}
for(var i = 0,len = divs.length; i<len; i++){
    divs[i].style.height = widthArray[i] + 'px';
}

3. 使用 requestAnimationFrame

在实际项目中往往没有上面提到的那样简单,有时尽管已经分离了读和写,但在写操作后面还是不可避免地存在读取操作,这个时候不妨使用 requestAnimationFrame,将写操作放在 requestAnimationFrame 中,浏览器会在新的一帧开始的时候立刻调用它们。

for(let i = 0,len = divs.length; i<len; i++){
    let width = divs[i].clientWidth;
    requestAnimationFrame(()=>{
        divs[i].style.height = width + 'px';
    })
}

优化效果

可以查看这个例子来对比一下这几种方案的性能差异。打开 Chrome DevTools 在 Timeline 中录制重新布局的过程,可以看到下面三种情形:

强制性同步布局:

这个时候会看到浏览器进行了很多次的重新计算样式(Recalculate Style) 和 布局(Layout),也叫做 reflow 的操作,且这一帧用时很长。

分离读写:

这个时候,浏览器只进行了一次 reflow,用时很短。

使用 requestAnimationFrame:

这个方案也很快,只是因为调用了 requestAnimationFrame 很多次添加了很多回调,这个时候会有很多函数调用。建议对于将该方法用在回调较少的场景下。其实另外一个可行的方案是在 requestAnimationFrame 中批量来写 DOM
元素。

总结

在需要操作 DOM 的时候,一定要注意避免强制性同步布局,遇到交替读写 DOM 的操作的时候,可以通过分离读写,使用 requestAnimationFrame 来避免强制性同步布局的出现。

Owner

wy-ei commented Dec 14, 2016

避免强制性同步布局

强制性同步布局,发生在使用 JavaScript 改变了 DOM 元素的属性,而后又读取 DOM 元素的属性。比如改变了 DOM 元素的宽度,而后又使用 clientWidth 读取 DOM 元素的宽度。这个时候由于为了获取到 DOM 元素真实的宽度,需要重新计算样式。

案例

想象一下,如果有一组 DOM 元素,我们需要读取它们的宽度,并设置其高度与宽度一致。

解决方案

1. 新手解决方法

for(var i = 0,len = divs.length; i<len; i++){
    var width = divs[i].clientWidth;
    divs[i].style.height = width + 'px';
}

执行这段代码就引起了强制性同步布局(forced synchonous layout),在每次迭代开始的时候都会进行重新计算布局,这是很昂贵的操作,千万要避免。

2. 分离读和写

以上场景下,我们可以使用两次循环,在第一次循环中只进行读取 DOM 元素宽度的操作,并将结果保存起来,在第二个循环中修改 DOM 元素的高度。

var widthArray = [];
for(var i = 0,len = divs.length; i<len; i++){
    var width = divs[i].clientWidth;
    widthArray.push(width);
}
for(var i = 0,len = divs.length; i<len; i++){
    divs[i].style.height = widthArray[i] + 'px';
}

3. 使用 requestAnimationFrame

在实际项目中往往没有上面提到的那样简单,有时尽管已经分离了读和写,但在写操作后面还是不可避免地存在读取操作,这个时候不妨使用 requestAnimationFrame,将写操作放在 requestAnimationFrame 中,浏览器会在新的一帧开始的时候立刻调用它们。

for(let i = 0,len = divs.length; i<len; i++){
    let width = divs[i].clientWidth;
    requestAnimationFrame(()=>{
        divs[i].style.height = width + 'px';
    })
}

优化效果

可以查看这个例子来对比一下这几种方案的性能差异。打开 Chrome DevTools 在 Timeline 中录制重新布局的过程,可以看到下面三种情形:

强制性同步布局:

这个时候会看到浏览器进行了很多次的重新计算样式(Recalculate Style) 和 布局(Layout),也叫做 reflow 的操作,且这一帧用时很长。

分离读写:

这个时候,浏览器只进行了一次 reflow,用时很短。

使用 requestAnimationFrame:

这个方案也很快,只是因为调用了 requestAnimationFrame 很多次添加了很多回调,这个时候会有很多函数调用。建议对于将该方法用在回调较少的场景下。其实另外一个可行的方案是在 requestAnimationFrame 中批量来写 DOM
元素。

总结

在需要操作 DOM 的时候,一定要注意避免强制性同步布局,遇到交替读写 DOM 的操作的时候,可以通过分离读写,使用 requestAnimationFrame 来避免强制性同步布局的出现。

Repository owner deleted a comment from synclife Jun 22, 2018

Repository owner deleted a comment from sunsmeil Jun 22, 2018

@wy-ei

This comment has been minimized.

Show comment
Hide comment
@wy-ei

wy-ei Jun 22, 2018

Owner

How To Reach 60FPS

这篇文章是我为了完成一次关于前端性能的分享而写下的,你可以在这里看到我分享时的 PPT。

什么是 fps,60fps 意味着什么?

fps(frames per second),指一秒内屏幕刷新的次数或者动画在一秒内更新的帧数。现代浏览器大多每秒刷新 60 次,为了和设备的刷新频率保持一致,动画也要保证每秒 60 更新帧。如果低于 60 fps,称动画发生了掉帧,如果掉帧严重,用户则能够明显地感觉到卡顿。高的帧率,意味着更连贯的动画,更流畅的滚动,这些总是能带来极好的用户体验。

构建 DOM 树、CSSOM 树、渲染树

要想高效地操作 DOM, 完成流畅的动画,需要了解浏览器是如何将 HTML/CSS/JavaScript 等资源渲染为 Web 页面的。下面就此过程进行描述:

浏览器接收到 HTML 文档后就会开始解析文档,并建立 DOM 树 (Document Object Model Tree),DOM 树中记录了当前文档的所有节点。同时浏览器使用内联的 style 标签或者外部加载的 CSS 文档来构建 CSSOM 树(CSS Object Model Tree),CSSOM 树中记录了各个节点的样式规则。随后联合 DOM 树和 CSSOM 树构建出渲染树(Render Tree),渲染树中记录了当前页面中所有可见节点的实际样式。之所以说实际样式,是因为 CSS 中可能出现 width: 50%color: inherit 这样的写法,浏览器需要自顶向下地去根据父节点来计算出某个节点的实际样式。

整个步骤,如下图所示:

text=渲染树的构建过程(图片来自 Chrome developer)

  • DOM 树:记录了文档的结构与内容
  • CSSOM 树:记录了 DOM 节点的样式规则
  • Render 树:表示 DOM 中每个节点真实的样式

得到了渲染树,浏览器还不能开始进行绘制,因为页面上存在太多元素,如果页面中有一个元素被改变,这个时候如果重绘整个页面就显得很浪费,毕竟很多时候只是很小的一部分被改变了。浏览器为了高效地绘制,提出了图层(layer)的概念,按照某些规则将 DOM 节点划分在不同的图层中,这样一个节点的改变,浏览器会智能地去重绘那些受到影响的图层,而非所有图层,浏览器绘制的时候是以图层为单位的。

细分后的过程,大致是这样:

text=Web 页面渲染流程

绘制过程就是浏览器调用绘图 API 来完成图层的绘制,绘制过程就是填充像素的过程,浏览器会调用一些类似于 moveTo, lineTo 这样的绘图 API,将 各图层绘制出来,得到一些像素点的集合,类似于一张位图(bitmap),这些位图随后被上传至 GPU,GPU 帮助浏览器将这些位图合并起来,得到最终显示在屏幕上的图片。

综上,浏览器渲染出 Web 页面的过程,大体可分为以下几个步骤:

  1. 解析 HTML/CSS 生成 DOM 树 CSSOM 树
  2. 联合 DOM 树和 CSSOM 树得到渲染树
  3. 将 Render 树划分为多个图层,并绘制图层
  4. 将各图层的数据上传至 GPU
  5. GPU 合并图层得到最终展示在屏幕上的图片

可以想象浏览器内部实现原本以上论述复杂千万倍,以上也只是从非常宏观的角度去描述了浏览器渲染页面的过程。其中还没牵扯到 JavaScript,不过知道以上这些内容,起码对浏览器的渲染流程有了一个大体的认识。

浏览器在每一帧中要做的工作

JavaScript 通过 API 来修改 DOM 树和 CSSOM 树,CSS 中的 animation 或 transition 都会改变渲染树,每当渲染树被改变后,浏览器都需要重新计算样式,样式计算会涉及多个 DOM 节点,因为有些样式存在继承关系,还有则是相对父节点的。

每一帧中浏览器都 可能 要进行下列部分或全部步骤:

text=每一帧浏览器可能要进行的工作

对上图中的各个步骤进行一个简要的解释说明:

  • JavaScript:运行 JavaScript 代码,期间可能会添加 DOM 节点,修改节点的样式等,这会影响 DOM 树和 CSSOM 树,最终影响渲染树。另外 CSS 动画和 CSS 过渡都会修改渲染树。
  • Recalculate Style :这个节点会根据 CSS 选择器来计算节点的最终样式。
  • Layout:一旦知道了各个节点关联的样式,这儿时候就能计算节点的实际尺寸以及其在屏幕上的位置,因为可能牵扯继承和相对单位,因此一个节点的改变可能会影响多个节点,比如修改了 <body> 的宽度,下面很多元素都会受到影响。
  • Update Layer Tree:Layer Tree 中记录了各个图层之间的层叠关系,这会影响最终谁那些元素在上那些元素在下。
  • Paint:填充像素,将图层上的文字、边框、阴影等绘制出来,绘制是基于图层的,绘制需要绘制的图层,最终得到一张位图,其中记录了当前图层的视觉表现。
  • Composite Layer:得到图层以后需要将其按照正确的层叠关系合并起来,最终得到一整块需要显示在屏幕上图片。

在 Chrome DevTools 可以清楚地看到这几个步骤:

部分步骤可以被跳过

如果修改了一个会影响元素的尺寸或位置的属性,比如 width 和 height 或者 top 等,需要重新进行 Layout 操作,随后会进行重绘,随后将图层合并得到新一帧。这就会执行以上的所有步骤。

但如果只是修改了 color 这样的不涉及节点尺寸或定位的属性,则不需要执行 Layout 这一步骤。因为 color 的修改,并不会影响元素的尺寸和位置,只需要进行一次重绘就好了,此时以上步骤中的 Layout 就被跳过了。

text=不需要重排

同样的,如果修改了一个都不需要进行重绘的属性,那么可以跳过 Layout 和 Paint 这两个步骤,此时只需要要进行图层的合并操作就能得到新一帧的图片。

text=不需要重排和重绘

不需要进行重排(Layout)和重绘(Paint)操作,自然会耗时更短,每一帧中浏览器需要进行的工作也就越少,一定程度上也就能够提升性能。由此看来对 DOM 树的修改、对 DOM 节点属性或样式的修改,需要付出的代价是不同的,某些操作可能会触发重排和重绘操作,而有些操作则可以完全跳过以上步骤。

规律

不过也可以得出如下的一个规律:

  • Layout: 涉及到 DOM 操作,DOM 节点的尺寸、位置的属性的修改会触发 layout 进而会导致 repaint(重绘)和图层合并。比如修改 width,margin,border 等样式,或者修改 clientWidth 等属性。
  • Paint: 涉及 DOM 节点的颜色的属性会导致重绘,比如 color,background,box-shadow 等
  • Composite: 目前常用的 CSS 属性中,对 opacity, transform, filter 这三个属性的修改只需要进行 Composite 操作。这几个属性的改变,GPU 只需要在合并图层之前对图层进行一些变换,比如 opacity 属性的改变,GPU 只需要在合并之前改变图层的 alpha 通道。其他两个属性的修改,GPU 也可以直接进行一些矩阵运算得到变换后的图层。

参考资料

paul irish 罗列了那些操作会触发重排,你可以在这里看到: What forces layout / reflow

另外在 https://csstriggers.com/ 这个网站上,Chrome 团队的一伙人列出了对 CSS 各属性的修改会引发以上那些操作。

在实践中可以时刻参考这两个列表,并结合调试工具,来避免没有不要的重排和重绘。

Reach 60fps

前面介绍了不少关于浏览器渲染过程的基础知识,旨在帮助对此不清楚的朋友从宏观上理清楚 Web 页面的渲染过程。

实现连贯的动画,流畅的滚动,了解以上基础知识对后续编码、优化有着巨大的好处。下面根据浏览器渲染原理,结合每一帧的浏览器需要做的各个步骤,给出了一些切实可行的优化方案,并提出一些注意事项。

后面的内容我想分 5 个点来介绍,分别是:

  1. 避免没有必要的重排
  2. 避免没有必要的重绘
  3. 利用 GPU 加速渲染
  4. 构建更为流畅的动画
  5. 正确地处理滚动事件

1. 避免没有必要的重排

每个前端工程师在入门的时候,都被告知 DOM 很慢,使用脚本对 DOM 进行操作的代价很昂贵,要批量修改 DOM 等等,关于 DOM 操作的话题已经有不少著作进行过论述了。强烈推荐 《高性能 JavaScript》 这本书,我觉得这本书应该是前端工程师必读。

虽说已经有很多关于 DOM 操作的内容了,这里我还是想提一个注意事项:避免强制性同步布局,因为我经常看到这个字眼,不妨提出来谈谈。

避免强制性同步布局

强制性同步布局(forced synchonous layout),发生在使用 JavaScript 改变了 DOM 元素的属性,而后又读取 DOM 元素的属性的时候,通常也说读取了脏 DOM 的时候。比如改变了 DOM 元素的宽度,而后又使用 clientWidth 读取 DOM 元素的宽度。这个时候为了获取到 DOM 元素真实的宽度,需要重新计算样式。也就是会重新进行计算样式(Recalculate Style)和计算布局( Layout)操作。

设想以下案例,有一组 DOM 元素,需要将其其高度设为与宽度一致,新手很快就能写出以下代码:

解决方案 1 - 简单粗暴:

for(var i = 0,len = divs.length; i<len; i++){
    var width = divs[i].clientWidth;
    divs[i].style.height = width + 'px';
}

执行这段代码的时候,每次迭代开始的时候,DOM 都是脏的(被改动过),为了获得真实的 DOM 尺寸,都会重新计算布局。该循环就会引发多次强制性同步布局,这是很低效的做法,千万要避免。

text=引发了强制性同步布局

从 Chrome DevTools 中很容易地发现该低效操作,可以看到浏览器进行了很多次的重新计算样式(Recalculate Style)和布局(Layout),也叫做 reflow(重排)的操作,且这一帧用时很长。

解决方案 2 - 分离读和写:

可以很轻松地解决这个问题,使用两次循环,在第一次循环中读取 DOM 元素宽度并将结果保存起来,在第二个循环中修改 DOM 元素的高度。

var widthArray = [];
for(var i = 0, len = divs.length; i<len; i++){
    var width = divs[i].clientWidth;
    widthArray.push(width);
}
for(var i = 0, len = divs.length; i<len; i++){
    divs[i].style.height = widthArray[i] + 'px';
}

text=分离读写后

分离读写,一个时刻只读取,另一个时刻只改写,这样就能很有效地避免强制性同步布局。

在实际项目中往往没有上面提到的那样简单,有时尽管已经分离了读和写,但在写操作后面还是不可避免地存在读取操作,这个时候不妨将写操作放在 requestAnimationFrame 中,浏览器会在下一帧执行这个对 DOM 的改写操作。关于 requestAnimationFrame 后文有详细的讲解。

补充资料

2. 避免没有必要的重绘

在开始之前需要回顾一下什么时候需要重绘:

  1. 当 DOM 节点的会触发重绘的属性(color,background 等)被修改后,会进行重绘
  2. 当 DOM 节点所在的图层中其他元素的会触发重绘的属性被修改后,整个层会被重绘
  3. 图片加载完成后会发生重绘,GIF 图片的每一帧都会发生重绘

在 Chrome DevTools 的 Rendering 选择卡中勾选 Painting Flashing 选项后,可以观察到页面上正在进行重绘的区域。

避免 fixed 定位元素在滚动时重绘

一个常见的场景是,网页有一个 fixed 定位的头部导航栏或者侧边栏。问题存在于每次滚动后,这些 fixed 定位的元素相对于整个内容区域的位置改变了。这就相当于一个图层中的某个元素的位置改变了,为了获得滚动后的图层,需要进行重绘,因此每次滚动都会进行重绘操作。

举个例子,在腾讯网首页上有如下 fixed 定位的元素:

不幸的是这几个 fixed 定位的元素和整个网页位于同一个图层:

滚动后,因为定位元素相对于整个文档的位置发生了改变,因此整个文档都需要被重绘。解决此类问题的方法就是将 fixed 定位的元素提升至单独的图层。使用 transform:translateZ(0); 这样的写法,可以强制将元素提升至单独图层,关于此后文中还有详细说明。

:Chrome 在高 dpi 的屏幕上会自动将 fixed 定位的元素提升至单独的图层,在低 dpi 的屏幕上不会提升,因此很多开发者在 MacBook Pro 上测试的时候,不会发现问题,但用户在低 dpi 的屏幕上访问的时候就出问题了。

将部分元素提升至单独图层,避免大面积重绘

使用 transform:translateZ(0); 这样的 CSS hark 写法会将元素提升至单独的图层。在这么做之前要考虑为什么要这样做,创建新的图层的目的应该是,避免某个元素的改变导致大面积重绘,比如某个小标签的颜色的改变,导致大面积重绘,因此将其提升至单独的图层中。

这是一个面板,其中内容区域的文字会不断地闪烁(文本的颜色会改变),如果将该文本使用 transform:translateZ(0); 提升至单独的图层,那么文本的颜色改变,就只会导致它所在的图层重绘,而不需要整个面板重绘。这是正确地利用 transform:translateZ(0); 的方式。因此,如果页面中存在小面积的 DOM 节点需要频繁地重绘,可以考虑将其提升至单独的图层中。你可以在这里看到 demo —— 避免大面积重绘

正确地处理动图

页面加载的时候为了更好的用户体验常常会使用一个 loading,但在页面加载完成后如何处理 loading 呢?一个错误的方法是将其 z-index 设置一个更小的值,将其隐藏起来,不幸的是就算 loading 不可见,浏览器依然会在每一帧对它进行重绘。因此对于像 loading 这样的动态图,在不需要显示的时候最好使用 display:none 或者 visibility: hidden; 来彻底隐藏,或者干脆移除 DOM。

3. 利用 GPU 加速网页渲染

前端工程师应该都听说过硬件加速,通常是指利用 GPU 来加速页面的渲染。早期浏览器完全依赖 CPU 来进行页面渲染。现在随着 GPU 的能力增强和普及,且目前绝大多数运行浏览器的设备上都集成了 GPU。浏览器可以利用 GPU 来加速网页渲染。

GPU 包含几百上千个核心,但每个核心的结构都相对简单, GPU 的结构也决定了它适合用来进行大规模并行计算。进行图层合并需要操作大量的像素,这方面 GPU 能比 CPU 更高效的完成。这里有个视频,很清楚地说明 CPU 与 GPU 的差别。

常常看到有文章指出使用 transform:translateZ(0); 这样的 hark 可以强制开启硬件加速来提高性能,这是错误的说法。下面就来说说硬件加速的实质:

何为硬件加速

GPU 能够存储一定数量的纹理(texture),也就是一个矩形的像素点集合。通常这个集合会对应到 Web 页面上的某个图层,GPU 能够高效地对这些像素点进行多种变换(位移、旋转、拉伸)操作。在实现动画的时候,利用 GPU 的这一特性,如果只需要对原像素集合在 GPU 内进行一次变换,就能得到新一帧的图层,那么动画的所有操作都在 GPU 内高效地完成了,没有重绘操作。

得到了变换后的图层,只需要再进行一次图层的合并,将该变换后的图层和其他图层合并起来,最终得到在屏幕上显示的整幅图片。GPU 的这一特性就常常被称为硬件加速。

要利用硬件加速也是有条件的,盲目地使用 transform:translateZ(0); 而不知原理,只会让事情变得更糟糕。硬件加速的本质是说让下一帧的图层在 GPU 内经过变换得来,但是如果某些操作 GPU 无法完成,必须动画修改了 DOM 节点的宽度,颜色等,这依然是需要在 CPU 端进行软件的重绘的,这种情况就无法利用硬件加速的机制。

使用 transform:translateZ(0); 会强制浏览器创建一个新的层,每创建一个层都需要消耗额外的内存,有太多的层就会消耗大量内存,这会导致设备内存不够用,有可能导致应用奔溃。另外这些图层最后需要上传至 GPU 进行图层合并,太多的层,会导致 GPU 和 CPU 之间的带宽不够用,反而影响性能。

目前常见的 CSS 属性中只有 filter, transform, opacity 这几个属性的改变可以在 GPU 端进行处理,这在前面已经提到过了,因此应该尽可能使用这些属性来完成动画。

后面会有更多关于利用 GPU 的这一特性的例子,下面先看一个需要注意的点:

避免无谓地新建图层

一个真实案例:

text=每个列表项都是一个图层

这是一个城市选择页,这个页面中的每一项都使用了 transform:translateZ(0); 强制提升至了单独的图层,滚动列表,并录制了一段 Timeline。

text=优化前

从上图中可以看到,性能是相当糟糕的,大量时间都花费在了图层的合并上,每一帧都需要合并上千个列表子项,这不是一件很轻松的事情。

为了体现,错误使用 transform:translateZ(0); 的严重性,下面来看看去掉后的效果,去掉该属性后,一片绿,没有任何性能问题。

text=优化后

因此在谈起硬件加速的时候,一定知道,什么是硬件加速,硬件加速是如何工作的,它能做什么,不能做什么。合理的利用 GPU 才能利用它帮我们构建出 60fps 的体验。

4. 构建更加流畅的动画

上面讲了,使用 transform 和 opacity 来创建动画(filter 的支持度还不够好)最为高效。因此每当需要用到动画的时候,首先要考虑使用这两个属性来完成,

避免使用会触发 Layout 的属性来进行动画

有时候看起来不太可能使用这两个属性来完成,不过仔细想想往往能够想到解决方案。考虑下面动画:

demo 地址:expand cord

一般的想法可能是修改每个卡片的 top, left, width, height 来实现这个功能,这样做当然可以实现效果,只是改变这些属性都会触发 Layout 进而触发 Paint 操作,在复杂应用上势必造成卡顿。下面介绍一种使用 transform 来完成此动画的方法。

// 拿到初始尺寸
let first = card.getBoundingClientRect();
// 加上最终状态的类名
card.classList.add('card--expand');
// 拿到最终尺寸
let last = card.getBoundingClientRect();

// 设置形变参考点
card.style.transformOrigin = '0 0';

// 计算需要位移和伸缩的量
let transform = {
  x: first.left- last.left,
  y: first.top - last.top,
  sx: first.width / last.width,
  sy: first.height / last.height
};
// 加上 transform 将其从最终状态缩小到最初状态
card.style.transform = `translate(${transform.x}px, ${transform.y}px)
    scale(${transform.sx},${transform.sy})`;

// 在下一帧
requestAnimationFrame(function(){
  // 加上过渡时间
  card.style.transition = 'transform .4s cubic-bezier(0,0,0.3,1)';
  // 将 transform 取消,这样就会慢慢过渡到最终状态
  card.style.transform = 'none';
});

以上思路是使用 getBoundingClientRect 将动画的始态和终态的尺寸和位置计算出来,然后利用 transform 来进行过渡,思路在代码注释中已经进行了说明。

经过这样的处理,原本需要使用 top, left, width, height 来进行的动画使用 transfrom 就搞定了,这会大大地提示动画的性能。

使用 transform, filter 和 opacity 来完成动画

使用以上 3 个属性来完成动画,可以避免在动画的每一帧进行重绘。但如果在动画中改变了其他属性,那也不能避免重新绘制。要尽可能地利用这几个属性来完成动画。涉及位移的考虑使用 translate,涉及大小的考虑 scale,涉及颜色的考虑 opacity,为了实现流畅的动画要想尽一切办法。

这里给出一个案例,Instagram 的安卓 APP 在登录的时候,有一个颜色渐变的效果,这种效果常常见到。

text=Instagram 登录页的背景色渐变效果

通过地不断地改变背景颜色能很快地实现,测试后会发现在低端设备上会感到卡顿,CPU 使用率飙升,这是因为修改背景颜色会导致页面重绘。为了不重绘也能达到同样的效果,我们可以使用两个 div,给它们设置两个不同的背景色,在动画中改变两个 div 的透明度,这样两个不同透明度的 div 叠加在一起就能得到一个颜色演变的效果,而整个动画只使用了 opacity 来完成,完全避免了重绘操作。

关于示例,你可以在此处看到: 使用 background 完成渐变 vs 使用 opacity 完成渐变

不要混用 transform, filter, opacity 和其他可能触发重排或重绘的属性,虽然使用 transform, filter, opacity 来完成动画能够有很好的性能,但是如果在动画中混合使用了其他的会触发重排或重绘的属性,那么依然不能达到高性能。

使用 requestAnimationFrame 来驱动动画

前面提到的动画大多是使用 CSS 动画 和 CSS 过渡 CSS 动画通常是事先定义好的,无法很灵活地控制,某些时候可能需要使用 JavaScript 来驱动动画。新手常常使用 setTimeout 来完成动画,问题在于使用 setTimeout 设置的回调会在主线程空闲的时候才会调用,想象下面场景:

setTimeout 在一帧的中间位置被触发,随后导致重新计算样式进而导致一个长帧。setTimeout/setInterval 主要存在以下局限性:

  1. 在页面不可见的时候依然会调用(耗电)
  2. 执行频率并不固定(一帧内可能多次触发,造成不必要的重排/重绘)

setTimeout/setInterval 会周期性的调用,及时当前网页并没有在活动。另外因为调用时机不确定可能引发的在同一帧内多次调用同一个回调,如果回调中触发了多次重绘,那么会出现在一帧中重绘多次的情况,这是没有必要的,且会导致掉帧。

requestAnimationFrame,一个专门用来驱动动画的 API,它有以下好处:

  1. 保证回调在下一帧调用
  2. 根据机器的刷新频率调整执行频率
  3. 当前网页不可见的时候不执行回调

虽然 requestAnimationFrame 是一个已经存在很多年的 API 了,但是还是存在诸多误读,其中最严重的是认为使用 requestAnimationFrame 能够避免重新布局和重绘,浏览器能够启动优化措施,让动画更流畅,这是错误的,浏览器能保证的仅仅是以上 3 条,在 requestAnimationFrame 的回调中进行强制同步布局依然会触发重排。

在编写使用 JavaScript 驱动的动画时,使用 requestAnimationFrame 可以将对 DOM 的写操作放在下一帧进行,这样该帧后面对 DOM 的读取操作就不会引发强制性同步布局,浏览器只需要在下一帧开始的时候进行一次重排。

5. 正确地处理滚动事件

现代浏览器都使用一个单独的线程来处理滚动和输入,这个线程叫做合成线程,它能够和 GPU 进行通信来告诉 GPU 如何移动图层,进行页面的滚动。如果页面上绑定了 touchmove,mousemove 这类事件,合成线程需要等待主线程执行相应的事件监听函数,因为这些函数里面可能会调用 preventDefault 来阻止滚动。

对于优化 scroll,touchmove,mousemove 等事件,其中一个最为重要的建议就是,要控制此类高频事件的回调的执行频率。说到控制频率,自然会想到 debounce 和 throttle 这两个函数。曾一度为止迷惑,不妨简要对这两个函数进行科普:

使用 debounce 或 throttle 控制高频事件触发频率

debounce 和 throttle 是两个相似(但不相同)的用于控制函数在某段事件内的执行频率的技术。

debounce

多次连续的调用,最后只调用一次

想象自己在电梯里面,门将要关上,这个时候另外一个人来了,取消了关门的操作,过了一会儿门又要关上,又来了一个人,再次取消了关门的操作。电梯会一直延迟关门的操作,直到某段时间里没人再来。

throttle

将频繁调用的函数限定在一个给定的调用频率内。它保证某个函数频率再高,也只能在给定的事件内调用一次。比如在滚动的时候要检查当前滚动的位置,来显示或隐藏回到顶部按钮,这个时候可以使用 throttle 来将滚动回调函数限定在每 300ms 执行一次。

需要注意的是这两个函数的使用方法,它们接受一个函数,然后返回一个节流/去抖后的函数,因此下面第二种用法才是正确的

// 错误
$(window).on('scroll', function() {
   _.throttle(doSomething, 300);
});

// 正确
$(window).on('scroll', _.throttle(doSomething, 300));

使用 requestAnimationFrame 来触发滚动事件的回调

如果在事件监听函数中进行了 DOM 操作,这可能会消耗不少时间,事件监听函数执行的时间变长,与 GPU 进行通信的合成线程也就迟迟接收不到通知,浏览器也就迟迟不知道如何滚动页面,由此引发的就是卡顿。对于这类同步的事件(浏览器等待事件执行完成),可以在事件触发的时候先读取需要获取的 DOM 元素的尺寸位置等信息,然后将其他改写 DOM 的操作安排在 requestAnimationFrame 中完成,浏览器能够更快地执行完事件回调,还能避免后续的读取 DOM 的时候发生重排。

另外,有时候希望事件在每一帧执行一次,此时是使用 throttle 是无法满足需求的,使用 requestAnimationFrame 可以保证每一帧都会调用,需要注意的是有的事件触发的频率可能是一帧好几次。因此在使用 requestAnimationFrame 的时候要注意判断是否在一帧内多次触发了回调。

let padding = false;
function onTouchmove (evt) {
    if (padding){
        return;        
    }
    padding = true;
    requestAnimationFrame(function(){
        // ...

        padding = false;
    });
}
document.body.addEventListener('touchmove', onTouchmove);

总结

这篇文章对浏览器的渲染过程进行了简要描述,然后根据浏览器渲染原理,分析实现流畅的动画需要注意的方方面面,并给出多个实现流畅动画的实用技巧。

不过规则最是不停在改变的,浏览器也不断在更新,一年前是性能瓶颈的点,现在可能已经不是瓶颈了。在开发过程中应该结合调试工具,去分析每一次重排和重绘,分析各个阶段的耗时,找出真正的问题所在。而不是仅仅记住一些条条框框。

如发现有错误,还请不吝指出。

Owner

wy-ei commented Jun 22, 2018

How To Reach 60FPS

这篇文章是我为了完成一次关于前端性能的分享而写下的,你可以在这里看到我分享时的 PPT。

什么是 fps,60fps 意味着什么?

fps(frames per second),指一秒内屏幕刷新的次数或者动画在一秒内更新的帧数。现代浏览器大多每秒刷新 60 次,为了和设备的刷新频率保持一致,动画也要保证每秒 60 更新帧。如果低于 60 fps,称动画发生了掉帧,如果掉帧严重,用户则能够明显地感觉到卡顿。高的帧率,意味着更连贯的动画,更流畅的滚动,这些总是能带来极好的用户体验。

构建 DOM 树、CSSOM 树、渲染树

要想高效地操作 DOM, 完成流畅的动画,需要了解浏览器是如何将 HTML/CSS/JavaScript 等资源渲染为 Web 页面的。下面就此过程进行描述:

浏览器接收到 HTML 文档后就会开始解析文档,并建立 DOM 树 (Document Object Model Tree),DOM 树中记录了当前文档的所有节点。同时浏览器使用内联的 style 标签或者外部加载的 CSS 文档来构建 CSSOM 树(CSS Object Model Tree),CSSOM 树中记录了各个节点的样式规则。随后联合 DOM 树和 CSSOM 树构建出渲染树(Render Tree),渲染树中记录了当前页面中所有可见节点的实际样式。之所以说实际样式,是因为 CSS 中可能出现 width: 50%color: inherit 这样的写法,浏览器需要自顶向下地去根据父节点来计算出某个节点的实际样式。

整个步骤,如下图所示:

text=渲染树的构建过程(图片来自 Chrome developer)

  • DOM 树:记录了文档的结构与内容
  • CSSOM 树:记录了 DOM 节点的样式规则
  • Render 树:表示 DOM 中每个节点真实的样式

得到了渲染树,浏览器还不能开始进行绘制,因为页面上存在太多元素,如果页面中有一个元素被改变,这个时候如果重绘整个页面就显得很浪费,毕竟很多时候只是很小的一部分被改变了。浏览器为了高效地绘制,提出了图层(layer)的概念,按照某些规则将 DOM 节点划分在不同的图层中,这样一个节点的改变,浏览器会智能地去重绘那些受到影响的图层,而非所有图层,浏览器绘制的时候是以图层为单位的。

细分后的过程,大致是这样:

text=Web 页面渲染流程

绘制过程就是浏览器调用绘图 API 来完成图层的绘制,绘制过程就是填充像素的过程,浏览器会调用一些类似于 moveTo, lineTo 这样的绘图 API,将 各图层绘制出来,得到一些像素点的集合,类似于一张位图(bitmap),这些位图随后被上传至 GPU,GPU 帮助浏览器将这些位图合并起来,得到最终显示在屏幕上的图片。

综上,浏览器渲染出 Web 页面的过程,大体可分为以下几个步骤:

  1. 解析 HTML/CSS 生成 DOM 树 CSSOM 树
  2. 联合 DOM 树和 CSSOM 树得到渲染树
  3. 将 Render 树划分为多个图层,并绘制图层
  4. 将各图层的数据上传至 GPU
  5. GPU 合并图层得到最终展示在屏幕上的图片

可以想象浏览器内部实现原本以上论述复杂千万倍,以上也只是从非常宏观的角度去描述了浏览器渲染页面的过程。其中还没牵扯到 JavaScript,不过知道以上这些内容,起码对浏览器的渲染流程有了一个大体的认识。

浏览器在每一帧中要做的工作

JavaScript 通过 API 来修改 DOM 树和 CSSOM 树,CSS 中的 animation 或 transition 都会改变渲染树,每当渲染树被改变后,浏览器都需要重新计算样式,样式计算会涉及多个 DOM 节点,因为有些样式存在继承关系,还有则是相对父节点的。

每一帧中浏览器都 可能 要进行下列部分或全部步骤:

text=每一帧浏览器可能要进行的工作

对上图中的各个步骤进行一个简要的解释说明:

  • JavaScript:运行 JavaScript 代码,期间可能会添加 DOM 节点,修改节点的样式等,这会影响 DOM 树和 CSSOM 树,最终影响渲染树。另外 CSS 动画和 CSS 过渡都会修改渲染树。
  • Recalculate Style :这个节点会根据 CSS 选择器来计算节点的最终样式。
  • Layout:一旦知道了各个节点关联的样式,这儿时候就能计算节点的实际尺寸以及其在屏幕上的位置,因为可能牵扯继承和相对单位,因此一个节点的改变可能会影响多个节点,比如修改了 <body> 的宽度,下面很多元素都会受到影响。
  • Update Layer Tree:Layer Tree 中记录了各个图层之间的层叠关系,这会影响最终谁那些元素在上那些元素在下。
  • Paint:填充像素,将图层上的文字、边框、阴影等绘制出来,绘制是基于图层的,绘制需要绘制的图层,最终得到一张位图,其中记录了当前图层的视觉表现。
  • Composite Layer:得到图层以后需要将其按照正确的层叠关系合并起来,最终得到一整块需要显示在屏幕上图片。

在 Chrome DevTools 可以清楚地看到这几个步骤:

部分步骤可以被跳过

如果修改了一个会影响元素的尺寸或位置的属性,比如 width 和 height 或者 top 等,需要重新进行 Layout 操作,随后会进行重绘,随后将图层合并得到新一帧。这就会执行以上的所有步骤。

但如果只是修改了 color 这样的不涉及节点尺寸或定位的属性,则不需要执行 Layout 这一步骤。因为 color 的修改,并不会影响元素的尺寸和位置,只需要进行一次重绘就好了,此时以上步骤中的 Layout 就被跳过了。

text=不需要重排

同样的,如果修改了一个都不需要进行重绘的属性,那么可以跳过 Layout 和 Paint 这两个步骤,此时只需要要进行图层的合并操作就能得到新一帧的图片。

text=不需要重排和重绘

不需要进行重排(Layout)和重绘(Paint)操作,自然会耗时更短,每一帧中浏览器需要进行的工作也就越少,一定程度上也就能够提升性能。由此看来对 DOM 树的修改、对 DOM 节点属性或样式的修改,需要付出的代价是不同的,某些操作可能会触发重排和重绘操作,而有些操作则可以完全跳过以上步骤。

规律

不过也可以得出如下的一个规律:

  • Layout: 涉及到 DOM 操作,DOM 节点的尺寸、位置的属性的修改会触发 layout 进而会导致 repaint(重绘)和图层合并。比如修改 width,margin,border 等样式,或者修改 clientWidth 等属性。
  • Paint: 涉及 DOM 节点的颜色的属性会导致重绘,比如 color,background,box-shadow 等
  • Composite: 目前常用的 CSS 属性中,对 opacity, transform, filter 这三个属性的修改只需要进行 Composite 操作。这几个属性的改变,GPU 只需要在合并图层之前对图层进行一些变换,比如 opacity 属性的改变,GPU 只需要在合并之前改变图层的 alpha 通道。其他两个属性的修改,GPU 也可以直接进行一些矩阵运算得到变换后的图层。

参考资料

paul irish 罗列了那些操作会触发重排,你可以在这里看到: What forces layout / reflow

另外在 https://csstriggers.com/ 这个网站上,Chrome 团队的一伙人列出了对 CSS 各属性的修改会引发以上那些操作。

在实践中可以时刻参考这两个列表,并结合调试工具,来避免没有不要的重排和重绘。

Reach 60fps

前面介绍了不少关于浏览器渲染过程的基础知识,旨在帮助对此不清楚的朋友从宏观上理清楚 Web 页面的渲染过程。

实现连贯的动画,流畅的滚动,了解以上基础知识对后续编码、优化有着巨大的好处。下面根据浏览器渲染原理,结合每一帧的浏览器需要做的各个步骤,给出了一些切实可行的优化方案,并提出一些注意事项。

后面的内容我想分 5 个点来介绍,分别是:

  1. 避免没有必要的重排
  2. 避免没有必要的重绘
  3. 利用 GPU 加速渲染
  4. 构建更为流畅的动画
  5. 正确地处理滚动事件

1. 避免没有必要的重排

每个前端工程师在入门的时候,都被告知 DOM 很慢,使用脚本对 DOM 进行操作的代价很昂贵,要批量修改 DOM 等等,关于 DOM 操作的话题已经有不少著作进行过论述了。强烈推荐 《高性能 JavaScript》 这本书,我觉得这本书应该是前端工程师必读。

虽说已经有很多关于 DOM 操作的内容了,这里我还是想提一个注意事项:避免强制性同步布局,因为我经常看到这个字眼,不妨提出来谈谈。

避免强制性同步布局

强制性同步布局(forced synchonous layout),发生在使用 JavaScript 改变了 DOM 元素的属性,而后又读取 DOM 元素的属性的时候,通常也说读取了脏 DOM 的时候。比如改变了 DOM 元素的宽度,而后又使用 clientWidth 读取 DOM 元素的宽度。这个时候为了获取到 DOM 元素真实的宽度,需要重新计算样式。也就是会重新进行计算样式(Recalculate Style)和计算布局( Layout)操作。

设想以下案例,有一组 DOM 元素,需要将其其高度设为与宽度一致,新手很快就能写出以下代码:

解决方案 1 - 简单粗暴:

for(var i = 0,len = divs.length; i<len; i++){
    var width = divs[i].clientWidth;
    divs[i].style.height = width + 'px';
}

执行这段代码的时候,每次迭代开始的时候,DOM 都是脏的(被改动过),为了获得真实的 DOM 尺寸,都会重新计算布局。该循环就会引发多次强制性同步布局,这是很低效的做法,千万要避免。

text=引发了强制性同步布局

从 Chrome DevTools 中很容易地发现该低效操作,可以看到浏览器进行了很多次的重新计算样式(Recalculate Style)和布局(Layout),也叫做 reflow(重排)的操作,且这一帧用时很长。

解决方案 2 - 分离读和写:

可以很轻松地解决这个问题,使用两次循环,在第一次循环中读取 DOM 元素宽度并将结果保存起来,在第二个循环中修改 DOM 元素的高度。

var widthArray = [];
for(var i = 0, len = divs.length; i<len; i++){
    var width = divs[i].clientWidth;
    widthArray.push(width);
}
for(var i = 0, len = divs.length; i<len; i++){
    divs[i].style.height = widthArray[i] + 'px';
}

text=分离读写后

分离读写,一个时刻只读取,另一个时刻只改写,这样就能很有效地避免强制性同步布局。

在实际项目中往往没有上面提到的那样简单,有时尽管已经分离了读和写,但在写操作后面还是不可避免地存在读取操作,这个时候不妨将写操作放在 requestAnimationFrame 中,浏览器会在下一帧执行这个对 DOM 的改写操作。关于 requestAnimationFrame 后文有详细的讲解。

补充资料

2. 避免没有必要的重绘

在开始之前需要回顾一下什么时候需要重绘:

  1. 当 DOM 节点的会触发重绘的属性(color,background 等)被修改后,会进行重绘
  2. 当 DOM 节点所在的图层中其他元素的会触发重绘的属性被修改后,整个层会被重绘
  3. 图片加载完成后会发生重绘,GIF 图片的每一帧都会发生重绘

在 Chrome DevTools 的 Rendering 选择卡中勾选 Painting Flashing 选项后,可以观察到页面上正在进行重绘的区域。

避免 fixed 定位元素在滚动时重绘

一个常见的场景是,网页有一个 fixed 定位的头部导航栏或者侧边栏。问题存在于每次滚动后,这些 fixed 定位的元素相对于整个内容区域的位置改变了。这就相当于一个图层中的某个元素的位置改变了,为了获得滚动后的图层,需要进行重绘,因此每次滚动都会进行重绘操作。

举个例子,在腾讯网首页上有如下 fixed 定位的元素:

不幸的是这几个 fixed 定位的元素和整个网页位于同一个图层:

滚动后,因为定位元素相对于整个文档的位置发生了改变,因此整个文档都需要被重绘。解决此类问题的方法就是将 fixed 定位的元素提升至单独的图层。使用 transform:translateZ(0); 这样的写法,可以强制将元素提升至单独图层,关于此后文中还有详细说明。

:Chrome 在高 dpi 的屏幕上会自动将 fixed 定位的元素提升至单独的图层,在低 dpi 的屏幕上不会提升,因此很多开发者在 MacBook Pro 上测试的时候,不会发现问题,但用户在低 dpi 的屏幕上访问的时候就出问题了。

将部分元素提升至单独图层,避免大面积重绘

使用 transform:translateZ(0); 这样的 CSS hark 写法会将元素提升至单独的图层。在这么做之前要考虑为什么要这样做,创建新的图层的目的应该是,避免某个元素的改变导致大面积重绘,比如某个小标签的颜色的改变,导致大面积重绘,因此将其提升至单独的图层中。

这是一个面板,其中内容区域的文字会不断地闪烁(文本的颜色会改变),如果将该文本使用 transform:translateZ(0); 提升至单独的图层,那么文本的颜色改变,就只会导致它所在的图层重绘,而不需要整个面板重绘。这是正确地利用 transform:translateZ(0); 的方式。因此,如果页面中存在小面积的 DOM 节点需要频繁地重绘,可以考虑将其提升至单独的图层中。你可以在这里看到 demo —— 避免大面积重绘

正确地处理动图

页面加载的时候为了更好的用户体验常常会使用一个 loading,但在页面加载完成后如何处理 loading 呢?一个错误的方法是将其 z-index 设置一个更小的值,将其隐藏起来,不幸的是就算 loading 不可见,浏览器依然会在每一帧对它进行重绘。因此对于像 loading 这样的动态图,在不需要显示的时候最好使用 display:none 或者 visibility: hidden; 来彻底隐藏,或者干脆移除 DOM。

3. 利用 GPU 加速网页渲染

前端工程师应该都听说过硬件加速,通常是指利用 GPU 来加速页面的渲染。早期浏览器完全依赖 CPU 来进行页面渲染。现在随着 GPU 的能力增强和普及,且目前绝大多数运行浏览器的设备上都集成了 GPU。浏览器可以利用 GPU 来加速网页渲染。

GPU 包含几百上千个核心,但每个核心的结构都相对简单, GPU 的结构也决定了它适合用来进行大规模并行计算。进行图层合并需要操作大量的像素,这方面 GPU 能比 CPU 更高效的完成。这里有个视频,很清楚地说明 CPU 与 GPU 的差别。

常常看到有文章指出使用 transform:translateZ(0); 这样的 hark 可以强制开启硬件加速来提高性能,这是错误的说法。下面就来说说硬件加速的实质:

何为硬件加速

GPU 能够存储一定数量的纹理(texture),也就是一个矩形的像素点集合。通常这个集合会对应到 Web 页面上的某个图层,GPU 能够高效地对这些像素点进行多种变换(位移、旋转、拉伸)操作。在实现动画的时候,利用 GPU 的这一特性,如果只需要对原像素集合在 GPU 内进行一次变换,就能得到新一帧的图层,那么动画的所有操作都在 GPU 内高效地完成了,没有重绘操作。

得到了变换后的图层,只需要再进行一次图层的合并,将该变换后的图层和其他图层合并起来,最终得到在屏幕上显示的整幅图片。GPU 的这一特性就常常被称为硬件加速。

要利用硬件加速也是有条件的,盲目地使用 transform:translateZ(0); 而不知原理,只会让事情变得更糟糕。硬件加速的本质是说让下一帧的图层在 GPU 内经过变换得来,但是如果某些操作 GPU 无法完成,必须动画修改了 DOM 节点的宽度,颜色等,这依然是需要在 CPU 端进行软件的重绘的,这种情况就无法利用硬件加速的机制。

使用 transform:translateZ(0); 会强制浏览器创建一个新的层,每创建一个层都需要消耗额外的内存,有太多的层就会消耗大量内存,这会导致设备内存不够用,有可能导致应用奔溃。另外这些图层最后需要上传至 GPU 进行图层合并,太多的层,会导致 GPU 和 CPU 之间的带宽不够用,反而影响性能。

目前常见的 CSS 属性中只有 filter, transform, opacity 这几个属性的改变可以在 GPU 端进行处理,这在前面已经提到过了,因此应该尽可能使用这些属性来完成动画。

后面会有更多关于利用 GPU 的这一特性的例子,下面先看一个需要注意的点:

避免无谓地新建图层

一个真实案例:

text=每个列表项都是一个图层

这是一个城市选择页,这个页面中的每一项都使用了 transform:translateZ(0); 强制提升至了单独的图层,滚动列表,并录制了一段 Timeline。

text=优化前

从上图中可以看到,性能是相当糟糕的,大量时间都花费在了图层的合并上,每一帧都需要合并上千个列表子项,这不是一件很轻松的事情。

为了体现,错误使用 transform:translateZ(0); 的严重性,下面来看看去掉后的效果,去掉该属性后,一片绿,没有任何性能问题。

text=优化后

因此在谈起硬件加速的时候,一定知道,什么是硬件加速,硬件加速是如何工作的,它能做什么,不能做什么。合理的利用 GPU 才能利用它帮我们构建出 60fps 的体验。

4. 构建更加流畅的动画

上面讲了,使用 transform 和 opacity 来创建动画(filter 的支持度还不够好)最为高效。因此每当需要用到动画的时候,首先要考虑使用这两个属性来完成,

避免使用会触发 Layout 的属性来进行动画

有时候看起来不太可能使用这两个属性来完成,不过仔细想想往往能够想到解决方案。考虑下面动画:

demo 地址:expand cord

一般的想法可能是修改每个卡片的 top, left, width, height 来实现这个功能,这样做当然可以实现效果,只是改变这些属性都会触发 Layout 进而触发 Paint 操作,在复杂应用上势必造成卡顿。下面介绍一种使用 transform 来完成此动画的方法。

// 拿到初始尺寸
let first = card.getBoundingClientRect();
// 加上最终状态的类名
card.classList.add('card--expand');
// 拿到最终尺寸
let last = card.getBoundingClientRect();

// 设置形变参考点
card.style.transformOrigin = '0 0';

// 计算需要位移和伸缩的量
let transform = {
  x: first.left- last.left,
  y: first.top - last.top,
  sx: first.width / last.width,
  sy: first.height / last.height
};
// 加上 transform 将其从最终状态缩小到最初状态
card.style.transform = `translate(${transform.x}px, ${transform.y}px)
    scale(${transform.sx},${transform.sy})`;

// 在下一帧
requestAnimationFrame(function(){
  // 加上过渡时间
  card.style.transition = 'transform .4s cubic-bezier(0,0,0.3,1)';
  // 将 transform 取消,这样就会慢慢过渡到最终状态
  card.style.transform = 'none';
});

以上思路是使用 getBoundingClientRect 将动画的始态和终态的尺寸和位置计算出来,然后利用 transform 来进行过渡,思路在代码注释中已经进行了说明。

经过这样的处理,原本需要使用 top, left, width, height 来进行的动画使用 transfrom 就搞定了,这会大大地提示动画的性能。

使用 transform, filter 和 opacity 来完成动画

使用以上 3 个属性来完成动画,可以避免在动画的每一帧进行重绘。但如果在动画中改变了其他属性,那也不能避免重新绘制。要尽可能地利用这几个属性来完成动画。涉及位移的考虑使用 translate,涉及大小的考虑 scale,涉及颜色的考虑 opacity,为了实现流畅的动画要想尽一切办法。

这里给出一个案例,Instagram 的安卓 APP 在登录的时候,有一个颜色渐变的效果,这种效果常常见到。

text=Instagram 登录页的背景色渐变效果

通过地不断地改变背景颜色能很快地实现,测试后会发现在低端设备上会感到卡顿,CPU 使用率飙升,这是因为修改背景颜色会导致页面重绘。为了不重绘也能达到同样的效果,我们可以使用两个 div,给它们设置两个不同的背景色,在动画中改变两个 div 的透明度,这样两个不同透明度的 div 叠加在一起就能得到一个颜色演变的效果,而整个动画只使用了 opacity 来完成,完全避免了重绘操作。

关于示例,你可以在此处看到: 使用 background 完成渐变 vs 使用 opacity 完成渐变

不要混用 transform, filter, opacity 和其他可能触发重排或重绘的属性,虽然使用 transform, filter, opacity 来完成动画能够有很好的性能,但是如果在动画中混合使用了其他的会触发重排或重绘的属性,那么依然不能达到高性能。

使用 requestAnimationFrame 来驱动动画

前面提到的动画大多是使用 CSS 动画 和 CSS 过渡 CSS 动画通常是事先定义好的,无法很灵活地控制,某些时候可能需要使用 JavaScript 来驱动动画。新手常常使用 setTimeout 来完成动画,问题在于使用 setTimeout 设置的回调会在主线程空闲的时候才会调用,想象下面场景:

setTimeout 在一帧的中间位置被触发,随后导致重新计算样式进而导致一个长帧。setTimeout/setInterval 主要存在以下局限性:

  1. 在页面不可见的时候依然会调用(耗电)
  2. 执行频率并不固定(一帧内可能多次触发,造成不必要的重排/重绘)

setTimeout/setInterval 会周期性的调用,及时当前网页并没有在活动。另外因为调用时机不确定可能引发的在同一帧内多次调用同一个回调,如果回调中触发了多次重绘,那么会出现在一帧中重绘多次的情况,这是没有必要的,且会导致掉帧。

requestAnimationFrame,一个专门用来驱动动画的 API,它有以下好处:

  1. 保证回调在下一帧调用
  2. 根据机器的刷新频率调整执行频率
  3. 当前网页不可见的时候不执行回调

虽然 requestAnimationFrame 是一个已经存在很多年的 API 了,但是还是存在诸多误读,其中最严重的是认为使用 requestAnimationFrame 能够避免重新布局和重绘,浏览器能够启动优化措施,让动画更流畅,这是错误的,浏览器能保证的仅仅是以上 3 条,在 requestAnimationFrame 的回调中进行强制同步布局依然会触发重排。

在编写使用 JavaScript 驱动的动画时,使用 requestAnimationFrame 可以将对 DOM 的写操作放在下一帧进行,这样该帧后面对 DOM 的读取操作就不会引发强制性同步布局,浏览器只需要在下一帧开始的时候进行一次重排。

5. 正确地处理滚动事件

现代浏览器都使用一个单独的线程来处理滚动和输入,这个线程叫做合成线程,它能够和 GPU 进行通信来告诉 GPU 如何移动图层,进行页面的滚动。如果页面上绑定了 touchmove,mousemove 这类事件,合成线程需要等待主线程执行相应的事件监听函数,因为这些函数里面可能会调用 preventDefault 来阻止滚动。

对于优化 scroll,touchmove,mousemove 等事件,其中一个最为重要的建议就是,要控制此类高频事件的回调的执行频率。说到控制频率,自然会想到 debounce 和 throttle 这两个函数。曾一度为止迷惑,不妨简要对这两个函数进行科普:

使用 debounce 或 throttle 控制高频事件触发频率

debounce 和 throttle 是两个相似(但不相同)的用于控制函数在某段事件内的执行频率的技术。

debounce

多次连续的调用,最后只调用一次

想象自己在电梯里面,门将要关上,这个时候另外一个人来了,取消了关门的操作,过了一会儿门又要关上,又来了一个人,再次取消了关门的操作。电梯会一直延迟关门的操作,直到某段时间里没人再来。

throttle

将频繁调用的函数限定在一个给定的调用频率内。它保证某个函数频率再高,也只能在给定的事件内调用一次。比如在滚动的时候要检查当前滚动的位置,来显示或隐藏回到顶部按钮,这个时候可以使用 throttle 来将滚动回调函数限定在每 300ms 执行一次。

需要注意的是这两个函数的使用方法,它们接受一个函数,然后返回一个节流/去抖后的函数,因此下面第二种用法才是正确的

// 错误
$(window).on('scroll', function() {
   _.throttle(doSomething, 300);
});

// 正确
$(window).on('scroll', _.throttle(doSomething, 300));

使用 requestAnimationFrame 来触发滚动事件的回调

如果在事件监听函数中进行了 DOM 操作,这可能会消耗不少时间,事件监听函数执行的时间变长,与 GPU 进行通信的合成线程也就迟迟接收不到通知,浏览器也就迟迟不知道如何滚动页面,由此引发的就是卡顿。对于这类同步的事件(浏览器等待事件执行完成),可以在事件触发的时候先读取需要获取的 DOM 元素的尺寸位置等信息,然后将其他改写 DOM 的操作安排在 requestAnimationFrame 中完成,浏览器能够更快地执行完事件回调,还能避免后续的读取 DOM 的时候发生重排。

另外,有时候希望事件在每一帧执行一次,此时是使用 throttle 是无法满足需求的,使用 requestAnimationFrame 可以保证每一帧都会调用,需要注意的是有的事件触发的频率可能是一帧好几次。因此在使用 requestAnimationFrame 的时候要注意判断是否在一帧内多次触发了回调。

let padding = false;
function onTouchmove (evt) {
    if (padding){
        return;        
    }
    padding = true;
    requestAnimationFrame(function(){
        // ...

        padding = false;
    });
}
document.body.addEventListener('touchmove', onTouchmove);

总结

这篇文章对浏览器的渲染过程进行了简要描述,然后根据浏览器渲染原理,分析实现流畅的动画需要注意的方方面面,并给出多个实现流畅动画的实用技巧。

不过规则最是不停在改变的,浏览器也不断在更新,一年前是性能瓶颈的点,现在可能已经不是瓶颈了。在开发过程中应该结合调试工具,去分析每一次重排和重绘,分析各个阶段的耗时,找出真正的问题所在。而不是仅仅记住一些条条框框。

如发现有错误,还请不吝指出。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment