恒美微站 Logo 恒美微站
  • 首页
  • 关于我们
  • 建站服务
  • 主题模板
  • 案例展示
  • 资讯中心
  • 联系我们

一文读懂JavaScript执行机制:从词法分析到事件循环与闭包

  • 首页
  • 资讯中心
  • /
  • 一文读懂JavaScript执行机制:从词法分析到事件循环与闭包

相关资讯

边缘计算网关桥梁监测方案:从传感器接入到算法部署的落地实践 2026/10/12 12:49:40
半边结构:多边形网格处理的核心数据结构 2026/10/12 12:49:40
Python天气预测与可视化:一套完整可运行的机器学习项目 2026/10/12 12:44:39

最新资讯

FastReport批量打印实战:从循环直打到队列调度的稳定方案
C++实战:从CSV解析到疫情数据统计管线
TDSC 2025区块链论文速读指南:从三看三问到笔记沉淀
【Spring AI Alibaba】百炼工作流 + Multi-Agent 搭建电商智能导购:把 Base URL 改到 TaoToken 的完整配置
H3C主网络工程实施方案模板:从调研、IP规划到割接回退的全流程实战指南
Apex英雄更新后闪退?驱动、反作弊与系统设置的完整排查指南

今日推荐

Debian新手入门:从部署到日常操作的完整指南
MongoDB复制集扩缩容实战:从rs.add到选主事故复盘
条形码目标检测数据集实战:从YOLOv8训练到部署

本周热门

UE动画修改实战:从资产编辑到重定向与蒙太奇驱动
统计随机数生成器攻击下的KLJN安全密钥交换协议Matlab仿真
政务API安全治理:资产测绘、低代码编排与行标对标实践

本月精选

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证
2026 大模型集体涨价:用 Python 做企业 Token 成本测算与选型避坑(附配置)

一文读懂JavaScript执行机制:从词法分析到事件循环与闭包

发布时间:2026/10/12 12:49:40
一文读懂JavaScript执行机制:从词法分析到事件循环与闭包 代码写完浏览器一跑结果和预期不一样面试被问到var提升、闭包、事件循环输出顺序一脸懵排查线上问题发现异步回调的执行时机永远猜不透……这些场景几乎是每个 JavaScript 开发者都绕不过去的坎。这次梳理我把 JavaScript 最核心的一套底层机制串成一条线从代码的词法分析到预编译执行上下文再到JS 栈调用栈里的同步执行最后落到事件循环接管异步任务顺带把闭包这个现象级产物讲透。整篇内容适合已经写过不少 JS 代码、但总觉得对“执行顺序”缺乏掌控感的人尤其建议准备面试或者想真正读懂框架源码的朋友认真看一遍。1. 一条线看懂JavaScript执行链路先说个整体认知JS 引擎拿到代码之后不是一行一行往下读那么简单。真实的过程更像是一条流水线——先对代码做“扫描分词”再根据作用域规则“预编译”然后“执行”执行过程中遇到异步则“挂起等待”等同步任务清空之后再回来处理。打个生活化的比方。你去餐厅点餐服务员先把菜单上的菜名“翻译”成后厨能懂的术语词法分析后厨根据菜单提前备好料、摆好锅预编译/上下文创建然后按顺序一道道炒调用栈执行。如果某个菜需要慢炖厨师不会干等而是先去炒别的菜等慢炖好了再回来出锅事件循环。而闭包就像后厨给某个回头客保留的“专属口味记录”——明明这桌菜都上完了记录还在下次点单还能用。理解这条链路最关键的一个点JS 是单线程的但体验上却是“多任务并行”的。原因就在于上面这条流水线被分成了“同步直通”和“异步绕行”两段而事件循环就是那个负责调度“异步绕行”任务的循环泵。把这条链路画在脑子里后面的所有概念其实都是在填这条链路上的细节。这套机制的影响范围比想象中大得多它不仅决定console.log的打印顺序还影响this指向、变量提升、闭包内存回收、Promise 与 setTimeout 的执行时机、React/Vue 的批处理策略等等。搞懂它等于拿到了解读一切 JS 运行现象的总钥匙。2. 词法分析代码在真正跑之前发生了什么2.1 分词、解析与词法作用域的确立很多人把“词法分析”想得很玄其实它干的事情非常机械把源码字符串拆成一个个有意义的“词法单元”token再按照语法规则拼成一棵“抽象语法树”AST。这个过程在代码执行前就完成了现代浏览器还会把它编译成字节码甚至机器码。但词法分析对开发者最有价值的部分不是 AST而是词法作用域Lexical Scope的确定。所谓词法作用域就是“你在写代码的时候把变量声明写在哪个大括号里这个变量就能被哪些地方访问”——它在代码解析阶段就已经定死了和函数在哪里调用完全没关系。function outer() { const a 1; function inner() { console.log(a); // 能访问 a因为 inner 写在 outer 里面 } inner(); } outer();这里inner能访问a仅仅是因为inner的词法位置在outer内部。即使把inner拿到别处去调用它依然记得outer里的a。这正是后面闭包能成立的原因词法作用域在“写代码”阶段就固定了运行阶段的作用域链只是把这个固定关系实例化而已。2.2 为什么说作用域是“静态”的动态语言很多但 JS 的作用域规则却是静态的静态指“代码结构决定而非调用方式决定”。这一点很多开发者会混淆最常见的误解是“函数在某个对象上调用了this就是那个对象那作用域也应该是动态的吧”这其实是把this绑定机制和作用域机制搅在一起了。this的指向确实要到调用时才确定这属于动态绑定但变量的可访问范围作用域链不归this管。this只是调用时传入的一个“隐藏参数”并不参与作用域链的构建。理解这个区别对你后面看闭包、看箭头函数都会有质的提升。给一个自查方法看到一段代码先问自己“这个变量在哪个作用域里声明了”再问“this此刻指向谁”。前者看代码结构后者看调用方式。把这两个问题分开80% 的this和闭包混淆其实都能避免。3. 预编译与执行上下文深入到执行前的“环境装修”3.1 变量提升的本质是上下文初始化预编译听起来像编译原理里的术语但在 JS 引擎里它对应的是一个非常具体的动作在代码执行之前创建执行上下文Execution Context并把变量声明、函数声明预先登记进去。看这个经典例子console.log(a); // undefined而不是报错 var a 10;为什么不是ReferenceError因为引擎在执行第一行之前已经“扫描”到了var a的声明把它登记进当前执行上下文的变量环境里初始值设为undefined。这就是变量提升Hoisting的真相并不是代码被移动了而是声明被提前登记了赋值仍在原地执行。函数声明则更进一步整个函数体都会被提前登记sayHi(); // 能正常打印 function sayHi() { console.log(hi); }JS 对“函数”和“变量”的区别对待很有深意函数声明是完整的“成品”引擎可以直接拿去用而var变量只是一个“空位”引擎只占了个坑值得等执行到赋值语句才填进去。3.2 执行上下文里到底装了什么东西一个执行上下文会包含三部分核心信息组成部分内容影响变量环境管理var声明的变量、函数声明决定了变量提升的可见范围词法环境管理let、const声明的变量产生了“暂时性死区TDZ”this绑定确定当前环境中this的指向影响函数内部对当前对象的访问这里必须单独说说let/const与var的差别。很多初学认为let不会提升其实是不准确的——let和const同样会被登记进词法环境只不过在声明语句执行之前访问它们会触发“暂时性死区”报错而不是拿到undefined。console.log(b); // ReferenceError: Cannot access b before initialization let b 20;用一款“登记但不允许提前取用”来理解let的行为门牌号挂上了但门锁着没到初始化那一刻谁都进不去。这个设计明显比var更严谨也是现代代码一律推荐let/const的根本原因。3.3 作用域链如何把上下文串联起来当引擎执行函数时会创建函数的执行上下文并把这个上下文的外层引用指向“定义这个函数时的词法环境”。由此从当前函数 → 外层函数 → 全局形成了一条链这就是作用域链Scope Chain。const globalVar global; function a() { const aVar a; function b() { const bVar b; console.log(bVar); // 先从自身找 console.log(aVar); // 自身没有往上一级找 console.log(globalVar); // 再往上找 } b(); } a();b内部访问变量时引擎不是满世界乱找而是沿着这条链逐级向上查找直到全局找不到才报ReferenceError。这就像在公司里找负责人先问自己部门再到上一层部门最后找到 CEO——路径是固定的不能跳级也不能乱窜。预编译阶段把这些上下文和链条都准备好之后真正执行才拉开帷幕。接下来就该 JS 栈登场了。4. js栈调用栈同步代码如何被逐帧执行4.1 调用栈的工作方式JS 栈更准确的说法是调用栈Call Stack是 JS 引擎用来追踪“当前正在执行哪个函数”的一种数据结构。它的特点是后进先出LIFO函数被调用时对应一个栈帧压入栈顶函数执行完毕则栈帧弹出。function one() { two(); console.log(one done); } function two() { three(); console.log(two done); } function three() { console.log(three done); } one();执行过程拆开看全局环境入栈作为最底层的“栈底”。调用one()one的上下文压栈。one里调用two()two压栈。two里调用three()three压栈。three打印完成弹出。回到two打印 “two done”弹出。回到one打印 “one done”弹出。回到全局全部结束。所以最终打印顺序是three done→two done→one done——内层函数永远先执行完后进栈的先出栈。理解了这一点你再看“为什么递归必须要有终止条件”就一目了然如果没有终止条件栈帧会无限压入最终触发Maximum call stack size exceeded报错这就是栈溢出。4.2 栈溢出不是玄学监控递归深度栈溢出在真实开发中非常常见尤其是处理深层嵌套的树形数据时。一个经典的踩坑场景就是递归遍历层级很深的 JSONfunction deepTraverse(node) { // 终止条件缺失或太深 deepTraverse(node.children[0]); }实际项目中递归深度超过 1 万层的场景很少见但并非没有比如遍历极度嵌套的评论树。解决办法有两个一是把递归改成显式的while 栈模拟二是在递归入口加深度判断超过阈值就报警或降级处理。后者简单粗暴前者才是根治。说到显式栈模拟熟悉算法题的人应该都不陌生// 用数组模拟调用栈等效实现递归 const stack [rootNode]; while (stack.length) { const node stack.pop(); // 处理 node stack.push(...node.children); }把“系统栈”换成了“堆上的数组”能遍历的数据规模瞬间从万级变成百万级。这是很多前端在性能优化时会用到的手法值得记下来。4.3 调用栈的致命陷阱同步阻塞调用栈还有一个特性是很多开发者忽视的它是单线程的同一时间只能执行一个函数。这意味着如果栈上有一个耗时的同步任务后面的所有代码都得干等着。const start Date.now(); while (Date.now() - start 3000) { // 空转 3 秒 } console.log(终于执行);这 3 秒里页面卡死点击无响应动画暂停。因为渲染线程和 JS 主线程是互斥的一旦 JS 栈被占满 3 秒浏览器连“画一帧”的机会都没有。所以绝不要在主线程上做长循环或大计算需要做就把任务拆成小片或者交给Web Worker跑。明白了同步执行靠调用栈下一步就触及异步了既然JS只有一个栈那setTimeout的回调、Promise的then到底在哪儿排队这就是事件循环的地盘。5. 事件循环异步任务到底是怎么被捞回来的5.1 宏任务与微任务两套排队体系事件循环Event Loop是 JS 运行时浏览器/Node.js 都实现了各自版本里负责“调度异步回调”的机制。它的核心思路主线程执行完一个“宏任务”后去清空所有的“微任务”再从宏任务队列里取下一个任务继续执行。如此反复。先定义清楚两个队列宏任务队列Macro Task QueuesetTimeout、setInterval、setImmediateNode、I/O回调、UI 渲染部分浏览器特殊处理等。微任务队列Micro Task QueuePromise.then/catch/finally、queueMicrotask、MutationObserver回调等。两者的优先级关系是每个宏任务之后必须把所有微任务清空再进入下一个宏任务。微任务就像“VIP 插队通道”只要 VIP 通道里还有人普通队列就永远轮不到。来看一个面试高频输出题console.log(script start); setTimeout(() { console.log(setTimeout); }, 0); Promise.resolve() .then(() { console.log(promise1); }) .then(() { console.log(promise2); }); console.log(script end);逐步推导全局代码整体是一个宏任务从上到下执行。打印script start。遇到setTimeout因为 0ms 到期回调被放入宏任务队列。遇到Promise.resolve().then回调被放入微任务队列。打印script end。当前宏任务执行完毕开始清空微任务队列打印promise1然后promise1的then注册到微任务队列尾部紧接着继续清空打印promise2。微任务队列清空事件循环从宏任务队列取出setTimeout回调打印setTimeout。最终输出script start → script end → promise1 → promise2 → setTimeout。这个顺序很多人背过但如果不理解“一个宏任务结束、清空微任务”这个节奏换个复杂点的题目照样会翻车。5.2 await 千万别当成同步async/await的时机是面试和实战中的双重难点。先记住一条规则await右边的表达式立即执行但await之后的代码会变成微任务继续执行。async function test() { console.log(a); await 1; console.log(b); } test(); console.log(c);推导过程调用test()同步执行到await 1之前打印a。await 1会把后续代码console.log(b)挂到微任务队列。test()让出执行权主线程回到全局代码打印c。全局宏任务结束清空微任务打印b。输出a → c → b。很多人的认知误区是把await当成“等一等再往下走”其实 JS 里根本没有“睡眠”这种机制await的本质是把后续操作包装成微任务让出主线程。所以遇到await时千万不要按照“写代码的顺序”去推输出而要问自己“这段代码之后哪些被放进了微任务队列”结合 Promise 和 await 的综合题推荐一个通用解法把所有任务先标成“宏”或“微”然后画一条时间线宏任务执行 → 清空微任务 → 下一个宏任务 → 清空微任务在时间线上把任务一个个落位这样基本不会错。5.3 渲染时机也由事件循环控制事件循环管的不只是 JS 回调还管着浏览器渲染。一次事件循环的完整流程大致是从宏任务队列取出一个任务执行。执行过程中产生的微任务全部清空。如果需要渲染浏览器自己判断执行渲染流程样式计算、布局、绘制。进入下一轮循环。这就是为什么requestAnimationFrame的回调会在渲染之前执行也是为什么在微任务里反复修改 DOM可能导致“渲染一直等不到”的原因。极端情况下如果微任务队列里不断追加新微任务比如递归调用queueMicrotask主线程会被微任务无限占住把渲染和宏任务全部饿死——这个场景就叫微任务死循环实际开发里遇到过不奇怪排查方法就是看任务管理器里的 CPU 占用和页面是否完全无响应。理解了事件循环最后要把闭包放到这条链路里重新审视。闭包不是“内存泄漏”的代名词它只是 JS 词法作用域与执行上下文机制的一个自然产物。6. 闭包一个贯穿始终的产物6.1 闭包的本质与形成条件闭包Closure的标准定义是函数与其词法环境的组合。当一个函数“记住”了它定义时的作用域即使它在那个作用域之外被执行依然能访问那个作用域里的变量这就构成了闭包。function createCounter() { let count 0; return function () { count; return count; }; } const counter createCounter(); console.log(counter()); // 1 console.log(counter()); // 2createCounter执行完毕按常理count应该被回收了。但因为返回的内部函数仍然持有着指向count所在环境的作用域链引用count就不会被垃圾回收而是活了下来。闭包的形成需要三个条件函数嵌套、内部函数引用外部变量、内部函数在外部被使用或者说内部函数被“带出去”了。三个条件缺一个都不算严格意义上的闭包产生。6.2 闭包的经典陷阱循环里 var 与 let 的区别闭包最经典的坑就是循环中var声明变量的问题for (var i 0; i 5; i) { setTimeout(() { console.log(i); }, 100); }输出是5 5 5 5 5不是0 1 2 3 4。原因拆成两层看var i是函数级作用域在全局或当前函数环境里只有一个i循环结束之后i变成 5。setTimeout的回调在循环结束后才执行那时访问的i已经是同一个变量的最终值——5。如果改成let i输出就变成0 1 2 3 4。因为let是块级作用域每次循环都创建了一个新的词法环境i的副本被闭包各自捕获。回调执行时访问的是各自保存的那份i而不是同一个共享变量。这背后其实是“闭包捕获的是变量所在的环境而不是值本身”。理解了这一点你再回头看“为什么推荐用const声明不可变变量”也能多一层体会如果闭包依赖的变量在外部被改掉了闭包里的结果也会跟着变这是很多 bug 的来源。6.3 闭包、垃圾回收与内存关注点很多人担心闭包会导致内存泄漏其实需要澄清一点闭包本身不会造成内存泄漏它只是延长了某些变量的生命周期。真正的问题是你是否“无必要地延长”了它。function setup() { const largeData new Array(1000000).fill(data); const handleClick () { console.log(clicked); }; window.addEventListener(click, handleClick); // 这里 largeData 虽然没被 handleClick 使用但因为同在一个闭包环境里 // 如果引擎不够聪明可能会把整个环境都保留 }现代引擎V8在编译阶段会做上下文解析优化只有被内部函数实际引用的变量才会被送入闭包环境没被引用的largeData可以从环境中剔除从而被正常回收。但需要留意的是这种优化在“函数内部函数”多层嵌套、变量访问较复杂的情况下并不总是尽善尽美所以建议闭包内部声明的、不想被保留的大型数据用完后显式置为null或者尽量把不该被捕获的变量定义在闭包外部。一个非常隐蔽的关联场景是事件监听器未移除class Page { constructor() { this.data { list: [] }; } init() { document.addEventListener(click, this.onClick.bind(this)); } onClick() { // 处理 } }如果页面销毁时不调用removeEventListenerthis.data就会因为事件回调闭包持有引用而无法被回收——这类问题用浏览器的Performance面板里的Retained Size和Detached DOM能看到排查时一旦发现 Detached 的节点持续增长优先怀疑闭包 事件监听器这条链。6.4 闭包在实战里最值得用的几个位置闭包不是面试题专属实战价值非常高。我平时用得最多的是这几类场景第一封装私有变量。用闭包模拟“私有属性”避免全局污染const bankAccount (function () { let balance 0; return { deposit(amount) { balance amount; return balance; }, withdraw(amount) { if (amount balance) return 余额不足; balance - amount; return balance; }, getBalance() { return balance; } }; })();外部无法直接修改balance只能通过暴露的方法操作天然实现了封装。这在写工具库、SDK 时特别好用比在类上加私有字段#语法要老派但依然广泛存在。第二函数柯里化与偏函数。闭包让“预设参数”成为可能function multiply(a) { return function (b) { return a * b; }; } const double multiply(2); console.log(double(5)); // 10double这个函数“记住了”a 2后续调用只需要传一个参数。很多函数式编程里复用“固定参数”的写法本质都是闭包。第三防抖与节流。防抖函数本身就是一个闭包的典型应用——它用闭包保存定时器 idfunction debounce(fn, delay) { let timer null; return function (...args) { if (timer) clearTimeout(timer); timer setTimeout(() { fn.apply(this, args); }, delay); }; }每次调用返回的函数都会访问上一次的timer这正是靠闭包把“上一次调用的状态”保留了下来。把这条链路反过来看防抖能写对前提是你理解了闭包和事件循环的配合。7. 实测一下三组经典题把整条链路串起来概念讲完得落地。这里选了三个既考概念又考综合的题目建议你先自己推一遍再看答案。7.1 综合输出顺序题上下文 栈 事件循环console.log(a); setTimeout(() { console.log(b); Promise.resolve().then(() { console.log(c); }); }, 0); Promise.resolve() .then(() { console.log(d); }) .then(() { console.log(e); }); console.log(f);推导同步执行打印a。setTimeout回调入宏任务队列。Promise的then入微任务队列第一段d。打印f。宏任务结束清空微任务打印d注册e的微任务随后打印e。宏任务为空取宏任务队列里的setTimeout回调执行打印b内部注册微任务c。当前宏任务setTimeout回调结束清空微任务打印c。答案a → f → d → e → b → c。这道题的要点是微任务清空发生在每个宏任务结束时而不是整个事件循环结束。b在宏任务回调里出现之后照样要把内部的微任务清完才进入下一轮宏任务。7.2 this 与作用域千万别用“距离”判断输出var name global; const obj { name: obj, getName: function () { return function () { console.log(this.name); }; } }; obj.getName()();这段输出是global不是obj。很多人第一眼凭“作用域距离”判断会以为内层函数能访问obj.name——但this不参与作用域链它是由调用方式动态决定的。内层函数是普通函数调用()此时this指向全局严格模式下是undefined。如果想让输出变成obj可以用箭头函数const obj { name: obj, getName: function () { return () { console.log(this.name); }; } }; obj.getName()();箭头函数没有自己的this它沿用了外层getName调用时的this指向obj。这就是前面说的“作用域是静态的、this是动态的”在实战中的直接体现。7.3 递归 栈溢出终止条件为什么如此关键function factorial(n) { if (n 1) return 1; return n * factorial(n - 1); } console.log(factorial(3)); // 6每一步递归都会在调用栈上压入一层新栈帧factorial(3)会依次压入factorial(3)、factorial(2)、factorial(1)然后逐层弹出计算结果。一旦n巨大或者终止条件写错比如n 1才返回 1 这种反向条件调用栈就会一路压到溢出。排查栈溢出有个实用技巧浏览器 DevTools 的Sources 面板会直接标识出溢出的栈帧路径一般能看到问题函数的调用链。如果栈帧一万多行不用数直接在递归函数入口打一个深度计数器即可快速定位。8. 避坑清单与日常开发总结完整走一遍概念、原理和实测最后分享几个我在真实开发里高频踩中、也帮团队排查过很多次的坑。整理成速查表方便你贴在编辑器旁边现象本质原因解决方案var变量提前打印是undefined变量提升声明登记了但赋值未执行统一使用let/const杜绝隐式依赖let声明前访问报错暂时性死区初始化后再使用或在块顶部声明循环 setTimeout输出全是一样的末尾值var共享同一个变量闭包捕获的是变量环境用let或 IIFE 绑定每次循环的值this在回调里丢失this动态绑定普通函数调用指向全局/undefined箭头函数、bind、或在外部保存self页面卡死、CPU 100%主线程同步死循环或微任务无限追加拆任务、Web Worker、检查递归微任务内存持续上涨事件监听器未移除 / 闭包持有大引用及时removeEventListener显式释放引用递归深度一大就栈溢出调用栈帧无限压入改写为while 显式栈的迭代形式还有一个习惯想特别强调写异步代码时先判断当前操作是宏任务还是微任务再决定是否依赖顺序。我在团队里见过太多人依赖“setTimeout 延迟 100ms 应该足够了吧”这种写法解决问题你永远不知道微任务是否已经清空、渲染是否已经完成。正确做法是靠回调、靠 Promise、靠事件通知来联动而不是靠定时器碰运气。从词法分析到事件循环这 6 个概念本来就不是孤立的知识点而是一条完整的执行链路。我的实际体会是把这条链路搞通之后再去看任何框架的源码、任何奇怪的 bug你都能先从“引擎视角”推测一遍问题的大致位置再结合调试工具去验证。这个过程会反复锤炼你对执行时机的敏感度而敏感度这东西恰恰是资深前端和普通前端最明显的分水岭。这套梳理适合每隔一段时间回炉一遍因为 JS 语言本身在迭代比如新增的queueMicrotask、WeakRef、FinalizationRegistry都在补强/调整这套机制的边缘能力但核心的执行上下文、调用栈、事件循环骨架从诞生到现在都没变过。把这篇内容吃透再多做几道变体题你对 JS 执行顺序的掌控力会有一次肉眼可见的跃升。

关于恒美微站

恒美微站专注于为个体商户、工作室提供极简自助建站服务,让每个人都能轻松拥有专业网站。

快速链接

  • 关于我们
  • 建站服务
  • 主题模板
  • 案例展示
  • 资讯中心

服务项目

  • 可视化建站
  • 拖拽编辑
  • 主题定制
  • SEO 优化
  • 网站托管

联系方式

  • 📍 地址:北京市朝阳区建国路 88 号
  • 📞 电话:400-888-8888
  • ✉️ 邮箱:info@hmyw.cn
  • 🕐 时间:周一至周日 9:00-18:00

© 2024 恒美微站 hmyw.cn 版权所有 | 京 ICP 备 12345678 号