写点什么

【得物技术】时间切片的实践与应用

用户头像
得物技术
关注
发布于: 刚刚

0x1:前言

每一个拥有【高级资深】title 的前端工程师,必定会对项目的整体性能优化有自己的独到见解。这是往前端业务架构方向转变的必须要具备的能力之一。

本文就给大家介绍一个性能优化的手段之一:时间切片(Time Slicing)

根据W3C性能小组的介绍,超过 50ms 的任务就是长任务。


表格内容摘抄自使用 RAIL 模型评估性能

根据上面的表格描述我们可以知道,当延迟超过 100ms,用户就会察觉到轻微的延迟。所以为了解决这个问题,每个任务不能超过 50ms。

为了避免当延迟超过 100ms,用户就会察觉到轻微的延迟这种情况,我们可以使用两种方案,一种是 Web Worker,另一种是时间切片(Time Slicing)

0x2:web worker

测试Demo代码在此

众所周知,JavaScript 语言采用的是单线程模型,也就是说,所有任务只能在一个线程上完成,一次只能做一件事。前面的任务没做完,后面的任务只能等着。

针对我们业务上来讲,一旦我们执行了过多的长任务,执行过程很容易就被阻塞,出现页面假死的现象。虽然我们可以将任务放在任务队列中,通过异步的方式执行,但这并不能改变 JS 的本质。

所以为了改变这种现状,whatwg推出了Web Workers

关于 web worker,不需要深入,想了解的同学可以查看MDN - web worker

  1. Web Worker 为 Web 内容在后台线程中运行脚本提供了一种简单的方法。

  2. 线程可以执行任务而不干扰用户界面。

  3. 可以使用XMLHttpRequest执行 I/O (尽管responseXMLchannel属性总是为空)。一旦创建, 一个 worker 可以将消息发送到创建它的 JavaScript 代码, 通过将消息发布到该代码指定的事件处理程序(反之亦然)。

Worker 线程一旦新建成功,就会始终运行,不会被主线程上的活动(比如用户点击按钮、提交表单)打断。这样有利于随时响应主线程的通信。但是,这也造成了 Worker 比较耗费资源,不应该过度使用,而且一旦使用完毕,就应该关闭。

Web Worker 有以下几个使用注意点。

  1. 同源限制:分配给 Worker 线程运行的脚本文件,必须与主线程的脚本文件同源。

  2. DOM 限制:Worker 线程所在的全局对象,与主线程不一样,无法读取主线程所在网页的 DOM 对象,也无法使用documentwindowparent这些对象。但是,Worker 线程可以navigator对象和location对象。

  3. 通信联系:Worker 线程和主线程不在同一个上下文环境,它们不能直接通信,必须通过消息完成。

  4. 脚本限制:Worker 线程不能执行alert()方法和confirm()方法,但可以XMLHttpRequest对象发出 AJAX 请求。

  5. 文件限制:Worker 线程无法读取本地文件,即不能打开本机的文件系统(file://),它所加载的脚本,必须来自网络。

我们可以看看使用了Web Worker之后的优化效果:

worker.js

self.onmessage = function () {  const start = performance.now()  while (performance.now() - start < 1000) {}  postMessage('done!')}
复制代码

myWorker.js

const myWorker = new Worker('./worker.js')setTimeout(_ => {  myWorker.postMessage({})  myWorker.onmessage = function (ev) {    console.log(ev.data)  }}, 5000)
复制代码



测试Demo代码在此, 有兴趣的小伙伴可以 down 下来学习

0x3:什么是时间切片

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

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



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



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



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

使用时间切片的缺点是,任务运行的总时间变长了,这是因为它每处理完一个小任务后,主线程会空闲出来,并且在下一个小任务开始处理之前有一小段延迟。

但是为了避免卡死浏览器,这种取舍是很有必要的。

0x4:如何实践时间切片

时间切片充分利用了“异步”,在早期,可以使用定时器来实现,我们称之为“手动切片”,例如:

btn.onclick = function () {  someThing(); // 执行了50毫秒  setTimeout(function () {    otherThing(); // 执行了50毫秒  });};
复制代码

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

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

btn.onclick = timeSlicing([someThing, otherThing], function () {  console.log('done~');});
复制代码

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

如果切片的粒度不大,那么手动自己改造函数其实也能接受,但是如果需要切割成粒度非常小的逻辑,那么使用generator函数特性,会更加方便。

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

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

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

btn.onclick = timeSlicing(function* () {  someThing(); // 执行了50毫秒  yield;  otherThing(); // 执行了50毫秒});
复制代码

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

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

btn.onclick = timeSlicing(function* () {  while (true) {    someThing(); // 执行了50毫秒    yield;  }});
复制代码

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

下面我们正式利用 Generator 开始封装一个时间切片执行器。利用generator的特性把每一次 yield 都放在requestIdleCallback里执行,直到全部执行完毕,就可以轻松达到时间切片的效果了。

//首先我们封装一个时间切片执行器function timeSlicing(gen) { if (typeof gen !== "function") throw new Error("TypeError: the param expect a generator function"); var g = gen(); if (!g || typeof g.next !== "function") return; return function next() { var start = performance.now(); var res = null; do { res = g.next(); } while (res.done !== true && performance.now() - start < 25); if (res.done) return; window.requestIdleCallback(next); };}//然后把长任务变成generator函数,交由时间切片执行器来控制执行const add = function(i){ let item = document.createElement("li"); item.innerText = 第${i++}条; listDom.appendChild(item); }function* gen(){ let i=0; while(i<100000){ yield add(i); i++ }}//使用时间切片来插入10W条数据function bigInsert(){ timeSlice(gen)()}
复制代码

0x5:时间切片实现斐波那契数列

每学习一门新编程语言,便就会被要求自己重新实现一遍斐波那契数列算法。那时,常用的方法即递归法和递推法。那时只对结果感兴趣,只要结果出来了,其他的仿佛就无所谓了。

在了解了 generator 生成器的方法后,便开始可以尝试使用 generator 方法去切片长任务执行。

首先介绍下斐波那契序 0,1,1,2,3,5,8,... 就每一项的值都是前两项相加得到的。

递归方法:

首先,先把之前的递归方法再再再实现一遍。

const fibonacci = (n) => {    if(n === 0 || n === 1)        return n;    return fibonacci(n-1) + fibonacci(n-2);}
// 调用console.log(fibonacci(40))
复制代码

递归的思路很简单,即不断调用自身方法,直到 n 为 1 或 0 之后,开始一层层返回数据。

使用递归计算大数字时,性能会特别低,原因有以下 2 点:

  1. 在递归过程中,每创建一个新函数,解释器都会创建一个新的函数栈帧,并且压在当前函数的栈帧上,这就形成了调用栈。因而,当递归层数过大之后,就可能造成调用栈占用内存过大或者溢出。

  2. 分析可以发现,递归造成了大量的重复计算。

generator 生成器:

Generator 是 ES2015 的新特性,得益于该特性,我们可以使用生成器方法,制作一个斐波那契数列生成器。

function *fibonacci(n, current = 0, next = 1) {    if (n === 0) {        return current;    }    yield current;    yield *fibonacci(n-1, next, current + next);}
// 调用const [...data] = fibonacci(num)console.log(data);
复制代码

测试Demo代码在此

0x6:总结

时间切片不是什么高级的 api,而是一种根据浏览器渲染特性衍生出的优化方案,是一种优化思想,把计算量过大,容易阻塞渲染的逻辑切割成一个个小的任务来执行,留给浏览器渲染的时间来达到肉眼可见的流畅,本质上并没有优化什么 js 的计算性能,所以,有些算法的逻辑该优化还是需要从算法的思想上去优化。


文/Davis

关注得物技术,做最潮技术人!

发布于: 刚刚阅读数: 2
用户头像

得物技术

关注

得物APP技术部 2019.11.13 加入

关注微信公众号「得物技术」

评论

发布
暂无评论
【得物技术】时间切片的实践与应用