有没有比setTimeout更准确的创建Javascript计时器的方法?

80

有一件事情一直让我感到烦恼,那就是Javascript中的setTimeout()方法总是不可预测。

在我的经验中,这个计时器在许多情况下都非常不准确。所谓的不准确,是指实际延迟时间似乎多或少变化了250-500毫秒。虽然这并不是很长的时间,但当用它来隐藏/显示UI元素时,时间可能是明显可见的。

有没有什么技巧可以确保setTimeout()执行得准确(而不需要使用外部API),还是这个问题无解?


有的!你应该在npm上检查precision-timeout-interval包。而且你还可以通过一个单一的计时器创建虚拟间隔/超时,参考:https://github.com/ufukbakan/virtual-intervals - Ufuk Bakan
有的!你应该在npm上检查precision-timeout-interval包。而且你还可以通过一个单一的计时器创建虚拟间隔/超时,参考:https://github.com/ufukbakan/virtual-intervals - undefined
16个回答

81
有没有什么诀窍可以保证setTimeout()的精确性(而不需要使用外部API)?这是不可能实现的。浏览器没有这样的设置。然而,您也不需要依赖它来计时。大多数动画库在很多年前就已经解决了这个问题:您可以使用setTimeout() 设置回调,但根据(new Date()).milliseconds(或等效值)的值确定需要执行的任务。这使您可以利用新版本浏览器中更可靠的定时器支持,同时在旧版本浏览器上表现得合适。
这还允许您避免使用太多的定时器!这很重要:每个定时器都是一个回调。每个回调都会执行JS代码。当JS代码正在执行时,浏览器事件(包括其他回调)被延迟或丢失。当回调完成后,其他回调必须与其他浏览器事件竞争以获得执行机会。因此,处理该时间间隔的所有待处理任务的一个定时器将比两个具有相同时间间隔的定时器更好,并且(对于短时间间隔)比两个具有重叠超时的定时器更好!
概括起来:停止使用setTimeout() 来实现“一个定时器/一个任务”的设计,使用实时时钟平滑UI操作。

从 worker 中调用 setTimeout() 怎么样?这样不会减少渲染造成的阻塞吗? - hidarikani
无法保证这一点,@hidarikani。顺便说一句,在本地代码中也可能存在同样的问题,在浏览器中,您没有能力要求高分辨率计时器和实时延迟。不是你想要的;那是浪费CPU的方法,并且您应该假设至少在某些情况下,浏览器和操作系统将尽其所能来保护电池寿命,并为此牺牲您的计时器准确性。 - Shog9
我认为这个答案现在实际上是错误的,因为我们现在有了requestAnimationFrame...另请参见Chris这个答案。这不是一个简单的解决方案,但它满足了OP的没有外部API的标准。 - Stijn de Witt
1
requestAnimationFrame提供更高的精度,但并不一定意味着更好的准确性。@StijndeWitt - 你仍然受浏览器的影响,可能会被其他处理程序拖延甚至可能会被你在回调中进行实现时所做出的任何选择所阻塞。Chris的技术很好,但如果你希望尽可能接近设定时间执行逻辑,仍需要根据检索到的时间值决定应该执行什么;requestAnimationFrame提供的保证甚至比setTimeout还要少。另请参见我之前给hidarikani的注意事项。 - Shog9
"requestAnimationFrame 提供的保证更少"。这是一个非常好的观点!谢谢。 - Stijn de Witt

37

REF: http://www.sitepoint.com/creating-accurate-timers-in-javascript/

This site helped me a lot.

To compensate for timer inaccuracy, you can use the system clock. If you run a timing function as a series of setTimeout calls, with each instance calling the next, you only need to calculate how inaccurate it is and subtract that difference from the next iteration to keep it accurate.

var start = new Date().getTime(),  
    time = 0,  
    elapsed = '0.0';  
function instance()  
{  
    time += 100;  
    elapsed = Math.floor(time / 100) / 10;  
    if(Math.round(elapsed) == elapsed) { elapsed += '.0'; }  
    document.title = elapsed;  
    var diff = (new Date().getTime() - start) - time;  
    window.setTimeout(instance, (100 - diff));  
}  
window.setTimeout(instance, 100);  

这种方法可以最小化漂移并将不准确性降低超过90%。

它解决了我的问题,希望能对你有所帮助。


1
@Mr_Chimp,你的代码中为什么需要 elapsed 变量? - Anton Rudeshko
1
@AntonRudeshko 呃...好问题。我的代码是从上面的链接改编而来的。认为它只是一个无用的剩余物 - Mr_Chimp
2
那篇文章末尾的 doTimer 函数正是我所需要的,它非常准确。 - Luc

11

我不久前也遇到了类似的问题,并采用了一种结合requestAnimationFrame和performance.now()的方法,效果非常好。

现在我可以制作精度约为12位小数的计时器:

    window.performance = window.performance || {};
    performance.now = (function() {
        return performance.now       ||
            performance.mozNow    ||
            performance.msNow     ||
            performance.oNow      ||
            performance.webkitNow ||
                function() {
                    //Doh! Crap browser!
                    return new Date().getTime(); 
                };
        })();

http://jsfiddle.net/CGWGreen/9pg9L/


5
如果你需要在给定的时间间隔内得到准确的回调,这个代码片段可能会有所帮助:

https://gist.github.com/1185904

function interval(duration, fn){
  var _this = this
  this.baseline = undefined
  
  this.run = function(){
    if(_this.baseline === undefined){
      _this.baseline = new Date().getTime()
    }
    fn()
    var end = new Date().getTime()
    _this.baseline += duration
 
    var nextTick = duration - (end - _this.baseline)
    if(nextTick<0){
      nextTick = 0
    }
    
    _this.timer = setTimeout(function(){
      _this.run(end)
    }, nextTick)
  }

  this.stop = function(){
    clearTimeout(_this.timer)
  }
}

4

shog9的回答已经涵盖了我想说的,不过关于UI动画/事件,我还想补充以下内容:

如果你有一个方框应该滑动到屏幕上,向下展开,然后淡入其内容,请不要尝试使所有三个事件分别延迟一段时间后依次触发 - 使用回调函数,这样一旦第一个事件完成滑动,它就会调用扩展器,一旦完成,它就会调用淡化器。jQuery可以轻松实现此操作,我相信其他库也可以。


3
如果您使用 setTimeout() 快速地让浏览器放弃执行,以便其用户界面线程可以赶上需要执行的任何任务(例如更新选项卡或不显示长时间运行脚本对话框),那么有一个称为 Efficient Script Yielding 的新 API,即 setImmediate(),可能更适合您。 setImmediate() 的操作方式非常类似于 setTimeout(),但如果浏览器没有其他任务要执行,它可能会立即运行。 在许多情况下,您使用 setTimeout(..., 16)setTimeout(..., 4)setTimeout(..., 0)(即,您希望浏览器运行任何未完成的用户界面线程任务并不显示长时间运行脚本对话框),您可以将 setTimeout() 替换为 setImmediate(),并删除第二个(毫秒)参数。
setImmediate()的区别在于,它基本上是一个yield;如果浏览器在UI线程上有一些事情要做(例如更新选项卡),它将在返回到您的回调之前执行此操作。然而,如果浏览器已经完成了所有工作,那么在setImmediate()中指定的回调函数将基本上立即运行。
不幸的是,它目前仅受IE9+支持,因为其他浏览器供应商存在一些推迟
不过,如果您希望使用它并希望其他浏览器在某个时候实现它,可以使用良好的polyfill如果您正在使用setTimeout()进行动画requestAnimationFrame是您最好的选择,因为您的代码将与监视器的刷新率同步运行。
如果您在较慢的频率(例如每300毫秒一次)使用setTimeout(),则可以使用类似于用户1213320建议的解决方案来监视计时器上次运行的时间戳,并补偿任何延迟。一种改进方法是,您可以使用新的高分辨率时间接口(又称window.performance.now()),而不是Date.now()来获取当前时间的毫秒级精度以上的分辨率。

2
你需要逐步靠近目标时间,这可能需要一些试错,但基本上如下操作:
在所需时间之前设置一个超时时间,约为100毫秒。
将超时处理程序函数设置为以下内容:
calculate_remaining_time
if remaining_time > 20ms // maybe as much as 50
  re-queue the handler for 10ms time
else
{
  while( remaining_time > 0 ) calculate_remaining_time;
  do_your_thing();
  re-queue the handler for 100ms before the next required time
}

但是你的 while 循环仍然可能会被其他进程中断,因此它仍然不完美。


这并不是一个糟糕的想法,假设你需要精确地在某个特定的时间点上完成任务。但如果你只是想要一个一致的时间间隔,你可以通过动态调整超时值来实现类似的结果,使其略小于间隔周期减去处理时间。即使对于像提醒或滴答声之类的东西,15毫秒的精度在大多数情况下可能已经足够了。 - Shog9
我唯一需要如此精确的时间是在尝试实现音乐节拍时。但是JavaScript计时器并不真正适合这项工作。即使使用这种设计,滴答声仍然相当不稳定,不太适合作为节拍器。 - Noel Walters

2

这里是一个演示Shog9建议的示例。它可以在6秒钟内平滑地填充jquery进度条,然后在填充完成后重定向到不同的页面:

var TOTAL_SEC = 6;
var FRAMES_PER_SEC = 60;
var percent = 0;
var startTime = new Date().getTime();

setTimeout(updateProgress, 1000 / FRAMES_PER_SEC);

function updateProgress() {
    var currentTime = new Date().getTime();

    // 1000 to convert to milliseconds, and 100 to convert to percentage
    percent = (currentTime - startTime) / (TOTAL_SEC * 1000) * 100;

    $("#progressbar").progressbar({ value: percent });

    if (percent >= 100) {
        window.location = "newLocation.html";
    } else {
        setTimeout(updateProgress, 1000 / FRAMES_PER_SEC);
    }                 
}

setTimer() 函数来自哪里? - Tim Hallman
@TimHallman 我想我是指 setTimeout()。我会相应地编辑。 - Dean

1
这是我为我的音乐项目制作的计时器,它可以在所有设备上准确运行。

var Timer = function(){
  var framebuffer = 0,
  var msSinceInitialized = 0,
  var timer = this;

  var timeAtLastInterval = new Date().getTime();

  setInterval(function(){
    var frametime = new Date().getTime();
    var timeElapsed = frametime - timeAtLastInterval;
    msSinceInitialized += timeElapsed;
    timeAtLastInterval = frametime;
  },1);

  this.setInterval = function(callback,timeout,arguments) {
    var timeStarted = msSinceInitialized;
    var interval = setInterval(function(){
      var totaltimepassed = msSinceInitialized - timeStarted;
      if (totaltimepassed >= timeout) {
        callback(arguments);
        timeStarted = msSinceInitialized;
      }
    },1);

    return interval;
  }
}

var timer = new Timer();
timer.setInterval(function(){console.log("This timer will not drift."),1000}


原来你的代码每100秒会漂移约116毫秒: 在调用timer.setInterval()之前,我添加了以下代码:<code>var startTime = new Date().getTime();</code> 并在回调函数中添加了: <code>var now = new Date().getTime(); var ms = (now - startTime) + 'ms';</code> 结果是:这个计时器会漂移。从开始算起的毫秒数为100116ms。 - TriumphST

0
很抱歉,我认为没有什么方法可以缓解这个问题。不过我认为这取决于客户端系统,因此更快的JavaScript引擎或机器可能会使其稍微更准确一些。

我有一台双核2.8 GHz的机器,但计时器仍然相当不准确。根据我的经验,CPU速度并不是非常重要。 - Dan Herbert

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接