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

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

image.png


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


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


image.png


运行代码如下,通过一个计数器来记录每一次 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); }
}
...


image.png


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


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


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


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


image.png


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


视频


(国内视频 https://www.bilibili.com/video/av456657611/


建议看国外的中英对照字幕,国内的翻译准确度一般


相关文章



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


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


while



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


理解起来也很简单:


image.png


代码如下:


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);
}


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


image.png


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


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


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


时间明显增加了一些,但是增加速度非常缓慢。


虽然我们用 Web Worker 修复时间看似被解决了。但是一方面, worker 线程会被 while 给占用,导致无法接受到信息,多个定时器无法同时执行,另一方面,由于 onmessage 还是属于事件循环内,如果主线程有大量阻塞还是会让时间越差越大,因此这并不是个完美的方案。


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)
    }
}


image.png


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


image.png


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


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


image.png


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


setTimeout 系统时间补偿



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


原方案


image.png


setTimeout系统时间补偿


image.png


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


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);
}


image.png


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


image.png


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


好了我们最后来总结一下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


相关文章
|
7月前
|
消息中间件 存储 监控
|
7月前
|
算法
字节面试官让我讲讲最小生成树,我忍不住笑了
字节面试官让我讲讲最小生成树,我忍不住笑了
|
7月前
|
存储 安全 Java
面试题:用过ThreadLocal吗?ThreadLocal是在哪个包下的?看过ThreadLocal源码吗?讲一下ThreadLocal的get和put是怎么实现的?
字节面试题:用过ThreadLocal吗?ThreadLocal是在哪个包下的?看过ThreadLocal源码吗?讲一下ThreadLocal的get和put是怎么实现的?
86 0
|
22天前
|
NoSQL 中间件 Java
字节面试:聊聊 CAP 定理?哪些中间件是AP? 哪些是CP? 说说 为什么?
45岁老架构师尼恩在其读者交流群中分享了关于CAP定理的重要面试题及其解析,包括CAP定理的基本概念、CAP三要素之间的关系,以及如何在分布式系统设计中权衡一致性和可用性。文章还详细分析了几种常见中间件(如Redis Cluster、Zookeeper、MongoDB、Cassandra、Eureka、Nacos)的CAP特性,并提供了高端面试技巧,帮助读者在面试中脱颖而出。尼恩还推荐了其团队编写的《尼恩Java面试宝典PDF》等资料,助力求职者准备面试,提升技术水平。
|
2月前
|
Arthas Kubernetes Java
字节面试:CPU被打满了,CPU100%,如何处理?
尼恩,一位拥有20多年经验的老架构师,针对近期读者在一线互联网企业面试中遇到的CPU 100%和红包架构等问题,进行了系统化梳理。文章详细解析了CPU 100%的三大类型问题(业务类、并发类、内存类)及其九种常见场景,提供了使用jstack和arthas两大工具定位问题的具体步骤,并分享了解决死锁问题的实战案例。尼恩还强调了面试时应先考虑回滚版本,再使用工具定位问题的重要性。此外,尼恩提供了丰富的技术资料,如《尼恩Java面试宝典》等,帮助读者提升技术水平,轻松应对面试挑战。
字节面试:CPU被打满了,CPU100%,如何处理?
|
2月前
|
Java API 对象存储
JVM进阶调优系列(2)字节面试:JVM内存区域怎么划分,分别有什么用?
本文详细解析了JVM类加载过程的关键步骤,包括加载验证、准备、解析和初始化等阶段,并介绍了元数据区、程序计数器、虚拟机栈、堆内存及本地方法栈的作用。通过本文,读者可以深入了解JVM的工作原理,理解类加载器的类型及其机制,并掌握类加载过程中各阶段的具体操作。
|
7月前
|
缓存 移动开发 前端开发
字节-2024最新前端面试题梳理-1
字节-2024最新前端面试题梳理-1
314 0
|
6月前
|
SQL 关系型数据库 MySQL
字节面试:MySQL自增ID用完会怎样?
字节面试:MySQL自增ID用完会怎样?
73 0
字节面试:MySQL自增ID用完会怎样?
|
6月前
|
存储 Java 容器
研二学妹面试字节,竟倒在了ThreadLocal上,这是不要应届生还是不要女生啊?
【6月更文挑战第1天】研二学妹面试字节,竟倒在了ThreadLocal上,这是不要应届生还是不要女生啊?
50 5
|
5月前
|
前端开发 数据挖掘
字节面试:领域、子域、核心域、通用域和支撑域怎么划分?
领域驱动设计(DDD)通过划分业务领域和子域简化复杂性。领域是业务问题的范围,子域是更小的专业部分。核心域代表业务的核心竞争力,如电商中的商品、订单和支付;通用域提供跨领域服务,如用户管理;支撑域支持核心功能,如物流、客服和数据分析。这种划分帮助团队专注关键业务,提高开发效率和软件对业务需求的契合度。
253 0