在做前端这几年,我见过太多新手项目里的诡异Bug,十有八九都是变量作用域出了岔子。你绑定的计数器莫名其妙被重置了,你写的工具函数被别的文件里的同名变量覆盖了,甚至连个for循环里的i都经常“不听话”。这些问题的根源,全都通向 JavaScript 的全局变量和局部变量机制。很多人觉得这块内容太基础,不值得细抠,但真正到了面试现场,一个var、let、const的辨析就能难倒一大片。这篇文章我会从最底层的查找规则讲起,把变量提升、暂时性死区、闭包、内存回收、模块化这些“连带知识点”全部打通,适合刚入门的新人,也适合准备面试、想系统性补齐基础的老同学。
1. 作用域的底层规则:变量到底存活在哪里
1.1 一个“跑得好好的计数器”为什么突然翻倍
先看一个我前阵子帮别人排查的真实案例。某新手写了个页面点击计数的功能,核心代码大致是这样的:
var total = 0; function clickButton() { total += 1; render(total); } function resetCount() { total = 0; // 想重置计数 }单看这段逻辑没有任何问题,点击加一,重置归零。但实际跑起来,用户只要先点过几次按钮,再执行别的功能,计数就莫名翻倍。为什么翻倍?因为另一个业务模块里也悄悄用了一个同名的全局变量:
// 另一个功能模块 var total = 10; // 初始化商品总数这段代码只要加载到同一个页面,就会把前面声明的total直接覆盖掉。JavaScript 的全局变量挂在全局对象上,整个页面共享,任何模块都能改,于是两个功能之间产生了严重污染。类似问题用“变量作用域”的思路去看,就非常清晰:全局变量是“公共停车场”,谁都能停,谁都会被堵。想要不被堵,就得学会把变量圈进自己的“私家车位”,也就是局部作用域。
1.2 三条分界线:全局、函数级与块级作用域
JavaScript 的作用域可以粗暴地分成三种:全局作用域、函数作用域、块级作用域。全局作用域不需要解释,页面加载时自动生成,所有顶层代码都存在这里。函数作用域是function内部的世界,一个函数等于一堵墙,墙内用var或function声明的变量,只能在墙里面访问。块级作用域是 ES6 引入的,一对花括号{}就是一个块,if、for、while、switch里都藏着这种块级领地。
这三者的区别,我用一个非常直观的对照表来呈现:
| 作用域类型 | 代表关键字 | 可见范围 | 典型特征 | 能否被外部访问 |
|---|---|---|---|---|
| 全局作用域 | 无,或省略声明 | 整个页面 | 挂载到全局对象 | 可以 |
| 函数作用域 | var、function | 声明所在的整个函数 | 函数内任何位置可见 | 不可以 |
| 块级作用域 | let、const | 声明所在的{}内部 | 精确到一对花括号 | 不可以 |
需要特别注意的是,var虽然在{}里写了,但它仍然属于函数作用域,不会老老实实住在块里。这一点坑了无数人,后面我会专门展开讲。
1.3 作用域链:内层函数的“顺杆爬”查找机制
搞清楚了作用域的分类,还得懂一个东西才能真正看透变量:作用域链。JavaScript 的变量查找不是凭空来的,代码执行到某个函数内部时,如果遇到一个变量,引擎会先在当前函数自己的作用域里找,找不到就往外层函数的作用域找,再找不到就一路找到全局作用域。如果全局也没有,直接报ReferenceError: xxx is not defined。
这个过程和住公寓很像。你住5楼想找一根数据线,先翻自己房间,再翻邻居,最后才翻楼下公共大厅,如果大厅里也没有,那就算你倒霉,线压根不存在。很多开着嵌套函数问“为什么这里的变量能访问”的同学,把这个“顺杆爬”的链条画一遍就全明白了:
var globalName = "全局变量"; function outer() { var outerName = "外层变量"; function inner() { var innerName = "内层变量"; console.log(innerName); // 内层变量 console.log(outerName); // 外层变量 console.log(globalName); // 全局变量 } inner(); } outer();inner函数里明明只声明了innerName,却能访问到outerName和globalName。这背后的原理就是:inner的父级作用域是outer,outer的父级是全局,查找时就沿着这条链一级一级向上摸。反过来就完全不行,outer函数里访问innerName会直接报错,因为outer根本不知道inner内部发生了什么——作用域链是单向的,子能看父,父看不了子。
2. var、let、const 的真实区别,不只是能不能改
2.1 var 的“函数级”身份,是历史遗留还是有意为之
很多人背过口诀:“var会变量提升,let有暂时性死区,const是常量不能改。”但真要问一句为什么,就哑火了。先把var的历史说清楚:ES6 之前,JavaScript 只有var一种声名,它的行为是“函数级”,也就是只要在一个函数里写了var total = 0,这个total的声明可以出现在函数内的任何位置,但因为声明会被提升到函数顶部,所以你在函数开头和函数末尾写效果差不多。
看这段:
function demo() { console.log(a); // undefined,但不报错 if (true) { var a = 100; } console.log(a); // 100,因为 var 没被限制在 if 块内 } demo();var a = 100明明写在if块里,块外却还能访问到,而且声明之前打印竟然是undefined而不是报错。这就是var的两大特性:函数级作用域和声明提升。ES6 的设计者显然不觉得这是优点,于是推出了let和const来补齐块级能力,这也是为什么现在新人一上来就被要求“别再用var”。
2.2 let 和 const 把作用域收进了一对花括号
let和const的行为几乎一致,最大的区别在于绑定成功后能不能重新赋值。它们都遵守块级作用域,看下面这段代码:
function demo() { if (true) { let b = 200; const c = 300; b = 400; // 正常,let 可以重新赋值 // c = 500; // 报错,const 不能重新绑定 } // console.log(b); // 报错,外面访问不到 // console.log(c); // 报错 } demo();let和const把变量圈在了if块的那对花括号里,外面死活访问不到。这样带来的直接好处是:你不再需要靠记忆去判断一个变量属于哪个“函数”,只要看它所在的{}范围就能确定边界。这些不比var符合直觉,也让代码的局部性大幅提升。
我在实际开发里的默认策略是:默认写const,只有确认这个变量的值后续需要重新赋值时,才改写成let。这样写的好处是什么?一个函数里所有const声明的变量可读性极强,含义也清晰,别人看代码时能立刻锁定哪些是“恒定引用”,哪些是“可变状态”,大大降低理解成本。
2.3 同名声明规则:var 可以重复,let/const 严格禁止
还有一条规则经常被忽略:同一个作用域内,var可以重复声明同一个名字,后一次声明只是重新赋值,不报错;let和const则直接不允许,重复声明就会抛SyntaxError。
var name = "A"; var name = "B"; // 不报错,覆盖 let age = 18; // let age = 20; // 报错:Cannot redeclare block-scoped variable 'age'有经验的开发者往往把这条规则当作代码质量的显微镜。一个文件里如果到处是重复的var声明,说明这里存在隐晦的命名冲突;而所有let和const都强制你保持清醒,不允许同一作用域里出现两个同名变量。从实践角度讲,后者比前者安全得多,尤其当一个项目里有几个人的代码混在一起时,var的重复声明常常引起非常难以定位的覆盖问题。
3. 变量提升与暂时性死区:面试最爱考、工作最容易错
3.1 var 的声明提升是“半吊子提升”
关于变量提升,我先给一个最经典的例子:
console.log(msg); // undefined var msg = "hello";很多人背了“var会提升”以后就以为打印的是hello,结果一跑却弹出undefined。这里的本质是——JavaScript 引擎在处理var msg = "hello"时,会把声明和赋值拆开。声明部分var msg被提升到作用域顶部,执行时先执行提升后的声明,此时变量的值是undefined;赋值部分msg = "hello"留在了原地,代码执行到对应行时才真正被赋值。
所以打印时,msg变量存在,但赋值还没发生,输出自然是undefined。这就像租房合同签了、人已经入住,但家具还没送过来,你打开房门看到的是空屋子,而不是家具齐全。很多面试题的难点就在这里:声明提升只带“空壳”,不带“值”。
3.2 let 也提升,但被暂时性死区锁住了
那let呢?多数人以为let不上提升,其实let和const在引擎层面同样会被提升,只是被一个叫“暂时性死区”(TDZ)的机制锁住了。在代码执行到实际声明语句之前,从作用域顶部到声明语句的这一段区间,被称为暂时性死区。
console.log(num); // ReferenceError: Cannot access 'num' before initialization let num = 1;为什么不直接像var一样给个undefined,而要强硬报错?这是设计上刻意的改进。旧时代的var把“变量已存在但还没初始化”的中间状态暴露给开发者,结果是大量undefined参与的隐式Bug,怎么查都查不出来。let和const选择在死区阶段直接禁止访问,等于逼你“先声明后使用”,从源头消灭了这类错乱。
3.3 三道经典面试题,给你一次性拆穿
面试题一:var与函数内的提升
var a = "global"; function fn() { console.log(a); // undefined var a = "local"; console.log(a); // local } fn();第一行打印undefined的原因,是因为函数内部的var a被提升到函数顶部,局部变量a已存在,但还没赋值,所以输出undefined。这里要重点理解:函数内部的提升只会发生在函数内部,不会把外面的全局a带了进来。第二行打印local,是赋值之后的结果。
面试题二:let与暂时性死区
let x = 1; function fn() { // 这里是 TDZ console.log(x); // ReferenceError let x = 2; } fn();有人说“外面明明定义了x = 1,为什么函数里拿不到”,原因是你函数内部又声明了一个let x,而声明前的区域构成了一个封闭的暂时性死区,在这个区域内引用x,法律上直接判给你本地这个还没初始化的x,但它根本不允许你动它,所以报错。这里的核心启示是:只要你在函数内部声明了同名变量,函数内部的“查找链”就会被自己的声明切断,外部同名变量立刻失效。
面试题三:三者混用的权重
var t = 1; function foo() { let t = 2; { var t = 3; // 语法层面上的行为? } }这段其实在严格模式下会直接报SyntaxError,因为同一个函数作用域里t已经用let声明过了,你再用var重复声明同一名字,无论嵌套在哪一层块里,都会被检测到冲突。这也是 ES6 带来的一个隐性严格化:var虽然允许重复,但遇到块级作用域的let/const就会直接投降报错。这个细节很多教程都没讲,但它恰恰是考察你“到底懂不懂作用域规则整合”的利器。
4. 实战中的变量陷阱与隔离方案
4.1 for 循环里 setTimeout 为什么全打印同一个值
每个前端都踩过这样一个坑:
for (var i = 0; i < 5; i++) { setTimeout(function () { console.log(i); // 0 1 2 3 4 还是 5 5 5 5 5? }, 100); }答案是打出五个5。原因就是var i属于函数作用域而不是块级作用域,整个循环共用同一个i变量,循环跑得飞快,等 100 毫秒之后定时器回调开始执行,i早已经在循环结束前被磨到了 5。闭包捕获的永远不是“某个时刻的值”,而是“变量本身”。你只看到回调函数里面引用了i,其实它引用的是那个全局(或函数级)的i,并非每次循环快照。
解决方式有两种。第一种 ES5 年代的做法是用 IIFE 来强行制造隔离:
for (var i = 0; i < 5; i++) { (function (index) { setTimeout(function () { console.log(index); }, 100); })(i); }通过自执行函数把i以参数index的形式传给新的作用域,每次迭代都会生成一个全新的函数上下文,index就把当时的值“冻结”住了。第二种是 ES6 更优雅的方案:
for (let i = 0; i < 5; i++) { setTimeout(function () { console.log(i); // 0 1 2 3 4 }, 100); }只需要把var换成let。因为let是块级作用域,每次循环都会创建一个新的块级绑定,给当前迭代的i拍一张快照。你不需要写任何闭包代码,语言本身替你解决了。
4.2 全局变量污染到底有多疼:一个跨文件命名冲突案例
回到文章开头的计数器案例,我再把它扩展成一个跨“文件”的完整场景。假设你有一个老项目,没有模块化工具,直接用多个<script>标签加载 JavaScript 文件:
// a.js var userData = { name: "A同学", age: 18 }; function showUser() { console.log(userData); } // b.js var userData = { score: 99 }; // 某位同事的复用命名 function showScore() { console.log(userData); }两个文件都声明了userData,后加载的b.js会直接覆盖a.js里的同名变量。表面上看两个文件都很“正常”,但运行时showUser打印出来的都是{ score: 99 },用户信息直接丢光。这种问题在真实项目中非常隐蔽,因为它在报错层面完全看不见,只会在特定交互时冒出诡异的数据错乱。
这种“全局污染”的根治办法就是别往全局挂变量。如果你的项目还在用老式脚本,至少把所有逻辑包进 IIFE 里,把变量藏到局部作用域;如果项目已经支持 ES 模块,那就用import/export来显式地声明依赖,彻底告别全局共享。
4.3 从 IIFE 到 ES 模块,作用域隔离的三种主流姿势
第一个方案是 IIFE,也就是“立即执行函数”。它最大的价值是创造了一个全新的函数作用域,让内部变量不会泄漏到全局:
(function () { var privateCount = 0; window.getCount = function () { return privateCount; }; })();这里privateCount外界完全访问不到,只有通过暴露出去的window.getCount才能拿到。这种“模块化”思路是 ES6 之前最流行的隔离手段,今天你打开很多老库源码,还能看到这种写法。
第二个方案是 ES 模块。通过export和import明确暴露和引用:
// counter.js let count = 0; export function increment() { count++; return count; } export default { increment };模块顶层声明的变量天然就是该模块私有的,不会挂到全局对象,也不会和其他模块冲突。这是现代前端最正统的实践方案。
第三个方案是现在前端工程里到处可见的构建工具配合。像打包工具把每个文件视为独立模块,自动处理作用域,还替你做了压缩混淆。不管是哪种方式,目的都一样:把“全局共享”降到最低,把“显式声明依赖”提到最高。这部分多花点时间理解,对以后的团队协作非常有帮助。
5. 作用域链、垃圾回收和严格模式,这些“隐性因素”别忽略
5.1 作用域链查找与性能真相
有同学问过:访问全局变量和访问局部变量的性能有差别吗?有,但没有某些老文说得那么夸张。JavaScript 引擎查找变量时,对于当前执行上下文里的局部变量,几乎是直接命中;而全局变量的查找往往要多跨几个环节。在现代 V8 等引擎里,很多优化会让这种差异变得非常小,但高频率调用下全局查找仍然可能出现细微的额外开销。
我的一般建议是,不要为“提升性能”去刻意把所有变量塞进局部作用域,而是从“代码可读性和隔离性”出发,因为性能收益远小于模块化带来的工程收益。但是如果遇到深度嵌套的循环里反复访问全局对象的属性,可以考虑先把全局属性先缓存到一个局部变量里:
// 不推荐 for (let i = 0; i < 100000; i++) { someValue += Math.max(globalConfig.maxValue, i); } // 推荐:先缓存到局部 const maxValue = globalConfig.maxValue; for (let i = 0; i < 100000; i++) { someValue += Math.max(maxValue, i); }这不是玄学,是减少作用域链查找和属性访问的常规优化思路。
5.2 闭包的内存与回收经验
闭包之所以能“记住”外部函数的局部变量,本质上是因为被返回的内部函数保留了对父级作用域的引用,导致这些局部变量不会随着父函数退出而立刻被 GC 回收。这在某些场景是好事,但在另一些场景就是隐患。最典型的坑是某个全局缓存数组里长期保存了带闭包的回调函数,里面的数据对象再大也不会被回收。我踩过的一次坑是:把一次性的浏览器事件监听器绑到了一个闭包上,组件销毁时没解绑,结果闭包里引用的那几 MB 图片数据被一直拽着不放,页面内存肉眼可见地涨。
实际开发中我的避坑经验有三点:第一,尽量避免在长生命周期对象里存放短生命周期数据的闭包;第二,能用模块级变量替代闭包缓存时就别硬用闭包;第三,在“用完即弃”的监听器上及时解绑。理解闭包和 GC 的关系,不是让你害怕用闭包,而是让你知道什么时候该主动释放资源。
5.3 “use strict”一键拦截意外全局
还有一种特别恐怖的“全局变量”是你不小心造出来的“意外全局”。看下面的代码,函数内部某个变量忘记写声明关键字:
function setInfo() { userInfo = { name: "A同学" }; // 没有 let / const / var } setInfo(); console.log(userInfo); // 能打印出对象因为在非严格模式下,JavaScript 允许你在赋值时隐式创建全局变量。这一条“老好人”机制害了无数项目:你函数里想写userInfo,手滑少写了let,它不报错,悄悄往全局塞了一把变量。等数据错乱发生时,根本不知道是谁写的。
解决办法是在 JavaScript 文件或函数顶部加上"use strict",所有意外赋值都会直接抛ReferenceError:
"use strict"; function setInfo() { userInfo = { name: "A同学" }; // 直接报错 }严格模式就像是给语言装了一道安检门,能把你“手滑产生的全局变量”挡在门外。我强烈建议所有新项目无论大小,第一行就写上严格模式,或者让构建工具自动注入。
6. 高频报错排查与个人自查清单
6.1 一张表区分 undefined、is not defined 和 NaN
排查作用域问题,最直接的入口就是看控制台报错。我把常见报错整理成一张速查表,方便你对照定位:
| 现象 | 报错示例 | 通常原因 | 排查方向 |
|---|---|---|---|
变量存在但值是undefined | 无报错,输出undefined | 变量被提升但未赋值;或声明在后面的代码,前面执行了 | 查找声明位置、是否被提升 |
| 变量未声明 | ReferenceError: xxx is not defined | 作用域链上根本没有这个名字 | 检查是否少写声明、是否在错误作用域访问 |
| 变量赋值前被引用 | ReferenceError: Cannot access 'xxx' before initialization | 进入暂时性死区(TDZ) | 检查let/const声明顺序 |
| 重复声明 | SyntaxError: Identifier 'xxx' has already been declared | 同一作用域内let/const重复 | 检查是否有同名变量 |
| 奇怪数值 | NaN | 对未初始化变量做算术运算 | 回溯计算链路,确认变量初始化时机 |
每次遇到这些报错,先冷静下来问自己三件事:这个变量我声明了吗?在哪个作用域声明的?执行到当前这行代码时,它处于什么状态?把这三点理清,八成问题都能自己定位。
6.2 写代码前后的变量自查清单
做过不少 Code Review,我总结了一套简洁好用的自查方法,每次写代码时在心里过一遍,能躲开大部分作用域坑:
- 新变量默认用
const,需要重新赋值才改let,坚决不写var,除非你在维护老代码。 - 声明变量时,先看它所在的
{}范围,确认这个变量是否会被外部误用。 - 不要在全局顶层写业务变量,宁可放进模块作用域或闭包里。
- 写回调函数时,检查回调里引用的变量是不是循环变量,如果是,确认用的是
let。 - 严格模式下开发,每次报错都当成一次发现隐性 bug 的机会。
- 函数内尽量避免“意外全局”,带
"use strict"是门槛最低的防护。
这套清单不需要背,写代码时多对照几遍就会内化成肌肉记忆。
6.3 我踩过无数次坑之后的学习状态分享
我始终记得早期写代码时,把一个变量声明放在文件末尾,却在文件开头去读取它的值,结果打印出undefined,当时怎么也想不通。直到我把作用域链和提升机制彻底吃透,才意识到所有问题都可以归结到“声明在哪里、初始化了吗、作用域到哪”这三句口诀上。全局变量和局部变量的知识点看起来稀松平常,但它就像地基里的钢筋,平时看不见,一旦出问题就是整栋楼的安全隐患。
如果你今天只带走一个实用习惯,那就是:默认const,谨慎let,遗忘var,再配合严格模式开发。坚持一段时间,那些曾经让你抓耳挠腮的“变量乱了吗”的问题,会在你脑子里形成一张清晰地图,代码写起来会顺手非常多。