这篇文章是2019年5月11号,我在上海FDConf2019上的分享整理。javascript
- 演讲主题:【让你的网页更丝滑】
- 时间:2019年5月11日(下午)
- 地点:上海 - FDCon2019 - B会场(全栈&全端专场)
- 演讲嘉宾:刘博文
PPT地址:ppt.baomitu.com/d/b267a4a3css
原文地址:github.com/berwin/Blog…html
你们好,我叫刘博文,今天给你们分享的主题叫《让你的网页更丝滑》,其实就是更流畅的意思。前端
简单介绍一下本身,2012年我从中专毕业,当时是17岁,2015年我加入了360最大的前端团队奇舞团,那一年我是20岁;2017年因为组织架构的变更,咱们组被拆分到360导航,因此我就变成360导航的一名前端工程师;2018年就是去年,由于公司是W3C的会员,因此我就加入了W3C的性能工做组。java
消息比较灵通的应该据说过我在上个月出版了一本讲Vue的书,叫作《深刻浅出Vue.js》。git
虽然出版了一本Vue的书,但其实从去年加入W3C性能工做组以后,我一直在学习和了解Web性能领域相关的知识。github
在讨论如何让网页更流畅以前,须要先思考一个问题就是什么样的网页是流畅的?web
这个问题我总结了一句话:在网页与用户产生交互的过程当中,让用户感受流畅。浏览器
你的网页不必定要有多快,它没有一个标准,你的标准就是让用户感受流畅就够了。另外一个重点就是说在交互过程当中,让用户感到流畅。因此延伸出一个问题,如何经过交互让用户感受流畅。这里面我把交互总结为两种类型,一种是被动的,一种是主动的。前端工程师
所谓被动交互就是不须要用户主动去触发什么,就可让网页在视觉上与用户产生交互。 好比说:Animation(动画)、开屏广告、自动播放的轮播图等都算被动交互。与之相反,须要用户主动去触发某些行为从而产生的反馈,我称它为主动交互,好比说用鼠标点某一个按纽产生的反馈,或使用键盘按下了某个键位产生的反馈。这个反馈能够是动画,任何东西均可以。那么被动交互如何让用户感受流畅?这是今天第一个关于优化的话题。
我在京东上搜索显示器,发现有一个筛选条件叫刷新率,最低的是60HZ,高的能够达到165HZ以上。
这个60HZ是什么意思?就是指屏幕每秒钟刷新60次。因此咱们能够经过屏幕做为参考,若是咱们的网页也能够每秒钟往屏幕传输60个画面,用户就会以为这个网页是流畅的,有一个单位叫作FPS,意思就是每秒钟往屏幕上传输的图像数量。FPS达到60,用户就会以为这个网页比较流程,换算下来,每一帧是16.7毫秒。
主动交互如何让用户感受流畅?我也把它总结成一句话,这句话叫:“经过响应的时间影响用户的感受”。就是说咱们能够经过操控这个时间来影响用户对网页的感受。
咱们看一个演示(Demo),这个演示很简单,就是我点击按纽的时候,我让这个函数延迟多少秒,而后把这个方块改变一下颜色。这下面是八个按纽,分别是10毫秒、30毫秒、50毫秒、100毫秒、200毫秒、300毫秒、500毫秒、1秒。(文章没法演示,能够到在线PPT里去体验,或者访问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里观察 ppt.baomitu.com/d/b267a4a3#…)
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后来我写了一篇文章进行了更详细与全面的介绍,文章地址:github.com/berwin/Blog…)
我这里有一个例子(观看文章的同窗能够经过在线PPT来查看视频,地址:ppt.baomitu.com/d/b267a4a3#…),咱们会看到浏览器并无卡死,经过捕获出的火焰图能够看到每一个被切割的小任务中间有不少空隙。
如今咱们聊下一个话题,保证被动交互让用户感受流畅。
前面咱们讲,若想保证被动交互让用户感受流畅,咱们须要保证每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,地址:ppt.baomitu.com/d/b267a4a3#…)
经过这个案例(Demo)咱们会看到当我点击按钮时,延迟了一会,子元素的宽度才缩小。这是为何呢?
仔细观察这段代码,咱们会发现,循环中的这行代码,实际上是两个操做,一个是读取元素的宽度,另外一个操做是设置元素的宽度。由于它是在循环里面执行,因此会致使一个现象,每次循环到读取元素宽度时,都会触发一次布局操做。
咱们来看这张图,当执行 container.offsetWidth
时浏览器因为不知道元素的宽度是多少,但我如今立刻就要知道这个元素的宽度是多少,因此这个布局不能异步,那么为了告诉我这个元素有多宽,必须立刻执行一次同步的布局操做,而随后的代码中又设置了元素的宽度,这其实就是要把刚刚执行的布局给否认掉,让布局失效。当下一轮循环又执行到 container.offsetWidth
读取元素的宽时,因为刚刚执行了设置元素的宽,因此浏览器又不知道当前元素的宽度是多少,因此它又要作一次强制同步布局。因此浏览器在不停的布局,让布局失效,布局,让布局失效直到循环结束。
咱们将火焰图捕获出来以后,咱们会在下面看到一排密密麻麻不少个任务。
放大以后是下面这张图,咱们能够看到这些任务全是样式计算和布局。这个问题严重就严重在,同一个页面内,两个没有任何关联的元素之间,也会存在这个问题,好比说个人logo改了宽,我再读取其余不相干的元素的宽,两个元素没有任何关系,可是也会有这个影响,只要他们在同一个文档内,因此有时候咱们一不当心就会犯错。
解决方案比较简单,就是我把会触发布局的操做踢出去,踢到循环的外面,这时候只读一次宽度,而且因为以前并无改变任何元素的几何属性,因此浏览器不须要作同步的布局,直接使用以前布局的结果就能够,而后用循环只设置子元素的宽度,就会避免刚才的问题。一样的案例(Demo),只是改了这一行代码,咱们点击按钮看一下效果(观看文章的同窗能够经过在线PPT来操做DEMO,地址:ppt.baomitu.com/d/b267a4a3#…),已经看不到任何的延迟了。
最终咱们捕获出的火焰图就比较正常,就是一个常规的管道应该有的样子,咱们先用 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。
谢谢你们。