• 如何实现准时的setTimeout


    最近一个粉丝去面字节,被面试官问到了这个问题来问我,一听感觉有点意思,于是对它进行了一番研究,可能研究的过程以及结果不一定是最好的,但是还是记录一下,为各位提供一些帮助。

    拿到这个问题,假设有这样的场景,我们需要用 setTimeout 做一个动画,并且需要控制他的频率,50ms 运行一次,首先我们先上图,来看看 setTimeout 的表现。

    运行代码如下,通过一个计数器来记录每一次 setTimeout 的调用,而设定的间隔 * 计数次数,就等于理想状态下的延迟,通过以下例子来查看我们计时器的准确性。

    function timer() {
       var speed = 50, // 设定间隔
       counter = 1,  // 计数
       start = new Date().getTime();
       
       function instance()
       {
        var ideal = (counter * speed),
        real = (new Date().getTime() - start);
        
        counter++;
        form.ideal.value = ideal; // 记录理想值
        form.real.value = real;   // 记录真实值
    
        var diff = (real - ideal);
        form.diff.value = diff;  // 差值
    
        window.setTimeout(function() { instance(); }, speed);
       };
       
       window.setTimeout(function() { instance(); }, speed);
    }
    timer();
    

    而我们如果在 setTimeout 还未执行期间加入一些额外的代码逻辑,再来看看这个差值。

    // ...
    window.setTimeout(function() { instance(); }, speed);
    for(var x=1, i=0; i<10000000; i++) { x *= (i + 1); }
    // ...
    

    可以看出,这大大加剧了误差。

    可以看到随着时间的推移, setTimeout 实际执行的时间和理想的时间差值会越来越大,这就不是我们预期的样子。类比真实的场景,对于一些倒计时以及动画来说都会造成时间的偏差都是不理想的。

    那么,从这个现象来看一下,为什么 setTimeout 会不准时呢?

    因为我们的代码往往并不是只有一个 setTimeout,大多数会遇到以下情况。

    详细要从浏览器的事件循环讲起,但是讲事件循环的文章太多了,文本就不再累赘地详细展开讲解。

    视频

    相关文章

    总结来说,因为浏览器页面是有消息队列和事件循环来驱动的,创建一个 setTimeout 的时候是将它推进了一个队列,并没有立即执行,只有本轮宏任务执行完,才会去检查当前的消息队列是否有有到期的任务。

    接下来我会用 4 这种方式来探索。

    while

    想得到准确的,我们第一反应就是如果我们能够主动去触发,获取到最开始的时间,以及不断去轮询当前时间,如果差值是预期的时间,那么这个定时器肯定是准确的,那么用 while 可以实现这个功能。

    理解起来也很简单:

    代码如下:

    function timer(time) {
        const startTime = Date.now();
        while(true) {
            const now = Date.now();
            if(now - startTime >= time) {
                console.log('误差', now - startTime - time);
                return;
            }
        }
    }
    timer(5000);
    
    打印:误差 0
    

    显然这样的方式很精确,但是我们知道 js 是单线程运行,使用这样的方式强行霸占线程会使得页面进入卡死状态,这样的结果显然是不合适的。

    Web Worker

    那么既然无法在当前主线程避免这个误差,我们能否另开一个线程去处理呢?当然可以,JavaScript 也提供给我们这样一个能力,通过 Web Worker 我们就可以在另一个线程来运行我们的代码。

    Web Worker为Web内容在后台线程中运行脚本提供了一种简单的方法。线程可以执行任务而不干扰用户界面。      -- 摘自MDN
    

    一个 worker 的简单的示例

    // main.js
    var myWorker = new Worker('worker.js');
    
    // 监听 worker
    myWorker.onmessage = function(e) {
      result.textContent = e.data;
      console.log('Message received from worker');
    }
    first.onchange = function() {
      // 向 worker 发送数据
      myWorker.postMessage([first.value,second.value]);
      console.log('Message posted to worker');
    }
    // worker.js
    onmessage = function(e) {
      // 接受主线程的数据
      console.log('Message received from main script');
      var workerResult = 'Result: ' + (e.data[0] * e.data[1]);
      console.log('Posting message back to main script');
      // 向主线程发送数据
      postMessage(workerResult);
    }
    

    那么接下来我们就要加 worker 和 while 相结合,以下为创建 worker 部分

    // worker生成器
    const createWorker = (fn, options) => {
        const blob = new Blob(['(' + fn.toString() + ')()']);
        const url = URL.createObjectURL(blob);
        if (options) {
            return new Worker(url, options);
        }
        return new Worker(url);
    } 
    // worker 部分
    const worker = createWorker(function () {
        onmessage = function (e) {
            const date = Date.now();
            while (true) {
                const now = Date.now();
                if(now - date >= e.data) {
                    postMessage(1);
                    return;
                }
            }
        }
    })
    

    我们通过在 worker 中写入一个 while 循环,当达到我们的预取时间的时候,再向主线程发送一个完成事件,就不会因为主线程的其他代码的干扰而造成数据不准的情况。

    let isStart = false;
    function timer() {
        worker.onmessage = function (e) {
           cb()
            if (isStart) {
                worker.postMessage(speed);
            } 
        }
        worker.postMessage(speed);
    }
    

    我们来看一下实际的效果。

    我们可以看到执行的时间和理想的时间非常相近,而那细微的差异应该就是线程通讯耗时。

    我们再来看看加入额外的代码逻辑的情况。

    // ...
    if (isStart) {
       worker.postMessage(speed);
    }
    for (var x = 1, i = 0; i < 10000000; i++) { x *= (i + 1); }
    // ...
    

    requestAnimationFrame

    先来看看他的定义

    window.requestAnimationFrame() 告诉浏览器——你希望执行一个动画,并且要求浏览器
    在下次重绘之前调用指定的回调函数更新动画。该方法需要传入一个回调函数作为参数,该回调函数
    会在浏览器下一次重绘之前执行,回调函数执行次数通常是每秒60次,也就是每16.7ms 执行一次,
    但是并不一定保证为 16.7 ms。
    

    我们也可以尝试一下将它来模拟 setTimeout

    // 模拟代码
    function setTimeout2 (cb, delay) {
        let startTime = Date.now()
        loop()
      
        function loop () {
          const now = Date.now()
          if (now - startTime >= delay) {
            cb();
            return;
          }
          requestAnimationFrame(loop)
        }
    }
    

    发现由于 16.7 ms 间隔执行,在使用间隔很小的定时器,很容易导致时间的不准确。

    再看看额外代码的引入效果。

    // ...
     window.setInterval2(function () { instance(); }, speed);
    }
    for (var x = 1, i = 0; i < 10000000; i++) { x *= (i + 1); }
    // ...
    

    略微加剧了误差的增加,因此这种方案仍然不是一种好的方案。

    setTimeout 系统时间补偿

    这个方案是在 stackoverflow 看到的一个方案,我们来看看此方案和原方案的区别

    原方案

    setTimeout系统时间补偿

    当每一次定时器执行时后,都去获取系统的时间来进行修正,虽然每次运行可能会有误差,但是通过系统时间对每次运行的修复,能够让后面每一次时间都得到一个补偿。

    function timer() {
       var speed = 500,
       counter = 1, 
       start = new Date().getTime();
       
       function instance()
       {
        var ideal = (counter * speed),
        real = (new Date().getTime() - start);
        
        counter++;
    
        var diff = (real - ideal);
        form.diff.value = diff;
    
        window.setTimeout(function() { instance(); }, (speed - diff)); // 通过系统时间进行修复
    
       };
       
       window.setTimeout(function() { instance(); }, speed);
    }
    

    再来看看加入额外的代码逻辑的情况。

    依旧非常的稳定,因此通过系统的时间补偿,能够让我们的 setTimeout 变得更加准时,至此我们完成了如何让 setTimeout 准时的探索。

    在 node 中测试

    function timer() {
      var speed = 500,
        counter = 1,
        start = new Date().getTime();
    
      function instance() {
        var ideal = (counter * speed),
          real = (new Date().getTime() - start);
    
        counter++;
    
        var diff = (real - ideal);
        // form.diff.value = diff;
        console.log('理想时间', ideal)
        console.log('实际时间', real)
        console.log('差异', diff)
        setTimeout(function () {
          instance();
        }, (speed - diff)); // 通过系统时间进行修复
    
      }
    
      setTimeout(function () {
        instance();
      }, speed);
    }
    
    timer()
    

    好了我们最后来总结一下4种方案的优缺点

    while Web Worker requestAnimationFrame setTimeout 系统时间补偿
    准确度
    主线程阻塞 阻塞 一般 不阻塞 不阻塞
    评分 ⭐️⭐️ ⭐️⭐️⭐️ ⭐️ ⭐️⭐️⭐️⭐️⭐️

    参考

    https://segmentfault.com/q/1010000013909430

    https://stackoverflow.com/questions/196027/is-there-a-more-accurate-way-to-create-a-javascript-timer-than-settimeout

    转载:字节面试官问粉丝,如何实现准时的setTimeout

  • 相关阅读:
    ORACLE SEQUENCE 介绍
    cocos2d 游戏开发:Cocos2d v3 &quot;hello world&quot;+显示飞船
    无线网络覆盖
    解决xShell4某些情况下按删除键会输出^H的问题
    Android开发经验之—intent传递大数据
    简单的REST的框架实现
    ListView 使用方法(Asp.Net)
    POJ2528 Mayor&#39;s posters 【线段树】+【成段更新】+【离散化】
    C#反射Assembly 具体说明
    HDU 4432 Sum of divisors (进制模拟)
  • 原文地址:https://www.cnblogs.com/taohuaya/p/14754652.html
Copyright © 2020-2023  润新知