乐趣区

关于前端:JS定时器执行不可靠的原因及解决方案

前言

在工作中利用定时器的场景十分多,但你会发现有时候定时器如同并没有依照咱们的预期去执行,比方咱们常遇到的 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 的间断执行。

举荐浏览

介绍回流与重绘(Reflow & Repaint),以及如何进行优化?\
这些浏览器面试题,看看你能答复几个?\
这一次带你彻底理解前端本地存储 \
面试官:说一说前端路由与后端路由的区别 \
JavaScript 之原型与原型链 \
Javascript 深刻之作用域与闭包 \
this 指向与 call,apply,bind

感觉文章不错,能够点个赞呀 ^_^ 另外欢送关注留言交换~

关注公众号,获取更多精选文章~

退出移动版