3. JavaScript 事件循环

188 阅读5分钟

Even Loop

JavaScript 是单线程的,所有 JavaScript 的任务是按次序执行的,虽然我们的 JavaScript 却有异步执行的方案。但是因为 JavaScript 是单线程的语言,所有的异步都是通过同步模拟的。

JavaScript 的任务分为两种:同步任务 和 异步任务。

当我们打开网站时,网页的渲染过程就是一大堆同步任务,比如页面骨架和页面元素的渲染。而像加载图片、音乐之类的占用资源大耗时久的任务就是执行的异步任务。所以我们经常看到图片的延迟加载(图片一般使用预加载和懒加载技术)。

81223744

导图要表达的内容用文字来表述的话:

  • 同步和异步任务分别进入不同的执行“场所”,同步的进入主线程, 异步的进入 Event Table 并注册函数。
  • 当指定的事情完成时,Event Table 会将这个函数移入 Event Quere。
  • 主线程内的任务执行完毕为空,会去 Event Queue 读取对应的函数,进入主线程。
  • 上述过程会不断重复,也就是常说的 Event Loop(事件循环)。

异步是使用场景

  • 定时任务:setTimeout、setInterval
  • 网络请求:Ajax请求,动态加载
  • 事件绑定

setTimeout 的执行机制

setTimeout(() => {
    task()
},3000)

sleep(10000000)

这里我们本意是 3s 后执行 task() ,但是我们却发现控制台执行 task() 需要的时间远远超过了 3s 。这是因为这里的 3s 只是 task() 进入 Event Queue 的时间,如果主线程中还有其他函数在执行(sleep),task() 是排在这个函数后面的,即使说它要等待前面的函数执行完才会进入主线程执行。过程如下:

  • task() 进入 Event Table 并注册,计时开始。
  • 执行 sleep 函数,task() 计时继续。
  • 3s 后,timeout 计时事件完成,task() 进入 Event Queue,但是 sleep() 还在执行,task() 继续等待。
  • sleep() 执行完成,task() 从 Event Queue 中进入主进程执行。

setTimeout(fn, 0) 不是 0s 后立即执行,而是等待主线程最早获得空闲时间后执行。但实际上,就算主线程是空,0ms 也是达不到了,最低是 4ms。

setInterval 的执行机制

setInterval 和 setTimeout 类似,不过 setInterval 是循环的执行。对于执行顺序来说,setInterval 会每隔指定的时间将注册的函数置入 Event Queue,如果前面的任务耗时太久,同样需要等待。

需要注意的是,对于 setInterval(fn, ms) 来说,不是每过 ms 执行一次,而是每过 ms 会将 fn 如 Event Queue。所有,一旦 setInterval 的回调函数 fn 执行的时间超过了延迟时间 ms,那么就完全看不出间隔!

Promise 和 process.nextTick(callback) 的执行机制

除了广义的同步任务和异步任务,我们对任务还有更精细的定义:

  • macro-task(宏任务):包括整体代码:script,setTimeout,setInterval
  • micro-task(微任务):包括 Promise,process。nextTick

不同的任务会进入其相应的 Event Queue。

事件循环的顺序,决定 js 代码执行的顺序。进入整体代码(宏任务)后,开始第一次循环。接着执行所有微任务。然后再次从宏任务开始,找到其中一个任务队列执行完毕,再执行所有的微任务。

81223745

如下代码:

console.log('1');

setTimeout(function() {
    console.log('2');
    process.nextTick(function() {
        console.log('3');
    })
    new Promise(function(resolve) {
        console.log('4');
        resolve();
    }).then(function() {
        console.log('5')
    })
})
process.nextTick(function() {
    console.log('6');
})
new Promise(function(resolve) {
    console.log('7');
    resolve();
}).then(function() {
    console.log('8')
})

setTimeout(function() {
    console.log('9');
    process.nextTick(function() {
        console.log('10');
    })
    new Promise(function(resolve) {
        console.log('11');
        resolve();
    }).then(function() {
        console.log('12')
    })
})

第一轮事件循环流程分析如下:

  • 整体script作为第一个宏任务进入主线程,遇到console.log,输出1。
  • 遇到 setTimeout,其回调函数被分发到宏任务Event Queue中。我们暂且记为 setTimeout1
  • 遇到 process.nextTick(),其回调函数被分发到微任务Event Queue中。我们记为 process1
  • 遇到Promise,new Promise直接执行,输出7。then 被分发到微任务Event Queue中。我们记为 then1
  • 又遇到了setTimeout,其回调函数被分发到宏任务Event Queue中,我们记为 setTimeout2
宏任务Event Queue 微任务Event Queue
setTimeout1 process1
setTimeout2 then1
  • 上表是第一轮事件循环宏任务结束时各Event Queue的情况,此时已经输出了1和7。
  • 我们发现了 process1then1 两个微任务。
  • 执行 process1,输出6。
  • 执行 then1,输出8。

第二轮事件循环流程分析如下:

好了,第一轮事件循环正式结束,这一轮的结果是输出1,7,6,8。那么第二轮时间循环从setTimeout1宏任务开始:

  • 首先输出2。接下来遇到了 process.nextTick(),同样将其分发到微任务Event Queue中,记为 process2。new Promise立即执行输出4,then也分发到微任务Event Queue中,记为 then2
宏任务Event Queue 微任务Event Queue
setTimeout2 process2
then2
  • 第二轮事件循环宏任务结束,我们发现有 process2then2 两个微任务可以执行。
  • 输出3。
  • 输出5。
  • 第二轮事件循环结束,第二轮输出2,4,3,5。

第三轮事件循环流程分析如下:

第三轮事件循环开始,此时只剩setTimeout2了,执行。

  • 直接输出9。
  • process.nextTick() 分发到微任务Event Queue中。记为 process3
  • 直接执行 new Promise,输出11。
  • 将then分发到微任务Event Queue中,记为then3。
宏任务Event Queue 微任务Event Queue
process3
then3
  • 第三轮事件循环宏任务执行结束,执行两个微任务 process3then3
  • 输出10。
  • 输出12。
  • 第三轮事件循环结束,第三轮输出9,11,10,12。

整段代码,共进行了三次事件循环,完整的输出为1,7,6,8,2,4,3,5,9,11,10,12。

(请注意,node环境下的事件监听依赖libuv与前端环境不完全相同,输出顺序可能会有误差)

参考: