前言
通常写倒计时效果,用的是 setInterval,但这会引发一些问题,最常见的问题就是定时器不准。
如果只是普通的动画效果,倒也无所谓,但倒计时这种需要精确到毫秒级别的,就不行了,否则活动都结束了,用户的界面上倒计时还在走,但是又参加不了活动,会被投诉的╮(╯▽╰)╭
一、 知识铺垫
1. setInterval 定时器
先说本文的主角 setInterval,MDN web doc 对其的解释是:
setInterval() 方法重复调用一个函数或执行一个代码段,在每次调用之间具有固定的时间延迟。
返回一个 intervalID。(可用于清除定时器)
语法: let intervalID = window.setInterval(func, delay[, param1, param2, ...]);
例:
值得注意的是,在 setInterval 里面使用 this 的话,this 指向的是 window 对象,可以通过 call、apply 等方法改变 this 指向。
setTimeout 与 setInterval 类似,只不过延迟 n 毫秒执行函数一次,且不需要手动清除。
至于 setTimeout 和 setInterval 的运行原理,就要牵扯到另一个概念: event loop (事件循环)。
2. 浏览器的 Event Loop
JavaScript 在执行的过程中会产生执行环境,这些执行环境会被顺序的加入到执行栈中,若遇到异步的代码,会被挂起并加入到 task (有多种 task) 队列中。
一旦执行栈为空, event loop 就会从 task 队列中拿出需要执行的代码并放入执行栈中执行。
有了 event loop,使得 JavaScript 具备了异步编程的能力。(但本质上,还是同步行为)
先看一道经典的面试题:
console.log('Script start');
setTimeout(() => {
console.log('setTimeout');
}, 0);
new Promise((resolve, reject) => {
console.log('Promise');
resolve()
}).then(() => {
console.log('Promise 1');
}).then(() => {
console.log('Promise 2');
});
console.log('Scritp end');
打印顺序为:
- “Script start”
- “Promise”
- “Script end”
- “Promise 1”
- “Promise 2”
- “setTimeout”
至于为什么 setTimeout 设置为 0,却在最后被打印,这就涉及到 event loop 中的微任务和宏任务了。
2.1 宏任务和微任务
不同的任务源会被分配到不同的 task 队列中,任务源可分为微任务( microtask )和宏任务( macrotask ).
在 ES6 中:
- microtask 称为 Job
- macrotask 称为 Task
macro-task(Task): 一个 event loop 有一个或者多个 task 队列。task 任务源非常宽泛,比如 ajax 的 onload,click 事件,基本上我们经常绑定的各种事件都是 task 任务源,还有数据库操作(IndexedDB ),需要注意的 是setTimeout、setInterval、setImmediate 也是 task 任务源。总结来说 task 任务源:
- setTimeout
- setInterval
- setImmediate
- I/O
- UI rendering
micro-task(Job): microtask 队列和 task 队列有些相似,都是先进先出的队列,由指定的任务源去提供任务,不同的是一个 event loop 里只有一个 microtask 队列。另外 microtask 执行时机和 macrotasks 也有所差异
- process.nextTick
- promises
- Object.observe
- MutationObserver
ps: 微任务并不快于宏任务
2.2 Event Loop 执行顺序
- 执行同步代码(宏任务);
- 执行栈为空,查询是否有微任务需要执行;
- 执行所有微任务;
- 必要的话渲染 UI;
- 然后开始下一轮 event loop,执行宏任务中的异步代码;
ps: 如果宏任务中的异步代码有大量的计算并且需要操作 DOM 的话,为了更快的界面响应,可把操作放微任务中。
setTimeout 在第一次执行时,会挂起到 task, 等待下一轮 event loop,而执行一次 event loop 最少需要 4ms,这就是为什么哪怕setTimeout(()=>{...}, 0)
都会有 4ms 的延迟。
由于 JavaScript 是单线程,所以 setInterval / setTimeout 的误差是无法被完全解决的。
可能是回调中的事件,也可能是浏览器中的各种事件导致的。
这也是为什么一个页面运行久了,定时器会不准的原因。
二、项目场景
在公司项目中遇到了倒计时的需求,但是已有前人写过组件了,因为项目时间赶,所以直接拿来用了,但使用的过程中,发现一些 Bug:
- 在某台安卓测试机上,手指滑动或者将要滑动的时候,毫秒数会停住,松开后才会继续走;
- 去到其他页面之后再回来,倒计时的分秒数不正确;
- 回到原来页面之后,重新请求数据,会导致倒计时加快;
第一个 Bug 是因为滑动阻塞了主线程,导致 macrotask 没有正常的执行。
第二个 Bug 是因为切换页面后,浏览器为了降低性能的消耗,会自动的延长之前页面定时器的间隔,导致误差越来越大。
第三个 Bug 是因为调用方法之前,没有清除定时器,导致监听时间戳的时候,又新增了定时器。
前两个 Bug 才是本文要解决的地方。
查了很多文章,大致解决方案有以下两种:
1. requestAnimationFrame()
MDN web doc 的解释如下:
window.requestAnimationFrame() 告诉浏览器——你希望执行一个动画,并且要求浏览器在下次重绘之前调用指定的回调函数更新动画。该方法需要传入一个回调函数作为参数,该回调函数会在浏览器下一次重绘之前执行
注意: 若你想在浏览器下次重绘之前继续更新下一帧动画,那么回调函数自身必须再次调用window.requestAnimationFrame()
requestAnimationFrame() 的执行频率取决于浏览器屏幕的刷新率,通常的屏幕都是 60Hz 或 75Hz,也就是每秒最多只能重绘60次或75次,requestAnimationFrame 的基本思想就是与这个刷新频率保持同步,利用这个刷新频率进行页面重绘。此外,使用这个API,一旦页面不处于浏览器的当前标签,就会自动停止刷新。这就节省了CPU、GPU和电力。
不过要注意:requestAnimationFrame 是在主线程上完成。这意味着,如果主线程非常繁忙,requestAnimationFrame 的动画效果会大打折扣。
利用 requestAnimationFrame 可以在一定程度上替代 setInterval,不过时间间隔需要计算,按 60Hz 的屏幕刷新率( fps )来算的话,1000 / 60 = 16.6666667(ms),也就是每16.7ms执行一次,但 fps 并不是固定的,有玩过 FPS(第一人称射击游戏)的玩家会深有体会。不过相对于之前不做任何优化的 setInterval 来说,误差要比原来的小得多。
我的解决方案是,设置一个变量 then,在执行动画函数之后,记录当前时间戳,再下一次进入动画函数的时候,用 [当前时间戳] 减去 [then] ,得到时间间隔,然后让 [倒计时时间戳] 减去 [间隔],并在离开页面时记录离开时间,进一步减小误差。
<script>
export default {
name: "countdown",
props: {
timestamp: {
type: Number,
default: 0
}
},
data() {
return {
remainTimestamp: 0
then: 0
};
},
activated () {
window.requestAnimationFrame(this.animation);
},
deactivated() {
this.then = Date.now();
},
methods: {
animation(tms) {
if (this.remainTimestamp > 0 && this.then) {
this.remainTimestamp -= (tms - this.then); // 减去当前与上一次执行的间隔
this.then = tms; // 记录本次执行的时间
window.requestAnimationFrame(this.animation);
}
}
},
watch: {
timestamp(val) {
this.remainTimestamp = val;
this.then = Date.now();
window.requestAnimationFrame(this.animation);
}
}
};
</script>
requestAnimationFrame 在使用过程中和 setInterval 还是有区别的,最大的区别就是不能自定义间隔时间。
如果倒计时只需要精确到秒,那么 1000ms 内执行 16.7 次对性能有点过于浪费了。而如果要模拟 setInterval ,还需要额外的变量去处理间隔,也降低了代码的可读性。
因此就继续尝试第二种方案: Web Worker。
2. Web Worker
Web Worker 是 JavaScript 实现多线程的黑科技,在阮一峰博客的解释如下:
JavaScript 语言采用的是单线程模型,也就是说,所有任务只能在一个线程上完成,一次只能做一件事。前面的任务没做完,后面的任务只能等着。随着电脑计算能力的增强,尤其是多核 CPU 的出现,单线程带来很大的不便,无法充分发挥计算机的计算能力。
Web Worker 的作用,就是为 JavaScript 创造多线程环境,允许主线程创建 Worker 线程,将一些任务分配给后者运行。在主线程运行的同时,Worker 线程在后台运行,两者互不干扰。等到 Worker 线程完成计算任务,再把结果返回给主线程。这样的好处是,一些计算密集型或高延迟的任务,被 Worker 线程负担了,主线程(通常负责 UI 交互)就会很流畅,不会被阻塞或拖慢。
Worker 线程一旦新建成功,就会始终运行,不会被主线程上的活动(比如用户点击按钮、提交表单)打断。这样有利于随时响应主线程的通信。但是,这也造成了 Worker 比较耗费资源,不应该过度使用,而且一旦使用完毕,就应该关闭。
具体教程可以看 阮一峰的博客 和 MDN - 使用 Web Workers ,不再赘述。
但是要在 Vue 项目中使用 Web Worker 的话,还是需要一番折腾的。
首先是文件载入,官方的例子是这样的:
var myWorker = new Worker('worker.js');
由于 Worker 不能读取本地文件,所以这个脚本必须来自网络。如果下载没有成功(比如404错误),Worker 就会默默地失败。
因此,我们就不能直接用 import 引入,否则会找不到文件,遂 Google 之,发现有两种解决方案;
2.1 vue-worker
这是 simple-web-worker 的作者针对 Vue 项目编写的插件,它可以通过像 Promise 那样调用函数。
Github地址: vue-worker
但是在使用过程中发现一些问题,那就是 setInterval 并不会执行:
传入的 val 是倒计时剩余的时间戳,但是运行发现,return 出去的 val 并没有改变,也就是 setInterval 并没有执行。理论上 Web Worker 会保留 setInterval 的。(可能是我的姿势有问题?去提了 issues,现在还是没有人答复,有大佬指教吗?)
倒计时最核心的 setInterval 无法执行,因此弃用此插件,执行 Plan B。
2.2 worker-loader
这是和 babel-loader 类似的 JavaScript 文件转义插件,具体使用已经有大神总结了,就不再赘述:
怎么在 ES6+Webpack 下使用 Web Worker
直接贴代码:
timer.worker.js:
self.onmessage = function(e) {
let time = e.data.value;
const timer = setInterval(() => {
time -= 71;
if(time > 0) {
self.postMessage({
value: time
});
} else {
clearInterval(timer);
self.postMessage({
value: 0
});
self.close();
}
}, 71)
};
countdown.vue:
<script>
import Worker from './timer.worker.js'
export default {
name: "countdown",
props: {
timestamp: {
type: Number,
default: 0
}
},
data() {
return {
remainTimestamp: 0
};
},
beforeDestroy () {
this.worker = null;
},
methods: {
setTimer(val) {
this.worker = new Worker();
this.worker.postMessage({
value: val
});
const that = this;
this.worker.onmessage = function(e) {
that.remainTimestamp = e.data.value;
}
}
},
watch: {
timestamp(val) {
this.worker = null;
this.setTimer(val);
}
}
};
</script>
这里出现了一个小插曲,本地运行的时候没问题,但是打包的时候报错,排查原因是把 worker-loader 的 rules 写在了 babel-loader 的后面,结果先匹配的 .js 文件,直接把 .worker.js 用 babel-loader 处理了,导致 worker 没能引入成功,打包报错:
webpack.base.conf.js (公司项目比较老,没有使用 webpack 4.0+ 的配置方式,不过原理是一样的)
module: {
rules: [
{
test: /\.vue$/,
loader: 'vue-loader',
options: {
vueLoaderConfig,
postcss: [
require('autoprefixer')({
browsers: ['last 10 Chrome versions', 'last 5 Firefox versions', 'Safari >= 6', 'ie > 8']
})
]
}
},
{
// 匹配的需要写在前面,否则会打包报错
test: /\.worker\.js$/,
loader: 'worker-loader',
include: resolve('src'),
options: {
inline: true, // 将 worker 内联为一个 BLOB
fallback: false, // 禁用 chunk
name: '[name]:[hash:8].js'
}
},
{
test: /\.js$/,
loader: 'babel-loader',
include: [utils.resolve('src'), utils.resolve('test')]
},
// ...
]
},
三、总结
经过一番折腾,对浏览器的 event loop 又加深了理解,不只是 setInterval 这样的定时器任务 ,其他高密集的计算也可以利用多线程去处理,不过要注意处理完毕后关闭线程,否则会严重消耗资源。 不过普通的动画还是尽量用 requestAnimationFrame 或者 CSS 动画来完成,尽可能的提高页面的流畅度。
第一次写技术博客,才疏学浅,难免有遗漏之处,如果还有更好的倒计时解决方案,欢迎各位大佬指教。
参考资料:
- 浏览器事件循环机制
- Web Worker 使用教程 - 阮一峰
- worker-loader 官方文档
- 怎么在 ES6+Webpack 下使用 Web Worker
版权声明:著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 作者: 布拉德特皮 原文链接:https://juejin.im/post/6844903825115971592