☰
深入理解JavaScript作用域:从全局与局部变量到闭包
2026/10/14 3:17:33 网站建设 项目流程

有一次我帮同事排查一个线上问题,页面加载到一半直接白屏,控制台报了一句xxx is not a function。顺着调用栈找下去,根因特别不起眼:某个公共脚本里有人写了一个赋值语句,少写了声明关键字,结果这个变量被挂到了全局,把另一个模块里同名的方法给覆盖了。这种事故在多人协作的项目里真的不算少见,它背后的问题只有一个——当事人压根没搞清楚,一个变量到底是全局变量还是局部变量,作用域到底是怎么划分的。

这篇文章就打算把这件事彻底讲透。我会从全局变量和局部变量的边界定义讲起,接着扒开JavaScript查找变量时的那套作用域链机制,再聊var、let、const在变量提升和暂时性死区上的巨大差异,然后用真实场景展示全局变量太随意会闯出什么祸,最后从闭包视角补上作用域理解上的最后一块拼图,并给出一套可以直接用的排查和避坑方法。适合刚学JavaScript不久、被作用域绕晕的同学,也适合写了两年代码但偶尔还会被变量覆盖问题坑一把的人。

1. 全局变量和局部变量的边界到底画在哪里?

1.1 先给“变量可见性”划个地盘

作用域这个词听起来玄,其实就是一句话:一个变量在哪些地方能被访问。

生活里有个很贴切的类比。全局变量像是商场大厅里的广播,你在任何一个角落都能听到,谁都能去动它,改了这条广播,整个商场都受影响。局部变量像是包间里的悄悄话,只有这个包间里的人听得见,走出门就没人知道了。写JavaScript的时候,你在函数外面声明的变量,基本就是“大厅广播”;在函数内部声明的变量,就是“包间悄悄话”。

看个最基础的例子:

let room = '大厅'; function speak() { let secret = '包间秘密'; console.log(room); // '大厅',函数内部能看到外面 console.log(secret); // '包间秘密' } speak(); console.log(secret); // ReferenceError: secret is not defined

函数内部的secret只能在函数体内访问,外面想拿直接报错;外面的room函数内部反而看得见。这个“向外看得到、向内看不到”的规则,就是作用域最核心的边界感。

1.2 var的“函数级”边界和let/const的“块级”边界

如果仅以“函数内外”来划分,还是太粗了。真正让不少人踩坑的,是var和let/const在边界粗细上的区别。

var是函数级作用域,它只管到函数这一层。let/const是块级作用域,遇到{}就会划界。

function check() { if (true) { var a = 'var在块里'; let b = 'let在块里'; } console.log(a); // 'var在块里' console.log(b); // ReferenceError: b is not defined }

a虽然写在if块里,但var只管函数边界,所以a实际上属于整个check函数,函数内任何位置都能访问。b被let限制在if的那个{}里,出了块就没影了。

这个区别是很多新手“为什么这个变量在外面还能用”的答案来源,也是不少老代码里“变量莫名其妙串值”的开端。后面聊变量提升的时候,这个问题还会被进一步放大。

1.3 三种声明方式的作用域速查表

把var、let、const放一起看,差异会非常直观:

声明方式作用域可重复声明可重新赋值声明前访问
var函数级可以可以得到undefined
let块级不可以可以报ReferenceError
const块级不可以不可以报ReferenceError

顺便提一个和const有关、经常被一起误解的细节:const指向的对象,其内部属性仍然可以修改。const obj = { count: 0 }之后执行obj.count = 1是合法的,因为const锁的是“变量和对象的绑定关系”,不是对象里面的内容。

提示:现代JavaScript项目的默认选择是const,需要重新赋值才用let,var尽量不要再写。这不是矫情,而是更清晰的声明方式能让作用域边界更精确,让错误暴露得更早。

2. 变量查找背后那套作用域链机制

2.1 从执行上下文到作用域链

作用域既然是个“可见范围”,那JavaScript引擎在运行的时候,是怎么知道一个变量到底该去哪找的?

这里要先引入执行上下文这个概念。每次调用一个函数,引擎都会为这次调用创建一个执行上下文,里面装着这个函数的局部变量、参数等内容,还带有一个指向外部作用域的引用。多个这样的上下文串起来,就形成了作用域链。

说起来抽象,其实可以想象成一条找东西的路线:当前函数里找不到,就顺着外部引用走到上一层函数,一层层往外,直到全局。每一层都是一个抽屉,找变量就是逐层翻抽屉。越内层的抽屉优先级越高,一旦翻到,就不会继续往外找。

2.2 就近遮蔽:同名变量谁说了算

作用域链按“由内向外”的顺序查找,意味着一个自然结果:内部变量会遮蔽外部同名变量。

let name = '外部名字'; function show() { let name = '内部名字'; console.log(name); // '内部名字' } show(); console.log(name); // '外部名字'

函数里那行let name相当于在“内层抽屉”放了一个同名标签,查找的时候先翻内层抽屉,翻到了就不会继续往外走。外面的name没有被改动,只是在这个函数内部被遮蔽了。

理解“遮蔽”这个词很重要。排查问题的时候,很多“我改了外面的变量怎么没生效”其实不是没生效,而是内层有一个同名变量把外面的挡住了。先去确认有没有同名变量,往往比盯着赋值语句瞎猜要快得多。

2.3 window、global和globalThis:最外层的兜底对象

作用域链的终点,在浏览器里是window,在Node.js里是global,它们统称全局对象。ES2020之后还多了一个globalThis,无论在哪个环境都能安全地指向全局对象。

用var在全局声明一个变量,或者在函数外面不写声明直接赋值,相当于给全局对象加了一个属性。这也是为什么页面里的库和插件都小心翼翼,因为全局对象上的一个小改动,整个页面环境都会感知。

一个容易忽略的细节:全局用let/const声明的变量,并不等同于全局对象的属性。

var a = 1; // window.a 存在,true let b = 2; // window.b 不存在,false

var会把变量挂到全局对象上,let不会。很多人在控制台发现window.b是undefined会愣一下,其实这只是两种声明方式挂到全局时的表现不同,不影响b仍然是全局可用的变量。理解这个差异,后期排查“为什么window上拿不到我定义的变量”这类小困惑时,会少走不少弯路。

3. 变量提升和暂时性死区:var与let/const最不一样的地方

3.1 var的声明提升是怎么发生的

JavaScript引擎在执行代码之前,会有一个编译准备阶段。它会先扫描代码里的var声明和函数声明,把它们“提前登记”到所在作用域的顶部。这就是变量提升。

注意,提升的是声明,不是赋值。所以下面这段代码不会报错,但结果会让人迷惑:

console.log(a); // undefined,不是报错 var a = 10;

引擎实际看到的顺序是这样的:

var a; // 声明被提到最前面,初始值是 undefined console.log(a); // undefined a = 10; // 赋值还在原位置

a确实被登记过了,只是赋值还没轮到,所以打印出undefined。这种“不报错但结果很奇怪”的行为,恰恰容易掩盖潜在问题,等真正出错时已经不好定位。

函数声明同样会整体提升,但函数表达式不会,这一点总是有人混淆:

sayHi(); // 正常执行,函数声明被提升 function sayHi() { console.log('hi'); } sayHello(); // TypeError: sayHello is not a function var sayHello = function () { console.log('hello'); };

把变量提升和函数声明提升的规则放进脑子里,再遇到“代码看起来没问题却行为诡异”的场景,思路会清晰很多。

3.2 let/const的暂时性死区:把错误挡在编译阶段

let和const声明同样会被引擎提前“意识”到,但处理方式和var完全不同。在声明语句执行之前访问它们,会直接抛ReferenceError,而不是拿到undefined。这段“知道有这个变量但不能碰”的区域,叫暂时性死区,简称TDZ。

console.log(x); // ReferenceError: Cannot access 'x' before initialization let x = 5;

为什么JavaScript要特意设计这个报错?因为var那种“先拿到一个undefined”的做法,本质上是在把问题往后拖延。很多Bug当时不报、运行到后面才莫名其妙出错。let/const把“声明前使用”直接变成硬错误,是JavaScript语言从“运行时才发现问题”往“尽早暴露问题”方向做出的重要改进。

一个很多开发者不知道的细节:typeof在TDZ里同样会抛错。

typeof someLet; // ReferenceError,而不是返回 'undefined' let someLet = 1;

这是因为typeof也要确认变量是否初始化,确认过程中同样触碰到了暂时性死区。习惯了typeof永远不报错的人,第一次看到这里多半会愣一下。

3.3 why var的历史包袱改不掉

看到这里你可能会问:既然let/const更合理,var这种有提升、能重复声明、作用域还只到函数级的声明方式,为什么不干脆删掉?

因为JavaScript要向后兼容。无数现存的老代码依赖var的行为逻辑,如果直接废除,整个生态会瞬间崩掉。所以新标准选择的是新增更严谨的声明方式,而不是删除旧的。这也是了解变量提升的一个现实意义:你读老项目、维护历史代码时,会经常面对var,不懂它的行为,遇到问题就是两眼一抹黑。

4. 全局变量太随意引发的连锁事故

4.1 隐式全局变量:少写一个关键字的蝴蝶效应

如果说普通全局变量还可以通过声明位置察觉,隐式全局变量则是完全“隐形”的。

不写任何声明关键字,直接给一个变量赋值,在非严格模式下,会发生这样的事:

function leak() { notDeclared = 100; } leak(); console.log(notDeclared); // 100,这个变量意外变成了全局变量

因为在宽松模式下,引擎找不到已有声明,就会把notDeclared悄悄挂到全局对象上。函数执行一次,一个全局变量就这么诞生了。这是很多Bug的真正源头:本想写个局部变量做临时计算,少写了let,结果整个项目都能访问到它,可能在另一个文件里被覆盖,也可能污染了别的库封装好的全局状态。

如果开了严格模式,情况会好很多:

'use strict'; function leak() { notDeclared = 100; // ReferenceError: notDeclared is not defined } leak();

所以我一直建议,新项目第一行就写'use strict',或者干脆用ES模块,默认就是严格模式。它把“隐式全局”这条路直接堵死,很多变量相关的低级错误会在运行的第一时间暴露。

4.2 命名冲突:多人协作下的互相踩踏

全局变量最直接的风险是命名冲突。两个脚本都定义了全局变量,后加载的脚本会覆盖先加载的,业务逻辑里引用的功能可能因此静默失效。

典型场景是:页面里先加载了某个基础工具库,里面有一个utils对象;业务代码为了图省事,也声明了一个utils,然后第二个utils就把第一个盖掉了。第三方库对外暴露的方法一夜之间消失,报错却出现在离现场很远的地方,排查难度极大。

这类事故在多人协作的项目里尤其常见。运气好,错误信息还能指向某个函数;运气不好,只是某些功能“悄悄不好用了”。这种“悄悄”是最可怕的,因为问题不会留在发生地,而是扩散到使用方。

4.3 全局变量为什么难以清理

局部变量在函数执行结束时就可以被回收,全局变量的生命周期却被拉得很长:只要页面还开着,它就一直挂在全局对象上。如果全局变量里存了一个大对象,使用完又不释放,内存就会一直占着。

还有一个容易混淆的细节:通过var或let/const声明的全局变量,在全局对象上的绑定都是不可删除的。真正能被delete掉的,是那种不写声明、直接赋值产生的隐式全局属性。

var g1 = 1; delete window.g1; // false,var声明的全局绑定不可删除 window.g2 = 2; delete window.g2; // true,直接赋值产生的属性通常可以删除

理解这一点,调试时就不会在delete一条全局变量失败后一头雾水。想回收全局变量占用的内存,最实际的做法是手动把它置为null或undefined。

4.4 针对全局变量污染的防护组合

日常工程里,我自己的防护顺序是这样的:

  1. 文件顶部写'use strict',ES模块天然严格。
  2. 用let和const,不用var。
  3. 打开ESLint规则:no-implicit-globals禁止隐式全局、no-undef禁止未声明变量、no-unused-vars禁止未使用变量。
  4. 尽量用ES模块组织代码,模块内部变量天然局部,只有显式export的内容才对外可见。

前三条几乎不增加任何开发成本,但能把“悄悄变成全局变量”“未声明就使用”这类问题在提交之前拦截下来。很多事故现场看起来复杂,回到根子上都只是缺少这种最基本的约束。

5. 闭包不是玄学:它是作用域链记性好过头了

5.1 函数为什么能记得出生时的环境

接下来聊一个作用域话题里绕不开的存在:闭包。

按惯例,先看代码:

function createCounter() { let count = 0; return function () { count++; return count; }; } const counter = createCounter(); console.log(counter()); // 1 console.log(counter()); // 2

一般来说,createCounter执行完,它内部的count属于局部变量,函数结束后就该被销毁。但这里返回了一个内部函数,这个内部函数能通过作用域链访问到count。引擎发现这个变量还有人用,就不会急着回收它,而是让count继续存活。内部函数带着那条作用域链“活了下来”,这就是闭包。

换个通俗的说法:闭包就是一个人离开了出生的房间,但口袋里还揣着那间房的钥匙,随时能回去拿东西。房间没有因为人走就拆掉,因为钥匙还在。

5.2 记性太好也会翻车:for循环里的var对let

闭包的这个特性,最容易暴露的地方就是for循环加事件处理函数。

for (var i = 0; i < 3; i++) { setTimeout(function () { console.log(i); }, 100); } // 输出:3 3 3

很多人第一次看到这个结果都懵了。原因在于:var的作用域是函数级的,for循环的大括号在var面前不是作用域边界,三次迭代共享的是同一个变量i。等定时器执行的时候,循环早就跑完,i已经是3,所以连续打出三个3。

换成let就不同了:

for (let i = 0; i < 3; i++) { setTimeout(function () { console.log(i); }, 100); } // 输出:0 1 2

let每一次迭代都会创建自己的块级绑定,每个回调闭包捕获的是各自迭代里的i。这个例子把“局部变量的生命周期”和“闭包捕获”两个概念同时演明白了,面试里出现频率高是有道理的。

如果遇到老代码只能用var,经典解法是立即执行函数:

for (var i = 0; i < 3; i++) { (function (j) { setTimeout(function () { console.log(j); }, 100); })(i); } // 输出:0 1 2

这个解法的本质,就是在var只有函数级作用域的前提下,人为造出一个新的局部变量j,让闭包去捕获它。

提示:读到这种老代码时,优先建议把var改成let,而不是在每层套IIFE。IIFE能解决问题,但它增加了视觉噪音,理解成本也更高。

5.3 闭包是封装私有状态的正路

全局变量太容易泄露,闭包又恰好能保护内部状态——这两件事放在一起看,自然得到一个结论:想共享一些状态但不想全局暴露,用闭包封装是正路。

上面createCounter其实就是最简单的私有状态封装。外部只能通过counter()操作count,不能直接改这个数字。这种模式可以扩展成更真实的场景:

const config = (function () { let debug = false; return { enableDebug: function () { debug = true; }, isDebug: function () { return debug; }, }; })(); config.enableDebug(); console.log(config.isDebug()); // true

debug被关在函数内部,外部拿不到,只能通过暴露出来的方法操作。这就是模块模式。在没有ES模块的年代,前端大量依赖这个思路来隔离全局状态。现在有了ES Module,模块天然就是作用域边界,闭包更多用在局部封装和工厂函数里,但“用局部状态替代全局状态”的思想始终没有变。

6. 带着作用域意识写代码:避坑清单与实用排查方法

6.1 调试时如何一眼确认变量归属

遇到“报错说变量找不到”“值对不上”这类问题,最快的办法不是到处console.log,而是直接在浏览器开发者工具里看清楚变量的作用域。

打开DevTools的Sources面板,在相关代码行打断点,右侧Scope窗格会列出当前的局部变量、闭包变量、全局对象。逐层展开,就能直观看到这个变量来自哪一层作用域。这个信息比任何console.log都可靠,因为它展示的是上下文现场。

还有一个少有人提的小技巧:在控制台里执行console.dir(某个函数),可以看到它的[[Scopes]]属性,里面列着这个函数诞生的作用域链。想知道一个函数能访问到哪些变量,直接看这一项就够了。

function outer() { let innerVar = 1; return function inner() { console.log(innerVar); }; } const fn = outer(); console.dir(fn); // 展开 [[Scopes]],能看到包含了 outer 作用域里的 innerVar

对于“某个变量是全局还是局部”这个最基础的问题,还有个土办法:全局搜一下这个变量的声明位置。如果声明在所有函数之外,那它就是全局;如果只在某个函数内部,那就是局部。配合Scope面板,基本不会判断错。

6.2 一份可以直接套用的代码审查检查清单

作用域相关的Bug大多有固定的形状,所以代码Review时盯着几个点看,性价比很高:

  • 有没有赋值语句左边只写了一个裸变量名?在非严格模式下,这是隐式全局变量的头号入口。
  • 有没有变量在声明语句之前出现?配合变量提升和TDZ的知识,快速判断会不会踩空。
  • 有没有在if、for、switch的块里用var?如果块外还有代码用到它,建议改成let。
  • 有没有同名变量在嵌套作用域里出现?确认遮蔽行为是有意设计,而不是无意覆盖。
  • 有没有向window或globalThis上挂属性?除非是受控的全局配置或插件扩展,否则建议用模块或闭包替代。

这不是要求代码里一个全局变量都不能有,而是让每个进入全局的名字都经过思考,知道它为什么非全局不可。

6.3 全局变量不是十恶不赦,关键在受控

聊到这里必须给全局变量说句公道话。全局变量本身不是事故,真正的事故是“无意识的全局变量”。受控的全局变量在很多场景是合理的:

  • 全局配置项,比如站点的基础信息、环境标识。
  • 跨模块共享的常量,用const挂到只读对象上,避免被改。
  • polyfill类功能,需要在页面加载早期往全局挂能力。
  • 工具函数库,把API挂到单一命名空间,避免散落各处。

关键是控制数量、控制命名空间、加只读保护、写清用途。如果代码里的全局变量数量超过一只手,就该停下来想想怎么收敛了。

6.4 从作用域视角看模块化:现代JavaScript的选择

ES模块其实是“作用域边界”的制度化。每个.js文件内部的变量天然属于文件私有,别人读不到;共享的东西必须显式import/export。

// utils.js export const formatTime = (s) => s; // main.js import { formatTime } from './utils.js';

这个设计把“可见性”变成了显式约定:没有export就不可见,没有import就用不到。相比“全局变量随手挂、到处读”的旧时代,它从根本上消解了命名冲突和意外覆盖的问题。回到整篇文章的主线:全局变量与局部变量的边界,从var的函数级,到let/const的块级,再到模块的文件级,JavaScript做的其实是同一件事——把变量的可见范围越画越精确,把意外偷看的窗口越关越小。

最后补个我自己的操作习惯。写完一段代码,我会先扫一眼:有没有裸变量名赋值?有没有在声明前使用变量?有没有var从块里漏出来?这三眼扫完,八成跟作用域相关的低级问题就提前没了。真遇到现场排查,先断点看Scope面板,再搜声明位置确认,通常五分钟内能定位。作用域这东西,第一次接触觉得绕,但只要你亲自被某个隐式全局变量坑过一次,后面就再也不会忘了。

如果你读完这篇文章只带走一句话,我希望是这句:变量声明得越窄,代码越安全。学会让局部变量待在该待的地方,比记住一堆术语有用得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询