前言

在工作中应用定时器的场景非常多,但你会发现有时候定时器好像并没有按照我们的预期去执行,比如我们常遇到的setTimeout(()=>{},0)它有时候并不是按我们预期的立马就执行。想要知道为什么会这样,我们首先需要了解Javascript计时器的工作原理。

定时器工作原理

为了理解计时器的内部工作原理,我们首先需要了解一个非常重要的概念:计时器设定的延时是没有保证的。因为所有在浏览器中执行的JavaScript单线程异步事件(比如鼠标点击事件和计时器)都只有在它有空的时候才执行。

这么说可能不是很清晰,我们来看下面这张图



图中有很多信息需要消化,但是完全理解它会让您更好地了解异步JavaScript执行是如何工作的。这张图是一维的:垂直方向是(挂钟)时间,单位是毫秒。蓝色框表示正在执行的JavaScript部分。例如,第一个JavaScript块执行大约18ms,鼠标点击块执行大约11ms,以此类推。

​ 由于JavaScript一次只能执行一段代码(由于它的单线程特性),所以每一段代码都会“阻塞”其他异步事件的进程。这意味着,当异步事件发生时(如鼠标单击、计时器触发或XMLHttpRequest完成),它将排队等待稍后执行。

​ 首先,在JavaScript的第一个块中,启动了两个计时器:一个10ms的setTimeout和一个10ms的setInterval。由于计时器是在哪里和什么时候启动的,它实际上在我们实际完成第一个代码块之前触发,但是请注意,它不会立即执行(由于线程的原因,它无法这样做)。相反,被延迟的函数被排队,以便在下一个可用的时刻执行。

​ 此外,在第一个JavaScript块中,我们看到鼠标单击发生。与此异步事件相关联的JavaScript回调(我们永远不知道用户何时会执行某个动作,因此它被认为是异步的)无法立即执行,因此,就像初始计时器一样,它被排队等待稍后执行。

​ 在JavaScript的初始块完成执行后,浏览器会立即问一个问题:等待执行的是什么?在本例中,鼠标单击处理程序和计时器回调都在等待。然后浏览器选择一个(鼠标点击回调)并立即执行它。计时器将等待到下一个可能的时间,以便执行。

setInterval调用被废弃

在click事件执行时,第20毫秒处,第二个setInterval也到期了,因为此时已经click事件占用了线程,所以setInterval还是不能被执行,并且因为此时队列中已经有一个setInterval正在排队等待执行,所以这一次的setInterval的调用将被废弃

浏览器不会对同一个setInterval处理程序多次添加到待执行队列。

​ 实际上,我们可以看到,当第三个interval回调被触发时,interval本身正在执行。这向我们展示了一个重要的事实:interval并不关心当前执行的是什么,它们将不加区别地排队,即使这意味着回调之间的时间间隔将被牺牲。

setTimeout/setInterval无法保证准时执行回调函数

​ 最后,在第二个interval回调执行完成后,我们可以看到JavaScript引擎没有任何东西可以执行了。这意味着浏览器现在等待一个新的异步事件发生。当interval再次触发时,我们会在50ms处得到这个值。但是这一次,没有任何东西阻碍它的执行,因此它立即触发。

OK,总的来说造成JS定时器不可靠的原因就是JavaScript是单线程的,一次只能执行一个任务,而setTimeout() 的第二个参数(延时时间)只是告诉 JavaScript 再过多长时间把当前任务添加到队列中。如果队列是空的,那么添加的代码会立即执行;如果队列不是空的,那么它就要等前面的代码执行完了以后再执行定时器任务必须等主线程任务执行才可能开始执行,无论它是否到达我们设置的时间

这里我们可以再来了解下Javascript的事件循环

事件循环

JavaScript中所有的任务分为同步任务与异步任务,同步任务,顾名思义就是立即执行的任务,它一般是直接进入到主线程中执行。而我们的异步任务则是进入任务队列等待主线程中的任务执行完再执行。

任务队列是一个事件的队列,表示相关的异步任务可以进入执行栈了。主线程读取任务队列就是读取里面有哪些事件。

队列是一种先进先出的数据结构。

上面我们说到异步任务又可以分为宏任务与微任务,所以任务队列也可以分为宏任务队列微任务队列

  • Macrotask Queue:进行比较大型的工作,常见的有setTimeout,setInterval,用户交互操作,UI渲染等;

  • Microtask Queue:进行较小的工作,常见的有Promise,Process.nextTick;

  1. 同步任务直接放入到主线程执行,异步任务(点击事件,定时器,ajax等)挂在后台执行,等待I/O事件完成或行为事件被触发。
  2. 系统后台执行异步任务,如果某个异步任务事件(或者行为事件被触发),则将该任务添加到任务队列,并且每个任务会对应一个回调函数进行处理。
  3. 这里异步任务分为宏任务与微任务,宏任务进入到宏任务队列,微任务进入到微任务队列。
  4. 执行任务队列中的任务具体是在执行栈中完成的,当主线程中的任务全部执行完毕后,去读取微任务队列,如果有微任务就会全部执行,然后再去读取宏任务队列
  5. 上述过程会不断的重复进行,也就是我们常说的事件循环(Event-Loop)



这里更详细的内容可以看我之前的文章探索JavaScript执行机制

导致定时器不可靠的原因

当前任务执行时间过久

JS 引擎会先执行同步的代码之后才会执行异步的代码,如果同步的代码执行时间过久,是会导致异步代码延迟执行的。

setTimeout(() => {
console.log(1);
}, 20);
for (let i = 0; i < 90000000; i++) { }
setTimeout(() => {
console.log(2);
}, 0);

这个按预期应该是会先打印出2,然后再打印1,但事实并不是如此,就算第二个定时器的时间更短,但中间那个for循环的执行时间远远超过了这两个定时器设定的时间。

setTimeout 设置的回调任务是 按照顺序添加到延迟队列里面的,当执行完一个任务之后,ProcessDelayTask 函数会根据发起时间和延迟时间来计算出到期的任务,然后 依次执行 这些到期的任务。

在执行完前面的任务之后,上面例子的两个 setTimeout 都到期了,那么按照顺序执行就是打印 12。所以在这个场景下,setTimeout 就显得不那么可靠了。

延迟执行时间有最大值

包括 IE, Chrome, Safari, Firefox 在内的浏览器其内部以32位带符号整数存储延时。这就会导致如果一个延时(delay)大于 2147483647 毫秒 (大约24.8 天)时就会溢出,导致定时器将会被立即执行。(MDN)

setTimeout 的第二个参数设置为 0 (未设置、小于 0、大于 2147483647 时都默认为 0)的时候,意味着马上执行,或者尽快执行。

setTimeout(function () {
console.log("你猜它什么时候打印?")
}, 2147483648);

把这段代码放到浏览器控制台执行,你会发现它会立马打印出 你猜它什么时候打印?

最小延时>=4ms(嵌套使用定时器)

在浏览器中,setTimeout()/setInterval() 的每调用一次定时器的最小间隔是4ms,这通常是由于函数嵌套导致(嵌套层级达到一定深度),或者是由于已经执行的setInterval的回调函数阻塞导致的。

  • setTimeout 的第二个参数设置为 0 (未设置、小于 0、大于 2147483647 时都默认为 0)的时候,意味着马上执行,或者尽快执行。

  • 如果延迟时间小于 0,则会把延迟时间设置为 0。如果定时器嵌套 5 次以上并且延迟时间小于 4ms,则会把延迟时间设置为 4ms

function cb() { f(); setTimeout(cb, 0); }
setTimeout(cb, 0);

在Chrome 和 Firefox中, 定时器的第5次调用被阻塞了;在Safari是在第6次;Edge是在第3次。所以后面的定时器都最少被延迟了4ms

未被激活的tabs的定时最小延迟>=1000ms

浏览器为了优化后台tab的加载损耗(以及降低耗电量),在未被激活的tab中定时器的最小延时限制为1S(1000ms)。

let num = 100;
function setTime() {
// 当前秒执行的计时
console.log(`当前秒数:${new Date().getSeconds()} - 执行次数:${100-num}`);
num ? num-- && setTimeout(() => setTime(), 50) : "";
}
setTime();

这里我在39秒时切到了其他标签页,我们会发现它后面的执行间隔都是1秒执行一次,并不是我们设定的50ms。

setInterval的处理时长不能比设定的间隔长

setInterval的处理时长不能比设定的间隔长,否则setInterval将会没有间隔的重复执行

但是对这个问题,很多情况下,我们并不能清晰的把控处理程序所消耗的时长,为了能够按照一定的间隔周期性的触发定时器,我们可以使用setTimeout来代替setInterval执行。

setTimeout(function fn(){
// todo
setTimeout(fn,10)
// 执行完处理程序的内容后,在末尾再间隔10毫秒来调用该程序,这样就能保证一定是10毫秒的周期调用,这里时间按自己的需求来写
},10)

解决方案

方法一:requestAnimationFrame

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

const t = Date.now()
function mySetTimeout (cb, delay) {
let startTime = Date.now()
loop()
function loop () {
if (Date.now() - startTime >= delay) {
cb();
return;
}
requestAnimationFrame(loop)
}
}
mySetTimeout(()=>console.log('mySetTimeout' ,Date.now()-t),2000) //2005
setTimeout(()=>console.log('SetTimeout' ,Date.now()-t),2000) // 2002

这种方案看起来像是增加了误差,这是因为requestAnimationFrame每16.7ms 执行一次,因此它不适用于间隔很小的定时器修正。

方法二: Web Worker

Web Worker为Web内容在后台线程中运行脚本提供了一种简单的方法。线程可以执行任务而不干扰用户界面。此外,他们可以使用XMLHttpRequest执行 I/O (尽管responseXMLchannel属性总是为空)。一旦创建, 一个worker 可以将消息发送到创建它的JavaScript代码, 通过将消息发布到该代码指定的事件处理程序(反之亦然)。

Web Worker 的作用就是为 JavaScript 创造多线程环境,允许主线程创建 Worker 线程,将一些任务分配给后者运行。在主线程运行的同时,Worker 线程在后台运行,两者互不干扰。等到 Worker 线程完成计算任务,再把结果返回给主线程。这样的好处是,一些计算密集型或高延迟的任务,被 Worker 线程负担了,主线程不会被阻塞或拖慢。

// index.js
let count = 0;
//耗时任务
setInterval(function(){
let i = 0;
while(i++ < 100000000);
}, 0); // worker
let worker = new Worker('./worker.js')
// worker.js
let startTime = new Date().getTime();
let count = 0;
setInterval(function(){
count++;
console.log(count + ' --- ' + (new Date().getTime() - (startTime + count * 1000)));
}, 1000);

这种方案体验整体上来说还是比较好的,既能较大程度修正计时器也不影响主进程任务

总结

由于js的单线程特性,所以会有事件排队、先进先出、setInterval调用被废弃、定时器无法保证准时执行回调函数以及出现setInterval的连续执行。

JS定时器不可靠的原因及解决方案的更多相关文章

  1. 移动Web与js定时器暂停或不准确计时的问题解决

    PC 上的 Firefox.Chrome 和 Safari 等浏览器,都会自动把未激活页面中的 JavaScript 定时器(setTimeout.setInterval)间隔最小值改为 1 秒以上: ...

  2. NIOS II CPU复位异常的原因及解决方案

    NIOS II CPU复位异常的原因及解决方案   近期在用nios ii做项目时,发现一个奇怪的现象,在NIOS II EDS软件中编写好的代码,烧写到芯片中,第一次能够正常运行,但是当我按下板卡上 ...

  3. vue 首次加载缓慢/刷新后加载缓慢 原因及解决方案

    # vue 首次加载缓慢/刷新后加载缓慢 原因及解决方案 最近做项目发现一个问题,页面每次刷新后加载速度都非常慢,20s左右,在开发环境则非常流畅,几乎感觉不到,本文参考望山的各种方案优化 1,关闭打 ...

  4. 玩转Windows服务系列——无COM接口Windows服务启动失败原因及解决方案

    将VS创建的Windows服务项目编译生成的程序,通过命令行 “服务.exe -Service”注册为Windows服务后,就可以通过服务管理器进行管理了. 问题 通过服务管理器进行启动的时候,发现服 ...

  5. js定时器的使用(实例讲解)

    在javascritp中,有两个关于定时器的专用函数,分别为: 1.倒计定时器:timename=setTimeout("function();",delaytime);2.循环定 ...

  6. PowerDesigner16.5 连64位MySQL,出错:SQLSTATE = IM014。原因及解决方案

  7. js定时器 特定时间执行某段程序的例子

    定时器想必大家并不陌生吧,在本文为大家详细介绍下js中是如何实现定时器的,具体原理及代码如下. 例子: $(function(){ var handler = function(){ //www.jb ...

  8. oracle超出打开游标的最大数的原因和解决方案

    oracle超出打开游标的最大数的原因和解决方案 分类: Oracle相关2012-06-05 10:36 6362人阅读 评论(0) 收藏 举报 oracle数据库sqljavasessionsys ...

  9. 使用js主函数的原因是等文档加载完了才给里面的元素添加东西 如果不使用主函数则文档加载时候无法找到元素则不能成功给元素添加事件

    使用js主函数的原因是等文档加载完了才给里面的元素添加东西 如果不使用主函数则文档加载时候无法找到元素则不能成功给元素添加事件

随机推荐

  1. LuoguP7892 『JROI-3』R.I.P. 题解

    Update \(\texttt{2021.10.11}\) 修改了一处公式错误,麻烦管理重新审核一下这篇已审核通过文章. Content 你在一个无限大的格子平面上,并且有 \(m\) 个长度为 \ ...

  2. LuoguB2104 矩阵加法 题解

    Content 给定两个 \(n\times m\) 的矩阵 \(A,B\),求 \(C=A+B\). 数据范围:\(1\leqslant n,m\leqslant 100\). Solution 我 ...

  3. Spring核心原理分析之MVC九大组件(1)

    本文节选自<Spring 5核心原理> 1 什么是Spring MVC Spring MVC 是 Spring 提供的一个基于 MVC 设计模式的轻量级 Web 开发框架,本质上相当于 S ...

  4. RabbitMQ 消息队列 实现RPC 远程过程调用交互

    #!/usr/bin/env python # Author:Zhangmingda import pika,time import uuid class FibonacciRpcClient(obj ...

  5. Java 自动给方法加注释

    在代码的方法中先写/**,然后按回车键,即是键盘上的Enter键 但是首先得配置一下,配置如图所示:

  6. 缓存Bigkey坚决不要用,拆分是王道

    大家好,我是架构摆渡人.这是实践经验系列的第四篇文章,这个系列会给大家分享很多在实际工作中有用的经验,如果有收获,还请分享给更多的朋友. 背景介绍 在高并发的业务场景中,缓存是必须要上的,用来扛高并发 ...

  7. 【LeetCode】698. Partition to K Equal Sum Subsets 解题报告(Python & C++)

    作者: 负雪明烛 id: fuxuemingzhu 个人博客: http://fuxuemingzhu.cn/ 目录 题目描述 题目大意 解题方法 回溯法 日期 题目地址:https://leetco ...

  8. Codeforces Round #358 (Div. 2) C. Alyona and the Tree

    C. Alyona and the Tree time limit per test 1 second memory limit per test 256 megabytes input standa ...

  9. 深度学习中常见的 Normlization 及权重初始化相关知识(原理及公式推导)

    Batch Normlization(BN) 为什么要进行 BN 防止深度神经网络,每一层得参数更新会导致上层的输入数据发生变化,通过层层叠加,高层的输入分布变化会十分剧烈,这就使得高层需要不断去重新 ...

  10. Service有多个实现类,它怎么知道该注入哪个ServiceImpl类

    方法一:Controller中注入service的时候使用@Autowired自动注入,@Qualifier("beanId")来指定注入哪一个. 方法二:Controller中注 ...