Time-Slicing时间切片模拟淘宝移动端模块化加载方案

性能优化的目的

我们每一次的界面变化,都要经历以下步骤:

image

我们都知道像素管道有五步,JavaScript->样式计算->布局->绘制->合成

人的眼睛大约每秒可以看到 60 帧,那么就代表我们每 16.7ms 就要看到 1 帧,一帧就要经历上图的 5 步,说明我们的每一个任务(task) 不宜过长,这样就会导致用户对于界面感知的不友好性

  • fps 是指页面每秒帧数
  • fps = 60 性能极佳
  • fps < 24 会让用户感觉到卡顿,因为人眼的识别主要是24帧

根据谷歌统计的数据,用户在不同时间段内接收到的反馈,可能直接影响到对于网站的用户留存,如下图:

image

在这里我们不深入讲对于这方面的一些细节,这篇文章主要是给大家讲一下,如果做任务切片,如何优化界面的渲染速度和响应速度

分析淘宝

淘宝的渲染方式

我们先看一下淘宝的渲染方式
1

通过图片和 Performancemain 部分,我们可以看得出来淘宝移动端的加载方式,是一块一块去加载的,暂时我们称之为 模块化加载

performance 的使用和如何查看性能优化的数据,可通过 性能优化篇 - Performance(工具 & api) 来了解 performance

淘宝的任务切片

image

我们放大以后可以看的出来,淘宝网在每一次的任务完成后,都会进行上面的 5 步进行界面的渲染,这样可能不如把所有的界面全部渲染完毕后,在进行样式计算、布局、绘制、计算位置等的速度快,但是这样可以保证,让用户在最短的时间内,可以看到我们的网站内容

简单的介绍一下渲染的步骤和对用户的影响,及淘宝的渲染方式,接下来我们开始实现一个任务切片的工具

任务切片源码介绍

任务切片,顾名思义就是我们要把每一个任务去做切片,缩短任务的执行时长,加快任务的渲染

这里要使用 es6 的 generator 的特性去实现任务切片

初始化任务

1
2
3
4
5
6
7
8
9
10
11
function init({ sliceList, callback }) {
if (!isFunction(callback)) {
console.error('callback 为必传参数并为 function');
return;
}
// 添加切片队列this.generator = this.sliceQueue({
sliceList,
callback
});
// 开始切片this.next();
}

在一开始的时候,我们需要至少两个参数:

sliceList 或者 sliceCount : 可以是数组,也可以是数字,数组就是用来切对应的内容去分块,数字就是按次去切片

callback : 这里需要使用者传一个回调函数,用来通知使用者切片到什么位置

切片队列

1
2
3
4
5
6
7
8
9
10
function* sliceQueue({ sliceList, callback }) {
let listOrNum = (isNum(sliceList) && sliceList) || (isArray(sliceList) && sliceList.length);
for (let i = 0; i < listOrNum; ++i) {
const start = performance.now();
callback(i);
while (performance.now() - start < 16.7) {
yield;
}
}
}

由于可以接收数组和数字,所以要先做兼容处理

接下来就是核心代码其中之一了:

我们要记录回调的执行时间,如果执行需要的时间少于 16.7ms,就停止继续执行下去,释放主线程让主线程可以利用这个时间再去做别的事情

如果大于的话,就在下一次绘制的时候去执行

这个时候大家可能会比较好奇,我们为什么要对任务执行时间短的去做切片,时间长的就不切呢?

其实这个要结合下一段代码来看,大家就会了解的比较清楚了

何时执行下一个切片任务

1
2
3
4
5
6
7
8
9
10
11
function next() {
const { generator } = this;
const start = performance.now();
let res = null;
do {
res = generator.next();
}
while (!res.done && performance.now() - start < 16.7);
if (res.done) return;
raf(this.next.bind(this));
}

有了这段代码,上面最后的长任务的执行没有打断就很好理解了

还是一样,任务执行的时间少于 16.7ms 就继续执行下一个切片任务

如果要是大于的话,我们就不需要执行下一个切片了,我们就要在下一次绘制(requestAnimFrame)的时候,去执行该任务,这样就可以把每一个任务给切开了

完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
<template>
<div id="app">
<div v-for="(item, index) in arr" :key="index">这里是测试数据:{{index}}</div>
</div>
</template>

<script>
import TaskSlice from "./index.js";
import { setTimeout } from "timers";
export default {
name: "app",
data() {
return {
arr: []
};
},
mounted() { // 对比
// before
// var arr = [...new Array(5000).keys()]; //模仿接口请求返回的数据
// this.arr = arr;

// after;
TaskSlice.init({
sliceList: 500,
callback: i => {
this.arr.push(i);
}
});
}
};
</script>

<style>
#app {
font-family: "Avenir", Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: #2c3e50;
}
</style>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
/**
* @name 任务切片
* @description 一个用来做性能优化的工具
* @author xichen Liu
* @version 1.0.0
*/

var raf = window.requestAnimationFrame ? window.requestAnimationFrame.bind(window) : setTimeout;

var isArray = arg => arg instanceof Array && arg.constructor === Array;

var isFunction = arg => arg instanceof Function && arg.constructor === Function;

function TaskSlice() { }

TaskSlice.prototype = {
init({ sliceList, callback }) {
if (!isFunction(callback)) {
console.error('callback 为必传参数并为 function');
return;
}
// 添加切片队列
this.generator = this.sliceQueue({
sliceList,
callback
});
// 开始切片
this.next();
},
/*
* next [执行下次队列]
* 依次执行切片,理想状态下每一个任务的执行时间不应该超过 16.7ms,如果超过了 16.7ms,就在启一个任务
* 详情:https://developers.google.com/web/fundamentals/performance/rail
*/
next() {
const { generator } = this;
const start = performance.now();
let res = null;
res = generator.next();
// do {
// }
// while (!res.done && performance.now() - start < 16.7);
if (res.done) return;
raf(this.next.bind(this));
},
/**
* sliceQueue [切片]
* @param { number } sliceList [切片次数]
* @param { function } callback [切片回调]
*/
* sliceQueue({ sliceList, callback }) {
// 处理次数
for (let i = 0; i < sliceList; ++i) {
// const start = performance.now(); // 返回一个表示从性能测量时刻开始经过的毫秒数
yield callback(i);
// 如果执行需要的时间少于 16.7ms,就停止继续执行下去
// 如果大于的话,就在下一次绘制的时候去执行
// while (performance.now() - start < 16.7) { // 1000/60=16.666666666666668
// yield;
// }
}
}
}

export default new TaskSlice();

before

image

after

image

让你的网页更丝滑

PPT地址:https://ppt.baomitu.com/d/b267a4a3

什么样的网页是流畅的

在讨论如何让网页更流畅之前,需要先思考一个问题就是什么样的网页是流畅的?

这个问题我总结了一句话:在网页与用户产生交互的过程中,让用户感觉流畅

图片

你的网页不一定要有多快,它没有一个标准,你的标准就是让用户感觉流畅就够了。另一个重点就是说在交互过程中,让用户感到流畅。所以延伸出一个问题,如何通过交互让用户感觉流畅。这里面我把交互总结为两种类型,一种是被动的,一种是主动的。

图片

所谓被动交互就是不需要用户主动去触发什么,就可以让网页在视觉上与用户产生交互。
比如说: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

先说Web Worker,我们看一段代码,我的网页里面有一个while循环,通常来讲这个循环会把浏览器卡死一秒钟,因为循环了一秒,现在我把它移动到 worker中 执行,就不会卡死浏览器了,它在worker线层中工作,就不会卡死主线程。这是一种解决方案,可以看一下效果。(由于文章无法演示效果,感兴趣的小伙伴可以到在线PPT里观察 https://ppt.baomitu.com/d/b267a4a3#/14)

1
2
3
4
5
6
7
8
9
10
11
12
13
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

Time Slicing就是把一个长任务给切割成无数个执行时间很短的任务。

图片

可以看到中间用户红框框起来的,内部有很多黄颜色的小竖线,其实每一个都是任务,放大之后,就是图中最下面的火焰图,可以看到中间是有空隙的。因为中间有空隙,浏览器就可以在这些空隙中做其他的事,比方说布局、样式计算、UI事件,所有事情都可以做。

实现时间切片功能的代码也并不是很复杂,就是下面这段代码,其实核心代码只有三四行。代码虽然不多,但是可能理解起来也没有那么容易,我为大家简单介绍一下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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后来我写了一篇文章进行了更详细与全面的介绍,文章地址:#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)

图片

我们在说说绘制的意思。图中白色的框是一个图层,这个框里面有一个黄色的方框;右边的与左边的是同一张图层,但是右边这个图层里面的黄色方块跑右边去了。注意,我同一张图层,但是内容变了,这时候浏览器要做一个事情就是“绘制”,通过重新绘制图层,才能让图层里面的内容发生变化。可以理解为,你有一个画板,你想把方框移到右面,那只能把之前的擦掉然后重新在右面画一个上去。

添加图层可以取消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。

时间切片(Time Slicing)

从用户的输入,再到显示器在视觉上给用户的输出,这一过程如果超过100ms,那么用户会察觉到网页的卡顿,所以为了解决这个问题,每个任务不能超过50ms,W3C性能工作组在LongTask规范中也将超过50ms的任务定义为长任务。

关于这50毫秒我在FDConf的分享中进行了很详细的讲解,没有听到的小伙伴也不用着急,后续我会针对这次分享的内容补一篇文章。

所以为了避免长任务,一种方案是使用Web Worker,将长任务放在Worker线程中执行,缺点是无法访问DOM,而另一种方案是使用时间切片。

什么是时间切片

时间切片的核心思想是:如果任务不能在50毫秒内执行完,那么为了不阻塞主线程,这个任务应该让出主线程的控制权,使浏览器可以处理其他任务。让出控制权意味着停止执行当前任务,让浏览器去执行其他任务,随后再回来继续执行没有执行完的任务。

所以时间切片的目的是不阻塞主线程,而实现目的的技术手段是将一个长任务拆分成很多个不超过50ms的小任务分散在宏任务队列中执行。

LongTask

上图可以看到主线程中有一个长任务,这个任务会阻塞主线程。使用时间切片将它切割成很多个小任务后,如下图所示。

task

可以看到现在的主线程有很多密密麻麻的小任务,我们将它放大后如下图所示。

task2

可以看到每个小任务中间是有空隙的,代表着任务执行了一小段时间后,将让出主线程的控制权,让浏览器执行其他的任务。

使用时间切片的缺点是,任务运行的总时间变长了,这是因为它每处理完一个小任务后,主线程会空闲出来,并且在下一个小任务开始处理之前有一小段延迟。
但是为了避免卡死浏览器,这种取舍是很有必要的。

如何使用时间切片

时间切片是一种概念,也可以理解为一种技术方案,它不是某个API的名字,也不是某个工具的名字。

事实上,时间切片充分利用了“异步”,在早期,可以使用定时器来实现,例如:

1
2
3
4
5
6
btn.onclick = function () {
someThing(); // 执行了50毫秒
setTimeout(function () {
otherThing(); // 执行了50毫秒
});
};

上面代码当按钮被点击时,本应执行100毫秒的任务现在被拆分成了两个50毫秒的任务。

在实际应用中,我们可以进行一些封装,封装后的使用效果类似下面这样:

1
2
3
btn.onclick = ts([someThing, otherThing], function () {
console.log('done~');
});

当然,关于ts这个函数的API的设计并不是本文的重点,这里想说明的是,在早期可以利用定时器来实现“时间切片”。

ES6带来了迭代器的概念,并提供了生成器Generator函数用来生成迭代器对象,虽然Generator函数最正统的用法是生成迭代器对象,但这不妨我们利用它的特性做一些其他的事情。

Generator函数提供了yield关键字,这个关键字可以让函数暂停执行。然后通过迭代器对象的next方法让函数继续执行。

对Generator函数不熟悉的同学,需要先学习Generator函数的用法。

利用这个特性,我们可以设计出更方便使用的时间切片,例如:

1
2
3
4
5
btn.onclick = ts(function* () {
someThing(); // 执行了50毫秒
yield;
otherThing(); // 执行了50毫秒
});

可以看到,我们只需要使用yield这个关键字就可以将本应执行100毫秒的任务拆分成了两个50毫秒的任务。

我们甚至可以将yield关键字放在循环里:

1
2
3
4
5
6
btn.onclick = ts(function* () {
while (true) {
someThing(); // 执行了50毫秒
yield;
}
});

上面代码我们写了一个死循环,但依然不会阻塞主线程,浏览器也不会卡死。

基于生成器的ts实现原理

通过前面的例子,我们会发现基于Generator的时间切片非常好用,但其实ts函数的实现原理非常简单,一个最简单的ts函数只需要九行代码

1
2
3
4
5
6
7
8
9
function ts (gen) {
if (typeof gen === 'function') gen = gen()
if (!gen || typeof gen.next !== 'function') return
return function next() {
const res = gen.next()
if (res.done) return
setTimeout(next)
}
}

代码虽然全部只有9行,关键代码只有3、4行,但这几行代码充分利用了事件循环机制以及Generator函数的特性。

创造出这样的代码我还是很开心的。

上面代码核心思想是:通过yield关键字可以将任务暂停执行,从而让出主线程的控制权;通过定时器可以将“未完成的任务”重新放在任务队列中继续执行。

避免把任务分解的过于零碎

使用yield来切割任务非常方便,但如果切割的粒度特别细,反而效率不高。假设我们的任务执行100ms,最好的方式是切割成两个执行50ms的任务,而不是切割成100个执行1ms的任务。假设被切割的任务之间的间隔为4ms,那么切割成100个执行1ms的任务的总执行时间为:

1
(1 + 4) * 100 = 500ms

如果切割成两个执行时间为50ms的任务,那么总执行时间为:

1
(50 + 4) * 2 = 108ms

可以看到,在不影响用户体验的情况下,下面的总执行时间要比前面的少了4.6倍。

保证切割的任务刚好接近50ms,可以在用户使用yield时自行评估,也可以在ts函数中根据任务的执行时间判断是否应该一次性执行多个任务。

我们将ts函数稍微改进一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function ts (gen) {
if (typeof gen === 'function') gen = gen()
if (!gen || typeof gen.next !== 'function') return
return function next() {
const start = performance.now()
let res = null
do {
res = gen.next()
} while(!res.done && performance.now() - start < 25);

if (res.done) return
setTimeout(next)
}
}

现在我们测试下:

1
2
3
4
5
6
7
8
ts(function* () {
const start = performance.now()
while (performance.now() - start < 1000) {
console.log(11)
yield
}
console.log('done!')
})();

这段代码在之前的版本中,在我的电脑上可以打印出 215 次 11,在后面的版本中可以打印出 6300 次 11,说明在总时间相同的情况下,可以执行更多的任务。

再看另一个例子:

1
2
3
4
5
6
7
ts(function* () {
for (let i = 0; i < 10000; i++) {
console.log(11)
yield
}
console.log('done!')
})();

在我的电脑上,这段代码在之前的版本中,被切割成一万个小任务,总执行时间为 46秒,在之后的版本中,被切割成 52 个小任务,总执行时间为 1.5秒。

总结

我将时间切片的代码放在了我的Github上,感兴趣的可以参观下:https://github.com/berwin/time-slicing

原文转自