倒計時的那些坑

咱們經常使用 setInterval 來實現前端倒計時,最近工做中遇到了兩個小坑,給你們分享分享javascript

  1. 在移動端,瀏覽器切到後臺,頁面的定時器就被暫停了,從新打開瀏覽器時,倒計時才繼續執行,這就致使倒計時執行時長變長了。前端

  2. 一個頁面有多個倒計時,好比商品列表的開售倒計時,當頁面打開停留較長時間後,會發現有的商品倒計時會不許確。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 以下圖性能

normal

看看移動端下的效果ui

stop-time

最後一次的執行時間明顯超過了預約運行時長,緣由上文說了,因此運行的時長鬚要改爲以下 剩餘運行時長 = 運行時長 - 間隔時間 - 線程暫停時間 線程暫停時間就是與上一次的執行時間間隔。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
}
複製代碼

在看看在移動端下的效果

new-stop-time

這下就算瀏覽器切到了後臺,最後的執行時間仍在預設範圍內,哈哈,解決,下班~ 沒有咱解決不了的bug(噓~這句話可別讓產品聽到了)

第二種場景

熟悉js事件循環的小夥伴都知道,js中的異步事件是經過一個循環隊列來實現的,定時器的回調函數會進入到宏隊列中,等待被執行,因此定時器的執行時間並非百分百準確的,若是主線程被阻塞(咱們這裏暫時先不考慮這種狀況)或者循環隊列有多個任務,或其中有耗時的操做,那麼定時器就會慢慢變得有偏差。

再回到場景中,頁面中同時存在多個定時器,就意味着循環隊列中會同時存在多個回調函數在等待執行,若回調函數中有一些同步的數據請求或耗時的時間計算等,在頁面打開的前一小段時間也許看不出來,但當頁面打開較長時間,累積的偏差愈來愈大,若用戶已經打開頁面,就等着倒計時結束搶購,結果等頁面倒計時結束時,搶購早已開始,用戶反手就是一個投訴啊。

致使這一問題的根本緣由就在於同時存在的定時器太多了,生成的回調事件都在排隊等着執行。減小定時器的數量就是咱們須要解決的問題。

我採用的辦法即是:採用觀察者模式來實現。

觀察者模式是一對多的設計,多個訂閱者在觀察者中添加訂閱,觀察者發現變化,通知相應的訂閱者,以下簡圖:

watch

接下來就是要去設計觀察者和訂閱者。

觀察者

觀察者只有一個,咱們將定時器放在觀察者中就很是適合,觀察者經過觀察定時器的變化,進而根據預設的條件通知訂閱者。

/** * @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,頁面上有七個倒計時,執行時長都不一致

time-mult

採用上述的方式,用一個定時器實現多個倒計時

// 業務對象
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拿去使用。

給我公號打個廣告

個人公號是 前端小黎同窗 也能夠掃描下面的二維碼關注

在掘金髮的文章也會同步在公號上發,公號還會發些其餘方面的文章,聊聊天啥的~

歡迎小夥伴們給個關注~

wx-code
相關文章
相關標籤/搜索