恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
JavaScript全局变量与局部变量:从作用域到闭包一次讲透
首页
资讯中心
/
JavaScript全局变量与局部变量:从作用域到闭包一次讲透
JavaScript全局变量与局部变量:从作用域到闭包一次讲透
发布时间:2026/10/14 1:22:47
前两天帮一个新同学排查一个连续点击按钮却始终只返回最初数值的问题折腾了十几分钟最后发现罪魁祸首就是一个不起眼的变量声明函数里顺手写了和全局变量同样的名字又没有加let结果所有操作都在改全局值。全局变量和局部变量听起来是 JavaScript 最基础的知识点可它们引发的 bug 和面试题在我这几年带人的经历里一直居高不下。这篇文章的目标很明确把全局变量、局部变量以及它们背后的作用域、变量提升、块级作用域、闭包这一整条链路讲透。不绕概念每个部分都给可直接运行的示例和我在真实项目中踩过的坑最后还会附上一份高频问题排查手册。无论你是刚接触 JavaScript 的初学者还是想系统梳理基础的前端开发者都可以把这篇当作一份实用手册来读。1. 先看两个场景作用域问题长什么样1.1 场景一函数内外同名变量结果为什么会“打架”先看一段看起来完全没问题的代码let number 10; function changeNumber() { number 20; } changeNumber(); console.log(number); // 20这段代码输出20。函数里没有重新声明只是给number赋值所以它改的就是全局变量。再看另一段只差一个letlet number 10; function changeNumber() { let number 20; } changeNumber(); console.log(number); // 10这次输出10。函数内部的let number创建了一条全新的局部变量它和外层的全局变量只是同名没有任何关系。函数内部操作的是自己的局部变量全局那个根本没动。真实项目里这个问题会变得非常隐蔽。我之前在一个数据管理类的页面上遇到过全局有一份userList某个函数里的循环中写了userList transform(userList)本意是处理局部临时数据结果每一次操作都在改全局列表导致页面其他模块同步刷新接口被反复调用。问题根源就是作用域边界没分清。1.2 场景二for 循环里的定时器为什么全是最后一个值这是几乎所有前端都见过的经典场景for (var i 0; i 5; i) { setTimeout(function () { console.log(i); }, 100); }输出结果是5 5 5 5 5而不是0 1 2 3 4。原因在于定时器里的回调函数并不会在循环过程中立即执行等 100 毫秒后循环早就结束了此时它访问的i还是同一个变量。var没有块级作用域循环里的i其实属于外层函数或全局作用域循环结束后它的值就是5所以五个回调拿到的都是5。如果把var改成letfor (let i 0; i 5; i) { setTimeout(function () { console.log(i); }, 100); }输出就变成了0 1 2 3 4。原因是每次循环的{}都形成了一个独立的块级作用域定时器回调捕获的是“当次迭代”中的那个i而不是同一个共享变量。这个例子把“作用域边界”和“闭包捕获”两个问题揉在了一起所以值得在文章最前面拿出来。2. 作用域到底怎么划分全局变量与局部变量的边界2.1 变量查找的本质是什么作用域可以理解成一套“变量可见规则”。某个名字在哪些代码片段里能用并不是运行时随机决定的而是由代码书写的位置决定的。JavaScript 采用的是词法作用域也就是说函数嵌套在哪里它就拥有哪一层的作用域链和你从哪个位置调用它没有关系。用一个生活类比公司里每个工位抽屉里的东西只有坐这个工位的人能拿这是局部变量公共区的柜子整层楼的人都能拿这是全局变量。如果某人在自己的抽屉里放了一个和公共区同名的收纳盒他取到的永远是抽屉里的那个而不是公共区的。这个“就近原则”就是作用域查找的基本逻辑。2.2 三种作用域层级全局、函数、块JavaScript 里常见的作用域可以粗略分为三类作用域创建位置可见范围生命周期全局作用域脚本顶层或模块最外层整个程序都能访问页面或程序运行期间函数作用域function内部函数体内以及内部嵌套函数函数调用期间块级作用域{}内部由let/const创建当前代码块代码块执行期间全局作用域是最大的一层。在浏览器里用var在顶层声明的变量会挂到window上let和const则不会。全局变量的生命周期最长从页面加载到页面关闭它都一直存在。函数作用域就是每个function自己圈出来的范围。函数参数也属于局部变量在函数体内部生效。函数内用var声明的变量无论写在函数体的哪个位置整个函数体都能看到这就是后面要说的变量提升。块级作用域是 ES6 引入let和const之后才真正被广泛使用的。if、for、while这些语句的大括号配合let/const会产生独立的变量边界。这个特性让很多原本只能在函数级别控制的可变性收缩到了更精确的代码块内。2.3 嵌套作用域与查找链看一个嵌套函数示例const globalVar 1; function outer() { const outerVar 2; function inner() { const innerVar 3; return globalVar outerVar innerVar; } return inner(); } outer(); // 6inner函数内部访问globalVar时自己的作用域里没有就到outer函数作用域里找outer里也没有就继续往外到全局作用域。每层作用域像一层抽屉由内向外逐层翻找找到第一个匹配的名字就停止。这里有一个非常关键的结论JavaScript 的作用域链是在函数定义时确定的不是在调用时确定的。同一个函数无论你从哪个地方调用它它能看到的变量范围都不会改变。理解这一点后面再看闭包就会顺畅很多。3. var、let、const 怎么选三个声明关键字的差异3.1 变量提升var 的“预声明”机制先看这段代码console.log(message); // undefined var message hello;它不会报错输出的是undefined。原因要把它拆开看var message; console.log(message); message hello;var的声明会被提升到当前函数作用域的顶部但赋值操作留在原处所以读取到的是undefined。这就是“变量提升”。这里要特别说明“提升”只是一个便于理解的比喻并不是真的把代码移动了。更准确地说是 JavaScript 引擎在编译阶段先扫描并登记了变量声明真正执行到那一行时才做赋值。这个机制会带来很多让人意外的情况比如function test() { if (false) { var temp 1; } console.log(temp); // undefined }if条件为false按理说temp不应该存在。但var temp被提升到了函数顶部所以console.log(temp)不会报错只会输出undefined。let和const也有提升但它们在初始化之前处于“暂时性死区”。同样在声明前访问console.log(value); // ReferenceError: Cannot access value before initialization let value 1;结果直接抛 ReferenceError。这个行为比var更早暴露问题也避免了“读取到一个幽灵undefined”之后继续错误执行。3.2 块级作用域let / const 如何让边界更安全最简单的验证方式if (true) { let blockValue 1; const blockConst 2; } console.log(blockValue); // ReferenceError: blockValue is not defined console.log(blockConst); // ReferenceError: blockConst is not definedlet和const只在当前代码块内部有效。离开{}这个变量就彻底消失了。var则没有这个边界if (true) { var leakValue 1; } console.log(leakValue); // 1除块级作用域之外重复声明也是两者的重要差异。var允许重复声明后声明的会覆盖前面的var name a; var name b; console.log(name); // blet和const在同一个作用域内不允许重复声明let age 20; let age 30; // SyntaxError: Identifier age has already been declaredconst还有一个特点声明时必须初始化而且变量绑定不能被重新赋值。const pi 3.14; pi 3.14159; // TypeError: Assignment to constant variable.不过const限制的是“绑定”本身对象和数组内部的属性或元素仍然可以修改const config { theme: dark }; config.theme light; // 正常把几个关键差异汇总成一张表声明方式变量提升块级作用域重复声明挂在全局对象上var有初始化为 undefined无允许是let有存在暂时性死区有不允许否const有存在暂时性死区有不允许否3.3 用 var 在全局声明为什么会影响 window在全局作用域用var声明的变量会成为全局对象的属性。在浏览器里全局对象就是windowvar a 1; window.a; // 1 let b 2; window.b; // undefined这个差异并不只是概念层面的。如果项目里有人写了一个var name可能会直接覆盖浏览器自带的window.name如果第三方脚本也定义了同名变量后加载的脚本就可能影响前一个脚本的行为。let和const不会挂到window上相当于把“模块内部变量”和“全局对象属性”这两类东西区分开从根上减少了很多冲突。我个人的选择原则很简单默认用const确定需要重新赋值的场景才用let基本不再主动写var。在遇到老代码里的var时先理解它的提升和共享行为再决定是直接改掉还是保守处理。4. 全局变量能不能用污染、隔离与模块化方案4.1 全局变量被滥用的三个典型问题很多初学者会问全局变量确实方便为什么项目里都建议少用第一个问题是命名冲突。多人协作时A 模块定义了listB 模块也定义了list后执行的代码会把前面的覆盖掉数据结果完全不可控。这就像公共厨房的台面上摆满了一模一样的调料瓶你根本分不清哪瓶是盐哪瓶是糖。第二个问题是隐式耦合。模块 X 直接读写某个全局变量模块 Y 也在读写同一个变量排错时很难说清楚是哪一个模块在什么时机改坏了数据。更麻烦的是这种依赖关系不会在代码里显式体现出来排查问题只能靠猜。第三个问题是测试困难。全局变量会跨用例保留状态前一个测试产生的数据可能影响后一个测试的结果。单元测试要求用例之间尽量隔离全局变量恰恰制造了共享状态。4.2 隔离方案命名空间、IIFE 与模块化推荐方案一层层递进。早期没有模块系统时通常用“命名空间对象”来收敛全局变量const App { version: 1.0.0, config: { theme: dark } };把多个变量收进一个全局对象里散落在全局的名字就少了很多。但这种方式只是降低了污染数量并没有真正隔离内部状态。更近一步的是 IIFE立即执行函数表达式const Counter (function () { let count 0; return { increment() { count 1; return count; }, reset() { count 0; } }; })(); console.log(Counter.increment()); // 1 console.log(Counter.count); // undefined外部无法直接访问count只能通过暴露出来的方法操作。这就是闭包隔离状态的经典应用也是全局变量与局部变量知识在工程化中最直观的落地。现在最推荐的是 ES Module。模块内部声明的变量天然是模块私有的只有通过export显式导出的部分才能被外部访问其他内容对全局没有任何污染。模块内的顶层变量虽然名字叫“全局”但它只在模块作用域内可见不会挂在window上。4.3 哪些情况下刻意使用全局变量反而合理我并不是“所有全局变量都该消灭”的绝对派。实际项目里经常有一些只读配置或平台级常量用const声明在模块顶层本质上就是模块内部的全局变量安全且清晰。真正挂在window上的全局变量建议限制在几类场景需要被传统脚本调用的入口方法、第三方 SDK 暴露的全局对象、以及调试用的开关。即使要用也应该收敛到唯一的命名空间下比如window.MyProject window.MyProject || {}; window.MyProject.debugEnabled false;而不是零散地声明几十个window.xxx。这样至少能减少命名冲突也为后续迁移到模块化留了余地。5. 控制台实操三步看清作用域链与闭包5.1 准备一段可观察的测试代码理论讲再多都不如亲手在调试器里看一次。在浏览器开发者工具中新建一个脚本粘贴这段代码const base 100; function createAdder() { let step 1; return function add(value) { return base step value; }; } const addOne createAdder(); debugger; console.log(addOne(5));打开开发者工具的 Sources 面板刷新页面代码会停在debugger那一行。5.2 利用 Scope 面板观察三类作用域在调试器的 Scope 面板中通常能看到 Local、Closure、Global 三类信息Local 是当前函数内部作用域Closure 是外层函数因为闭包而被保留的变量环境Global 是整个程序都能访问的全局作用域。单步执行进入add函数内部你会看到value在 Local 中step在 Closure 中base在 Global 中。尽管createAdder已经执行完了它的step仍然存在这正是因为返回的add函数持有对createAdder词法环境的引用。建议顺手做一个小改动把let step 1改成var step 1再观察一次。结果并没有本质变化闭包捕获的依然是整个外层环境而不是某个具体变量值。这一点能帮助你理解为什么很多人说“闭包捕获的是变量而不是值”。5.3 再测试块级作用域在调试器里的表现用let写一个循环for (let i 0; i 3; i) { setTimeout(() console.log(i), 0); }在调试器里逐步查看每一轮循环的作用域你会发现每一轮都有一个独立的 Block 级作用域里面保存着当次迭代的i。这就是块级作用域在引擎层面的真实形态。换成var之后再观察只有一个共享的函数级环境循环结束后i统一变成3。这种观察方式比单纯背结论有效得多建议面试或复习前自己动手跑一遍印象会非常深刻。6. 高频问题排查手册面试和日常开发都逃不过6.1 var 循环加 setTimeout 输出最后一个值先说最经典的解法。替代方案一把var改成let这也是现在最推荐的写法for (let i 0; i 5; i) { setTimeout(() console.log(i), 100); }替代方案二用 IIFE 包一层把i作为参数传进去for (var i 0; i 5; i) { (function (index) { setTimeout(function () { console.log(index); }, 100); })(i); }每次循环时 IIFE 都会立刻执行index被固定在当次调用的参数里闭包捕获到的是各自独立的index所以输出正确。替代方案三用forEach代替for循环因为forEach每次迭代本身就是一次新函数调用[0, 1, 2, 3, 4].forEach(function (i) { setTimeout(function () { console.log(i); }, 100); });实际开发中遇到老代码时可能暂时没法全局替换var可以用 IIFE 或forEach做局部修复。6.2 函数内漏掉声明为什么全局变量会被悄悄改function demo() { result 10; // 漏了 let 或 const } demo(); console.log(result); // 10非严格模式在没有开启严格模式的普通脚本里给未声明的变量赋值JavaScript 会自动创建出一个全局变量。这个行为非常危险一个函数内部的临时赋值凭空污染了全局作用域。解决方案是开启严格模式。在脚本顶部写use strict;或者直接使用 ES Module 的写法。严格模式下给未声明变量赋值会立即抛出 ReferenceError错误在发生的那一刻就暴露出来而不是等页面数据莫名出错后再回头排查。这是我建议所有项目都默认开启严格模式的主要原因。6.3 同名遮蔽与暂时性死区导致的取值错误有一种情况很多人面试时会栽跟头const name global; function print() { console.log(name); const name local; } print();这段代码会抛 ReferenceError输出不了undefined也输出不了global。原因是函数内部已经存在局部name的声明它形成了对全局name的遮蔽但const声明在初始化之前又处于暂时性死区所以console.log(name)既访问不到外层全局变量也不能读取到内层还没初始化的变量。如果把const改成varfunction print() { console.log(name); // undefined var name local; }输出undefined因为var name提升到函数顶部只是还没有赋值。如果完全没有局部声明function print() { console.log(name); // global }输出global。一个例子串起了变量提升、暂时性死区、作用域遮蔽三个核心概念值得反复体会。6.4 高频问题排查速查表现象常见原因处理建议循环里的 setTimeout 全部输出同一个值var没有块级作用域回调共享同一个变量改用let或使用 IIFE /forEach函数内赋值后全局数据被修改函数内绕过了局部声明操作了外层变量明确局部声明或显式设计方案修改外部状态函数内读取变量报 ReferenceError局部遮蔽或暂时性死区检查声明位置和初始化顺序变量名冲突后加载代码覆盖前加载代码全局变量过多缺乏隔离使用模块化方案减少挂载到window重复声明变量不报错结果出乎意料使用了var严格模式下改用let/const7. 我沉淀下来的五个编码习惯聊了这么多最后分享几个我自己在实际项目中反复验证过的习惯算不上什么高深理论但能有效减少作用域相关的低级错误。第一默认const。只有当你明确知道这个变量在后面需要被重新赋值时才改成let。var基本上可以退出日常书写了除非在维护老代码。第二开启严格模式。不管写脚本还是项目代码都默认带上。它能帮你拦截“隐式全局变量”这类坏行为成本几乎为零。第三全局命名空间保持唯一。如果确实需要暴露少量全局入口用window.项目标识这样的方式收敛而不是散落几十个独立变量。第四函数内不要随手修改变量而不做声明。如果确定要修改外部状态把意图显式写清楚最好通过参数传入、返回值带出避免隐式依赖。第五用 lint 工具或编辑器的提示把规范自动化。人的记忆力不可靠但工具会坚持检查。团队里统一配置后作用域相关的问题大部分在写代码阶段就会被标记出来。如果你现在正被一段旧代码里的作用域问题折磨到怀疑人生先别急着改业务逻辑按这篇文章的思路把变量的声明位置、声明方式、嵌套层级逐个过一遍大概率几分钟就能定位到真正的问题。JavaScript 全局变量和局部变量这个知识点说到底是关于“边界”的学问——弄清楚变量在哪里存在、在哪里可见很多莫名其妙的 bug 都会迎刃而解。