JavaScript作用域与闭包:从变量提升到作用域链的完整解析 很多写了几年 JavaScript 的人一听到“作用域”三个字第一反应都是“哦就是全局变量和局部变量嘛”。但你要是真在面试或团队 code review 里追问下去——比如“let 到底有没有提升”“闭包拿到的究竟是变量的值还是变量的引用”“块级作用域和函数作用域叠加在一起时查找顺序是怎样的”——不少人就开始含糊了。我写这篇复习笔记就是想把这些看起来基础、实则烫手的问题一次性理清楚。它适合写过一阵子 JS、能跑通业务代码、但想系统性加固语言基础的人也适合准备面试、需要快速把 JS 核心概念串成体系的人。我尽量不按教科书顺序来而是沿着一条主线展开变量从创建到被读取到底经过了怎样的身份判定和路径查找。1. 先搞清楚作用域到底是个什么东西很多人把作用域理解为“变量能被访问的范围”这个说法没错但它太笼统。真正要理解作用域得先明白 JavaScript 引擎在跑你的代码之前做了什么。1.1 变量在进入世界之前先要被登记JavaScript 是动态语言但它并不是完全“边解释边执行”的。现代引擎在执行一段代码前会先做一个类似编译的过程词法分析、语法分析、生成可执行代码。在这个阶段引擎会扫描当前作用域内所有的var声明和函数声明给它们建立“登记表”。这张表在规范里叫环境记录Environment Record你可以粗暴地把它理解成“这个作用域的房间清单”。举个例子function greet() { console.log(message); var message hello; } greet();这段代码输出的是什么不是“hello”也不是报错而是undefined。原因就是在编译阶段引擎把var message的声明登记进了greet函数的环境记录里但赋值操作还没开始。所以执行console.log(message)时message 已经存在了只是值还是undefined。这个过程就叫“声明提升”它是作用域系统的第一块基石。1.2 作用域的作用是解决“这个名字归谁管”为什么不把所有变量都放到全局因为会打架。两个不同开发者各自写了一个叫data的变量如果都挂在全局后加载的那个会把前面的覆盖掉而且没有任何提示。作用域的本质就是给名字划定“管辖区域”每个区域内部独立管理自己的变量。计算机科学里管这个叫“命名隔离”说人话就是每段代码都知道自己该用哪个房间里的哪件东西不至于走错门拿错货。更底层的意义在于生命周期管理。全局变量跟页面同生共死函数内的变量在函数调用时创建、在函数返回后消失块级变量在离开块后立刻失去意义。有了作用域引擎才能知道一个变量应该在什么时候被创建、什么时候可以回收。否则内存迟早被那些用完不丢的变量塞满。1.3 词法作用域写代码时就已经定了“你属于谁”JavaScript 采用的作用域模型叫词法作用域Lexical Scope也叫静态作用域。所谓“词法”就是“写代码时的位置、语法结构”。也就是说一个变量能访问到哪些东西在代码写完、还没运行的时候就已经定死了跟函数在哪里被调用没有关系。这跟动态作用域比如 bash 里某些行为是相反的。动态作用域看的是“调用栈”谁调用我我就用谁的变量词法作用域看的是“源代码结构”我写在哪个函数里面我就能看到哪个函数链上的变量。JS 走的是后者。这也是闭包能成立的前提——函数内部记住了自己定义时的环境即使后来跑到别处执行依然能找到当初那个“房间”。我见过很多初学者包括早年的我纠结一个问题“为什么我在函数外面定义的变量函数里面能访问但在函数里面定义的变量外面就访问不到”理解了环境记录和词法作用域这个问题就很自然了外面的房间不在内层函数的视野里所以内层能看到外面的但外层房间压根不知道内层房间的存在因为从外层往内层看是“不透明”的墙。2. 三种作用域的边界地图全局、函数、块级ES6 之前JavaScript 只有全局作用域和函数作用域两种。ES6 引入了let和const带来了块级作用域。现在复习作用域必须把这三者摆在一起看不然很容易在分析代码时漏掉一条关键路径。2.1 全局作用域所有人的公共客厅在浏览器里全局作用域的主人就是windowNode.js 里是global现在统一规范叫globalThis。你在顶层用var声明的变量或者直接不写声明就赋值的变量最后都会挂到全局对象身上。全局作用域的生命周期跟页面一致关掉标签页才会被回收。var globalVar 我是全局的; function testScope() { innerVar 我没写声明也变成全局的了; } testScope(); console.log(window.globalVar); // 我是全局的 console.log(window.innerVar); // 我没写声明也变成全局的了第二行赋值没有用var/let/const引擎在当前函数作用域里找不到innerVar的声明就会沿着作用域链往外层找最终在全局作用域里也找不到然后直接在全局对象上新建一个属性。这种“隐式全局变量”是作用域系统里最危险的漏洞之一后面讲坑的时候我会专门展开。2.2 函数作用域每次调用都开一个新房间函数作用域的特点是每次函数调用都会创建一个新的环境记录。这意味着同一个函数调用两次内部变量就是两批完全独立的存在互不干扰。这有点像你去同一家餐厅点同一道菜每次上桌的份量可能都一样但那是两份不同的菜。function counter() { let count 0; return function () { count; return count; }; } const a counter(); const b counter(); a(); // 1 a(); // 2 b(); // 1a 和 b 的 count 彼此无关函数作用域还有一个容易忽略的细节函数参数也属于函数作用域里的变量。function foo(a) { ... }这个a就是函数作用域中一个已初始化的变量外部访问不到。2.3 块级作用域ES6 给花括号注入了灵魂用let和const声明的变量作用域被限定在最近的一对花括号内。这里的“花括号”包括if、for、while、switch也包括裸的{ ... }。if (true) { let blockVar 我在块里; var functionVar 我在函数里; } console.log(blockVar); // ReferenceError: blockVar is not defined console.log(functionVar); // 我在函数里这段例子最能说明var和let的分水岭var无视块级边界直接提升到当前函数作用域let老老实实待在块里出去了就找不到。注意for循环是个特殊情况——每次迭代都会创建一个新的绑定环境这也是let能解决“循环闭包”经典问题的主要原因。2.4 三张地图的对照表作用域类型声明方式可见范围创建时机销毁时机典型场景全局作用域var/function在顶层声明未声明赋值全局一切代码脚本加载页面关闭全局配置、入口函数函数作用域函数体内的声明、参数整个函数体函数调用函数返回工具函数内部数据块级作用域let/const在一对花括号内该块内部执行到该块离开该块循环变量、临时值这张表看起来简单但碰到“函数作用域里嵌套块级作用域块级作用域里又嵌套函数作用域”的复杂情况时很多人的思路就乱了。记住一个总原则作用域是嵌套的树形结构每个内部节点既能访问自己的那层空间也能顺着链条往上访问每一层父级空间。下面一节专门讲这条“链条”。3. 作用域链内层看得到外层外层永远看不见内层作用域链是理解闭包和变量查找的核心。它的形成时机比很多人以为的要早——在函数定义的时候就定了。3.1 查找路径一条向上的单向电梯当代码要读取一个变量时引擎会先在当前作用域的环境记录里找找不到就去外层作用域的环境记录里找再找不到继续往外……直到全局作用域。如果全局也没有var读取会得到undefined因为声明提升时初始化为了undefinedlet/const读取或未声明变量读取会抛ReferenceError。const outer 外层; function middle() { const inner 内层; function innermost() { console.log(inner); // 内层当前作用域找不到向外层 middle 找到 console.log(outer); // 外层继续向外找到全局 } innermost(); } middle();这条查找路径只有一个方向从内向外。外层作用域永远无法读取内层作用域的变量这是作用域最基本的“墙”。类比一下你在公司里自己工位抽屉里的东西只有你能拿但你办公室门口的公共区域、整层楼的公共茶水间、全公司的前台资料你都能用。反过来公司前台不可能跑到你抽屉里随便拿你的私人物品。3.2 为什么作用域链在定义时就固定了JavaScript 是词法作用域所以作用域链的“形状”在代码解析阶段就已经确定。函数在定义时会保存一个内部属性[[Environment]]指向当时的词法环境。这个环境其实就是当前外层作用域的引用。以后不管这个函数被传递到哪里调用它记住的始终是定义时那条链。用代码解释let name global; function readName() { return name; } function wrapper() { let name wrapper; return readName(); // 输出什么 } console.log(wrapper()); // global我看到不少新人第一反应是输出wrapper理由是“我在 wrapper 里调用 readName那它应该能看到 wrapper 里的 name 啊”。但这个推断在 JavaScript 里不成立。readName是在全局作用域定义的它的[[Environment]]指向的是全局环境。它内部引用name时查找链是“readName 函数作用域 → 全局作用域”根本没有 wrapper 这一层。所以输出的是全局的global。这就是词法作用域和动态作用域的区别——这里是最容易混淆的地方。3.3 作用域链的尽头与“找不到”的语义查不到变量时不同声明方式的表现还不一样读取一个未声明的变量比如console.log(notDeclaredAtAll)直接抛ReferenceError: notDeclaredAtAll is not defined。读取一个用var声明但未赋值的变量得到undefined不会报错。给未声明变量赋值非严格模式会在全局创建属性严格模式下抛ReferenceError。这里我插一句实操经验遇到xxx is not defined时先分清是“没有声明”还是“声明了但访问不到”。如果是后者十有八九是作用域层级搞错了——可能这个变量定义在某个函数或块里你在外面访问自然找不到。调试时按执行顺序从上往下看调用栈通常就能定位到到底是哪一层断掉了。4. 提升与“暂时性死区”var、let、const 的三国杀提到作用域绕不开三兄弟var、let、const。它们在外观上都是声明变量但在作用域行为上差异巨大。理解它们的区别比背一百个面试题都管用。4.1 var声明提升了初始化跟了半程前面已经举例var的声明会被提升到当前函数作用域顶部并且初始化成undefined。赋值操作停在原地不动。所以执行顺序实际是var a; console.log(a); // undefined a 1;这就是为什么有“var 的提升是声明初始化成 undefined”的说法这跟let/const完全不同。4.2 let/const声明提升了但处于“暂时性死区”let和const也有提升这是很多人学反了的地方。它们并不是“不提升”而是提升后不初始化从作用域顶部到真正声明赋值之间的区间叫“暂时性死区”Temporal Dead ZoneTDZ。在这段区域里访问变量会直接抛ReferenceError。console.log(x); // ReferenceError: Cannot access x before initialization let x 10;注意引擎其实知道x在这个作用域里存在但它的值处于“不可读”状态。用“房子装修”来类比var是毛坯房刚装好门牌你就可以进去但里面是个空房间所以读到undefinedlet是房间还没装修完门口拉了警戒线必须等let x 10这行执行完才撤掉警戒线。const更严格赋值必须在声明时完成且之后不能改。TDZ 还有几个隐蔽触发点typeof运算符碰到 TDZ 变量时也会抛错而不是返回undefined。这跟未声明变量不同typeof notExist返回undefined但typeof tdzzzVar一个被 let 声明但还没执行到会抛ReferenceError。在同一作用域内如果块级代码提前引用了let后面的变量也在 TDZ 里。类声明也有类似 TDZ 的行为——类不会被提升先使用后声明一样报错。4.3 函数声明的提升优先级函数声明的提升比var更彻底不仅声明了名字整个函数体都被提升到作用域顶部。所以你可以“先调用后声明”sayHi(); // hi function sayHi() { console.log(hi); }但注意一个细节如果var和函数声明重名函数声明优先如果var已经先赋值后面的函数声明又会覆盖它。真实代码里不建议这样写但面试和 code review 里经常看到这种故意挖坑的写法。我用一条规则总结先把函数声明提升到顶部再处理 var 声明var 同名的不会覆盖已提升的函数。4.4 提升和块级作用域叠加后的怪异行为ES6 之后函数声明出现在块级作用域里比如if内部行为变得很微妙。在严格模式下块内的函数声明实际被视为let一样只在该块内可见在非严格模式下老引擎的处理各不相同。规范为了兼容搞了一套“块级函数声明”的 Annex B 语义不同环境表现不一致。这里我不展开各浏览器的历史差异只给一条实操建议不要在块级作用域里直接写函数声明。要定义块内函数用函数表达式赋值给let变量if (condition) { const myFn function () { console.log(安全的函数); }; myFn(); }这样语义明确也不容易踩兼容性陷阱。5. 闭包不是魔法它是词法作用域链的自然产物一说闭包好多人觉得高深。其实闭包就是一个函数“记住了”它定义时的词法环境。这个环境里引用的变量在该函数创建时所在的执行上下文已经销毁的情况下依然被保留下来。它的存在不是 JS 刻意设计的某种高级能力而是词法作用域 垃圾回收机制相互作用的结果。5.1 闭包形成的三个条件严格来说任何函数都能访问它定义时外层作用域的变量但这个“外层的变量”如果不在函数返回后继续存留我们一般感知不到闭包。真正意义上的“形成闭包”通常具备这三个条件函数内部引用了外层作用域的变量。这个函数被定义在某个作用域中。这个函数在它自己的词法作用域之外被调用或返回。function createMessage(prefix) { return function (content) { return prefix : content; }; } const warning createMessage(警告); console.log(warning(内存不足)); // 警告: 内存不足prefix本来是createMessage调用时的局部变量按理说createMessage执行完就该被回收了。但因为返回的函数还在引用它引擎就不得不把prefix继续留在内存里。这个prefix就在闭包里。5.2 闭包吃的是“变量引用”不是“变量快照”这是我见过最多人犯迷糊的点。闭包捕获的不是变量在某一时刻的值而是变量的绑定引用。看看这段代码for (var i 0; i 3; i) { setTimeout(function () { console.log(i); }, 100); } // 输出 3 3 3为什么不是 0、1、2因为整个循环只创建了一个i它属于全局或外层函数作用域setTimeout 回调里闭包引用的都是同一个i。循环结束后i的值已经变成了 3三个回调拿到的自然都是 3。这就叫“引用了同一个绑定”。用let换掉varfor (let i 0; i 3; i) { setTimeout(function () { console.log(i); }, 100); } // 输出 0 1 2为什么这次又对了因为for循环配合let时引擎会为每一轮迭代单独创建一个新的绑定环境每一轮的回调函数捕获的是“当前那一轮自己的i”。同一时刻虽然逻辑上只有一个i名称但背后是不同的绑定实例。这个细节非常精妙也是块级作用域在循环场景里最有价值的应用。5.3 闭包的实际用途和内存陷阱闭包在业务代码里最常见的三类用途封装私有状态、预先绑定参数柯里化、事件监听/回调中保持上下文。私有状态像前面例子里的计数器count对外不可见只能通过返回的函数操作。柯里化createMessage示例就是先绑定前缀再绑定内容。事件回调比如在一个循环中批量绑定按钮事件用闭包冻结循环变量。内存方面要特别留神闭包会让被引用的变量无法被垃圾回收这是特性不是 bug。但如果闭包长期存活又捕获了一个巨大的对象那这块内存就长期释放不掉。我遇到过一次真实案例一个单页应用里某个工具函数在模块加载时创建了一个大对象缓存然后返回了一个内部函数使用这个缓存。这个内部函数又被挂到了全局事件监听上页面不刷新缓存永不释放。后来我把缓存改成按需初始化并且提供清理函数内存占用才降下来。用闭包时应该想清楚谁活多久捕获的东西就可能活多久。5.4 查看闭包console.dir 是你的好帮手调试闭包时与其靠猜不如用console.dir直接展开函数对象的内部结构在浏览器控制台里能看到函数对应的[[Scopes]]属性点开就是一个作用域链列表闭包变量一目了然const add createCounter(); console.dir(add); // Scopes: [closure: {count: ...}, global: ...]这个技巧在排查“变量为什么不是我期望的值”时特别高效几乎能当场看到闭包捕获的到底是哪个层级的变量。6. 用作用域的思路重构代码历史包袱与现代实践现在 JavaScript 代码已经高度模块化但日常项目里还是能看到各种被全局污染的老代码、老写法。这一节我想聊聊怎么用作用域的视角去审视和重构代码。6.1 全局污染是怎么发生的最常见的全局污染来自三件事在全局作用域用var声明变量。忘记写声明直接赋值。第三方脚本随手在window上挂属性。它们共同的后果是命名冲突、意外覆盖、难以排查。举个例子一个页面同时引入了两个库两个库都用var data ...后加载的库就会覆盖前一个库的data。如果库 A 内部的代码在运行时才读取data那看到的可能已经是库 B 的数据了。(function () { var apiVersion 1.0; })(); console.log(apiVersion); // ReferenceError传统上用 IIFE立即执行函数表达式包一层制造一个独立的函数作用域把变量藏起来。这个方法在今天依然有效但 ES6 之后我们有了更现代的工具。6.2 从 IIFE 到 ES Module作用域隔离的演进IIFE 的作用本质是“用函数作用域做隔离层”。ES Module 出现后每个模块本身就是顶层作用域模块里声明的变量不会自动暴露到全局只有export出去的才是公开接口。这让代码在语言层面就完成了隔离不需要再手工包裹。// module-a.js let internalState 私有; export function getState() { return internalState; }即使两个模块都定义了internalState它们互不干扰。这比 IIFE 更自然也解决了依赖加载顺序的问题。所以现在写新代码我强烈建议直接使用模块化方案而不是自己在全局挂一堆命名空间对象。6.3 块级作用域重构实例临时变量不再漏到外面改造前的代码let cache; if (needsUpdate) { cache loadData(); process(cache); } console.log(cache); // 其实不该能拿到这个改造后if (needsUpdate) { const cache loadData(); process(cache); } console.log(cache); // ReferenceError外部不关心这个临时值这种小改动看起来不起眼但好处很大临时变量跟着逻辑块走块结束就消失外部作用域更干净。团队合作时别人读到后面的代码不会平白多出一堆中间变量要理解。6.4 async/await 与作用域快照的一个实操细节还有一次我在处理一个批量下载功能时遇到一个典型的 async 循环闭包问题。当时需要逐项读取一个数组内容每个元素异步操作后引用索引。有同事不小心在循环里用了var来声明索引变量结果回调执行时索引全部是最后一个值。这种问题在async/await时代其实更隐蔽因为循环体的同步段和 await 之后的异步段对变量的引用方式不一样。我的经验是能就近声明就别外提变量能用const就别用let能用for...of就不要手写带索引的 for 循环配合闭包。现代语法里for...of配合let天然为每次迭代创建新绑定写起来既清晰又不容易踩闭包坑。7. 我把这些年踩过的作用域“坑”都列出来了最后这一部分是我认为整篇里“含金量”最高的全部来自自己或朋友的真实踩坑经历。每一条我都给了可复现的最小示例和解释。你可以把它们当成一份自查清单复习完作用域后自我检查一遍。7.1 条件块里的 var你以为它只在块内但它出去了if (true) { var result 我在 if 里; } console.log(result); // 我在 if 里很多初学者看到var result ...写在if块里觉得外面访问不到。实际上var只认函数边界不认块。只要你不在函数里它就到全局。如果多个条件分支都写了同名var还会互相覆盖。建议新代码一律用let/const让作用域和花括号真正对齐。7.2 循环里 setTimeout var所有回调共享同一个 ifor (var i 0; i 3; i) { setTimeout(() console.log(i), 100); } // 3 3 3前面已经解释过了根本原因是闭包引用同一个i绑定。解决方案有三种用let代替var最推荐。用 IIFE 包一层把i作为参数传进函数for (var i 0; i 3; i) { (function (j) { setTimeout(() console.log(j), 100); })(i); }用Array.from或forEach配合每次回调参数也是新绑定的方式之一。7.3 参数名和内部 var 同名引擎心态很稳你心态别崩function foo(a) { var a 10; console.log(a); } foo(5); // 10函数参数已经在函数作用域里了再写var a其实是重新声明同一个变量不报错值被更新。但如果写的是let a 10在同一作用域内和参数重名就会直接报SyntaxError连代码都跑不起来。这是let和参数之间一个容易忽略的冲突点。7.4 未声明直接赋值你以为是局部实际是全局function setConfig() { config { debug: true }; } setConfig(); console.log(window.config); // { debug: true }在非严格模式下config没有声明赋值时会沿作用域链向上查找找不到就在全局对象上新建属性。等于是偷偷污染了全局。开发环境里如果你开了 ESLint这种代码会被直接标红如果没开严格模式线上可能出 bug 还很难查。建议所有文件开头都启用严格模式use strict或用 ES Module至少让这种问题尽早暴露成报错而不是默默变成全局属性。7.5 块级作用域里访问外层同名变量由内向外挡不住let value outer; { console.log(value); // ReferenceErrorTDZ let value inner; }这个例子很有意思块内部的let value声明将整个块范围标记为它的作用域所以在块内部访问value引擎直接找到这个块级绑定但console.log(value)发生在声明之前处于 TDZ于是报错。它不会“退回”去读取外层的outer。很多人以为会打印outer原因是没有意识到一旦块内有同名声明整个块内的标识符就被它接管了。7.6 对象方法里的 this 和作用域链是两码事写 JS 常常把this和作用域混在一起其实它们完全不是一回事。作用域链解决“变量名字去哪里找”this解决“当前函数被谁调用”。看这个例子const obj { data: obj data, getData() { return this.data; } }; const fn obj.getData; console.log(fn()); // this 是 undefined严格模式或 window非严格模式方法丢到普通调用后this就丢了。如果用箭头函数定义方法this又会沿词法作用域向上捕获。这是另一个大话题但复习作用域时值得记一笔箭头函数里的 this 是词法绑定的、沿作用域链找的普通函数里的 this 是动态绑定的、沿调用方式找的。7.7 同名变量遮蔽内层变量“遮住”外层变量const amount 100; function convert() { const amount 200; return amount * 1; } console.log(amount); // 100 convert(); // 200这种“遮蔽”在业务代码里很常见通常不是 bug但多了以后会增加阅读成本。我的习惯是遮蔽最多出现一层如果嵌套三四层都有同名变量代码阅读起来就非常烧脑不如直接改名。复盘复习到最后我记住了什么这篇复习写到这里我自己也把作用域这条线重新走了一遍。如果让我用一句话总结作用域在 JavaScript 里的地位我会说作用域是变量从“出生”到“被查找”的全套规则。它决定了你的变量是否安全、闭包是否成立、内存是否泄漏、代码是否可维护。每次写代码时我都建议大家在脑子里过三个问题这个变量在哪里声明它的可见范围到哪里为止当多个作用域都可见它时谁的优先级更高把这三个问题养成习惯作用域基本上就算内化成肌肉记忆了。最后再分享一个我调试作用域问题的小技巧只要遇到“变量值不对”先不看业务逻辑直接去看变量声明的位置。一个变量读出来的值永远取决于它的查找路径上的最近一层声明而不是你“觉得它应该是哪一层”。想通了这一点很多玄学 bug 其实就是作用域的数学题答案早就写在代码结构里了。