事件循环

setTimeout、setInterval

延时时间参数 delayTime

setTimeout/setInterval(function () {

}, delayTime)
1
2
3

注意,delayTime的时间,是指在delayTime时间后将函数放入事件队列中,而不是立即放入事件队列中等待delay时间后执行

堆积未执行的 setInterval 回调只会执行一个

let count = 5;
let timer;
let countdown = () => {
    console.log(--count);
    if (count <= 0) {
        clearInterval(timer);
        console.log('计时结束')
    }
}
timer = setInterval(countdown, 1000);

const init = Date.now();
while(Date.now() - init < 10000) {}
1
2
3
4
5
6
7
8
9
10
11
12
13

尝试执行以上代码你会发现,尽管因为while语句阻塞了 10s,期间可能有 10 个回调函数进入了事件队列里,但是在阻塞结束后,仍然只输出了4,之后每隔 1s 依次输出32..

这是因为,尽管在主线程阻塞的过程中有多个回调函数进入了事件队列,发生了回调函数的堆积,但是在阻塞结束后执行事件队列里的回调函数时,只会执行一个。因此使用setInterval进行倒计时是不太准确的。

示例

for (var i = 0; i < 10; i++) {
    setTimeout(function() { console.log(i); }, 100 * i);
}
1
2
3

这段代码的意图是什么?有什么问题?

  • 解决方法一:IIFE(立即执行函数表达式)
for (var i = 0; i < 10; i++) {
    (function(i) {
        setTimeout(function() { console.log(i); }, 100 * i);
    })(i);
}
1
2
3
4
5
  • 解决方法二:IIFE + 返回函数
for (var i = 0; i < 10; i++) {
    setTimeout(function (i) {
        return function () {
            console.log(i);
        };
    }(i), 100*i);
}
1
2
3
4
5
6
7
  • 解决方法三:letlet不仅是在循环里引入了一个新的变量环境,而是针对每次迭代都会创建这样一个新作用域)
for (let i = 0; i < 10; i++) {
    setTimeout(function() { console.log(i); }, 100 * i);
}
1
2
3

宏任务/微任务队列

Event Loop 里存在两类队列,分别为宏任务(Macrotask)和微任务(Microtask)。每一个宏任务都有一个微任务队列。

  • 宏任务
    • script(整体代码)
    • setTimeout
    • setInterval
    • setImmediate
    • I/O
    • UI rendering
  • 微任务
    • process.nextTick
    • Promises
    • Object.observe
    • MutationObserver

执行过程如下:

  • JavaScript 引擎首先从消息队列中取出第一个宏任务执行。该宏任务执行过程中,若是产生另一个宏任务则将其加入消息队列;若是产生微任务,则加入到该宏任务的微任务队列。
  • 宏任务执行完毕后,依次按序执行该宏任务的微任务队列里的每一个微任务。微任务执行过程中,若是产生宏任务则将其加入消息队列;若是产生微任务,则继续加入到当前的微任务队列里。
  • 继续从消息队列中取出下一个宏任务执行,重复以上步骤。

宏任务、微任务与渲染的关系

  1. 执行一个宏任务
  2. 执行该宏任务的所有的微任务
  3. 最后,(如有必要)浏览器进行渲染

浏览器循环进行以上步骤。

浏览器的独有的 Event Loop

requestAnimationFrame

requestAnimationFrame函数的回调函数会加入到渲染这一边的队列中,它在渲染的三个步骤(S:?, L:layout,P:paint)之前被执行。通常用来处理渲染相关的工作。

requestAnimationFrame只在渲染过程之前运行,因此严格遵守“执行一次渲染一次”。

和渲染动画相关的,多用requestAnimationFrame,不会有掉帧的问题(即某一帧没有渲染,下一帧把两次的结果一起渲染了)

示例一

box.style.transform = 'translateX(1000px)'
requestAnimationFrame(() => {
    box.style.tranition = 'transform 1s ease'
    box.style.transform = 'translateX(500px)'
})
1
2
3
4
5

上面这段代码的本意从让box元素的位置先从0瞬间移动到右边1000px处,然后再以动画形式缓慢移动到右边500px处。

但是因为requestAnimationFrame是在渲染过程之前进行的,导致box.style.transform = 'translateX(1000px)'box.style.transform = 'translateX(500px)'都在下一帧出现之前执行,也就是这两行代码合并了(或者说后者覆盖了前者),最终展现的结果是,box元素的位置从0以动画的形式缓慢移动到右边500px处。

那如何实现原先代码的本意呢?

  • requestAnimationFrame回调里再调用一次requestAnimationFrame
// 该行的代码是在下一帧渲染之前调用(主进程代码)
box.style.transform = 'translateX(1000px)'
requestAnimationFrame(() => {
    // 该行的代码是在下一帧渲染之前调用(第一个 requestAnimationFrame 回调里)
    requestAnimationFrame(() => {
        // 该行的代码是在下一帧渲染之后,下下一帧渲染之前调用(第二个 requestAnimationFrame 回调里)
        box.style.tranition = 'transform 1s ease'
        box.style.transform = 'translateX(500px)'
    })
})
1
2
3
4
5
6
7
8
9
10
  • 两次transform赋值之间获取一下当前的计算样式
box.style.transform = 'translateX(1000px)'
getComputedStyle(box) // 伪代码,只要获取一下当前的计算样式即可
box.style.tranition = 'transform 1s ease'
box.style.transform = 'translateX(500px)'
1
2
3
4

用户点击与代码触发点击

button.addEventListener('click', () => {
  Promise.resolve().then(() => console.log('microtask 1'))
  console.log('listener 1')
})
button.addEventListener('click', () => {
  Promise.resolve().then(() => console.log('microtask 2'))
  console.log('listener 2')
})
1
2
3
4
5
6
7
8

在浏览器上运行后,用户点击按钮,会按顺序打印:

listener 1
microtask 1
listener 2
microtask 2
1
2
3
4

但如果在上面代码的最后加上button.click(),打印顺序会有所区别:

listener 1
listener 2
microtask 1
microtask 2
1
2
3
4

用户点击按钮的打印结果很容易解释:click回调是一macrotaskpromise.then()添加的回调是一microtask,每个macrotask执行完后都要先将所有的microtask都执行完才能继续执行下一个macrotask

但是对于在代码里主动调动button.click(),就稍微怪异一些,而这怪异的行为是由浏览器的内部实现造成的:使用button.click()时,浏览器的内部实现是把 2 个 listener 都同步执行。因此listener 1之后,执行队列还没空,还要继续执行listener 2之后才行。所以listener 2会早于microtask 1。重点在于浏览器的内部实现,click方法会先采集有哪些 listener,再依次触发。

Node.js 的 Event Loop

process.nextTick

process.nextTick方法可以在当前"执行栈"的尾部----下一次 Event Loop(主线程读取"任务队列")之前----触发回调函数。也就是说,它指定的任务总是发生在所有异步任务之前。

process.nextTick(function A() {
  console.log(1);
  process.nextTick(function B(){console.log(2);});
});

setTimeout(function timeout() {
  console.log('TIMEOUT FIRED');
}, 0)

console.log('0')

// 运行结果:
// 0
// 1
// 2
// TIMEOUT FIRED
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

上面代码中,由于process.nextTick方法指定的回调函数,总是在当前"执行栈"的尾部触发,所以不仅函数 A 比 setTimeout 指定的回调函数 timeout 先执行,而且函数 B 也比 timeout 先执行。这说明,如果有多个process.nextTick语句(不管它们是否嵌套),将全部在当前"执行栈"执行。

setImmediate

setImmediate方法则是在当前"任务队列"的尾部添加事件,也就是说,它指定的任务总是在下一次 Event Loop 时执行,这与setTimeout(fn, 0)很像。

setImmediate方法与setTimeout方法的区别,可参考阮一峰-JavaScript 运行机制详解:再谈Event Loopopen in new window

注意事项

定时器时间的确定

我们调用setTimeout(fn, delay)函数后,fn 会交由定时器线程,定时器线程在到达delay时间后,将fn加入到事件队列中。

用户触发事件/代码触发事件 区别

文章 Tasks, microtasks, queues and schedulesopen in new window 里提到,由用户交互触发的事件回调和代码里触发的事件回调,执行时间是不一样的。

简单说,如果是由用户交互触发的事件,事件回调函数会加入到任务队列中,等待下一次 Event Loop;如果是由代码触发的事件,回调会立即同步执行。

此外,同一事件的冒泡行为,是在同一 Event Loop 里执行的,类似于 microtask。

Reference