咱们经常使用 setInterval
来实现前端倒计时,最近工做中遇到了两个小坑,给你们分享分享javascript
在移动端,浏览器切到后台,页面的定时器就被暂停了,从新打开浏览器时,倒计时才继续执行,这就致使倒计时执行时长变长了。前端
一个页面有多个倒计时,好比商品列表的开售倒计时,当页面打开停留较长时间后,会发现有的商品倒计时会不许确。java
咱们逐个分析看看浏览器
定时器被暂停是由于浏览器将页面的线程中止了,毕竟浏览器已经被切到后台,为了性能考虑,因此将页面线程中止也是合理的,这就致使咱们的定时器一并被暂停,可产品可不一样意这操做啊。dom
只能祭出杀手锏:这个需求实现不了,而后含泪接着改 bug异步
看个栗子函数
let text = document.querySelector('#text');
let beginTime = document.querySelector('#beginTime');
let end = document.querySelector('#end');
let runTime = 10 * 1000;
beginTime.innerHTML = moment().format('YYYY-MM-DD HH:mm:ss');
let interval = setInterval(() => {
if (runTime > 0) {
runTime -= 1000
text.innerHTML = 'currentTime:' + moment().format('YYYY-MM-DD HH:mm:ss')
} else {
end.innerHTML = 'end';
clearInterval(interval)
}
}, 1000)
复制代码
runTime
表示倒计时的执行总时长, currentTime
显示每次执行时的当前时间,那么指望时最后一次的执行时间应该等于倒计时开始的时间加上执行的时长 currentTime = beginTime + runTime
以下图性能
看看移动端下的效果ui
最后一次的执行时间明显超过了预约运行时长,缘由上文说了,因此运行的时长须要改为以下 剩余运行时长 = 运行时长 - 间隔时间 - 线程暂停时间
线程暂停时间就是与上一次的执行时间间隔。this
把定时器部分红修改以下,watchTimeInterval
是封装后的自带校准的定时器函数
watchTimeInterval(runTime,1000,()=>{
text.innerHTML = 'currentTime: ' + moment().format('YYYY-MM-DD HH:mm:ss') //new Date().getTime();
},()=>{
end.innerHTML = ' interval end';
})
/** * @description * 倒计时-计时器-浏览器进程切后台后,去除进程暂停时间 * @param {number} time 倒计时时长,单位毫秒 * @param {number} point 倒计时间隔 * @param {function} func 倒计时执行函数 * @param {function} timeOverFunc 倒计时结束执行函数 * @returns {TimeOut} 倒计时惟一标识 * @example * Utils.watchTimeInterval(10*1000, 1000, () => {}, () => {}) */
function watchTimeInterval(time, point, func, timeOverFunc) {
let _time = time;
let startTime = new Date().valueOf();
let interval = setInterval(() => {
let gap = (new Date().valueOf() - startTime - point);
if (gap < 0) {
gap = 0;
}
_time = _time - gap;
startTime = new Date().valueOf();
if (_time > 0) {
func && func();
_time -= point;
} else {
interval && clearInterval(interval)
timeOverFunc && timeOverFunc();
}
}, point)
return interval
}
复制代码
在看看在移动端下的效果
这下就算浏览器切到了后台,最后的执行时间仍在预设范围内,哈哈,解决,下班~ 没有咱解决不了的bug(嘘~这句话可别让产品听到了)
熟悉js事件循环的小伙伴都知道,js中的异步事件是经过一个循环队列来实现的,定时器的回调函数会进入到宏队列中,等待被执行,因此定时器的执行时间并非百分百准确的,若是主线程被阻塞(咱们这里暂时先不考虑这种状况)或者循环队列有多个任务,或其中有耗时的操做,那么定时器就会慢慢变得有偏差。
再回到场景中,页面中同时存在多个定时器,就意味着循环队列中会同时存在多个回调函数在等待执行,若回调函数中有一些同步的数据请求或耗时的时间计算等,在页面打开的前一小段时间也许看不出来,但当页面打开较长时间,累积的偏差愈来愈大,若用户已经打开页面,就等着倒计时结束抢购,结果等页面倒计时结束时,抢购早已开始,用户反手就是一个投诉啊。
致使这一问题的根本缘由就在于同时存在的定时器太多了,生成的回调事件都在排队等着执行。减小定时器的数量就是咱们须要解决的问题。
我采用的办法即是:采用观察者模式来实现。
观察者模式是一对多的设计,多个订阅者在观察者中添加订阅,观察者发现变化,通知相应的订阅者,以下简图:
接下来就是要去设计观察者和订阅者。
观察者只有一个,咱们将定时器放在观察者中就很是适合,观察者经过观察定时器的变化,进而根据预设的条件通知订阅者。
/** * @description * 为解决页面中同时存在多个倒计时的状况下,生成多个计时器致使计时出现误差的问题。 * 采用观察者模式,由一个定时器控制多个倒计时事件 * @class SuspendTimeNotify */
class SuspendTimeNotify {
/** * Creates an instance of SuspendTimeNotify. * @param {*} [ intervalPoint=200 ] intervalPoint:计时器执行的间隔时间 * @memberof SuspendTimeNotify */
constructor(params) {
const {intervalPoint=200} = params || {}
this._currentTime = Date.now(); // 定时器回调函数执行的当前时间点
this._passTime = 0; // 已经执行的时长
this.observers = []; // 订阅者列表
this._interval = null; // 定时器id
this._intervalPoint = intervalPoint // 定时器间隔
}
/** * @description * 添加订阅者 * @param {object} observer * @memberof SuspendTimeNotify */
attach(observer) {
let item = {
key: `${this.observers.length}_key`,
target: observer
}
this.observers.push(item);
}
/** * @description * 中止观察者的倒计时对象和状况订阅者 * @memberof SuspendTimeNotify */
stop() {
this.observers = [];
this._interval && clearInterval(this._interval)
}
/** * @description * 通知订阅者,订阅者经过 update 返回是否还继续订阅,若为 false ,则从订阅者队列中删除 * @memberof SuspendTimeNotify */
notifyObserver() {
let deleteKeys = '';
for (const { key, target } of this.observers) {
let result = target.update(this._passTime);
if (result) {
deleteKeys += `${key},`
}
}
if (deleteKeys) {
this.observers = this.observers.filter(({ key }) => deleteKeys.indexOf(key) < 0)
}
}
/** * @description * 启动倒计时 * @memberof SuspendTimeNotify */
start() {
if (this._interval) {
clearInterval(this._interval)
}
this._interval = setInterval(() => {
let _nowTime = Date.now();
this._passTime += _nowTime - this._currentTime;
this._currentTime = _nowTime;
this.notifyObserver();
}, this._intervalPoint);
}
}
复制代码
在 notifyObserver
方法中调用了订阅者 target.update()
方法,经过这个方法通知订阅者,为了不已经不须要执行定时器的订阅者还存在队列中,因此订阅者须要返回 boolean
值,表示是否继续订阅。
设计好了观察者,订阅者就较简单了,只要实现 update
方法便可,但为了订阅者的结构可以统一,且尽可能与业务对象低耦合,因此单独实现 SuspendTimeObserve
的订阅者对象。
这里要求业务对象须要实现 run 方法,用于执行定时器的回调。
/** * @description * 定时器订阅者 * @class SuspendTimeObserve */
class SuspendTimeObserve {
/** *Creates an instance of SuspendTimeObserve. * @param {object} item 业务对象,业务对象可经过 run 方法获取定时器执行回调 * @param {number} countdownTime 须要倒计时的总时长,单位毫秒 * @memberof SuspendTimeObserve */
constructor(item, countdownTime) {
this.item = item
this.countdownTime = countdownTime
}
/** * @desc * 接收观察者的通知事件 * @param {number} passTime 已经执行的时长,单位毫秒 * @returns {boolean} 是否继续订阅 * @memberof SuspendTimeObserve */
update(passTime) {
var leftCountdownTime = this.countdownTime - passTime;
this.item.run && this.item.run({ leftCountdownTime, passTime });
return leftCountdownTime <= 0;
}
}
复制代码
咱们看个demo,页面上有七个倒计时,执行时长都不一致
采用上述的方式,用一个定时器实现多个倒计时
// 业务对象
class Plan {
constructor(i, time) {
this.item = window.document.querySelector('#_' + i)
this.time = time;
this.item.querySelector('#runtime').innerHTML = 'runtime= '+time/1000 + '秒 '
}
run({ leftCountdownTime }) {
if (leftCountdownTime > 0) {
this.item.querySelector('#currentTime').innerHTML = 'currentTime= '+ moment().format('YYYY-MM-DD HH:mm:ss');
}
}
getTime() { return this.time }
}
// 建立观察者
const suspendTimeNotify = new SuspendTimeNotify({ intervalPoint: 1000 });
for (let i = 1; i < 7; i++) {
let runTime = i * Math.floor(Math.random() * 10)
const plan = new Plan(i, runTime * 1000)
// 由业务对象建立订阅者
const ob = new SuspendTimeObserve(plan, plan.getTime())
// 添加订阅者
suspendTimeNotify.attach(ob)
}
// 启动定时器
suspendTimeNotify.start()
document.querySelector('#current').innerHTML = 'current= '+moment().format('YYYY-MM-DD HH:mm:ss');
复制代码
这篇文章涉及的知识点比较简单,用简单的方式去解决一些小问题,文章中封装的函数和类,你们能够尝试试copy拿去使用。
个人公号是 前端小黎同窗 也能够扫描下面的二维码关注
在掘金发的文章也会同步在公号上发,公号还会发些其余方面的文章,聊聊天啥的~
欢迎小伙伴们给个关注~