
JavaScript变量声明这个话题看起来是每个前端开发者都觉得这有什么好讲的基础知识但我在团队里做了几百次代码评审之后发现一个挺反直觉的现象写了三五年 JavaScript 的人照样会在 var、let、const 的选择上翻车会解释不清变量提升会在循环闭包里被作用域坑到怀疑人生。这篇文章我打算把变量声明这层窗户纸彻底捅破。不光是告诉你哪个关键字该用哪个更重要的是把背后的机制讲清楚——为什么 var 会有那些历史遗留问题let 和 const 的块级作用域到底是怎么工作的暂时性死区是怎么产生的以及在真实项目里到底应该怎么选、怎么写。无论你是刚入门的新手还是想系统梳理一遍基础的老手这篇文章应该都能让你有点收获。1. 声明、初始化与赋值先把概念分清楚很多人搞不清 var、let、const 的区别根源在于没把声明初始化赋值这三件事分开理解。这三者看起来是同一件事但 JavaScript 引擎处理起来是完全不同的阶段。1.1 三个动作的本质区别先看一段最简单的代码let count; // 声明创建变量绑定 count 10; // 赋值给变量绑定一个值let count是声明它做两件事在当前作用域创建一个名为 count 的绑定然后把它的值初始化为 undefined。注意只要写了 let变量就会被初始化——这个初始化为 undefined是自动完成的。count 10是赋值它把 count 这个绑定指向新的值 10。如果 count 后面再被赋值为 20那只是这个绑定的值变了绑定本身还在。而 const 是一步到位的组合拳const maxSize 1024; // 声明 初始化 赋值一步完成const 要求声明的同时必须初始化而且要保证这个绑定在生命周期内不会被重新赋值。简单说const 创建了一个只读绑定。这三个动作用一个生活类比就很好理解声明是注册了一个账户初始化是往账户里存第一笔钱赋值是后续的存取操作。let 是先开账户再慢慢操作const 是注册那一刻就必须把钱存进去而且之后的余额只能看不能动。1.2 为什么这个区分如此重要理解这三个动作的区分直接决定了你能不能看懂后续的话题。比如变量提升提升的只有声明这个动作初始化并不会被提升。再比如暂时性死区它的本质就是变量已经完成声明但尚未完成初始化的那段时间。如果脑子里没有声明和初始化分开的概念后面这些机制全都会变成玄学。我在评审代码的时候经常看到这样的写法var config; // ... 中间隔了几十行逻辑 ... config { theme: dark };这本身没错但它把声明和赋值拉得太远阅读者很难判断 config 到底什么时候被真正初始化。这也是为什么后面要聊 const 和 let 的推荐用法——声明和初始化的距离越近代码的可读性就越高。2. var 的工作原理为什么历史包袱这么重var 是 ECMAScript 第一个版本就存在的声明方式可以说 JavaScript 的很多老毛病都跟它有关系。要理解 let 和 const 的设计动机得先看清楚 var 到底是怎么工作的。2.1 函数作用域var 眼里没有块var 的作用域规则很简单粗暴它属于当前函数作用域或者全局作用域但一定不属于块级作用域。这里的块指的是 if、for、while 这些花括号包裹的区域。看这个例子这是我在代码评审里经常遇到的经典场景function processItems(items) { if (items.length 0) { var status ready; for (var i 0; i items.length; i) { // 处理每个 item } } console.log(status); // ready可以访问 console.log(i); // items.length照样可以访问 }放在函数作用域的视角下status 和 i 根本不在 if 块内部它们在 processItems 整个函数作用域内。所以你可以在 if 块外面访问它们。这在很多其他语言里是不可思议的但 js 的老代码里到处都是这种写法。这个设计带来的直接后果是变量生命周期被无意义地拉长。你在 if 块里定义的临时变量函数后面的所有代码都能访问、都能修改这为 bug 埋下了大量伏笔。2.2 变量提升看见的是声明看不见的是赋值变量提升hoisting是 var 最著名的特性。JavaScript 引擎在执行代码之前会先把整个作用域里的 var 声明提前处理掉。console.log(message); // 输出 undefined不会报错 var message hello;上面这段代码在绝大多数语言里都会直接报未定义但 JavaScript 会输出 undefined。原因在于引擎实际执行的是这样的逻辑var message; // 声明被提升到最前面 console.log(message); // 此时还是 undefined message hello; // 赋值发生在原地注意一个关键点提升的只是声明不提升赋值。很多初学者以为整个var message hello都会被提升这是错误的。赋值操作依然停留在原来的位置。理解这个机制之后就明白为什么先使用后声明这种写法这么危险了——你拿到的永远是一个初始化的 undefined而不是期待中的值而且在复杂逻辑里这种错误极难定位。2.3 重复声明var 的全然容忍var 还有一个让强迫症十分难受的特性同一个作用域内可以重复声明同一个变量。var name 张三; var name 李四; var name; // 不报错相当于什么都不做这在工程上是个大坑。尤其是在多人协作的代码库里一个公共函数里出现同名 var后写的覆盖先写的运行顺序一变行为就完全不同。ESLint 里的no-redeclare规则就是专门用来防这个的。2.4 经典闭包陷阱的根源var 的函数作用域配合循环会产生一个著名的坑for (var i 0; i 5; i) { setTimeout(function() { console.log(i); }, 100); } // 输出5 5 5 5 5因为 var 声明的 i 属于整个函数作用域循环结束后 i 的值是 5五个回调函数共享的是同一个 i所以全打印 5。修复方式有很多但最干净的就是用 let 替代 var——这件事放到后面讲块级作用域时再展开。3. let 与 const块级作用域和暂时性死区ES6 引入了 let 和 const核心目的就是把 var 留下的这些坑一一填上。要理解它们的设计重点看两块块级作用域和暂时性死区。3.1 块级作用域终于有了变量只活在自己的块里let 和 const 是块级作用域。什么意思就是用花括号包起来的地方才看得到这个变量。function checkPermission(user) { if (user.isAdmin) { let canEdit true; } console.log(canEdit); // ReferenceError: canEdit is not defined }这在行为上更接近于 Java、C# 这些现代语言。变量只存在于它所在的块内出了块就彻底消失这大大降低了变量被意外修改和污染的风险。循环场景下块级作用域的价值体现得淋漓尽致for (let j 0; j 5; j) { setTimeout(function() { console.log(j); }, 100); } // 输出0 1 2 3 4for 循环的圆括号其实构成了一个外层块而循环体是内层块。let 声明在每一轮迭代都会创建一个新的绑定所以每个回调捕获的都是当轮迭代的 j。这个机制不是魔法它就是块级作用域的自然结果。3.2 暂时性死区为什么先使用后声明直接报错let 和 const 也提升但这个提升和 var 完全不同。它们同样在作用域顶部创建了绑定但在初始化完成之前任何访问都会抛 ReferenceError。这段绑定已存在但不可访问的区间就是暂时性死区Temporal Dead Zone简称 TDZ。console.log(value); // ReferenceError: Cannot access value before initialization let value 42;为什么 var 提升之后能用拿到 undefinedlet 提升之后却报错因为引擎的设计哲学变了与其让你拿到一个 undefined 然后到处找 bug不如在早期直接崩溃提醒你访问了一个还没初始化的变量。这是语言设计的进步强制你按顺序写代码。需要注意的是typeof 也救不了你if (typeof foo undefined) { // ... } let foo 1;在 foo 的 TDZ 区域内typeof 照样会抛 ReferenceError而不是返回 undefined。这是 typeof 检查的一个例外场景平时写防御性代码时要注意。3.3 const 的不可变性绑定不可变不等于值不可变const 和 let 最核心的区别是const 声明的变量绑定不能被重新赋值。const PI 3.14159; PI 3.14; // TypeError: Assignment to constant variable.但很多人对 const 有一个深层次的误解以为 const 声明的对象就不能改了。看这个const user { name: 张三 }; user.name 李四; // 合法可以修改 user.age 25; // 合法可以新增属性const 锁定的只是user 这个变量指向的对象引用对象内部的内容不存在任何锁定。要彻底理解这一点得区分绑定不可变和值不可变两个概念。const 保证绑定不可变值是否可变取决于值的类型。如果你需要对象本身也不可变得用Object.freezeconst config Object.freeze({ theme: dark, fontSize: 14 }); config.theme light; // 严格模式下会报错非严格模式下静默失败但 Object.freeze 是浅冻结嵌套对象仍然可改。深层冻结需要用递归或第三方库这个后面聊实用建议时再说。4. 三者的核心对比什么时候用什么把 var、let、const 放在一张表里看各自的性格就一目了然。维度varletconst作用域函数作用域块级作用域块级作用域变量提升有提升声明有提升表现但受 TDZ 限制有提升表现但受 TDZ 限制暂时性死区无有有重复声明允许不允许不允许重新赋值允许允许不允许全局声明成为 window 属性是否否4.1 决策规则默认 const需要改才用 letvar 基本不用我在团队里定的规范非常简单只有一句话优先使用 const当变量需要重新赋值时使用 let永远不使用 var。这个规范不是拍脑袋定的它有一个很重要的副作用代码评审时看到 const 就知道这个变量的绑定关系在整个生命周期内不会变减少了理解代码的心智负担。而看到一个 let脑子里立刻会有个信号——这个变量会被改需要重点关注它的变化逻辑。举一个实际例子const total items.reduce((sum, item) sum item.price, 0); let discount 0; if (user.isVIP) { discount 0.2; } else if (user.isNewUser) { discount 0.1; } const finalPrice total * (1 - discount);total 和 finalPrice 都是算出来就不变的量用 const 表达得非常清晰。discount 因为要经历多个分支的赋值用 let 是合理的。如果用 var 写这段代码discount 会泄漏出函数作用域虽然结果一样但可读性和安全性都打了折扣。4.2 那些需要绕开的场景const 不是万能的有几个地方要特别注意。第一const 声明的数组或对象内容是可以通过调用方法修改的const queue []; queue.push(task1); // 合法 queue.length 0; // 合法清空数组如果你希望数组不能 push 也不能清空只有 const 是不够的。需要配合 Object.freeze 或者设计成不可变数据结构比如用Object.freeze([...])冻结数组。第二在 for 循环里用 const 要小心。普通的 for 循环计数器是不断变化的用 const 会直接报错。但 for...of 循环里每次迭代的变量是一个新的绑定用 const 反而更合适for (const item of items) { console.log(item); // 每次迭代都是新绑定const 没问题 }这个细节很多写了几年代码的人都没注意到。4.3 声明风格的迁移节奏如果你现在维护的是一个老项目代码里全是 var不建议一次性全部替换。风险太大了很容易在回归测试里踩坑。我建议按这样的顺序来做先把函数内的临时变量改成 let因为基本没有副作用再把只在初始化时赋值一次的变量改成 const逐个验证最后处理全局变量这个要格外谨慎因为全局 var 会挂到 window 上改动可能影响其他脚本每一步都跑一遍测试确认没有问题再进行下一步。这样渐进式的迁移比大换血要稳得多。5. 边界条件和进阶场景var、let、const 的日常用法讲完了接下来是那些平时遇不到一遇到就头大的边角场景。这几个场景我在实际项目中都真实踩过坑值得单独拿出来说。5.1 全局声明的逃逸问题window 上的属性长尾巴在全局作用域使用 var 声明变量会变成 window 对象的属性var globalVar hello; console.log(window.globalVar); // hello而全局的 let 和 const 不会let globalLet world; console.log(window.globalLet); // undefined这个差异有什么实际影响如果你的页面会加载多个脚本或者需要和第三方库交互用 var 声明全局变量它就成了全局共享的状态容易被其他脚本无意中覆盖。用 let 和 const 定义全局变量可以避免污染 window 对象。另外一个细节在浏览器里用window.xxx ...显式赋值的全局变量和 var 声明行为一样是一个可配置属性而 var 声明的是不可配置属性。这个差异在delete操作和属性检测时会有细微区别。5.2 switch-case 和块级作用域一个隐藏的坑switch 语句的 case 不是独立的作用域块。这意味着在一个 case 里用 let 或 const 声明的变量在另一个 case 里可能会报错。switch (type) { case a: let name A; break; case b: console.log(name); // SyntaxError: Identifier name has already been declared break; }因为整个 switch 共享一个块级作用域两个 case 里的 let 声明会被视为同一个块里的变量所以报的是重复声明错误。解决办法是给每个 case 自己包一层花括号switch (type) { case a: { let name A; break; } case b: { // 这里是新的块可以放心使用 let otherName B; break; } }这个细节在 ESLint 的no-case-declarations规则里也有体现。5.3 循环里的 let闭包陷阱的根除与代价前面提到 for 循环配 let 可以解决闭包陷阱但它的底层机制值得多说两句。let fns []; for (let i 0; i 3; i) { fns.push(() console.log(i)); } fns.forEach(fn fn()); // 0 1 2这背后的逻辑是for 循环的每次迭代都会创建一个新的词法环境把当轮的 i 绑定进去闭包捕获的正是这个环境。用 var 的时候只有一个环境闭包共享的是同一个 i。这也是为什么 let 变量在循环中的行为和我们直觉上每一轮应该有独立值的预期完全吻合。这里有一个少有人提的代价每轮迭代创建新环境是有微小的内存开销的。现代 JavaScript 引擎对此做了大量优化实际性能差异可以忽略不计完全不值得用 var 去换这点性能。5.4 严格模式下的声明行为在严格模式use strict下JavaScript 的解析规则会收紧很多和变量声明相关的至少有两点第一对未声明变量赋值直接报错use strict; count 10; // ReferenceError: count is not defined非严格模式下给一个完全没有声明过的变量赋值会静默创建一个全局变量——这是最隐蔽的 bug 来源之一。严格模式把这个口子彻底堵上了。ES 模块天生就是严格模式所以现在写现代代码基本不会再遇到这个坑但老项目里要特别注意。第二删除未声明的变量或函数参数会被禁止。use strict; delete someVar; // 非严格模式下返回 false严格模式下直接抛 SyntaxError建议所有新代码都开启严格模式这是最便宜的代码质量保障。5.5 解构赋值与声明同源不同命解构赋值本质是在批量创建绑定const { width, height } dimensions; // 批量声明绑定不可变 let { left, top } position; // 批量声明绑定可变很多人不知道的是解构时也可以用 var只是同样不建议var { name, age } data; // 能工作但引入了函数作用域还有一个冷门知识点解构赋值中变量已声明的情况要特别注意括号问题let x, y; ({ x, y } point); // 需要包一层括号因为行首花括号会被解析为语句块如果去掉这层括号这行代码会被当成块级语句解析然后报 x is not defined。这不是变量声明的锅是语法解析的边界行为但写代码时确实很容易被绊倒。6. 团队规范与实用建议最后聊点实际的这些知识在团队协作和日常开发里到底怎么落地。我经历过从 var 满天飞到严格规范的过程分享几条经过验证的经验。6.1 一份简单可执行的变量规范我目前带的项目规范文件里和变量声明相关的就三条所有新代码禁止使用 var已存在 var 代码在修改时顺手改为 let 或 const变量默认用 const只有确认需要重新赋值时才改为 let对象和数组默认用 const确认需要整体替换引用时才用 let这三条规则写进 Code Review 检查单里配合 ESLint 的prefer-const规则基本可以从工具链上强制落地。ESLint 的prefer-const会自动标记那些可以且应该用 const 却用了 let的地方一键修复。碰到老代码里的 var什么时候顺手改我的经验是当你需要修改某个函数逻辑时顺手把函数内的 var 一起清理干净。不要专门安排一个重构周期去大面积替换那样风险高、回归测试成本大。6.2 命名规范让变量名自己说话声明方式之外命名对可读性的影响完全不亚于关键字选择。我有几个习惯变量名要体现它存的是什么而不是它是怎么声明的。比如const elementType elem.tagName.toLowerCase()清楚表达存的是元素类型。反过来const temp div这种写法三个月后自己都看不懂。布尔值用 is、has、can、should 开头const isVisible true; const hasPermission false; const canSubmit true;之所以这么做是为了让条件判断读起来像自然语言if (hasPermission isVisible)一眼就知道在判断什么。常量用全大写下划线分隔const MAX_RETRY_COUNT 3; const DEFAULT_TIMEOUT_MS 5000;这样在代码里出现时读者能立刻识别出这是一个固定值不会随便去改。6.3 初始化的时机声明越晚越好变量声明的位置也有讲究。一个非常实用的原则是首次使用前才声明不要把所有变量堆在函数顶部。这是出于两个考虑。第一声明和使用靠得越近越容易理解这个变量的来龙去脉。第二可以自然缩小变量的生命周期和作用域范围减少跨代码块被误修改的概率。有一个反直觉的地方需要提醒const 和 let 的提升确实存在但它们受到 TDZ 的保护。所以把声明放到使用前的位置不光是风格问题更是避免意外的关键。比如在函数开头访问一个后面才声明的 let 变量是会直接抛错的。6.4 给对象加只读的几种方式最后聊一下当 const 无法满足真的不想让你改的需求时还有什么招数。最基础的是 Object.freeze浅冻结。如果对象里嵌套了对象需要自己写一个深冻结function deepFreeze(obj) { Object.keys(obj).forEach(key { const value obj[key]; if (value typeof value object !Object.isFrozen(value)) { deepFreeze(value); } }); return Object.freeze(obj); }更工程化的方案是使用不可变数据结构的库如 Immer 或 Immutable.js。这类库涉及的更新模式不同适合在数据复杂度比较高、需要大量操作嵌套结构的场景里使用。如果只是简单的配置对象Object.freeze已经足够了。6.5 常见错误清单把这些年代码评审里最常见的问题汇总成一份清单方便你对照自查。症状原因正确做法函数内变量被意外覆盖var 泄漏了块级作用域改成 let 或 const循环闭包全部拿到同一个值循环变量用 var注入同一个绑定换成 let对 const 对象整体赋值报错试图重新绑定 const 变量确认意图改用 let 或修改对象属性访问 let 变量报 ReferenceError触碰到暂时性死区把声明放到使用之前严格模式下对未知变量赋值报错变量未声明先声明再赋值switch 的 case 内变量声明报错switch 共享块级作用域给 case 加花括号这张表我直接放在项目文档里新同学入职时看一眼就能避开绝大多数声明相关的坑。JavaScript 变量声明从前到后梳理完最核心的一条经验其实非常简单声明方式不只关乎语法更关乎代码的长期可维护性。const 管住那些不该变的绑定let 负责诚实表达这个变量确实会变而 var 应当被当作历史遗留产物逐渐淘汰。我在实际项目中实践这套规范好几年代码出问题的概率确实有了肉眼可见的下降。如果你正准备在新项目里建立规范从默认 const必要时 let告别 var开始一定是性价比最高的第一步。