我们在实现功能的时候经常会用到前端倒计时,例如在抢购的场景,细心的同学肯定会发现将网页放置一段时间不进行任何操作,倒计时会越来越慢,明明已经过去60s了,然而前端倒计时只显示过了55s。且放置时间越久,误差也就越大。这样的倒计时便失去了其意义,用户体验也相对较差。

通过相关资料的查找,发现SetInterval函数实现倒计时是不准确的。JavaScript的运行是单线程,setInterval的回调函数并不是时间到后立即执行,而是等系统计算资源空闲下来后才执行。而且假如这时候我们离开页面,浏览器可能会减缓甚至停止这个计时器,等页面重新被用户聚焦才会正常运行,这时候倒计时与正常数据就会相差一大截。这应该是浏览器为了保障性能,牺牲不活动页面的JavaScript线程导致的。并且这个问题在移动端浏览器更甚,计时器基本上是停止的。

setInterval函数计时是不可信的,其计算时间周期可以做如下计算: setInterval每次周期时间 = 周期性时间 + 耗时任务时间占用“Interval线程”时间 + 页面挂起时间。 扩展阅读:一家之言:说说 JavaScript 计时器的工作原理

这就引入了我们的第一个问题,“1.如何通过代码自校准计时器?”。 观察页面(如下图),我们发现这个页面有3个倒计时,由3个JS计时器分别控制。在前面说过计时器是不可信的,越多的计时器只会产生更多的耗时任务并拖累JavaScript主线程。由此引发第二个问题,“2.在多个计时器完成相同功能时,如何减少计时器的数量?” 产品列表

自校准计时器

思路: 每次执行时,setInterval回调函数中与上次回调函数的当前时间做差值,修正回调函数之间的延迟。

let _passTime = 0;//定时器总耗时
let _currentTime = Date.now();//初始时间

function notifyObject(){
    console.log(_passTime); // 已经流逝
}

window.setInterval(() =>{
    let _nowTime = Date.now();// 当前时间
    _passTime += _nowTime - _currentTime; // 与上次回调的差值
    _currentTime = _nowTime;// 重置初始时间为当前时间
    notifyObserver();//执行
}, 200);

使用观察者模式减少计时器的使用

思路: JavaScript线程永远只有一个计时器(观察者)。所有需要进行倒计时的产品订阅这个计时器(订阅者),计时器更新通知订阅者修改。

完整代码

//观察者类
let SuspendTimeNotify = function() {
    let _currentTime = Date.now();
    let _passTime = 0;
    let observers = [];
    let _interval = null;

    function attach(observer) {
        observers.push(observer);
    }

    function stop() {
        observers.length = 0;
        $interval.cancel(_interval);
    }

    function notifyObserver(target) {
        for (let i = 0; i < observers.length; i++) {
            let _result = observers[i].update(_passTime);
            //任意一个订阅者倒计时结束,结束观察者运行,并重置数据
            if (_result) {
                stop();
                target.initYYlcInfo();
            }
        }
    }

    function update(target) {
        // 每200毫秒通知订阅者更新
        _interval = window.setInterval(function() {
            let _nowTime = Date.now();
            _passTime += _nowTime - _currentTime;
            _currentTime = _nowTime;
            notifyObserver(target);
        }, 200);
    }

    return {
        attach: attach,
        update: update,
        stop: stop
    }
}

//订阅者类
let SuspendTimeObserve = function(item,endTime) {
    //格式化时间字符串
    function formatTimeString(end) {
        let leftSecond = parseInt(end / 1000);
        let day = Math.floor(leftSecond / (60 * 60 * 24)) < 0 ? 0 : Math.floor(
            leftSecond / (60 * 60 * 24));
        let hour = Math.floor((leftSecond - day * 24 * 60 * 60) / 3600) < 0 ?
            0 : Math.floor((leftSecond - day * 24 * 60 * 60) / 3600);
        let minute = Math.floor((leftSecond - day * 24 * 60 * 60 - hour *
            3600) / 60) < 0 ? 0 : Math.floor((leftSecond - day * 24 * 60 * 60 -
            hour * 3600) / 60);
        let second = Math.floor(leftSecond - day * 24 * 60 * 60 - hour * 3600 -
            minute * 60) < 0 ? 0 : Math.floor(leftSecond - day * 24 * 60 * 60 -
            hour * 3600 - minute * 60);

        hour = day * 24 + hour;
        minute = minute;
        second = second;
        return hour + ":" + minute + ":" + second;
    }

    //返回是否倒计时结束
    function update(_passTime) {
        let _countdownTime = endTime - _passTime;
        item.action = formatTimeString(_countdownTime);
        return _countdownTime <= 0;
    }
    return {
        update: update
    }
}

//调用
const _notify = new SuspendTimeNotify();
const list = [{intervalTime:1000},{intervalTime:1200}];

for (let item in list) {
    _notify.attach(new SuspendTimeObserve(item,intervalTime));//添加订阅
}
_notify.update($scope);