彻底弄懂 JavaScript 执行机制:从单线程到 Event Loop

作者:异步漫步

时间:2026-10-22

浏览:197

本文深入解析 JavaScript 的单线程执行模型,详细阐述同步与异步任务的区分,以及 Event Loop 事件循环的工作原理。通过对比宏任务(Macro-task)与微任务(Micro-task)的执行顺序,结合 setTimeout、Promise 和 process.nextTick 等具体案例,清晰展示代码在浏览器与 Node.js 环境下的执行流程,帮助开发者彻底掌握异步编程的核心机制。

文章目录

JavaScript 单线程模型基础

无论是 JavaScript 初学者还是资深开发者,在面试或日常开发中,经常需要判断一段代码的输出结果及其执行顺序。由于 JavaScript 采用单线程模型,我们可以得出一个基本结论:

  • JavaScript 代码是按照语句在源代码中出现的顺序依次执行的。

看到这里,读者可能会觉得这是常识:JavaScript 难道不是逐行执行吗?确实如此,但正因为它是逐行执行的,我们往往会产生一种误解,认为 JavaScript 的执行过程总是如此简单直接。


let a = '1';
console.log(a);
let b = '2';
console.log(b);

然而,实际的执行机制远比这复杂。


setTimeout(function(){
 console.log('定时器开始啦')
});

new Promise(function(resolve){
 console.log('马上执行for循环啦');
 for(var i = 0; i < 10000; i++){
  i == 99 && resolve();
 }
}).then(function(){
 console.log('执行then函数啦')
});

console.log('代码执行结束');

基于“JavaScript 按语句出现顺序执行”这一理念,我自信地写下了预期的输出结果:

//"定时器开始啦" //"马上执行for循环啦" //"执行then函数啦" //"代码执行结束"去chrome上验证下,结果完全不对,瞬间懵了,说好的一行一行执行的呢?

因此,我们需要彻底弄清楚 JavaScript 的执行机制。

1. 关于 JavaScript JavaScript 是一门单线程语言。尽管最新的 HTML5 标准引入了 Web-Worker 技术,但 JavaScript 核心的单线程特性并未改变。因此,所有 JavaScript 版本的“多线程”实际上都是用单线程模拟出来的,所谓的 JavaScript 多线程不过是纸老虎。

同步与异步任务及 Event Loop 原理

2. JavaScript 事件循环 既然 JavaScript 是单线程,它就像只有一个窗口的银行,客户必须排队办理业务。同理,JavaScript 的任务也必须一个接一个地顺序执行。如果一个任务耗时过长,后续任务就必须等待。试想一下,如果浏览新闻时,超清图片加载缓慢,网页是否会一直卡顿直到图片显示完毕?为了解决这个问题,程序员将任务分为两类:

  • 同步任务
  • 异步任务

当我们打开网站时,页面骨架和元素的渲染过程属于大量同步任务。而加载图片、音乐等占用资源大且耗时久的任务,则属于异步任务。虽然这部分有严格的文字定义,但为了以最低的学习成本彻底弄懂执行机制,我们通过导图来说明:

彻底弄懂 JavaScript 执行机制:从单线程到 Event Loop 配图
 

导图所表达的内容,用文字表述如下:

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

我们不禁要问:如何知道主线程执行栈是否为空?JavaScript 引擎中存在 monitoring process(监控进程),它会持续不断地检查主线程执行栈是否为空。一旦为空,就会去 Event Queue 检查是否有等待被调用的函数。

说了这么多文字,不如直接看一段代码更直观:


let data = [];
$.ajax({
 url:www.javascript.com,
 data:data,
 success:() => {
  console.log('发送成功!');
 }
})

console.log('代码执行结束');上面是一段简易的 ajax 请求代码:

  • ajax 进入 Event Table,注册回调函数 success。
  • 执行 console.log('代码执行结束')。
  • ajax 事件完成,回调函数 success 进入 Event Queue。
  • 主线程从 Event Queue 读取回调函数 success 并执行。

相信通过上面的文字和代码,你已经对 JavaScript 的执行顺序有了初步了解。接下来我们来研究进阶话题:setTimeout。

setTimeout 的执行机制与延迟问题

3. 又爱又恨的 setTimeout

大名鼎鼎的 setTimeout 无需再多言,大家对它的第一印象就是异步可以延时执行。我们经常这样实现延时 3 秒执行:


setTimeout(() => {
 console.log('延时3秒');
},

3000)渐渐地 setTimeout 用的地方多了,问题也出现了。有时候明明写的延时 3 秒,实际却 5、6 秒才执行函数,这又是怎么回事?

先看一个例子:


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

console.log('执行console');根据前面的结论,setTimeout 是异步的,应该先执行 console.log 这个同步任务,所以我们的结论是:


//执行console
//task()去验证一下,结果正确!然后我们修改一下前面的代码:
setTimeout(() => {
 task()
},3000)

sleep(10000000)乍一看其实差不多嘛,但我们把这段代码在 chrome 执行一下,却发现控制台执行 task() 需要的时间远远超过 3 秒。说好的延时三秒,为啥现在需要这么长时间?

这时候我们需要重新理解 setTimeout 的定义。我们先说上述代码是怎么执行的:

  • task() 进入 Event Table 并注册,计时开始。
  • 执行 sleep 函数,很慢,非常慢,计时仍在继续。
  • 3 秒到了,计时事件 timeout 完成,task() 进入 Event Queue。但是 sleep 也太慢了吧,还没执行完,只好等着。
  • sleep 终于执行完了,task() 终于从 Event Queue 进入了主线程执行。

上述流程走完,我们知道 setTimeout 这个函数,是经过指定时间后,把要执行的任务(本例中为 task())加入到 Event Queue 中。又因为是单线程任务要一个一个执行,如果前面的任务需要的时间太久,那么只能等着,导致真正的延迟时间远远大于 3 秒。

我们还经常遇到 setTimeout(fn, 0) 这样的代码,0 秒后执行又是什么意思呢?是不是可以立即执行?

答案是不会的。setTimeout(fn, 0) 的含义是,指定某个任务在主线程最早可得的空闲时间执行。意思就是不用再等多少秒了,只要主线程执行栈内的同步任务全部执行完成,栈为空就马上执行。举例说明:


//代码1
console.log('先执行这里');
setTimeout(() => {
 console.log('执行啦')
},0);//代码2
console.log('先执行这里');
setTimeout(() => {
 console.log('执行啦')
},3000);代码1的输出结果是:
//先执行这里
//执行啦代码2的输出结果是:
//先执行这里
// ... 3s later
// 执行啦关于setTimeout要补充的是,即便主线程为空,0毫秒实际上也是达不到的。根据HTML的标准,最低是4毫秒。有兴趣的同学可以自行了解。

setInterval 的时间间隔特性

4. 又恨又爱的 setInterval

上面说完了 setTimeout,当然不能错过它的孪生兄弟 setInterval。它们差不多,只不过后者是循环执行。对于执行顺序来说,setInterval 会每隔指定的时间将注册的函数置入 Event Queue。如果前面的任务耗时太久,那么同样需要等待。

唯一需要注意的一点是,对于 setInterval(fn, ms) 来说,我们已经知道不是每过 ms 秒会执行一次 fn,而是每过 ms 秒,会有 fn 进入 Event Queue。一旦 setInterval 的回调函数 fn 执行时间超过了延迟时间 ms,那么就完全看不出来有时间间隔了。这句话请读者仔细品味。

Promise、process.nextTick 与宏微任务

5. Promise 与 process.nextTick(callback)

传统的定时器我们已经研究过了,接着我们探究 Promise 与 process.nextTick(callback) 的表现。

Promise 的定义和功能本文不再赘述,不了解的读者可以学习一下阮一峰老师的 Promise。而 process.nextTick(callback) 类似 node.js 版的“setTimeout”,在事件循环的下一次循环中调用 callback 回调函数。

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

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

不同类型的任务会进入对应的 Event Queue,比如 setTimeout 和 setInterval 会进入相同的 Event Queue。

事件循环的顺序,决定 JavaScript 代码的执行顺序。进入整体代码(宏任务)后,开始第一次循环。接着执行所有的微任务。然后再次从宏任务开始,找到其中一个任务队列执行完毕,再执行所有的微任务。听起来有点绕,我们用文章最开始的一段代码说明:


setTimeout(function() {
 console.log('setTimeout');
})
new Promise(function(resolve) {
 console.log('promise');
}).then(function() {
 console.log('then');
})

console.log('console');

  • 这段代码作为宏任务,进入主线程。
  • 先遇到 setTimeout,那么将其回调函数注册后分发到宏任务 Event Queue。(注册过程与上同,下文不再描述)
  • 接下来遇到了 Promise,new Promise 立即执行,then 函数分发到微任务 Event Queue。
  • 遇到 console.log(),立即执行。
  • 好啦,整体代码 script 作为第一个宏任务执行结束,看看有哪些微任务?我们发现了 then 在微任务 Event Queue 里面,执行。
  • ok,第一轮事件循环结束了,我们开始第二轮循环,当然要从宏任务 Event Queue 开始。我们发现了宏任务 Event Queue 中 setTimeout 对应的回调函数,立即执行。
  • 结束。

事件循环,宏任务,微任务的关系如图所示:

彻底弄懂 JavaScript 执行机制:从单线程到 Event Loop 配图
 

复杂代码的事件循环执行案例分析

我们来分析一段较复杂的代码,看看你是否真的掌握了 JavaScript 的执行机制:


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。

JavaScript 单线程模型基础

  • 上表是第一轮事件循环宏任务结束时各 Event Queue 的情况,此时已经输出了 1 和 7。
  • 我们发现了 process1 和 then1 两个微任务。
  • 执行 process1,输出 6。
  • 执行 then1,输出 8。

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

首先输出 2。接下来遇到了 process.nextTick(),同样将其分发到微任务 Event Queue 中,记为 process2。new Promise 立即执行输出 4,then 也分发到微任务 Event Queue 中,记为 then2。

JavaScript 单线程模型基础

  • 第二轮事件循环宏任务结束,我们发现有 process2 和 then2 两个微任务可以执行。
  • 输出 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

第三轮事件循环宏任务执行结束,执行两个微任务 process3 和 then3。

输出 10。 输出 12。

第三轮事件循环结束,第三轮输出 9,11,10,12。

整段代码,共进行了三次事件循环,完整的输出为 1,7,6,8,2,4,3,5,9,11,10,12。(请注意,node 环境下的事件监听依赖 libuv 与前端环境不完全相同,输出顺序可能会有误差)

总结与关键概念回顾

6. 写在最后

(1) JavaScript 的异步 我们从最开头就说 JavaScript 是一门单线程语言。不管是什么新框架、新语法糖实现的所谓异步,其实都是用同步的方法去模拟的。牢牢把握住单线程这点非常重要。

(2) 事件循环 Event Loop 事件循环是 JavaScript 实现异步的一种方法,也是 JavaScript 的执行机制。 (3) JavaScript 的执行和运行 执行和运行有很大的区别。JavaScript 在不同的环境下,比如 node、浏览器、Ringo 等等,执行方式是不同的。而运行大多指 JavaScript 解析引擎,是统一的。 (4) setImmediate 微任务和宏任务 还有很多种类,比如 setImmediate 等等,执行都是有共同点的,有兴趣的同学可以自行了解。

(5) 最后的最后

  • JavaScript 是一门单线程语言
  • Event Loop 是 JavaScript 的执行机制

总结

以上所述是小编给大家介绍的 JavaScript 执行机制,希望对大家有所帮助。如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对本站网站的支持!

实用工具推荐

电子包浆

绿化、压缩、噪点和虚拟水印,自由调节做旧程度。

使用 ›
图片压缩

选择画质和输出格式,预览压缩前后的大小。

使用 ›
相关文章