1. 为什么这三个概念总是一起出现
经常有前端同事问我:JavaScript 的异步到底怎么理解?你说它是单线程的吧,它却能同时发出好几个网络请求;你说它是多线程的吧,setTimeout 的回调明明要等主线程空闲才执行。这个问题的答案,就藏在执行上下文、执行栈和事件循环这三个概念里。
这一篇我会把这三个概念彻底讲透。理解它们,不是为了应付面试,而是为了在调试异步代码、排查页面卡顿、分析并发请求时序时,心里有一张完整的“运行地图”。你不用再靠猜、靠 console.log 一个一个试,而是能直接在大脑中推演代码的执行顺序,这是中级开发者向高级开发者进阶的关键一步。
这一篇适合谁看?对 JavaScript 的执行机制只停留在“知道有回调”阶段的初中级开发者,或者面试前想系统梳理这块知识的人。我尽量不堆概念,把原理讲到“为什么是这样”的层面,这样你才能真正记住。
先说一个整体认知:这三个概念其实回答了三件不同的事。执行上下文回答的是“代码在什么样的环境下运行”;执行栈回答的是“同步代码按什么顺序执行”;事件循环回答的是“异步回调什么时候插入回来执行”。三者合起来,就是一套完整的 JavaScript 运行机制。
2. 执行上下文:每一段代码都有自己的“生存环境”
2.1 三种上下文:全局、函数、eval
执行上下文简单说,就是 JavaScript 引擎在解析和执行一段代码时,为这段代码创建的一个“运行环境记录”。这个环境记录里存着变量、函数声明、作用域链、this 的指向等信息。
JavaScript 里只有三种执行上下文:
- 全局执行上下文:整个脚本启动时创建,浏览器里对应 window 对象,Node.js 里对应 global。一个页面只有一个。
- 函数执行上下文:每调用一次函数就会创建一个,函数执行完就销毁。
- eval 执行上下文:调用 eval() 时创建。这玩意儿性能差、有作用域污染风险,业界默认不用,了解即可。
我举个例子帮你理解。你写了两段完全不相干的代码,一段是全局变量声明,另一段是函数内部的局部变量声明,它们能互不干扰,就是因为各自在不同的执行上下文里“生活”。
2.2 创建阶段的三件事:变量环境、词法环境、this 绑定
很多初学者只知道“执行上下文是代码执行的环境”,但不知道它在创建时具体做了什么。这里我拆开讲。
一个执行上下文被创建时,引擎会依次做三件事:
- 创建词法环境(Lexical Environment),用于处理
let、const声明的变量,以及函数声明。它负责登记这些标识符,并建立对外部词法环境的引用(这就是作用域链的雏形)。 - 创建变量环境(Variable Environment),用于处理
var声明的变量。注意,var和let/const被刻意分开存储,这是 ES6 之后的一个设计细节,理解这个能帮你避开很多作用域相关的坑。 - 确定 this 的绑定。全局上下文中 this 指向全局对象;函数上下文中 this 取决于函数如何被调用,而不是在哪里定义。
这里有个关键点:变量提升(hoisting)现象就是因为第 1、2 步在代码真正执行之前就发生了。引擎先把所有var声明登记为undefined,把函数声明完整登记为函数对象,然后才开始一行一行执行代码。这也是为什么你能在声明之前使用var变量和函数声明。
2.3 变量提升和函数提升:为什么曾经的面试必考题
上面说到登记声明,具体表现是什么?我直接给代码:
console.log(a); // undefined var a = 1; console.log(foo); // [Function: foo] function foo() {} console.log(b); // 报错:Cannot access 'b' before initialization let b = 2;第一行输出undefined而不是报错,因为var a的声明被“提升”到了顶部,初始化为undefined,赋值a = 1留在原地。函数foo的整个函数体都被提升,所以第二行能直接打印出函数对象。而let b虽然也提升了,但它进入了“暂时性死区”(TDZ),在声明语句之前访问会直接抛错。
这个设计的“为什么”值得多说一句:var提升是早期 JavaScript 设计缺陷的遗留,let/const的 TDZ 是 ES6 有意做出的修正,让变量在声明前不可见。所以现在写代码我更推荐用let/const,var的行为太反直觉,容易埋坑。
2.4 作用域链:每个上下文如何“往外看”
执行上下文创建时,除了登记自己的变量,还会保存一个引用,指向它的外层执行上下文。这个引用链就是作用域链(Scope Chain)。
比如这样一个嵌套函数:
var globalVar = 'global'; function outer() { var outerVar = 'outer'; function inner() { var innerVar = 'inner'; console.log(globalVar, outerVar, innerVar); } inner(); } outer();inner函数执行时,它的执行上下文里只有innerVar的登记。当代码访问outerVar时,引擎先在当前上下文找,找不到就顺着作用域链去outer的上下文找,再找不到就去全局上下文。这个过程叫“标识符解析”。
这里有个很容易混淆的点:作用域链是在函数定义时确定的,不是在调用时。因为 JavaScript 采用词法作用域(静态作用域),一个函数能访问哪些变量,由它在源码中的位置决定,而不是由调用位置决定。你可以做个实验,把inner函数赋值给一个全局变量,再在全局调用它,结果它依然能访问outerVar,因为作用域链已经写死了。
注意:每次函数调用都会创建新的执行上下文,但作用域链的“形状”在函数定义时就固定了。所以闭包能“记住”定义时的变量,本质就是它持有了这条链的引用。
3. 执行栈:函数调用的“后进先出”账本
3.1 栈的结构:入栈与出栈的完整过程
执行栈(Call Stack),也叫调用栈,是一个后进先出(LIFO)结构的数据结构。它记录着当前所有正在执行中的函数执行上下文。栈底永远坐着全局执行上下文,栈顶是正在运行的函数。
我写一段代码,模拟压栈和弹栈的过程:
function add(a, b) { return a + b; } function calc() { const result = add(2, 3); return result * 10; } calc();这段代码的执行步骤如下:
- 全局执行上下文压栈,此时栈底是全局。
- 调用
calc(),创建calc的执行上下文并压栈。 calc内部调用add(2, 3),创建add的执行上下文并压栈。add执行完毕,返回值,add的上下文弹栈。calc拿到返回值,执行result * 10,然后calc的上下文弹栈。- 全局上下文在脚本结束前一直留在栈底。
这就是为什么说“栈”很形象:函数调用像往栈顶叠盘子,叠上去又取下来,永远从最上面开始处理。
如果不了解执行栈,你可能很难理解“函数里为什么能调用另一个函数”“为什么递归太深会溢出”。因为递归每一层都是一次函数调用,都会往栈上压一个上下文,栈的高度是有限制的。
3.2 栈溢出:递归的坑与调试技巧
我见过很多新手写递归没有终止条件,或者终止条件永远达不到,结果浏览器报RangeError: Maximum call stack size exceeded。这个错误就是在说:执行栈满了,不能再压了。
举个例子:
function loop() { loop(); } loop();这个函数会无限递归,引擎为了防止内存耗尽,会给栈设置一个最大深度(浏览器里大约一万到几万层,不同引擎不一样),超了直接抛出异常。
调试这类问题有个小技巧:看到栈溢出错误时,别只盯着报错那行,而是看错误信息里的调用栈列表。它会把当前栈里所有函数一层层列出来。大多数时候,你会发现某个函数在反复调用自己,或者互相循环调用(a -> b -> a -> b),那就是问题所在。
实操心得:排查递归问题,先在函数开头加一个
console.log打印当前层数,看看是否在持续增长。如果层数一路冲到数千层都没有停,终止条件大概率写错了。
3.3 执行栈与任务队列的分工:同步代码归栈里,异步回调归队列
这是理解事件循环的铺垫,我单独拿出来说。
执行栈里跑的是同步代码——也就是按顺序、一口气执行完的代码。异步代码(比如setTimeout的回调、Promise 的.then回调、网络请求的返回处理)不会直接进入执行栈,而是先进入任务队列等待。
当执行栈里的同步代码全部跑完,栈空了,事件循环才会从任务队列里取出一个回调任务,放进执行栈里执行。这就是事件的“循环”本质:不断重复“检查栈是否为空 → 从队列取任务 → 执行 → 再检查”。
这个分工意味着一个关键结论:只要执行栈里还有代码在跑,异步回调就永远排不上队。
我见过一个经典问题:页面里有个按钮,点击后触发setTimeout(fn, 0),但页面还是卡了。原因就是点击回调里有一段非常耗时的同步代码,setTimeout的回调虽然已经到点,但它只能等同步代码结束后才能被执行。setTimeout(0)不代表“立刻执行”,只代表“尽快排队”。
4. 初识事件循环:异步回调到底什么时候跑
4.1 宏任务与微任务:两种队列的区分
事件循环里有一个核心概念:任务队列不是一条,而是至少两条——宏任务队列(Macro Task Queue)和微任务队列(Micro Task Queue)。
这两者的区别可以这样理解:
- 宏任务:
setTimeout、setInterval、I/O回调、requestAnimationFrame、事件绑定的回调、MessageChannel等。每个宏任务对应一次“相对独立”的任务。 - 微任务:
Promise.then/catch/finally、MutationObserver、queueMicrotask等。微任务产生后,在当前宏任务结束前就会被清空。
关键规则:每次执行完一个宏任务,引擎会立即清空整个微任务队列,然后再去宏任务队列里取下一个任务。
这个规则比很多人想象的强得多。不是说“微任务优先于宏任务”,而是“每执行一个宏任务,就要清空所有已存在的微任务”。微任务里如果再产生微任务,会继续执行,直到微任务队列被彻底清空。
4.2 经典代码拆解:setTimeout 与 Promise 的执行顺序
老规矩,直接上代码:
console.log('1. 同步代码开始'); setTimeout(() => { console.log('2. 宏任务:setTimeout'); }, 0); Promise.resolve() .then(() => { console.log('3. 微任务:Promise then 1'); }) .then(() => { console.log('4. 微任务:Promise then 2'); }); console.log('5. 同步代码结束');整段代码的执行顺序是:
1. 同步代码开始 5. 同步代码结束 3. 微任务:Promise then 1 4. 微任务:Promise then 2 2. 宏任务:setTimeout详细推演一遍:
- 全局同步代码开始执行,依次打印 1、5。
- 遇到
setTimeout,回调被放入宏任务队列。 - 遇到
Promise.resolve().then(),第一个.then回调被放入微任务队列。 - 同步代码执行完毕,执行栈清空。
- 事件循环检查微任务队列,发现两个
.then回调,依次取出执行,打印 3、4。第二个.then的回调是在第一个.then执行后才入队的,但仍然在当前宏任务清空微任务的阶段被处理。 - 微任务队列清空,事件循环从宏任务队列取出
setTimeout回调解锁,打印 2。
这个例子建议你自己动手跑一遍,再改一改,比如在微任务里再放一个微任务,或者宏任务里注册新宏任务,加深印象。
4.3 事件循环完整流程:取一个宏任务,清空所有微任务
把上面例子扩展到浏览器环境,完整的事件循环流程是这样的:
- 执行一个宏任务(最开始是全局脚本)。
- 如果这个宏任务产生了微任务,进入微任务队列。
- 宏任务执行完,清空微任务队列,直到队列为空。
- (浏览器环境)如果需要渲染,执行渲染操作。
- 回到第 1 步,从宏任务队列取下一个宏任务。
这个流程里我特意提了渲染,因为很多初学者会把“更新页面 DOM”和“事件循环”割裂开来看。实际上,浏览器更新画面也是被插入到事件循环里的——通常是在一定时间间隔后,浏览器会执行一次渲染,并等待这次渲染的输入事件等任务。理解这一点,就能明白为什么长耗时的同步任务会导致页面“卡死”——因为渲染根本没机会插入。
注意:状态更新但不立刻渲染。也就是同步代码里改了十次 DOM,浏览器不一定渲染十次,很可能只在渲染时机到来时合并成一次。这也是为什么你可以在一个循环里反复改 DOM,不会每次都触发重排,代价是如果依赖中间状态读取,你可能读到的是旧的渲染结果。
4.4 微任务与页面渲染的微妙关系
这里再加深一层:微任务的执行时机非常特殊,它一定发生在当前宏任务结束后、下一次渲染之前。所以在微任务里改动 DOM,通常能保证在渲染前生效。
而requestAnimationFrame的回调会被安排在“下一次渲染之前”执行,经常有人问它跟微任务谁先谁后。简单结论:如果微任务队列非空,微任务先清空,然后才是 rAF 回调,最后到渲染。不过不同浏览器的具体调度策略有些差异,生产代码别依赖这个顺序。
对于异步开发者,有一个更实用的建议:别在主线程上用微任务做太重的计算。微任务一旦入队,会阻塞后续宏任务,包括渲染、用户输入处理都会延后。某个页面里如果疯狂Promise.resolve().then()连环产生几千个微任务,浏览器照样会卡。
5. 常见问题与排查技巧实录
5.1 栈溢出与死循环:现象看起来一样的两个坑
前面讲过的无限递归,报错是Maximum call stack size exceeded。但还有一种情况:没有递归,而是while(true)死循环,比如:
while (true) { // 干点什么,但永远不退出 }这两种问题的差别很关键:
- 递归溢出:执行栈被快速堆满,报栈溢出。
- 死循环:执行栈不增长,但一个宏任务永远执行不完,事件循环被卡死,页面无响应,浏览器标签页风扇狂转。
怎么区分?打开 DevTools 的 Performance 面板,录制几秒钟。如果 JS 调用栈堆得很深,是递归溢出;如果一条长长的线占满主线程时长,是死循环。
调试死循环的方式:在循环里加一个计数器,每 1000 次打印一次,观察它跑到哪了。或者用debugger配合条件断点,命中次数超过阈值时停下。
实操心得:我在调一个轮播图逻辑时遇到过死循环,表现是页面加载后立刻卡死。用 Performance 面板一录,发现是某个
while循环里忘记更新循环变量,导致永远走不完。这种肉眼很容易漏,用工具看一眼比瞎试快得多。
5.2 Promise 的 executor 为什么是“同步执行”的
很多人以为new Promise((resolve, reject) => { ... })里的内容会异步执行。这是错的。Promise 的 executor(执行器函数)是立即同步执行的,.then里的回调才是异步的。
看这段代码:
console.log('1'); new Promise((resolve) => { console.log('2'); resolve(); }).then(() => { console.log('3'); }); console.log('4');输出顺序是1、2、4、3。console.log('2')在 Promise 构造时同步执行,而.then回调以微任务方式入队。
这个设计的“为什么”:Promise 的 executor 是用来初始化异步操作的,你需要在创建 Promise 的那一刻就启动这个操作,比如发起网络请求。如果 executor 是异步的,请求会被延后,失去意义。
5.3 async/await 的微任务细节与陷阱
async函数是 Promise 的语法糖,await后面的代码相当于.then回调,也会变成微任务执行。有个容易踩的坑:await的下一行代码不一定在“下一轮事件循环”执行,而是在“当前微任务队列被清空”前执行。
async function task() { console.log('1'); await Promise.resolve(); console.log('2'); } task(); console.log('3');输出顺序是1、3、2。await之后当前async函数会先挂起,await右侧的 Promise 已经 resolve,但console.log('2')被推迟为微任务,等同步代码执行完、微任务清空时执行。
还有一个常见误区:在for循环里连续await,每轮都会产生一次微任务排队,整体会比同步代码慢不少。如果对性能敏感,能并行发起的请求就Promise.all,能减少事件循环的轮转次数。
5.4 用 DevTools 查看执行栈与任务队列
Chrome DevTools 里调试异步代码有几个实用功能,我整理成一个速查表:
| 场景 | 工具 | 操作方式 |
|---|---|---|
| 查看当前执行栈 | Sources 面板的 Call Stack | 在断点处直接查看函数调用栈 |
| 查看异步任务执行链 | Sources 面板的 Async Stack | 开启异步堆栈跟踪,能在 Promise 回调里看到是谁触发了它 |
| 分析主线程任务分布 | Performance 面板 | 录制后查看火焰图里的长任务、任务队列分布 |
| 查看微任务执行时机 | Performance 面板的火焰图 | 关注 Micro Task 区域,能看到微任务在哪一段执行 |
具体排查手法:想在某个异步回调里看它的“来龙去脉”,在回调第一行打断点,打开 Call Stack 时如果只有孤零零的一行,说明调用栈已经退出。这时候切到 Async Stack 视图,能看到这个回调是从哪个 Promise 链或者setTimeout里被调度过来的,非常有用。
5.5 长任务与页面卡顿:事件循环角度的定位法
页面卡顿的根源,往往是一个执行时间特别长的“长任务”(Long Task)。从事件循环角度看,长任务就是占用执行栈太久,导致渲染、输入、回调都被延后。
定位方法:
- Performance 面板录制复现过程。
- 找到火焰图里颜色很深的、占位很长的任务。
- 点击任务,看它对应哪个函数,对应哪段代码。
- 如果是同步重计算,考虑拆分成多个片段,用
requestAnimationFrame或setTimeout分批执行,让渲染有机会插入。
我曾经处理过一个数据导入功能,导入几千行数据时全在主线程里做格式化、去重、校验,页面直接卡到 2 秒以上。后来我改成把数据切成每 200 行一个批次,用setTimeout(0)或者requestIdleCallback分批处理,每批次之间让出主线程,页面立刻流畅了。这个改动背后的原理,就是事件循环的“插入渲染”机制。
注意:拆分长任务时不要图省事用
async/await包裹一个巨大循环,那样依旧是一个连续的大任务。关键是要让出主线程,也就是在批次之间真正产生“栈空”的时机。
6. 我个人觉得最值得记住的一条经验
做了几年前端,我踩过最多的坑就在异步时序上。每次出现“有时候能复现、有时候不能”的诡异 bug,十有八九是没搞清楚执行上下文、执行栈和事件循环的协作关系。尤其是 Promise 微任务和渲染时机的那些细微差别,不亲手调试,靠看文档是记不住的。
我自己的建议是:把第 4.2 节那个经典代码的顺序亲自跑一遍,再用 DevTools 的 Performance 面板录制看微任务和宏任务的分布,胜过背十篇面试题。有时候 Debug 模式下看起来正常的顺序,Release 行为都可能因为引擎优化而改变,所以核心是理解机制,而不是死记输出结果。
最后分享一个小技巧:遇到难排查的异步问题,先把所有相关回调里加上console.trace(),它能打印出当前调用栈。配合微任务、宏任务的知识,你就能快速判断这段代码到底卡在哪个队列里,问题往往迎刃而解。