在前端开发中,我们每天都在和 setTimeout、Promise、fetch 等异步 API 打交道。但你是否真正理解它们背后的执行机制?为什么 console.log('End') 总是在 setTimeout 回调之前打印?为什么 var 和 let 在循环中的行为差异如此之大?
本文将结合 JavaScript 执行上下文、调用栈、任务队列与事件循环 的完整生命周期,通过两个对比案例(let vs var)深入剖析异步执行的底层逻辑,并辅以可视化流程说明,帮助你构建清晰的执行模型。
一、核心概念速览
在进入案例前,先快速回顾四个关键组件:
| 概念 | 作用 | 特点 |
|---|---|---|
| 执行上下文(Execution Context) | 代码执行的环境容器 | 包含变量、this、作用域链;分全局、函数、块级(ES6+) |
| 调用栈(Call Stack) | 管理执行上下文的 LIFO 栈 | 函数调用入栈,执行完出栈 |
| 任务队列(Task Queue) | 存放异步回调的 FIFO 队列 | 来源:setTimeout、DOM 事件、I/O 等 |
| 事件循环(Event Loop) | 协调调用栈与任务队列的调度器 | 调用栈空闲时,从队列取任务压入栈执行 |
💡 关键原则:JavaScript 是单线程语言,所有同步代码必须执行完毕后,异步回调才有机会运行。
二、典型案例对比:let vs var 在循环中的异步行为
场景描述
我们分别用 let 和 var 声明循环变量,观察 setTimeout 回调的输出结果:
✅ 案例 A:使用 let(推荐写法)
javascript
javascript
编辑
console.log('Start');
for (let i = 0; i < 3; i++) {
setTimeout(() => {
console.log('let:', i);
}, 100);
}
console.log('End');
输出:
vbnet
text
编辑
Start
End
let: 0
let: 1
let: 2
❌ 案例 B:使用 var(经典陷阱)
javascript
javascript
编辑
console.log('Start');
for (var i = 0; i < 3; i++) {
setTimeout(() => {
console.log('var:', i);
}, 100);
}
console.log('End');
输出:
vbnet
text
编辑
Start
End
var: 3
var: 3
var: 3
🤔 为什么
var输出全是3?而let能正确捕获每次的值?
三、执行流程深度解析
3.1 共同起点:初始化与同步执行
两个案例在 状态一(初始化)→ 状态二(同步执行) 阶段完全一致:
-
初始化:压入全局执行上下文。
-
执行
console.log('Start')→ 打印'Start'。 -
进入
for循环:- 每次调用
setTimeout,其回调函数被注册为宏任务(macrotask),放入任务队列。 - 注意 :此时回调并未执行,只是被"安排"在未来某个时间点执行。
- 每次调用
-
执行
console.log('End')→ 打印'End'。 -
同步代码执行完毕,进入 状态三:调用栈空闲。
此时,任务队列中有 3 个待执行的 setTimeout 回调。
3.2 分歧点:变量作用域与闭包捕获
🔹 let 的块级作用域机制(ES6+)
let i在每次循环迭代时,都会创建一个新的块级作用域。- 每个
setTimeout回调通过闭包 捕获的是当前迭代的i值。 - 因此,三个回调分别绑定
i=0、i=1、i=2。
✅ 本质:每个回调拥有独立的词法环境。
🔸 var 的函数作用域缺陷
var i声明在全局作用域 (或函数作用域),整个循环共享同一个i。- 当
setTimeout回调最终执行时(100ms 后),循环早已结束,i的值已变为3(循环条件i < 3失败时i++执行了一次)。 - 所有回调都引用同一个
i,因此输出全是3。
❌ 本质:闭包捕获的是变量引用,而非值快照。
3.3 异步任务执行阶段(状态四)
事件循环检测到调用栈空闲后,依次取出任务队列中的回调:
- 对于
let:依次执行,打印0, 1, 2。 - 对于
var:依次执行,但都读取到i = 3。
执行完毕后,若无新任务加入,程序进入 状态五:程序真正结束。
四、可视化执行流程图
less
text
编辑
[初始化]
↓
[同步代码执行] → setTimeout 注册3个回调 → 任务队列: [cb0, cb1, cb2]
↓
[调用栈空闲] ←→ [事件循环调度]
↓
[执行 cb0] → 打印 i 值
↓
[调用栈空闲] ←→ [调度 cb1]
↓
[执行 cb1] → ...
↓
[cb2 执行完毕]
↓
[任务队列为空 → 程序结束]
五、解决方案:如何让 var 行为正确?
虽然应优先使用 let,但在旧代码或面试题中,你可能需要修复 var 的问题。常见方案:
方案 1:IIFE(立即执行函数)
javascript
javascript
编辑
for (var i = 0; i < 3; i++) {
(function(j) {
setTimeout(() => {
console.log('IIFE:', j);
}, 100);
})(i);
}
方案 2:使用 bind 绑定参数
javascript
javascript
编辑
for (var i = 0; i < 3; i++) {
setTimeout(console.log.bind(null, 'bind:', i), 100);
}
✅ 两者都能将当前
i的值"固化"传入回调。
六、拓展思考:微任务 vs 宏任务
本文聚焦 setTimeout(宏任务),但现代 JS 还有 微任务 (microtask),如 Promise.then、queueMicrotask。
- 执行优先级:微任务 > 宏任务
- 调度时机 :每次调用栈清空后,先清空微任务队列,再处理宏任务
例如:
javascript
javascript
编辑
setTimeout(() => console.log('macro'), 0);
Promise.resolve().then(() => console.log('micro'));
console.log('sync');
输出:
arduino
text
编辑
sync
micro
macro
💡 理解微/宏任务差异,是掌握
async/await、Vue nextTick等高级特性的基础。
七、总结要点
| 要点 | 说明 |
|---|---|
| 同步优先 | 所有同步代码执行完,异步回调才可能运行 |
| let ≠ var | let 创建块级作用域,避免闭包陷阱 |
| 闭包捕获引用 | var 回调读取的是变量最终值,非循环时的快照 |
| 事件循环是调度器 | 不是"多线程",而是"排队执行" |
| 程序结束条件 | 调用栈 + 任务队列 + 微任务队列 全部为空 |
八、注意事项
- 不要依赖
setTimeout(fn, 0)立即执行:它仍需等待当前同步代码和微任务完成。 - 避免在循环中创建大量闭包:可能引发内存泄漏(尤其在 IE 时代)。
- Node.js 与浏览器事件循环略有不同:Node 有多个宏任务队列(timers、I/O、check 等阶段)。
九、结语
理解 JavaScript 的执行模型,不仅能写出更可靠的异步代码,还能在面试中从容应对"事件循环"类高频问题。记住:JS 是单线程的,但通过事件循环实现了高效的异步协作。
🌟 动手建议:打开浏览器控制台,亲自运行上述代码,观察输出顺序,加深理解。
参考文献:
- ECMAScript® 2024 Language Specification
- Jake Archibald: The Event Loop
- MDN Web Docs: Concurrency model and the event loop