这篇文章是2019年5月11号,我在上海FDConf2019上的分享整理。
- 演讲主题:【让你的网页更丝滑】
- 时间:2019年5月11日(下午)
- 地点:上海 - FDCon2019 - B会场(全栈&全端专场)
- 演讲嘉宾:刘博文
大家好,我叫刘博文,今天给大家分享的主题叫《让你的网页更丝滑》,其实就是更流畅的意思。
简单介绍一下自己,2012年我从中专毕业,当时是17岁,2015年我加入了360最大的前端团队奇舞团,那一年我是20岁;2017年由于组织架构的变动,我们组被拆分到360导航,所以我就变成360导航的一名前端工程师;2018年就是去年,因为公司是W3C的会员,所以我就加入了W3C的性能工作组。
消息比较灵通的应该听说过我在上个月出版了一本讲Vue的书,叫做《深入浅出Vue.js》。
虽然出版了一本Vue的书,但其实从去年加入W3C性能工作组之后,我一直在学习和了解Web性能领域相关的知识。
在讨论如何让网页更流畅之前,需要先思考一个问题就是什么样的网页是流畅的?
这个问题我总结了一句话:在网页与用户产生交互的过程中,让用户感觉流畅。
你的网页不一定要有多快,它没有一个标准,你的标准就是让用户感觉流畅就够了。另一个重点就是说在交互过程中,让用户感到流畅。所以延伸出一个问题,如何通过交互让用户感觉流畅。这里面我把交互总结为两种类型,一种是被动的,一种是主动的。
所谓被动交互就是不需要用户主动去触发什么,就可以让网页在视觉上与用户产生交互。 比如说:Animation(动画)、开屏广告、自动播放的轮播图等都算被动交互。与之相反,需要用户主动去触发某些行为从而产生的反馈,我称它为主动交互,比如说用鼠标点某一个按纽产生的反馈,或使用键盘按下了某个键位产生的反馈。这个反馈可以是动画,任何东西都可以。那么被动交互如何让用户感觉流畅?这是今天第一个关于优化的话题。
我在京东上搜索显示器,发现有一个筛选条件叫刷新率,最低的是60HZ,高的可以达到165HZ以上。
这个60HZ是什么意思?就是指屏幕每秒钟刷新60次。所以我们可以通过屏幕作为参考,如果我们的网页也可以每秒钟往屏幕传输60个画面,用户就会觉得这个网页是流畅的,有一个单位叫做FPS,意思就是每秒钟往屏幕上传输的图像数量。FPS达到60,用户就会觉得这个网页比较流程,换算下来,每一帧是16.7毫秒。
主动交互如何让用户感觉流畅?我也把它总结成一句话,这句话叫:“通过响应的时间影响用户的感觉”。就是说我们可以通过操控这个时间来影响用户对网页的感觉。
我们看一个演示(Demo),这个演示很简单,就是我点击按纽的时候,我让这个函数延迟多少秒,然后把这个方块改变一下颜色。这下面是八个按纽,分别是10毫秒、30毫秒、50毫秒、100毫秒、200毫秒、300毫秒、500毫秒、1秒。(文章无法演示,可以到在线PPT里去体验,或者访问https://code.h5jun.com/pojob)
你会发现当我点击200毫秒的按钮时,这个反馈速度,用户会觉得这个东西有一点卡,当我点击100毫秒的按钮时,已经感觉不卡了,当然更快更好。所以你会发现100毫秒是一个临界点,从我们的输入,包括键盘按键和鼠标点击到最终输出到眼睛里,这个时间100毫秒是临界点。超过这个时间,用户就会觉得有点卡,所以100毫秒是关键点。
我们再看一个例子,代码和刚才是一样的,现在只有一个按纽是100毫秒,刚才我说100毫秒,用户就会觉得很流畅。其实你会发现还是卡一下,但是不是说每次都卡,有的时候不卡,为什么有的时候卡有的时候不卡?
因为我们的目标是从输入到输出总时间是100毫秒以内,用户才会觉得流畅。但其实我这个代码有一个问题是这个函数的执行时间是100毫秒,所以如果当我点击这个按纽一瞬间,如果有其他任务在执行,就会把我这个函数堵塞住,被阻塞的时间加上函数执行的100毫秒,现在整体时间已经超过100毫秒,所以我刚才点击这个按纽,你会发现有时候卡,有时候不卡,不卡的时候是因为我点击这个按纽的时候,恰巧没有其他的任务在执行。
所以为什么会有这个问题?因为大家都知道JS是单线程的,浏览器同一时间内只能执行一个任务,所以为了避免这个问题,解决方案就是说所有的任务执行时间不能超过50毫秒。如果我所有的任务都不超过50毫秒,假设最糟糕的情况下,我点击这个按纽的一瞬间,有其他的任务在执行,但其实他的任务执行时间最多是50毫秒,我的任务执行时间也是保持在50毫秒以内,其实总共也不会超过100毫秒,所以用户依然会觉得很流畅,即便是最糟糕的情况下。
可以看一下这个粉色的地方,从input到response总时间是100毫秒,红色区域是被阻塞的部分,黄色是函数执行的时间和时机,你会发现我这两个任务都保持在50毫秒以内的情况下,我可以保证我的总时间是100毫秒以内完成的,这个50毫秒不是我定的,W3C性能工作组有一个Longtask规范也对这种情况做了规定。
这个规范就规定所有的任务,包括函数执行,包括什么都算上,不能超过50毫秒,超过50毫秒就被定义为长任务,所谓长任务就是执行时间过长的任务,这是不合理的,应该被解决的任务。性能监控一般都会通过图中的代码来监控与捕获长任务,可以看到这个entryType是longtask的。
总结一下,如何让用户感觉流畅?就是响应时间保持在100毫秒以内,动画要16.7毫秒传输一帧到屏幕上,空闲任务不能超过50毫秒,其实不只是空闲任务,所有任务都不能超过50毫秒,加载时间是1000毫秒,所谓的页面秒开就是从这里来的。这四个单词的首字母加在一起组成一个单词叫RAIL,这是一个术语,它代表以用户为中心的性能模型,我们刚才讲的也是这个话题,感兴趣大家可以回去查一下。
今天讲第二个概念叫像素管道。所谓像素管道,就是说我们通常会在网页触发一些视觉变化,你用JS改了颜色和宽度等等,随后浏览器就会做样式计算,浏览器还会做布局、绘制,合并图层等,这个过程叫做像素管道。
但是有的时候,不是所有的样式都会触发布局,有的时候不需要布局的,我们通过一些优化手段也可以取消Paint(绘制)这一步。有一个网站叫 csstriggers,可以看哪些属性触发了布局,哪些触发了Paint,这个网站有列表可以看。
今天第一个关于如何优化的话题叫如何保证主动交互让用户感觉流畅,其实刚才我们介绍说想保证主动交互让用户感觉流畅需要避免长任务,所以这个副标题叫如何避免长任务。
如何避免长任务,有两种方案:一种叫 Web Worker ,还有一种方案叫 Time Slicing(时间切片)。
先说Web Worker,我们看一段代码,我的网页里面有一个while循环,通常来讲这个循环会把浏览器卡死一秒钟,因为循环了一秒,现在我把它移动到 worker中 执行,就不会卡死浏览器了,它在worker线层中工作,就不会卡死主线程。这是一种解决方案,可以看一下效果。(由于文章无法演示效果,感兴趣的小伙伴可以到在线PPT里观察 https://ppt.baomitu.com/d/b267a4a3#/14)
const testWorker = new Worker('./worker.js')
setTimeout(_ => {
testWorker.postMessage({})
testWorker.onmessage = function (ev) {
console.log(ev.data)
}
}, 5000)
// worker.js
self.onmessage = function () {
const start = performance.now()
while (performance.now() - start < 1000) {}
postMessage('done!')
}
可以看到现在浏览器没有被堵塞掉。
我们通过捕获火焰图,发现优化前其实长任务是主线程中工作,优化之后是放在 Worker 来进行的,所以我的主线依然可以处理其他的任务。
Web Worker虽然好,但是它有一个缺陷,就是它没有办法摸DOM。如果你想操作DOM,那么就没法在Worker中执行。我就是要循环超过100毫秒,我又想在循环中操作DOM,这时候怎么办?有一个方案叫 Time Slicing。
Time Slicing就是把一个长任务给切割成无数个执行时间很短的任务。
可以看到中间用户红框框起来的,内部有很多黄颜色的小竖线,其实每一个都是任务,放大之后,就是图中最下面的火焰图,可以看到中间是有空隙的。因为中间有空隙,浏览器就可以在这些空隙中做其他的事,比方说布局、样式计算、UI事件,所有事情都可以做。
实现时间切片功能的代码也并不是很复杂,就是下面这段代码,其实核心代码只有三四行。代码虽然不多,但是可能理解起来也没有那么容易,我为大家简单介绍一下。
function block () {
ts(function* () {
const start = performance.now()
while (performance.now() - start < 1000) {
console.log(11)
yield
}
console.log('done!')
})
}
setTimeout(block, 5000)
function ts (gen) {
if (typeof gen === 'function') gen = gen()
if (!gen || typeof gen.next !== 'function') return
(function next () {
const res = gen.next()
if (res.done) return
setTimeout(next)
})()
}
这些代码首先有两个点,第一个点就是我利用 yield
关键字,让函数暂停执行,大家都知道在Generator函数中有一个 yield
关键字,这个关键字可以让函数暂停执行,这是很关键的特性。我利用的另一个特性就是 setTimeout
的能力,它可以将任务丢到宏任务队列里面排队让我的任务恢复执行,所以我结合这两个特性,用这个代码就可以实现Time Slicing的功能。
代码中我下面这个ts函数其实是我封装的工具函数,我上面其实是我的案例。案例中我这个循环其实正常来说是同步的,循环时会把我的浏览器卡死一秒钟,但是我在里面加了一个 yield
关键字。所以每次执行都会停一下,停止这一瞬间,其实就是把浏览器的主线程给让出来,或者说叫释放出来了,如果不停的执行,在这一秒钟内浏览器干不了别的事,现在我的这个任务执行了一会就停了,浏览器就可以去执行别的任务。然后我在后面的宏任务中再让我这个任务恢复执行。这个代码可能不是那么好理解,可以自己回去慢慢研究。
(关于Time Slicing后来我写了一篇文章进行了更详细与全面的介绍,文章地址:berwin/Blog#38)
我这里有一个例子(观看文章的同学可以通过在线PPT来查看视频,地址:https://ppt.baomitu.com/d/b267a4a3#/19),我们会看到浏览器并没有卡死,通过捕获出的火焰图可以看到每个被切割的小任务中间有很多空隙。
现在我们聊下一个话题,保证被动交互让用户感觉流畅。
前面我们讲,若想保证被动交互让用户感觉流畅,我们需要保证每16.7毫秒传输新的一帧到屏幕上,所以我们这个标题应该改成 如何保障动画每16.7毫秒传输新的一帧到屏幕上 。
这张图是前面我们讲的管道,这个只是图变了一下,若想保证每16.7毫秒传输新的一帧到屏幕上,我们需要保障这个像素管道的总时间在16.7毫秒之内。
所以为了保障这个总时间在16.7毫秒之内,我们首先需要保障的事情就是JavaScript的执行时间一定要小于10毫秒,因为浏览器去执行渲染也是有时间消耗的,所以我们应该给浏览器预留出来6.7毫秒。
但其实像素管道的每一步,都有可能导致总时间超过16.7毫秒,所以只是保障JavaScript执行时间小于10毫秒是不够的。我们要针对每一步进行更细致的优化,来保证总时间小于16.7毫秒。
我们先讨论样式计算,关于样式计算有一个重要的话题是选择器匹配。
我们这里有两个选择器,其实选择的是同一个元素,但其实在浏览器里,处理选择器匹配的时候,时间是不一样的,下面更简单的选择器速度更快一点。我在Chrome文档中看到他们说计算某元素的样式时,有50%的时间是用于选择器匹配。
通常如果只是用选择器匹配了一个元素或很少的元素,那么再复杂的选择器,时间上也没有什么太多的影响。但是当选择器匹配到的元素越多的时候,选择器之间的性能差异就体现出来了。
下面有三个圈,和三个选择器,我们可以看到第一个选择器是稍微复杂一点的,第二个选择器就是普通的选择器,第三个选择器也比较复杂。我点击这个按纽看三个选择器的执行时间是多少。
可以看到第一个是1.28毫秒,第二个是0.5毫秒,第三个是4.9毫秒,结果虽然在数量上没差太多,但是第三个比第二个慢了9.8倍。
所以我们会发现选择器越简单速度越快,其实这个差距在元素越来越多的情况下,它就会越来越严重,但通常绝大部分的项目其实并没有那么多的元素,所以这个问题也没有暴露的这么明显,了解一下就可以了。
第二个问题是布局抖动,它是新手写代码最容易出现的问题,一不小心就犯错了。
我们还是回到像素管道,其实像素管道的每一步都是异步的,js改了样式,其实它是异步的去计算样式,布局,绘制,图层合并,每一步都是异步的。
但是有时候一不小心就会出现一个词叫做强制同步布局,通过这个名就知道,这个布局变成了同步的布局。
浏览器本应是异步的去执行布局操作,但现在却跑到了JS里面去同步的执行了。为什么会导致强制同步布局呢?我们来看一段代码。
第一行代码是设置一个元素的宽度,第二行代码是获取元素的宽度,仔细思考一下会发现第一行代码设置了元素的宽,但其实布局操作是异步的,所以我执行第二行代码的时候,浏览器没有还没有进行布局。因为我第二行代码是想获取这个元素的宽,但是这时候浏览器还没有布局,那么浏览器为了回答我这个问题(宽度是多少),它必须要在此时此刻做一次布局,这个时候这个布局是同步的。
我们将火焰图捕获出来也验证了这一点,布局在我们这个js的里面执行,因为JS里面执行了布局所以把JS的执行时间拉长了。这样是不对的,解决方案很简单,只是调换一下顺序,我如果先获取一个元素出来,其实获取的是上次布局的宽度,我并没有改变布局,所以直接读就可以了,我第二行代码才会改宽度,然后再异步触发布局,这样捕获出来的火焰图布局就跑到JS后面去了。
但是通常如果只是这个案例(Demo),其实很简单,你这个再怎么写,也不会有什么问题,因为影响就是很小,但是如果这个问题发生在循环里面,你的元素很多的情况下,这个问题就被放大。
这个案例(Demo)也比较简单,代码右边有很多DIV,粉红色的框是这些DIV的父容器,可以看到父容器比这些DIV窄,当我点击“走你~”按钮时,让所有子元素的宽度等于父元素的宽度。(观看文章的同学可以通过在线PPT来操作DEMO,地址:https://ppt.baomitu.com/d/b267a4a3#/27)
通过这个案例(Demo)我们会看到当我点击按钮时,延迟了一会,子元素的宽度才缩小。这是为什么呢?
仔细观察这段代码,我们会发现,循环中的这行代码,其实是两个操作,一个是读取元素的宽度,另一个操作是设置元素的宽度。因为它是在循环里面执行,所以会导致一个现象,每次循环到读取元素宽度时,都会触发一次布局操作。
我们来看这张图,当执行 container.offsetWidth
时浏览器由于不知道元素的宽度是多少,但我现在马上就要知道这个元素的宽度是多少,所以这个布局不能异步,那么为了告诉我这个元素有多宽,必须马上执行一次同步的布局操作,而随后的代码中又设置了元素的宽度,这其实就是要把刚刚执行的布局给否定掉,让布局失效。当下一轮循环又执行到 container.offsetWidth
读取元素的宽时,由于刚刚执行了设置元素的宽,所以浏览器又不知道当前元素的宽度是多少,所以它又要做一次强制同步布局。所以浏览器在不停的布局,让布局失效,布局,让布局失效直到循环结束。
我们将火焰图捕获出来之后,我们会在下面看到一排密密麻麻很多个任务。
放大之后是下面这张图,我们可以看到这些任务全是样式计算和布局。这个问题严重就严重在,同一个页面内,两个没有任何关联的元素之间,也会存在这个问题,比如说我的logo改了宽,我再读取其他不相干的元素的宽,两个元素没有任何关系,但是也会有这个影响,只要他们在同一个文档内,所以有时候我们一不小心就会犯错。
解决方案比较简单,就是我把会触发布局的操作踢出去,踢到循环的外面,这时候只读一次宽度,并且由于之前并没有改变任何元素的几何属性,所以浏览器不需要做同步的布局,直接使用之前布局的结果就可以,然后用循环只设置子元素的宽度,就会避免刚才的问题。同样的案例(Demo),只是改了这一行代码,我们点击按钮看一下效果(观看文章的同学可以通过在线PPT来操作DEMO,地址:https://ppt.baomitu.com/d/b267a4a3#/28),已经看不到任何的延迟了。
最终我们捕获出的火焰图就比较正常,就是一个常规的管道应该有的样子,我们先用 js 来触发样式计算,然后浏览器再去布局,再执行绿色的Paint和图层合并,每一步都是异步的。
下一个话题是绘制与合成,你会发现前面我们讲的,就是 JavaScript 和样式计算,还有布局都是单独讲的,但是绘制与合成我们放在一起讲,等下我们再讲为什么。
我们先讲什么是合成,所谓合成就是浏览器和PhotoShop一样,都有图层的概念,可以看到我这张图最左侧有三个图层,我们从侧面观察这个图层,你会发现眼睛在上面,鼻子在中间,最下面是脸,其实是三个图层是叠加在一起的,这三个图层合并成一张图之后,就是我们最右边的这张图,就是一个人的脸。
图层有一个最大的特点就是如果图层的位置变了,浏览器只需要重新去合成,就可以得到一张新的图。注意,如果图层的位置变了,但是图层的内容没变,那么浏览器只需要重新合并图层,就可以得到一张新的图,这个过程是不需要绘制(Paint)的。
我们在说说绘制的意思。图中白色的框是一个图层,这个框里面有一个黄色的方框;右边的与左边的是同一张图层,但是右边这个图层里面的黄色方块跑右边去了。注意,我同一张图层,但是内容变了,这时候浏览器要做一个事情就是“绘制”,通过重新绘制图层,才能让图层里面的内容发生变化。可以理解为,你有一个画板,你想把方框移到右面,那只能把之前的擦掉然后重新在右面画一个上去。
所以你发现绘制产生的效果和图层合并产生的效果是一样的,我通过改变图层的位置能实现和我重新绘制的效果是一样的。
实际上我想说明什么?我想告诉大家告诉大家添加图层可以取消Paint。
我们都知道像素管道有五步,JavaScript->样式计算->布局->绘制->合成,但是通过添加图层可以取消绘制这步,五步变成四步,那其实这个时间要更简短一些。
可以看到这个图,主要看右边的图,就是图层这个位置,这张图的图层在不停的变,浏览器通过合并图层就可以实现方框移动的效果。这个过程不需要绘制的,你用这个火焰图捕获也是捕获不到绘制的。
图层这么好,如何创建图层?
我们可以使用CSS的will-change来创建图层,在will-change
不兼容的情况下,你可以用 transform: translateZ(0);
来代替。
你会发现图层这东西这么好,可以把像素管道从五步变成四步,我们是不是可以这样操作,所有元素都设置will-change
,浏览器是不是就没有绘制了?
这其实是不行的,因为浏览器做图层管理也是需要消耗的,如果你这样做,其实带来的效果反而是负面的,所以这个是不推荐的。
现在我们从 JavaScript 到图层合并,我们通过一系列的手段已经可以保证每一帧的像素管道总时间在 16.7 毫秒以内,那么就可以保证每 16.7 毫秒给屏幕传输新的一帧吗?
还不够。
图中这是一个时间轴,每个时间节点之间的间隔是 16 毫秒,我们通常会使用Timer触发一个函数改变一些样式,从而实现视觉的效果。
你会发现中间有一个16毫秒没有输出的,这 16 毫秒丢帧了,这一帧在屏幕上并没有传输任何图像,因为我这个Timer不能保证函数在每一帧最开始执行,保证不了函数的执行频率,所以就会导致这个问题。
现在整个Web平台,只有一个API可以解决这个问题,可以让我们的函数在每一帧最开始执行。这个API叫做requestAnimationFrame,使用它触发函数可以保证函数在每一帧的最开始执行,同时只有我们保证函数总体时间在 16.7 毫秒以内,现在就可以下图的效果,我第一帧、第二帧、第三帧、第四帧很均匀,从时间轴上也看不到丢帧的现象存在。现在我们终于可以保证不丢帧的情况下达到 60 FPS。
最后做一个总结,首先我们讲了什么样的网页是用户觉得比较流畅的,我们讲的第二个概念叫像素管道,通过后面的介绍,你会发现像素管道还是很重要的。
然后我们讲了优化主动交互,有两种方案,一个是web-worker,还有一个是 time-slicing。
我们还介绍了如何优化被动交互,保证 JS 执行时间 10 毫秒以为,样式计算(选择器)与性能,布局抖动以及如何避免布局抖动,做好图层管理和绘制的权衡,和requestAnimationFrame。
谢谢大家。