一般写倒计时效果,用的是 setInterval,但这会引起一些问题,最多见的问题就是定时器不许。css
若是只是普通的动画效果,倒也无所谓,但倒计时这种须要精确到毫秒级别的,就不行了,不然活动都结束了,用户的界面上倒计时还在走,可是又参加不了活动,会被投诉的╮(╯▽╰)╭html
先说本文的主角 setInterval,MDN web doc 对其的解释是:vue
setInterval() 方法重复调用一个函数或执行一个代码段,在每次调用之间具备固定的时间延迟。webpack
返回一个 intervalID。(可用于清除定时器)git
语法: let intervalID = window.setInterval(func, delay[, param1, param2, ...]);
例:github
值得注意的是,在 setInterval 里面使用 this 的话,this 指向的是 window 对象,能够经过 call、apply 等方法改变 this 指向。web
setTimeout 与 setInterval 相似,只不过延迟 n 毫秒执行函数一次,且不须要手动清除。面试
至于 setTimeout 和 setInterval 的运行原理,就要牵扯到另外一个概念: event loop (事件循环)。ajax
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');
复制代码
打印顺序为:
至于为何 setTimeout 设置为 0,却在最后被打印,这就涉及到 event loop 中的微任务和宏任务了。
不一样的任务源会被分配到不一样的 task 队列中,任务源可分为微任务( microtask )和宏任务( macrotask ).
在 ES6 中:
macro-task(Task): 一个 event loop 有一个或者多个 task 队列。task 任务源很是宽泛,好比 ajax 的 onload,click 事件,基本上咱们常常绑定的各类事件都是 task 任务源,还有数据库操做(IndexedDB ),须要注意的 是setTimeout、setInterval、setImmediate 也是 task 任务源。总结来讲 task 任务源:
micro-task(Job): microtask 队列和 task 队列有些类似,都是先进先出的队列,由指定的任务源去提供任务,不一样的是一个 event loop 里只有一个 microtask 队列。另外 microtask 执行时机和 macrotasks 也有所差别
ps: 微任务并不快于宏任务
ps: 若是宏任务中的异步代码有大量的计算而且须要操做 DOM 的话,为了更快的界面响应,可把操做放微任务中。
setTimeout 在第一次执行时,会挂起到 task, 等待下一轮 event loop,而执行一次 event loop 最少须要 4ms,这就是为何哪怕setTimeout(()=>{...}, 0)
都会有 4ms 的延迟。
因为 JavaScript 是单线程,因此 setInterval / setTimeout 的偏差是没法被彻底解决的。
多是回调中的事件,也多是浏览器中的各类事件致使的。
这也是为何一个页面运行久了,定时器会不许的缘由。
在公司项目中遇到了倒计时的需求,可是已有前人写过组件了,由于项目时间赶,因此直接拿来用了,但使用的过程当中,发现一些 Bug:
第一个 Bug 是由于滑动阻塞了主线程,致使 macrotask 没有正常的执行。
第二个 Bug 是由于切换页面后,浏览器为了下降性能的消耗,会自动的延长以前页面定时器的间隔,致使偏差愈来愈大。
第三个 Bug 是由于调用方法以前,没有清除定时器,致使监听时间戳的时候,又新增了定时器。
前两个 Bug 才是本文要解决的地方。
查了不少文章,大体解决方案有如下两种:
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。
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 之,发现有两种解决方案;
这是 simple-web-worker 的做者针对 Vue 项目编写的插件,它能够经过像 Promise 那样调用函数。
Github地址: vue-worker
可是在使用过程当中发现一些问题,那就是 setInterval 并不会执行:
传入的 val 是倒计时剩余的时间戳,可是运行发现,return 出去的 val 并无改变,也就是 setInterval 并无执行。理论上 Web Worker 会保留 setInterval 的。(多是个人姿式有问题?去提了 issues,如今仍是没有人答复,有大佬指教吗?)
倒计时最核心的 setInterval 没法执行,所以弃用此插件,执行 Plan B。
这是和 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 动画来完成,尽量的提升页面的流畅度。
第一次写技术博客,才疏学浅,不免有遗漏之处,若是还有更好的倒计时解决方案,欢迎各位大佬指教。
参考资料: