阅读 1483

通过定时器、时间分片、Web Worker优化长任务

提示

希望你能了解什么是 Event Loop(事件循环),以及对 Web Worker 有所了解,以便更容易吸收

什么是长任务

W3C 性能组规定:执行时长大于 50ms 的任务,定义为长任务

那么我们如何对长任务进行优化?

setTimeout

js 是单线程语言,它的作用主要用于操作DOM。 js执行也非常简单(从上往下执行),但 js 里也有异步方法,比如 xhr 、 setTimeout 等等。

setTimeout 的作用是:将当前任务推入任务队列,当主线程同步的代码执行完成后判断 setTimeout 设置的时间有没有到,如果到了将任务推出队列执行。

下面是 Event Loop 示例图:

由于长任务执行时间长,会阻塞主线程,用户能感觉到页面卡顿,所以我们经常会采用 setTimeout 把长任务推入任务队列,等到同步代码执行完成后再处理长任务:

const t = setTimeout(() =>{
    clearTimeout(t);
    // 长任务
},0)
复制代码

注意点

由于前端处理长任务的场景并不多,一般由服务端处理完后给到前端,所以 setTimeout 能解决大部分的长任务问题,但是该方法不能用于时间过长的任务,比如一个任务需要秒级时长,用户仍然会感觉页面卡死。

下面这个场景也会导致卡顿:

 #box {
        width: 100px;
        height: 100px;
        background: green;
      }
      
<div id="box"></div>

// 动画 大概需要2s跑完
var start = null;
var element = document.getElementById('box');
element.style.position = 'absolute';

function step(timestamp) {
  if (!start) start = timestamp;
  var progress = timestamp - start;
  element.style.left = Math.min(progress / 10, 200) + 'px';
  if (progress < 2000) {
    window.requestAnimationFrame(step);
  }
}

window.requestAnimationFrame(step);


setTimeout(function() {
  // 500ms的长任务
  var now = performance.now();
  while (now + 500 > performance.now()) {}
}, 100);
复制代码

上面代码中使用 setTimeout 来处理长任务,但是页面中有个需要 2s 跑完的动画效果,当执行到 setTimeout 时,动画仍在运行,这时在队列里的长任务被推出并执行导致主线程阻塞,动画出现卡顿,这时最好不阻塞主线程,那么使用 Web Worker 是最合适的(Web Worker 在下文介绍)

使用时间分片

时间分片并不是某个 api,而是一种技术方案,它可以把长任务分割成若干个小任务执行,并在执行小任务的间隔中把主线程的控制权让出来,这样就不会导致UI卡顿。

React 的 Fiber 技术核心思想也是时间分片,Vue 2.x 也用了时间分片,只不过是以组件为单位来实施分片操作,由于收益不高 Vue 3 把时间分片移除了。

演示

为了好理解,先写段长任务代码,将主线程阻塞 1秒钟:

const start = performance.now();
let count = 0;
while (performance.now() - start < 1000) {}
console.log('done!');

复制代码

该段脚本霸占主线产长达 1s 的时间 我们可以封装一个 ts 方法,让这个长任务被分割成多个小任务执行:

ts(function* (){
    const start = performance.now();
    let count = 0;
    while (performance.now() - start < 1000) {
        yield;
    }
    console.log('done!');
})()
复制代码

先看看效果吧:

从图里看到,一个长任务被切成了诺干个小任务,在每个小任务间隔中把主线程的控制权交出来,这样就不会导致页面卡顿

基于 Generator 函数实现时间分片方法

基于 Generator 函数的执行特性,我们很容易使用它来实现一个时间分片函数:

function ts(gen) {
  if (typeof gen === 'function') gen = gen();
  if (!gen || typeof gen.next !== 'function') return;
  return function next() {
    const start = performance.now();
    const res = null;
    do {
      res = gen.next();
    } while (!res.done && performance.now() - start < 25);
    if (res.done) return;
    setTimeout(next);
  };
}
复制代码

上面代码中,做了一个 do while:如果当前任务执行时间低于 25ms 则多个任务一起执行,否则作为一个任务执行,一直到 res.done = true 为止。

代码核心思想:通过 yield 关键字可以将任务暂停执行,并让出主线程的控制权;通过setTimeout将未完成的任务重新放在任务队列中执行

使用 Web Worker 优化

文章开头提过 js 是单线程,但浏览器不是。 Web Worker 允许我们在后台创建独立于主线程的其他线程,所以我们可以把一些费时费力的长任务交给 Web Worker。

使用

由于共享线程浏览器支持情况较差,本章我们只介绍专用线程。

我们创建一个文件夹,并在里面创建 index.html 和 worker.js 目录如下:

.
├── index.html
└── worker.js
复制代码

index.html 代码:

<input type="text" id="ipt" value="" />
<div id="result"></div>

<script>
    const ipt = document.querySelector('#ipt');
    const worker = new Worker('worker.js');
    
    ipt.onchange = function() {
      // 通过postMessage发送消息
      worker.postMessage({ number: this.value });
    };
    
    // 通过onmessage接收消息
    worker.onmessage = function(e) {
      document.querySelector('#result').innerHTML = e.data;
    };
</script>
复制代码

worker.js 代码:

// self 类似主线程中的 window
self.onmessage = function(e) {
  self.postMessage(e.data.number * 2);
};
复制代码

总结

  • 对于大多数场景 setTimeout 够用,但要注意使用场景
  • 需要更长时间来执行的任务,可以使用时间分片或者交给 Web Worker 来解决

参考