☰
JavaScript变量声明详解:var、let、const的机制与最佳实践
2026/9/29 17:21:33 网站建设 项目流程

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 4

for 循环的圆括号其实构成了一个外层块,而循环体是内层块。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 提升之后能用(拿到 undefined),let 提升之后却报错?因为引擎的设计哲学变了:与其让你拿到一个 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.freeze:

const config = Object.freeze({ theme: 'dark', fontSize: 14 }); config.theme = 'light'; // 严格模式下会报错,非严格模式下静默失败

但 Object.freeze 是浅冻结,嵌套对象仍然可改。深层冻结需要用递归或第三方库,这个后面聊实用建议时再说。

4. 三者的核心对比:什么时候用什么

把 var、let、const 放在一张表里看,各自的性格就一目了然。

维度varletconst
作用域函数作用域块级作用域块级作用域
变量提升有,提升声明有提升表现,但受 TDZ 限制有提升表现,但受 TDZ 限制
暂时性死区无有有
重复声明允许不允许不允许
重新赋值允许允许不允许
全局声明成为 window 属性是否否

4.1 决策规则:默认 const,需要改才用 let,var 基本不用

我在团队里定的规范非常简单,只有一句话:优先使用 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,不建议一次性全部替换。风险太大了,很容易在回归测试里踩坑。我建议按这样的顺序来做:

  1. 先把函数内的临时变量改成 let,因为基本没有副作用
  2. 再把只在初始化时赋值一次的变量改成 const,逐个验证
  3. 最后处理全局变量,这个要格外谨慎,因为全局 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 一份简单可执行的变量规范

我目前带的项目,规范文件里和变量声明相关的就三条:

  1. 所有新代码禁止使用 var,已存在 var 代码在修改时顺手改为 let 或 const
  2. 变量默认用 const,只有确认需要重新赋值时才改为 let
  3. 对象和数组默认用 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"开始,一定是性价比最高的第一步。

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

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

立即咨询