资讯动态

var/let/const 深度解析:作用域、变量提升与闭包实战避坑指南

发布时间:2026/9/7 20:31:19 来源:尧图企业网站定制
我先说个结论var/let/const 这三个关键字几乎决定了你写出来的 JS 代码是“能跑就行”还是“能维护十年”。很多新手甚至有一定经验的同学在变量声明上一直靠“习惯”和“感觉”选型直到某天被一个诡异的 bug 折磨到怀疑人生回头一看问题就出在作用域上。这篇内容我会结合真实场景把三者的机制、差别、选型逻辑和踩坑记录一次讲透尤其是作用域和闭包联动时那些“教科书里没写透”的细节。这篇内容适合谁刚入门 JS 想打牢基础的新人写过一阵子但总被作用域问题坑的初中级前端以及需要带新人、做代码评审的同学。内容不依赖框架纯粹聚焦 JavaScript 语言本身看完你至少能回答三个问题为什么 var 在 for 循环里用 var 声明会出幺蛾子let 和 const 到底怎么选以及“变量提升”和“暂时性死区”这些概念在实际开发里到底意味着什么。1. 从“变量提升”说起var 到底哪里不省心1.1 变量提升不是功能是历史包袱很多初学 JS 的人第一次听到“变量提升”这个概念觉得还挺神奇为什么我在声明之前使用变量得到的是 undefined 而不是报错console.log(name); // undefined var name 小明;按直觉代码是从上到下执行的第一行用 name 的时候它还没被声明应该抛 ReferenceError 才对。但 var 的行为是声明会被提升到当前作用域顶部但赋值留在原地。所以上面这段代码的真实执行流程等价于var name; console.log(name); // undefined name 小明;这个机制在 ES5 时代大家忍了但它带来的问题非常实际。比如初学者经常写的这段function foo() { console.log(flag); if (false) { var flag 永远不会执行到这里; } }flag 的声明被提升到函数顶部if 块里的赋值没执行所以你得到的是 undefined而不是 ReferenceError。这其实掩盖了你的逻辑意图——你本是想在特定条件下才创建这个变量但 var 根本不给你“块级”这个维度它只认函数作用域。1.2 var 的变量提升究竟提升到了哪var 的声明会被提升到当前函数作用域的顶部。注意关键点不是提升到最近的块比如 if、for、while 的{}而是提升到最近的函数。如果不在任何函数里就提升到全局作用域。举个例子下面这段代码在循环里用 var 声明变量for (var i 0; i 5; i) { // 干点事 } console.log(i); // 5循环外面依然能访问到 i如果你学过其他语言比如 C 或 Java这个行为绝对让你不舒服。i 明明写在 for 的小括号里按常理应该是循环“内部”的临时变量循环结束就该消失。但 var 不这么干它直接把这个 i 提升到了全局作用域于是循环结束后 i 还大摇大摆地活着值还定格在 5。这个特性直接引发了一个经典的面试题场景——循环里绑定事件var buttons document.querySelectorAll(button); for (var i 0; i buttons.length; i) { buttons[i].onclick function () { console.log(点击了第 i 个按钮); }; }无论你点哪个按钮输出的都是“点击了第 5 个按钮”。原因非常简单onclick 函数里的 i 并不是在绑定事件时拷贝了一份而是引用了同一个 i 变量。循环结束后 i 是 5所以每个函数打印的当然都是 5。要解决这个问题ES5 时代只能靠立即执行函数表达式或者干脆用 let。1.3 立即执行函数ES5 时代的人工“块级作用域”在 let 出现之前前端想模拟“块级作用域”唯一的办法就是 IIFEImmediately Invoked Function Expression立即执行函数表达式for (var i 0; i 5; i) { (function (index) { buttons[index].onclick function () { console.log(点击了第 index 个按钮); }; })(i); }原理是利用函数作用域给 index 创建了一个独立的环境每次循环都会生成一个全新的函数作用域index 的值被稳定地保存下来。这个方案有效但写起来繁琐、可读性也差。更麻烦的是它解决的是“现象”而不是“本质”——本质是 var 缺少块级作用域。所以在 ES6 引入 let 和 const 之后IIFE 用于模拟块级作用域的场景基本绝迹了现在只有在写一些老库或者特殊隔离场景下才看到它。2. let 与 const 的块级作用域改了机制还是改了语法2.1 块级作用域到底是什么“块”let 和 const 最核心的变化就是引入了“块级作用域”。什么是块简单说一对花括号{}就是一个块。if、for、while、switch、try-catch 里面的花括号都是块甚至单独写一对花括号也算。{ let a 1; const b 2; var c 3; } console.log(a); // ReferenceError: a is not defined console.log(b); // ReferenceError: b is not defined console.log(c); // 3a 和 b 只在大括号内部有效出了块就找不到了。c 因为是 var 声明的依然不受块限制直接跑到全局。这个变化看起来只是“变量被关进了笼子”但它带来的连锁反应非常深。比如现在写 for 循环for (let i 0; i 5; i) { // ... } console.log(i); // ReferenceError: i is not defined问题解决而且不是靠语法糖 hack而是作用域机制本身的修正。2.2 暂时性死区let/const 的“硬保护”var 有变量提升let 和 const 没有。但它们的行为不是简单的“不提升”而是引入了暂时性死区Temporal Dead Zone简称 TDZ。先看现象console.log(a); // ReferenceError: Cannot access a before initialization let a 1;如果 var 时代这只是 undefinedlet 时代直接报错。为什么因为在变量声明语句执行之前这个变量处于“已存在但不可访问”的状态你碰它它就给你一个 ReferenceError。这就是暂时性死区的含义。我觉得 TDZ 是一个非常好的设计它把“声明前使用”从一种“可容忍的小毛病”变成了一种“显式的错误”。这逼迫程序员写出更可预测的代码少了很多隐形的逻辑黑洞。不过 TDZ 有个容易让人忽略的点typeof 也不能幸免。typeof a; // ReferenceError: Cannot access a before initialization let a 1;以前很多人说“typeof undefinedVariable 是安全的”确实对于根本没有声明过的变量typeof会返回undefined而不报错。但在 TDZ 区域内一个已经声明但尚未初始化的 let/const 变量typeof也会抛错。这个细节在面试里经常出现实际开发中如果你习惯在文件顶部用 typeof 做环境检测就得格外注意声明顺序。2.3 const 的不可变性到底锁住了什么const 的“不可变”是另一个容易让人误解的地方。它真正锁定的是变量的绑定关系也就是“这个变量名指向哪个内存地址”这件事不可变而不是“这个内存地址里存的数据不可变”。这句话有点绕直接看代码const obj { name: 小明 }; obj.name 小红; // 完全没问题对象内容可以改 obj { name: 小红 }; // TypeError: Assignment to constant variable.obj 指向的对象本身的属性可以随意修改除非你用了 Object.freeze 等额外手段但是你不能把 obj 重新指向另一个对象。对于基本类型的值也是一样的逻辑const num 1; num 2; // TypeError因为基本类型的变量名直接绑定到值重新赋值就相当于改变了绑定关系所以报错。这意味着你用 const 声明数组或对象时push、pop、修改属性、给对象加字段这些操作都是合法的。这很反直觉但你必须接受。真正需要“完全不可变”时要靠 Object.freeze、结构化克隆或者其他不可变数据方案const 本身并不负责这个。3. 实战选型var、let、const 分别用在哪儿3.1 默认规则能 const 就 const不行再 let现在前端的社区共识基本是默认用 const只有变量需要被重新赋值时才用 letvar 在新代码里基本不用。这个原则不是教条而是有实际理由的。const 表达的是“这个绑定不会变”读者看到 const 就知道这个变量的引用关系稳定心智负担小。const 能在编译期就阻止某些低级错误——比如你手滑给一个不该变的变量重新赋值编译器直接告诉你。let 表示“这个变量后续会变”相当于给代码的读者一个信号减少不必要的猜测。有同学可能会问我知道这个变量后面要存不同的值为什么不直接用 let那就用 let 啊。选型就两条路默认 const一旦需要重新赋值就换 let。没有第三种复杂情况。你写函数的时候参数处理也适用这个逻辑function calcTotal(prices) { let total 0; // total 要累加用 let const count prices.length; // count 不变用 const for (let i 0; i count; i) { total prices[i]; } return total; }3.2 什么场景你仍然可能碰到 var这里得说句公道话不是说 var 彻底一无是处。它的存在自有理由虽然在新项目里基本用不上但你还是要认识它因为老代码、旧库、面试题和某些特殊环境里你肯定还会撞上。第一个场景是老项目维护。很多前人留下的 ES5 代码全篇 var你要改就得先理解 var 的作用域逻辑不能粗暴全局替换成 let否则可能因为你没搞懂当时的依赖关系而引入 bug。第二个场景是Node.js 的 CommonJS 模块里的某些写法还有浏览器控制台里快速验证一些全局变量时var 仍然可以直接挂到 window 上方便调试但这仅限于临时场景。第三个场景是代码压缩工具或编译器产物。你看一些压缩后的代码里面可能混着 var这是压缩器处理的结果不需要你手动去写。还有一点要提醒不要在 window 上挂靠太多全局变量。var 声明在全局作用域时会成为 window 的一个属性比如var a 1;之后window.a就是 1。但let a 1;则不会它只在全局作用域中存在不会成为 window 的属性。这个差别在模块化开发和全局命名空间管理上有实际意义比如你要给第三方库暴露一个全局对象用window.MyLibrary ...更明确而不是依赖 var 的隐式属性绑定。3.3 全局污染怎么避免既然提到全局污染就多说两句。在浏览器里全局作用域是很多人共享的环境你往 window 上挂一个a别人也可能挂一个a后加载的脚本会把先加载的覆盖掉这是典型的全局污染。解决思路有几种使用 ES Module 或者 CommonJS 模块把变量封闭在模块内部。用 IIFE 包裹脚本对老的非模块化代码有效。如果必须暴露到全局用命名空间模式window.MyNamespace window.MyNamespace || {}; window.MyNamespace.version 1.0.0;只要涉及到变量声明你就得想清楚这个变量到底属于哪个作用域。var 容易让人放松警惕let/const 则强制你思考块的结构。这也是为什么我现在不管写什么代码第一反应都是 const而不是 var。4. 作用域链、闭包与变量生命周期它们是如何联动的4.1 作用域链是什么跟变量查找有什么关系变量的“可访问性”由作用域决定而作用域之间是有嵌套关系的。你在一个函数里访问一个变量如果当前作用域里找不到JavaScript 引擎就会往上一层作用域去找一直找到全局作用域为止。这个逐层查找的链路就是作用域链。const globalName 全局; function outer() { const outerName 外层; function inner() { const innerName 内层; console.log(globalName); // 全局 console.log(outerName); // 外层 console.log(innerName); // 内层 } inner(); }inner 里能访问 outerName 和 globalName靠的就是作用域链的向上查找。注意这个查找是基于词法作用域lexical scoping的——也就是代码写在哪里决定了他的外层作用域而不是他在哪里被调用。这句话太重要了我再展开一下。词法作用域的意思是函数的作用域在“定义”那一刻就已经决定了不管你把它传到哪里、在哪里执行它的作用域链都不会变。这和 this 的行为正好相反this 是调用时决定的而作用域链是定义时决定的。看一个例子const name 全局; function printName() { console.log(name); } function run() { const name run 内部; printName(); // 输出什么 } run(); // 输出“全局”printName 在定义的时候它的外层作用域是全局所以它访问 name 时找的是全局的 name而不是 run 内部的 name。哪怕它是在 run 里面被调用的作用域链还是按照定义时的位置来找。这是很多面试和实战里出 bug 的地方。4.2 闭包与 let/const 的化学反应闭包的本质是一个函数“记住”并可以访问它定义时的外层作用域的能力。当你把一个函数返回出去或者在外部引用它这个函数连同它的作用域链一并被保留下来外层作用域里的变量不会被垃圾回收因为还有人在引用它们。经典的计数场景function createCounter() { let count 0; return function () { count; return count; }; } const counter createCounter(); counter(); // 1 counter(); // 2count 变量在 createCounter 执行完之后按理说应该被回收但因为返回的函数还在引用它所以它“活着”。这个机制没问题但如果你配合 var 使用就容易踩到坑。比如这个非常经典的陷阱function createFunctions() { var funcs []; for (var i 0; i 3; i) { funcs.push(function () { console.log(i); }); } return funcs; } const results createFunctions(); results[0](); // 3 results[1](); // 3 results[2](); // 3var 声明的 i 是函数级作用域的整个 createFunctions 里只有一个 i循环结束后 i 变为 3。所有函数打印的都是同一个 i所以全是 3。把 var 改成 let 就完全不一样function createFunctions() { const funcs []; for (let i 0; i 3; i) { funcs.push(function () { console.log(i); }); } return funcs; } const results createFunctions(); results[0](); // 0 results[1](); // 1 results[2](); // 2let 在 for 循环的“每次迭代”都会创建一个全新的绑定每个闭包捕获的是当前迭代的 i而不是共用一个变量。这个行为在 ECMAScript 规范里是有明确说明的let 声明的循环变量在每次迭代时会重新绑定。我记得当年读规范看到“per-iteration bindings”这个概念时才真正理解为什么 let 能解决这个老问题。它不是又加了一层 hack而是从语义层面重新设计了循环变量的生命周期。4.3 JS 函数与变量声明的关系函数表达式 vs 函数声明既然聊到函数和变量就顺便把函数声明Function Declaration和函数表达式Function Expression的区别理顺一下因为这是 JS 函数里最容易和变量提升混淆的地方。// 函数声明整体提升 foo(); // foo 执行了 function foo() { console.log(foo 执行了); } // 函数表达式只有变量声明提升赋值不提升 bar(); // TypeError: bar is not a function var bar function () { console.log(bar 执行了); };函数声明会连同函数体一起提升到顶部所以你可以“先使用后声明”。而函数表达式本质是一次变量赋值var bar 会提升但赋的那个函数不会提升所以在赋值之前调用 bar 时bar 是 undefined执行 undefined 当然报 TypeError。这个差异在实际编码中直接影响你的代码组织和风格。我的习惯是模块化的逻辑尽量用函数声明它更直观也符合“先定义后用”的阅读习惯需要动态创建或作为回调传递的才用函数表达式。至于 var 声明的函数表达式在新代码里一样用 const 替代功能和意图都更明确。5. 高频踩坑场景与排查实录5.1 经典坑位一循环闭包的时候到底救不救得回来前面已经演示了循环闭包的坑。在实际项目中最常见的变体是 setTimeout 配合循环for (var i 1; i 5; i) { setTimeout(function () { console.log(i); }, i * 100); }输出结果不是 1 2 3 4 5而是 6 6 6 6 6因为循环结束后 i 已经是 6而 setTimeout 的回调在循环结束后才执行。很多老教程会教你用 IIFE 修现在直接用 let 就完事for (let i 1; i 5; i) { setTimeout(function () { console.log(i); }, i * 100); }输出 1 2 3 4 5完美。这个修复简单到让人恍惚但如果你从 var 的角度去理解就知道这不是“运气好”而是 let 的 per-iteration binding 机制在起作用。5.2 经典坑位二TDZ 引发的诡异 ReferenceError实战里我遇到过一个比较隐蔽的 TDZ 问题大概长这样function test() { console.log(typeof value); // ReferenceError let value hello; }当时同事的第一反应是“typeof 怎么会报错呢”其实原因就是 value 在 let 声明的 TDZ 区域内typeof 也无法安全访问。我当时让他把 let 改成 var他就理解了这个机制的差别。这个案例告诉我不要因为 typeof 通常安全就放松警惕当变量在当前作用域内有 let/const 声明时TDZ 依然会拦你。5.3 经典坑位三const 声明对象后你以为不能改很多新人把 const 理解成“常量”觉得 const 声明的对象不能改属性一改就报错。其实刚才已经说了const 锁的是绑定关系不是对象内容。实际开发里我看到过有人为了避免“修改对象”而用 Object.freeze但 freeze 是浅冻结嵌套对象照样能改。const person Object.freeze({ name: 小明, address: { city: 北京 } }); person.address.city 上海; // 这行不会报错因为 freeze 是浅冻结如果真需要深冻结得自己实现递归冻结或者用 immutable 相关的库。大多数业务场景其实不需要冻结你要做的是保证代码逻辑不乱改不该改的数据而不是依赖运行时的强制约束。5.4 一个常见问题速查表场景varletconst变量提升声明提升值为 undefined不提升TDZ 区域访问会报错不提升TDZ 区域访问会报错作用域函数作用域块级作用域块级作用域重新赋值允许允许不允许绑定锁定循环闭包共享同一个变量容易出问题每次迭代独立绑定安全不可重新赋值不适合做循环计数变量浏览器全局会成为 window 属性不会成为 window 属性不会成为 window 属性使用建议新代码避免需要变量重新赋值时使用默认首选还有几个快速判断口诀声明后不变就 const声明后会变就 let看到新代码用 var就停下来想想能不能用 const 替换。这基本能覆盖 90% 的日常场景。5.5 变量声明与“JS 中判断字符串是否包含”这类方法无关但有关系经常看到大家搜索“js 判断字符串是否包含”“js map 方法”这类具体 API 问题然后绕回来问变量怎么选型。我倒想提醒一件事很多 API 在使用时变量声明方式会影响你的代码可读性。比如你写const str hello world; const hasHello str.includes(hello); const mapped [1, 2, 3].map((num) num * 2);str、hasHello、mapped 都是需要 const 的因为你不会去改变它们的绑定。如果你写成 let读者就会疑惑“这个变量的值后续会被改吗”平白增加理解的负担。这跟 API 本身无关但跟变量选型有关。反过来如果 map 的回调里需要累加一个临时值那一定要用 letlet total 0; const numbers [1, 2, 3]; numbers.forEach((num) { total num; // total 在变let 是对的 });变量声明的选型不是一个孤立的语言知识点它渗透在每一个 API 调用、每一个函数编写细节里。这也是我为什么强调“默认 const”因为这是一种用声明方式传达意图的编程习惯。6. 我的个人实操体会从写 JS 到现在我对 var/let/const 的体会可以用一句话概括var 是 ES5 时代的历史产物let 和 const 是 ES6 对语言机制的修正而选型本质上是在管理代码的可预测性。我现在写代码的默认姿势是所有临时变量先写 const编辑器提示 “Cannot assign to const” 的时候再改成 let。这不是矫情是因为我踩过太多次因为变量被意外重新赋值导致的 bug而 const 能在编译期就拦下这类问题。对于 for 循环计数器几乎只用 let对于全局变量或者一些需要动态挂载到 window 的接口我会有意识地用 window.xxx 显式赋值配合命名空间隔离避免污染全局。最后再分享一个小技巧你在做代码迁移或者重构的时候可以尝试把旧代码里的 var 全部改成 let然后跑一遍测试和类型检查。那些报错的地方往往就是旧代码里依赖 var 变量提升的“隐形逻辑”你会很惊讶地发现原先一些你以为“能跑”的代码其实依赖着很不安全的作用域行为。这种“暴力替换 测试暴露问题”的做法比人肉分析快得多也更能帮你理解 var 的历史特性到底在哪些地方起作用。变量声明的选择看着是芝麻大的小事但它决定了你的代码是脆弱的还是稳健的。希望这篇内容能帮你在下一次写变量的时候多想一步“它应该活在哪里、活多久、能不能变”——这三个问题的答案就是 var/let/const 的全部秘密。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价