JS 的执行顺序不是靠“从上到下读一遍”就能搞定的。我见过不少人写代码几年遇到变量提升、闭包、定时器回调混在一起时照样踩坑。有一次线上有个功能时好时坏查到最后问题出在一个var变量在函数声明之前被使用加上回调时序错位两个问题叠加导致状态被覆盖。从那以后我把这套底层机制彻底捋了一遍发现只要把执行上下文、词法分析、闭包、调用栈、事件循环这五件事串成一条线几乎所有 JS 执行顺序的坑都能预测到。这篇博文适合两类人一是写 JS 一年多、想进阶的前端开发者二是正在准备面试、需要系统梳理执行机制的人。我会用实际代码 执行过程拆解的方式把“代码到底怎么跑起来”这件事讲透包括常见的变量提升、闭包引用、宏任务微任务顺序、栈溢出这些高频问题最后给出一套排查技巧。1. 执行上下文与调用栈代码运行的骨架1.1 执行上下文到底是什么执行上下文可以理解成 JS 引擎在执行某段代码时创建的一个“环境记录”里面保存着当前可用的变量、函数声明、作用域链指向以及this的值。每个执行上下文都有三个阶段创建阶段变量和函数声明会被记录、执行阶段代码逐行运行、销毁阶段上下文出栈后局部环境被回收。JS 里有三类执行上下文全局执行上下文程序启动时创建浏览器环境对应windowNode.js 环境对应global。整个页面只有一个。函数执行上下文每次调用函数都会创建一个新的函数执行完就销毁。还有一种极少提的eval执行上下文现在基本不建议使用这里不展开。创建函数执行上下文时引擎会做三件事创建变量对象记录参数、var 声明、函数声明、创建作用域链从当前作用域逐级向外链接、确定this的指向。也就是说this的取值在上下文创建时就已经决定了不是在运行时临时算的。1.2 调用栈的压栈与出栈调用栈Call Stack是 JS 引擎管理执行上下文的数据结构特点是后进先出LIFO。你可以把它想象成自助餐厅的一摞盘子后放的盘子最先被取走先放的反而压在下面。当代码调用一个函数时引擎会为这个函数创建新的执行上下文压入栈顶函数执行完毕栈顶的上下文被弹出程序回到上一层继续执行。下面这段代码的执行过程很典型function outer() { console.log(outer 开始); inner(); console.log(outer 结束); } function inner() { console.log(inner 执行中); } outer();执行过程是这样的全局上下文入栈代码运行到outer()创建outer上下文并入栈。outer内部输出“outer 开始”然后调用inner()创建inner上下文并入栈。inner执行完毕输出“inner 执行中”上下文出栈回到outer。outer继续输出“outer 结束”执行完毕出栈回到全局上下文。这个过程在浏览器的 Sources 面板里可以看得很清楚在inner函数内部打一个断点右侧 Call Stack 区域会列出当前栈帧inner在最上面往下是outer再往下是全局。这就是“调用栈”这个名词的来源。1.3 栈溢出递归没写好就会炸调用栈是有容量上限的不同浏览器和不同设备上数值不一样但普遍是上万帧的量级。如果递归函数没有终止条件或者递归深度超过合理范围就会抛出一个经典错误RangeError: Maximum call stack size exceeded。function recurse() { recurse(); } recurse(); // ❌ 栈溢出这里最需要记住的判断标准是栈溢出发生在“调用没有返回”的情况下。如果函数里的递归是异步回调触发的比如setTimeout(recurse, 0)栈会先清空再重新调用不会溢出。这一点和第 4 节的事件循环有强关联先记住这个结论后面会展开讲。注意尾调用优化Tail Call Optimization在 ES6 规范里被提出了但主流浏览器至今没有全面实现所以写递归时不要依赖它尽量控制递归深度或改成循环。2. 词法分析与变量提升代码运行前的“预编译”2.1 词法分析在做什么“预编译”这个词在不同资料里有不同含义我按实际流程来讲。JS 引擎处理代码分为两个大阶段解析阶段和执行阶段。解析阶段的核心就是词法分析和语法分析。词法分析Lexing把源代码字符串拆成一个一个的“词法单元”Token比如var、a、、10分别就是四种 Token。语法分析Parsing把这些 Token 按 JS 语法规则组装成抽象语法树AST。之后引擎再根据 AST 生成可执行的字节码或机器码。这个过程对开发者是黑盒但它的副作用极其明显变量提升Hoisting。关键在于词法分析阶段引擎会扫描整个作用域把var声明的变量和函数声明提前登记到当前作用域的变量对象里这就是“预编译”的核心逻辑。它会导致一个很常见的现象console.log(a); // undefined而不是报错 var a 10;原因就是var a的声明被提前了但赋值没有提前。上面的代码实际上等价于var a; console.log(a); a 10;2.2 变量提升与暂时性死区var的变量提升只是“声明提升”值仍然是undefined。函数声明则不一样是整个函数体都会被提升sayHello(); // 正常运行输出 Hello function sayHello() { console.log(Hello); }这就能解释为什么你在代码里可以把函数定义写在调用之后——其实这已经成了很多团队推荐的一种风格先用函数表达意图再在文件底部定义具体实现可读性反而更好。let和const的出现改变了一部分规则。它们也会被提升但在“声明生效”之前访问变量会抛出ReferenceError这段时间被称为“暂时性死区”TDZTemporal Dead Zone。console.log(x); // ❌ Cannot access x before initialization let x 10;这个设计的意图是让变量先声明后使用避免var那种“无声的 undefined”带来的隐蔽 bug。实践中我很建议能不用 var 就不用 varlet和const的语义更清晰。你可能会问为什么var要设计成提升这有历史原因——早期 JS 需要支持在函数内部任何位置声明变量并保证在函数体内全程可访问提升机制简化了早期引擎的实现。现在回头看这是一个遗留设计工程上不推荐依赖它。2.3 函数声明、函数表达式与立即执行的区别函数声明会整体提升函数表达式不会。区别极容易踩坑func1(); // 正常运行函数声明整体提升 var func2 function() { console.log(这是函数表达式); }; func2(); // 正常运行如果写成这样func2(); // ❌ TypeError: func2 is not a function var func2 function() { console.log(这是函数表达式); };原因就是func2作为var变量被提升了但此时它的值是undefined调用undefined()自然会报错。还有一个很常见的场景——立即执行函数表达式IIFEvar result (function(a, b) { return a b; })(1, 2); console.log(result); // 3IIFE 在解析阶段不参与“函数整体提升”因为它是一个表达式。它执行时创建临时执行上下文执行完立即销毁不会污染外层作用域。这在闭包章节里会被再次用到先记下来。实战提示如果你在代码中发现了变量提升导致的 bug先别急着调整声明位置尝试把var改成const或let很多问题会自动消失因为 TDZ 机制会在编译阶段就给你报错而不是运行时静默变成undefined。3. 作用域链与闭包变量如何被“记住”3.1 作用域链的查找机制先说一个最基本的规则JS 的作用域在函数定义时就确定了与函数在哪里被调用无关。这和你直觉可能相反所以用代码验证var a global; function fn1() { console.log(a); // 输出 global } function fn2() { var a local; fn1(); // 这里的 a 依然是 global } fn2();fn1在定义时它的作用域链是fn1 → 全局所以不管从哪个函数里调用它它都只能看到定义位置的父级作用域而不是调用它的fn2里的局部变量。这就是“词法作用域”的含义。作用域链的查找过程是沿着当前函数 → 外层函数 → … → 全局逐层向上。每层找到一个匹配的变量名就停止找不到就抛ReferenceError。3.2 闭包的形成条件与本质闭包形成的条件有两个一个函数在另一个函数内部被定义并且内部函数引用了外部函数的变量同时内部函数在外部函数之外被使用比如被 return 出去或者挂到全局回调里。function createCounter() { let count 0; return function() { count; return count; }; } const counter createCounter(); console.log(counter()); // 1 console.log(counter()); // 2createCounter执行完后理论上它的局部变量count应该被销毁。但返回的内部函数仍然保持对外部作用域count的引用JS 引擎发现这个引用仍然存在就不会回收count。这个内部函数就是闭包。闭包的“本质”可以理解为函数把定义时的词法环境打包带走了以后在任何地方调用都能拿回那份“快照”。生活类比你离开了一个办公室但你的储物柜钥匙还在以后每次回来都能打开柜子拿东西。3.3 循环中的闭包混乱一个高频错误闭包最容易出问题的场景就是循环绑定事件for (var i 0; i 3; i) { setTimeout(function() { console.log(i); // 输出 3、3、3 }, 100); }为什么会输出三个 3而不是 0、1、2因为var声明的i是函数级作用域整个循环共享一个i变量。setTimeout回调执行时循环早已结束i已经变成 3。回调函数虽然形成了闭包但闭包里存的不是“每一轮的 i”而是对同一个i的引用。三种解法使用let每次循环创建新的块级作用域每个i独立。使用 IIFE 包裹把每轮的i作为参数传入。使用bind或箭头函数配合let。// 解法 1let推荐 for (let i 0; i 3; i) { setTimeout(function() { console.log(i); // 0, 1, 2 }, 100); } // 解法 2IIFE for (var i 0; i 3; i) { (function(j) { setTimeout(function() { console.log(j); // 0, 1, 2 }, 100); })(i); }3.4 闭包的内存与性能注意事项闭包不是“内存泄漏”但长期不被释放的闭包确实会造成内存占用持续上升。常见的场景是一个外部函数被多次调用每次返回的闭包都被保存进数组或长期存活的事件回调里外部函数的变量就无法被回收。排查方法打开浏览器 DevTools 的 Memory 面板抓取堆快照Heap Snapshot搜索外部函数名或者闭包引用的变量名看是否存在大量实例。发现后将不再使用的引用设为null或者在事件监听器移除时同时释放闭包引用。实战提示不要为了炫技到处使用闭包。闭包的价值在于“封装私有状态”比如计数器、缓存器、防抖节流函数内部的变量存储。如果只是需要传递一个临时函数优先用普通函数或箭头函数避免不必要的内存长期占用。4. 事件循环与异步任务单线程如何不卡死4.1 为什么需要事件循环JS 是单线程语言这意味着同一时间只能执行一段代码。但浏览器里的 JS 经常需要处理网络请求、定时器、用户交互事件如果这些操作都要同步等待结果页面就会陷入假死状态。解决方案是JS 线程只负责执行代码遇到耗时任务就交给浏览器其他线程处理任务完成后把回调放进消息队列等 JS 线程空闲时再来执行。这个“有空就取任务来执行”的机制就是事件循环Event Loop。它是连接 JS 同步执行和异步任务的枢纽。4.2 宏任务与微任务的拆分逻辑在事件循环里异步任务被分成两类宏任务MacroTaskscript整体代码、setTimeout、setInterval、I/O 操作、UI 渲染、MessageChannel等。微任务MicroTaskPromise.then、queueMicrotask、MutationObserver、async/await之后的代码。事件循环的执行顺序是执行一个宏任务首次执行时是整段 script。执行完当前宏任务后立即把微任务队列里所有微任务执行完。如果有必要执行渲染。取出下一个宏任务继续循环。关键点在于微任务队列是“清空”式的宏任务一次只能取一个。这意味着微任务里再产生微任务仍然会在当前循环里全部执行完不会推后到下一轮。来一个经典验证setTimeout(function() { console.log(setTimeout 1); Promise.resolve().then(function() { console.log(promise 1); }); }, 0); Promise.resolve().then(function() { console.log(promise 2); setTimeout(function() { console.log(setTimeout 2); }, 0); }); // 输出顺序 // promise 2 // setTimeout 1 // promise 1 // setTimeout 2推演过程整段 script 是第一个宏任务执行时把setTimeout 1注册为宏任务把promise 2注册为微任务。script 执行完清空微任务队列输出promise 2在这期间又注册了setTimeout 2。第一轮宏任务结束。第二轮宏任务先执行setTimeout 1输出后注册微任务promise 1宏任务执行完清空微任务队列输出promise 1。第三轮执行setTimeout 2。这就是最终顺序。4.3 setTimeout 的“最小延迟”陷阱浏览器对setTimeout有最小延迟限制嵌套级别超过 5 层时最小延迟是 4ms。也就是说setTimeout(function() { console.log(延迟 0); }, 0);这条代码不可能是立刻执行的至少在 4ms 以后某些浏览器甚至更慢。所以我们常说的“setTimeout(0)在下一轮宏任务执行”是准确的。但如果你在微任务里不断产生新的微任务理论上可以让宏任务永远等不到执行机会这就是“饥饿”问题。实践中要注意不要在微任务里无节制地创建微任务否则宏任务队列可能被无限推迟。4.4 async/await 的执行时机拆解async函数内部的await会把后续代码放进微任务队列但很多初学者容易忽略一个细节await本身会先执行右侧表达式然后立即让出执行权。async function asyncFn() { console.log(async 开始); await console.log(await 右侧); console.log(async 结束); } console.log(同步开始); asyncFn(); console.log(同步结束); // 输出顺序 // 同步开始 // async 开始 // await 右侧 // 同步结束 // async 结束过程很好推调用asyncFn()时函数体开始同步执行输出“async 开始”执行到await时先把右侧的console.log(await 右侧)执行完然后暂停函数把后续的“async 结束”放入微任务队列当前同步代码继续跑完“同步结束”宏任务结束后清空微任务输出“async 结束”。这个顺序最容易被低估的点是await右侧的代码是同步执行的不是异步的。实战提示调试复杂异步顺序时多使用console.trace()而非console.log()它能打印当前调用栈。如果是处理 Promise 链式的场景在.then()里打印标识信息能快速定位到哪一环延迟了。5. 综合实战一段代码看透执行顺序5.1 综合代码逐行推演下面这一段代码几乎涵盖了前面所有知识点建议你先在脑子里推理输出顺序再打开控制台验证。var a 1; function run() { console.log(run 开始, a); var a 2; setTimeout(function() { console.log(宏任务 1, a); }, 0); Promise.resolve().then(function() { console.log(微任务 1, a); }); (function() { console.log(IIFE 立即执行, a); var b 10; })(); console.log(run 结束, a); } run(); console.log(全局结束, a);第一步变量提升分析函数声明run整体提升var a声明提升。全局a初始为 1。第二次给a赋值发生在run内部这个a是局部变量。第二步执行顺序推演整段 script 宏任务开始var a 1执行a 1。调用run()进入 run 上下文。内部var a声明提升注意此时局部a的值还是undefined。console.log(run 开始, a)输出run 开始 undefined——这就是变量提升的影响。执行var a 2局部 a 2。注册setTimeout回调为宏任务。注册Promise.then回调为微任务。IIFE 立即执行输出IIFE 立即执行 2内部 b10 在 IIFE 上下文内部不影响外部。输出run 结束 2。run()返回run 上下文出栈但闭包setTimeout回调仍然持有对 run 局部变量a的引用。回到全局输出全局结束 1——全局的 a 从来没被改过因为 run 里的 a 是局部变量。script 宏任务执行完毕清空微任务队列输出微任务 1 2闭包引用的 a 是局部 a值为 2。下一轮宏任务输出宏任务 1 2。最终输出顺序是run 开始 undefined IIFE 立即执行 2 run 结束 2 全局结束 1 微任务 1 2 宏任务 1 2这个例子的核心信息量很大变量提升导致 “run 开始 undefined”、闭包导致两个异步回调访问到的是同一个局部 a2、微任务比宏任务先执行。你能完整推演出来说明掌握度已经不错了。5.2 用断点与调试工具观察调用栈推荐一个非常直观的调试练习方法在 DevTools 的 Sources 面板中打开上面的代码。在console.log(run 开始, a)这一行打一个断点。刷新页面右侧面板会展示当前作用域变量a: undefined同时 Call Stack 里能看到run和全局两个帧。点击”单步执行“Step逐行观察a的变化重点观察作用域面板里a何时从undefined变成 2。在setTimeout回调内部打一个断点观察 Call Stack 里已经没有run帧了——因为run早已执行完这就是闭包生效的直接证据。这个方法能让你“肉眼看到”闭包和执行上下文的生命周期比单纯看文档有效得多。5.3 常见问题速查表症状根因解决方案在var声明之前使用变量得到undefined变量提升改用const/let在声明后再使用使用let但声明前访问变量报 ReferenceError暂时性死区调整声明顺序循环绑定时事件回调输出最后一次循环的变量var共享同一个变量闭包引用同一引用改用let或 IIFE 传参递归报Maximum call stack size exceeded调用栈溢出检查递归终止条件改用迭代多个Promise.then打印顺序与预期不符微任务队列清空机制 宏任务延迟推演队列顺序必要时用queueMicrotask显式控制setTimeout(0)内的代码为什么不是立刻执行宏任务队列 最小延迟 4ms勿将定时器视为同步工具异步函数中await前后的打印顺序奇怪await右侧同步执行后续代码进微任务记住 await 两段式行为5.4 排查执行顺序问题的独门经验最后分享几个我在实际开发中积累的排查习惯。第一遇到报错先看调用栈图片不要只看错误信息。错误信息往往只告诉你“哪一行出了问题”调用栈能告诉你“是怎么走到这一步的”尤其在处理闭包和异步时栈帧能还原完整的执行路径。第二不要把“调试”和“打印”混为一谈。在代码里大量加console.log容易干扰异步时序因为控制台打印本身也消耗时间。更稳妥的方案在网络请求的回调、定时器回调、事件回调里加唯一标识符比如一个累加的序号再通过控制台过滤查看顺序。第三善用debugger语句。在不确定的地方写一个debugger浏览器会在那自动断点同时你可以打开 Scope 面板查看所有闭包引用的变量值。这个方法对排查“闭包引用了错误变量”的场景几乎一抓一个准。第四学习这套机制的时候不要只记结论而是反复用代码验证自己的推演。我个人习惯是先写一段包含变量提升、闭包、宏任务微任务的代码在纸上写出预期输出顺序然后再在控制台跑一遍比对。练多了你对执行顺序的直觉会变得非常准线上遇到类似问题基本能通过静态阅读就锁定范围。JS 执行顺序这套知识难点不在单个概念而在于它们之间的联动。变量提升影响的是“代码还没跑时的状态”执行上下文决定了“每个函数运行时能看到什么”闭包让变量在函数结束后还能存活事件循环则决定了“异步代码什么时候补跑”。把这四件事连成一条时间线你会发现自己看任何一段 JS 代码脑子里都能自动浮现出它的执行轨迹。这是我个人认为前端开发中最值得花时间吃透的基础功之一没掌握的时候觉得代码“玄学”掌握之后一切都有迹可循。