1. 变量声明这件事为什么值得花一整篇来聊JavaScript变量声明是我在带新人时最喜欢用来摸底的一个知识点。它看起来简单无非是 var、let、const 换个关键字但几乎所有人都会在实际代码里被它的行为绊一下明明在 if 里定义的变量函数外面居然也能访问循环里加个 setTimeout打印出来的全是同一个数字报错信息明明写着 “Cannot access x before initialization”代码却看不出哪里有问题。这些现象本质上都是变量声明机制造成的不把这一层逻辑捋清楚后面写函数、学事件处理、看别人的开源项目都会反复踩同样的坑。这篇内容我打算把变量声明当作一门必须搞懂的地基知识来全解析既覆盖新手需要的 JavaScript 基础语法也包含 var、let、const 在提升、作用域、全局对象、模块这些场景下的深层行为。我会尽量用真实案例说话每段都给出可以复制到控制台验证的代码片段以及我自己在项目里沉淀下来的排查经验。不管你是刚开始接触 JavaScript还是写了几年想系统梳理一遍这篇应该都能给你一些新的视角。1.1 一次拦截“全局污染”的现场先看一段非常常见的代码var count 1; function update() { count 100; } update(); console.log(count); // 100这个例子看起来人畜无害但如果update函数里并没有任何变量声明count 100会先沿着作用域链往外找count找到了就直接修改外层变量。这里的count是全局的所以函数内部的赋值直接污染了全局。更隐蔽的是 if 块里的 varvar flag true; if (flag) { var temp inside; } console.log(temp); // inside在大多数现代编程语言里if 块里的变量到外面就不应该可见但在 JavaScript 中var 声明只受函数作用域约束不会因为一个 if 块就“关起来”。很多项目习惯在 if 里写临时逻辑临时变量却因此泄漏到了整个函数甚至全局等代码一长变量互相覆盖的问题排查起来真的会怀疑人生。这种“泄漏”就是变量声明机制中最典型的坑也是我每次写 JavaScript 基础分享时一定会拿出来讲的第一案例。理解了它才能理解为什么后来会出现 let 和 const。1.2 var、let、const 三兄弟各自的分工var、let、const 是 JavaScript 中三种声明变量的方式差别非常大可以先用一张表看全貌特性varletconst作用域函数作用域块级作用域块级作用域重复声明允许不允许不允许变量提升提升并初始化为 undefined提升但进入暂时性死区提升但进入暂时性死区声明前访问得到 undefined抛 ReferenceError抛 ReferenceError是否需要初始值否否是绑定是否可重新赋值可可不可var 是 JavaScript 早期就有的声明方式函数作用域让它容易被忽略块结构重复声明会让同名变量被静默覆盖。let 是把 var 的“函数级”细化为“块级”每次进入一对花括号就是一个新作用域而且不允许在同一作用域里重复声明同名变量。const 和 let 一样具备块级作用域和暂时性死区但更严格声明时必须给初始值之后这个变量不能再被重新赋值。个人建议很简单默认用 const需要重新赋值再用 letvar 只在维护远古代码时去理解。这不是赶时髦而是 const 和 let 能直接屏蔽掉 var 的几大类坑代码一写出来可读性就能提升一个档次。1.3 声明方式选型是语法问题也是代码边界问题变量声明方式不只是选哪个关键字它直接决定了变量的可见边界和生命周期。一个变量被声明在哪里作用域就划到哪里全局声明的变量所有地方都能摸到函数内部声明的变量只能函数内使用块级声明只能在那一对花括号内存在。把作用域设计好代码就不需要靠一堆别扭的命名前缀去防冲突。举一个很实际的场景模块里某个函数需要一个内部计数器如果声明在函数内部并用 let 圈住外部无论如何访问不到如果一不小心声明在全局不仅外部能改还可能和第三方脚本里的同名变量起冲突。变量声明到位代码边界自然清晰。另一个决策点是可读性。const 一写读者立刻知道这个变量的引用不会变let 一写说明它后续有重新赋值的可能。这种“看见代码就知道意图”的效果是纯靠注释很难做到的。变量声明选型因此不应该随意它既是一个语法问题也是一个代码边界设计问题。2. 声明机制的底层逻辑提升、暂时性死区与作用域2.1 变量提升到底提了什么变量提升是理解 var 的一块基石也是新手最容易误解的地方。看这段代码console.log(a); // undefined var a 1;输出不是报错而是undefined。原因是 var 的声明语句会被 JavaScript 引擎在编译阶段“登记”到当前作用域顶部但赋值语句仍然停留在原来的位置。上面这段代码实际上等价于var a; console.log(a); a 1;函数声明更特别它不止声明被提升整个函数体也会一起被提升。所以在定义之前调用函数是合法的console.log(foo()); // function result function foo() { return function result; }这里要澄清一点提升并不是引擎在“物理上移动代码”而是在执行之前先把 var 声明和函数声明记录进作用域的环境记录中。let 和 const 同样会被登记但登记之后并不会像 var 那样先塞一个 undefined而是进入一种冻结状态声明之前的代码想访问就会被报错拦下来。这就是下一节要说的暂时性死区。2.2 暂时性死区的本质与意义暂时性死区是 let 和 const 声明特有的机制英文是 Temporal Dead Zone通常简称 TDZ。只要在声明语句之前访问这个变量不管访问方式是读取、赋值还是 typeof都会抛 ReferenceErrorconsole.log(x); // ReferenceError: Cannot access x before initialization let x 10;那为什么 var 不会有这种报错因为 var 在声明前访问时返回 undefined整个生命周期的起点是模糊的。TDZ 的设计就是为了消除这种模糊性一个变量必须等你把它初始化完之后才能使用否则就明确报错。这就像一个货架提前划好了位置但货物没上架之前你不能伸手去拿一伸手就会触发警报。TDZ 还有一个容易被忽略的坑不是只有“声明之前”才触发块内同名的变量遮蔽外层变量时会在整个块的一开始就形成 TDZ。比如let y 1; { console.log(y); // ReferenceError不是 1 let y 2; }这段代码里内层let y遮蔽了外层的y因此从块开始的那一刻起块内的y就进入了 TDZ哪怕console.log在声明的物理代码之前访问到的也不是外层那个 1。理解了这个机制很多“莫名奇妙”的运行时错误就能对号入座了。2.3 作用域链与闭包对声明的影响变量声明的位置决定了它在作用域链里的角色。JavaScript 查找一个变量时会先查当前作用域找不到就沿着作用域链逐级向外找直到全局作用域。内层函数可以看到外层的声明但外层永远看不到内层的声明let outer outer; function inner() { let innerVar inner; console.log(outer); // outer } console.log(innerVar); // ReferenceError当内层函数被当作返回值带回时它会把外层变量一起“记住”这就是闭包。即使外层函数已经执行结束那些变量也不会被回收因为闭包仍然持有引用。闭包非常有用但和 var 配合时会出问题。最经典的例子就是循环加定时器for (var i 0; i 3; i) { setTimeout(() console.log(i)); } // 输出 3 3 3i是 var 声明函数作用域内只有同一个i三个定时器回调共享这个变量。等到回调真正执行时循环已经结束i变成了 3所以全部输出 3。把 var 改成 let 就完全不一样let 在每一轮迭代都会创建一个新的绑定回调拿到的就是当轮的i。这个差异是变量声明作用域机制最直接的体现。2.4 块级作用域为什么能减少 bug块级作用域是 let 和 const 带来的最大进步。所谓块就是代码中用花括号包起来的那一段区域包括 if 体、循环体、switch 体、函数体以及单独的一对花括号。let/const 声明只在块内生效if (true) { let msg hello; } console.log(msg); // ReferenceError换成 varmsg 就会跑到函数作用域里块外依然能访问。很多 bug 的根源就在这里一个临时变量本应该只在分支里用却被 var 带到了整个函数后续逻辑不小心改了它数据就变得不可预测。块级作用域把变量关在它该在的笼子里这是最直观的防呆设计。有个细节要特别注意switch 的 case 共享同一个块级作用域。如果在不同的 case 里声明同名 let 变量会直接报重复声明错误switch (value) { case 1: let result one; break; case 2: let result two; // SyntaxError break; }解决办法是给每个 case 外面再包一对花括号把各自的作用域隔开。这个属于踩过坑才能记住的细节提早知道了能省一次语法报错的调试时间。3. 变量声明的几种姿势从基础语法到解构与循环3.1 声明语法与命名规则变量声明的基础语法很简单可以一次声明一个也可以一屏声明多个var a; let b 1; const c hello; let x 1, y 2; var m, n;一个关键规则是 const 必须带初始值而且每个变量都要单独初始化。const a, b 1;这种写法会直接报语法错误。var 和 let 可以先声明后赋值声明后没有赋值时值默认为undefined。标识符命名有一些硬性规则可以由字母、数字、下划线_、美元符号$组成不能以数字开头不能是 JavaScript 保留字比如if、for、class都不能当变量名。驼峰式命名 camelCase 是主流习惯常量常用全大写加下划线比如MAX_SIZE布尔类型变量建议用isReady、hasLoaded这种前缀开头含义一目了然。我不太建议用单个字母做变量名循环计数器里的i、j可以例外其他地方尽量用有意义的词。看起来多敲了几个字符却能在半年之后回读代码时省下大量猜测时间。变量名是给同行看的也是给未来的自己看的。3.2 声明与赋值分离时容易踩的坑let 和 var 声明后不赋值默认都是 undefinedlet a; var b; console.log(a); // undefined console.log(b); // undefinedconst 则不允许这种“先声明后赋值”的操作const c; // SyntaxError: Missing initializer in const declaration这个设计很好理解const 的语义是绑定不可变那么在声明时就必须确定绑定值。实际项目中我经常看到这种先声明后赋值的写法let result; if (condition) { result fetchData(); } console.log(result);这段代码如果某个分支漏掉赋值result就是 undefined后面再用就可能出错。更稳妥的做法是给一个默认值let result null;或者直接在 if 内部声明避免变量在没有初始化的情况下到处跑。声明的本质是明确变量的生命周期让它在所有路径上都处于可预期状态。另一个容易被忽略的点是赋值时机。var 的声明提升会把 undefined 暴露在声明前但赋值并不会提升。所以经常出现这样的 bug代码里明明给变量赋了值但因为赋值语句在访问之后才执行读到的一直是 undefined。遇到这类问题先看赋值语句在源码中的位置再看声明方式通常五分钟就能定位。3.3 解构声明数组、对象、默认值与嵌套解构声明是 ES6 语法中对变量声明的一次大扩展它允许从数组或对象中一次性取出多个值并声明成变量。数组解构按位置对应const [first, second] [1, 2]; console.log(first); // 1 console.log(second); // 2对象解构按属性名匹配const { name, age } { name: Tom, age: 22 }; console.log(name); // Tom console.log(age); // 22解构时可以设置默认值这是处理不稳定数据结构的利器const { name anonymous, age 0 } {}; console.log(name); // anonymous如果属性不存在或者值为 undefined默认值就会生效。注意如果属性值是null默认值不会生效这一点非常多坑。也就是说{ address none } { address: null }拿到的address是null而不是none。在解析接口返回数据时一定要清楚服务端可能给null。嵌套解构则用来解析层级更深的数据例如const { data: { list [] } {} } response;右边必须先给response一个空的默认对象否则当response本身是 undefined 时整个解构直接抛错。我处理接口数据时会坚持这一层默认值防护宁可多写一点也不想线上因为一个字段缺失就白屏。3.4 循环里的声明for、for-in、for-of 的行为差异循环是变量声明最常见的聚集地之一不同循环结构搭配不同声明关键字行为差异非常大。先看for循环for (var i 0; i 3; i) {} console.log(i); // 3var 让 i 泄漏到函数作用域 for (let j 0; j 3; j) {} console.log(j); // ReferenceErrorlet 将 j 关在循环块内let 版本还意味着每一轮循环都会创建一个新的i绑定定时器经典问题就是靠这个特性解决的。那能不能用 const 写普通 for 循环不能因为第三段的i会尝试重新赋值const 会直接抛 TypeError。for-in 和 for-of 的情况不同它们允许 const 声明迭代变量因为每次迭代都会产生一个新的绑定for (const item of [1, 2, 3]) { console.log(item); // 1 2 3 } const obj { a: 1, b: 2 }; for (const key in obj) { console.log(key); // a b }这里给一个非常实际的经验如果要在循环里保存当轮的值并异步使用优先写for...of配合 const 或 let别再用 var 加 IIFE 那种绕弯子的方案。很多 JavaScript 学习手册介绍循环时只讲语法不会提醒这些声明搭配带来的运行时差异但这个确实会影响每一行代码。4. 变量声明在函数、全局和模块里的特殊表现4.1 函数声明与变量声明的优先级同一作用域里如果同时出现 function 声明和 var 声明JavaScript 对它们的态度很微妙。先看这个console.log(typeof foo); // function function foo() {} var foo str;函数声明的提升优先级高于 var 声明所以在执行到var foo str之前作用域里的 foo 是函数。但赋值语句会按代码顺序执行如果先把字符串赋值给 foo函数就被覆盖了var foo str; function foo() {} console.log(foo); // str这种覆盖是非常危险的因为你根本看不出原本的设计意图。我建议在真实代码里永远不要写同名函数和同名变量。早期有些库依靠这种覆盖做 polyfill现在完全可以用更清晰的方式实现。如果用了 let/const重复声明会直接报 SyntaxError反而能逼着你把命名理顺。4.2 全局对象的隐式属性在浏览器环境里脚本顶层的 var 和 function 声明会变成 window 对象的属性这是 JavaScript 早期设计留下的遗产var g 1; console.log(window.g); // 1但 let 和 const 不会这样let g2 2; console.log(window.g2); // undefinedvar 造成的全局属性污染对现代项目很棘手页面里引入多个脚本时同名全局变量会互相覆盖第三方 SDK 也可能动到你的全局变量。最简单的方式是不再依赖顶层 var把所有逻辑放进模块或函数里。如果需要跨文件共享状态也应该通过模块系统 export/import而不是往 window 上挂各种值。在 Node.js 或者其他运行环境统一的全局对象可以用globalThis来拿。它跨环境一致比 window 更可靠但直接往 globalThis 上挂变量仍然是一种需要谨慎对待的行为。4.3 模块作用域声明变得“封闭”ES Module 中的顶层声明和传统脚本完全不是一个概念。模块顶层作用域是模块自己不是全局作用域。一个模块里声明的私有变量另一个模块除非通过导出接口访问否则根本接触不到// util.js const privateData secret; export function getData() { return privateData; }另一个文件想直接访问privateData会得到 ReferenceError。只有getData被 export 后才能被外部调用。这种封闭性让变量声明变得像一个封装良好的黑盒你不需要担心模块间命名冲突。还有几个模块相关的细节模块默认处于严格模式未声明直接赋值会报错import 进来的标识符本质上类似于 const 声明不能在当前模块里重新赋值。这些约束让模块代码更加安全但调试时也要意识到报错可能不是来自某个文件内部而是跨模块作用域边界导致的问题。4.4 严格模式对声明的约束在非严格模式下的普通函数里给一个从未用 var/let/const 声明过的变量名直接赋值JavaScript 会隐式创建一个全局变量function set() { value 1; } set(); console.log(value); // 1这是很多奇怪 bug 的来源函数内部一个拼写错误居然在全局新建了一个变量。开启严格模式后这种赋值会立刻抛 ReferenceErroruse strict; function set() { value 1; // ReferenceError: value is not defined }严格模式的约束还包括禁止 with 语句、把静默失败改成抛错、限制特殊标识符等。只要条件允许我建议所有代码都开启严格模式或者直接使用 ES Module因为模块已经默认处严格模式。变量声明只有在这种约束下才能真正变得“可控”。5. 报错排查与避坑经验5.1 TDZ 报错Cannot access before initialization这是 let/const 最常见的一种运行时报错完整信息通常是ReferenceError: Cannot access x before initialization。遇到它先检查是不是在声明语句之前就用了这个变量console.log(x); // ReferenceError let x 1;再看有没有块级作用域遮蔽问题。我之前遇到过一个案例代码结构简化后长这样let value outer; function main() { console.log(value); // ReferenceError不是因为用了外层 value而是这里也有一个 value let value inner; }核心原因就是第 2.2 节讲的 TDZ 遮蔽。排查手段是把报错行附近的代码全部看一遍确认是否存在同名 let/const 绑定以及该绑定是否在访问语句之前初始化。异步回调里一般不会触发 TDZ因为回调通常晚于初始化执行但如果通过某种方式在声明前调用了回调同样会踩中。5.2 undefined 与未声明的区别一个变量已声明但未赋值访问它得到undefined一个变量从未声明直接访问它会抛 ReferenceErrorlet a; console.log(a); // undefined console.log(b); // ReferenceError: b is not defined不过typeof对未声明变量是安全的console.log(typeof undeclared); // undefined这个特性经常被用来检测某个浏览器 API 是否存在。但要注意它不能区分“未声明”和“已声明但未赋值”两者返回的都是undefined。如果想知道某个属性是否真实存在于对象上用in操作符更准确console.log(toString in window); // true理解这个区别能帮你快速定位很多诡异报错。看到is not defined先怀疑拼写错误或变量没有声明看到undefined则重点检查赋值路径。5.3 重复声明var 可以、let/const 不行var 允许在同一作用域重复声明这个特性在旧代码中比想象中更常诱发问题var a 1; var a 2; // 合法silently overwrite let b 1; let b 2; // SyntaxError: Identifier b has already been declared出现 let/const 重复声明报错时说明代码里已经存在同名绑定。解决方式不是把 let 改成 var而是重命名其中一个变量或者把它放进更精确的作用域。如果你在代码审查中看到有人为了“消掉报错”把 let 改成 var一定要拦下来否则后面就是无穷无尽的覆盖问题。注意不同作用域的同名变量是正常的比如函数内部let a遮蔽全局a这不叫重复声明。只有同一个作用域重复声明才会报错。理解这个区分调试时就不会被带偏。5.4 闭包捕获循环变量经典问题var 循环加闭包的问题在第 2.3 节已经展示过最经典的是点击事件for (var i 0; i 3; i) { button[i].addEventListener(click, function () { console.log(i); // 全部输出 3 }); }解决方案优先级最高的是把 var 改成 letfor (let i 0; i 3; i) { button[i].addEventListener(click, function () { console.log(i); // 0 1 2 }); }如果必须兼容老环境也可以用 IIFE 捕获当轮值for (var i 0; i 3; i) { ((j) { button[j].addEventListener(click, function () { console.log(j); }); })(i); }这里的核心是理解闭包捕获的是变量绑定不是变量当时的“快照”。var 让所有闭包捕获同一个 bindinglet 让每一轮都有新的 binding。这个原理想通了以后遇到类似问题就能直接用底层逻辑推导而不是靠死记解决方案。5.5 const 声明对象为什么还能修改属性很多初学者会误以为 const 代表“常量”于是写出const obj { a: 1 };后看到obj.a 2能被执行就开始怀疑 JavaScript 坏了。其实 const 锁的是绑定不是值。const obj锁的是obj这个变量名不能再指向其他对象但对象内部的属性依然可以改const obj { a: 1 }; obj.a 2; // ok obj {}; // TypeError: Assignment to constant variable.如果真想冻结对象属性不被修改可以使用Object.freeze(obj)。但要清醒一点这是一个浅冻结嵌套对象仍然可变const frozen Object.freeze({ nested: { count: 0 } }); frozen.nested.count; // 依然可以因为 freeze 没有深层递归所以在做不可变数据管理时光靠 freeze 不够更可靠的做法是每次更新都生成一个新对象这也是 React/Vue 生态中不可变更新思想的底层来源。理解 const 的这层含义很多面试题和实际代码审查问题都能迎刃而解。5.6 我实际项目里的声明风格约定最后分享几条我自己在团队项目里严格执行的变量声明规则。这些不是学院派规定都是我从线上 bug 里一条条总结出来的默认用 const只有确认后面需要重新赋值时才改成 letvar 基本从日常编码中消失。让变量的作用域尽量小一个变量只声明在它最靠近使用位置的块里不要为了省事提到外层。对象解构时对所有可能为空的字段显式设置默认值同时留意属性值是null时默认值不生效。不向 window 或 globalThis 挂载任何业务变量跨文件共享的状态一律通过模块导出。代码审查时只要看到let却从没有被重新赋值就会建议改成 const看到重复声明先检查是否用了 var。声明方式看起来是小事但它决定了变量的生命周期、可见范围和后续可维护性。把 var、let、const 的机制吃透把作用域边界守住很多隐性问题在写代码的阶段就能提前消失。