有件事儿我记忆特别深。去年我参与前端岗位的终面,问了一道自认为“送分”的基础题,结果十个人里至少五个人答得七零八碎。题目本身很简单——“说说 var、let、const 的区别”。很多人在简历里写“熟练掌握 JavaScript”,可真到了要把这三个关键字讲透的时候,连自己平时代码为什么这么写都说不明白。这不是初级岗位的专利,不少写了三五年业务代码的前端,理解也停留在“var 会提升,let 和 const 不会”这种碎片化层面。
所以我想用一篇尽量贴近实际开发的文章,把这三兄弟的关系彻底梳理一遍——不只是背结论,还要理解语言设计者为什么这样做,以及我们在真实项目里到底应该怎么选。如果你准备面试,这篇文章能让你从“背出区别”升级为“讲透原理”;如果你已经在带团队,那这份内容还能帮你把代码规范落到实际评审里。
1. 从一个让我当场翻车的面试现场说起
1.1 一道变量提升题,为什么我讲不完整
面试时我在白板上写了一行极其简单的代码:
javascript复制console.log(a)
var a = 10
我问:“输出什么?”大部分候选人能答出 undefined,也知道这涉及变量提升。可当我接着问“如果把 var 换成 let 呢”的时候,现场几乎都会沉默一下,然后有人小声说“报错”。
“报什么错?为什么会报这个错?”我再追问,能完整答上来的人就更少了。
这段经历让我意识到,很多前端对 var、let、const 的认知是“背面试题背出来的”,而不是“写代码写出来的”。var 会提升所以输出 undefined,let 不会提升所以报错——这种说法对不对?对,但只有一半。恰恰是缺少的那一半,让很多人进入正规项目之后开始怀疑自己基础不牢。这篇文章我不想只甩出结论,而是把整个演化逻辑穿起来讲。理解了“为什么”之后,很多东西根本不用背。
1.2 var 的三张面孔:函数作用域、提升、重复声明
先说 var 身上的三张面孔,这也是理解它为什么被“嫌弃”的关键。
第一张面孔:var 是函数作用域,而不是块级作用域。
javascript复制function demo() {
var flag = true
if (flag) {
var inner = '可以访问'
}
console.log(inner) // '可以访问'
}
demo()
在很多编程语言的直觉里,if 的大括号应该是一个“块”,块内声明的变量块外不能访问。但在 JavaScript 里,var 没有块级边界,只要在同一个函数内,花括号根本不构成访问边界。这带来的现实问题是什么?变量污染。你本想在 if 里声明一个临时变量,结果整个函数都能改它;如果两个分支里声明了同名变量,相互之间还会干扰。
第二张面孔:var 声明会被提升,但赋值不会。
这是 hoisting 的经典机制。当代码进入函数作用域时,JS 引擎会先把所有 var 声明“提到”作用域顶部,并初始化成 undefined,然后再一句一句执行。所以:
javascript复制console.log(a) // undefined
var a = 10
等价于引擎内部先做了这一步:
javascript复制var a
console.log(a) // undefined
a = 10
第三张面孔:var 允许重复声明。
javascript复制var a = 1
var a = 2
console.log(a) // 2,没有任何报错
这个特性看着方便,实则有毒。在大型项目里,很难保证一个函数内部没有别人定义的同名变量,一旦同名,你的赋值就会悄悄覆盖它的值,而这种覆盖在运行时才会暴露,排查成本不低。
除了这三张面孔,还要补充一个容易忘的细节:在浏览器全局作用域下用 var 声明,会挂到 window 对象上。
javascript复制var globalVar = 'hello'
console.log(window.globalVar) // 'hello'
而 let 和 const 不会:
javascript复制let globalLet = 'world'
console.log(window.globalLet) // undefined
这意味着,用 var 声明的全局变量理论上可以被任何脚本通过 window.globalVar 访问或覆盖,变量名稍有撞车,页面行为就可能失控。对现代工程化项目来说,这是不能接受的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 循环、闭包与块级作用域:let 解决的不只是一个作用域问题
2.1 让无数前端感到困惑的循环闭包问题
聊完 var,咱们来看一个从 ES5 时代就存在的经典问题。
javascript复制for (var i = 0; i < 5; i++) {
setTimeout(function () {
console.log(i)
}, 100)
}
很多人第一次看到这段代码,直觉上会以为输出 0、1、2、3、4。但实际输出是 5、5、5、5、5。我第一次被这道题坑到的时候去查资料,发现解析五花八门:有说是“异步执行”的,有说是“闭包捕获变量”的,甚至有人直接建议背答案。但把作用域链条理清楚之后,原因其实非常直接:
- var 是函数作用域,所以整个 for 循环里只有一个
i; - setTimeout 的回调是异步执行的,等它们真正执行时,同步的循环早就跑完了;
- 此时唯一的
i已经递增到 5; - 五个回调访问的是同一个
i,所以全都打印 5。
在 ES6 之前,唯一的解法是用立即执行函数(IIFE)给每次迭代创建一个独立环境:
javascript复制for (var i = 0; i < 5; i++) {
;(function (index) {
setTimeout(function () {
console.log(index)
}, 100)
})(i)
}
把 i 作为参数传进函数,每次函数调用都会在栈上留下一个独立的 index,回调闭包捕获的是这个独立的 index,问题就解决了。这种做法能跑,但读起来真的很丑,而且很容易在大型代码里被误删。
2.2 每次迭代绑定:let 在 for 循环里的魔法
let 出现之后,这个问题被“根治”了——注意我说的是根治,不是缓解。
javascript复制for (let i = 0; i < 5; i++) {
setTimeout(function () {
console.log(i)
}, 100)
}
// 输出 0 1 2 3 4
为什么能做到?因为 ES6 规范规定,for 循环的循环头声明的 let 变量,会在每一轮迭代中创建一个新的词法环境(规范术语叫 PerIterationLexicalEnvironment)。你可以理解为:每一轮迭代都有属于自己的 i,当前轮的回调闭包捕获的是当前轮的 i,各轮互不干扰。
生活化地讲:var 时代相当于五个小孩共用一支笔,谁最后拿到笔谁说了算;let 时代是每个小孩手里都有自己那支笔,互不抢。
这个“每次迭代重新绑定”的特性,对 for...of 同样适用。所以下面这种写法是合法的:
javascript复制for (const item of [10, 20, 30]) {
console.log(item) // 10 20 30
}
注意,for...of 循环里可以用 const,因为每一轮迭代都是新的绑定,item 本身在单轮内没有被重新赋值。但普通的计数器循环不能用 const:
javascript复制for (const i = 0; i < 3; i++) {
console.log(i) // TypeError: Assignment to constant variable
}
因为条件更新语句 i++ 要求修改 i 的值,而 const 不允许修改绑定。这个区别是面试里比较细的点,写代码时也容易踩。
2.3 暂时性死区(TDZ):看似报错,实则是保护
再说说 let 和 const 共有的一个机制——TDZ(Temporal Dead Zone,暂时性死区)。
很多人只知道“let 声明之前访问会报错”,但不知道为什么。一个比较准确的理解是:let 声明的变量也存在“提升”,但不是 var 那种“提升并初始化为 undefined”的待遇,而是“提升了,但进入不可访问状态”,直到代码真正执行到声明那一行才完成初始化。在这个声明行之前的区域,就是暂时性死区。访问它,直接抛 ReferenceError。
javascript复制{
console.log(x) // ReferenceError: Cannot access 'x' before initialization
let x = 10
}
乍一听这很反人类:var 在声明前访问好歹给个 undefined,let 直接给红牌。但仔细想想,这个红牌其实是保护——它避免了“变量还没初始化就被读取”这种最容易产生隐蔽 bug 的情况。undefined 会让你误以为变量有个值,而 ReferenceError 会把问题当场暴露出来。
所以在实际开发里,我的态度一直是:**TDZ 不是缺陷,是语言层面的一种防御性设计。**它只是要求你遵守一个最基本的纪律——先声明,再使用。
这里还有一个写代码经常会遇到的坑,顺便提一嘴。switch 的多个 case 共享同一个块级作用域,所以在里面用 let/const 声明同名变量会报错:
javascript复制let fruit = 'apple'
switch (fruit) {
case 'apple':
let message = '这是一个苹果'
break
case 'banana':
let message = '这是一根香蕉' // SyntaxError: Identifier 'message' has already been declared
break
}
解决办法很简单:用花括号把每个 case 包成一个独立块。这个细节很容易被忽略,写多了之后你会发现自己对“块级作用域”的感知会越来越强。
3. const 的“不可变”是最大的误解:绑定、值与对象冻结
3.1 一个新手最容易踩的报错现场
在团队带新人的时候,我经常看到这种报错截图:
javascript复制const config = { url: '/api/user', method: 'GET' }
config = { url: '/api/post', method: 'POST' }
// TypeError: Assignment to constant variable.
新人会问:不是说 const 不能改吗?我明明是在重新赋值啊,为什么报错?这个问题的反直觉之处在于“const 到底不能改什么”。
一句话回答:const 保证的是“绑定(binding)不可变”,也就是变量名始终指向同一个内存地址,不允许被重新指向。它并不保证这个地址上的值不可变。
3.2 绑定的不可变 vs 值的不可变
用代码把这个区分讲透。
javascript复制const user = { name: '张三', age: 28 }
这里的不可变,指的是 user 这个名字始终指向那个堆对象,不能把它改成指向另一个对象:
javascript复制user = { name: '李四' } // TypeError: Assignment to constant variable
但是,它指向的这个对象本身,内部的属性是可以改的:
javascript复制user.name = '李四'
user.age = 29
delete user.age
console.log(user) // { name: '李四' }
这些都是合法操作。因为你在修改对象内部的属性值,而不是让 user 这个变量重新指向别人。同理,const 数组也可以 push、pop、splice:
javascript复制const list = [1, 2, 3]
list.push(4)
console.log(list) // [1, 2, 3, 4]
从规范角度看,var、let、const 三者跟内存的关系可以这么记:
| 关键字 | 作用域 | 可重复声明 | 提升特性 | 绑定可重新赋值 |
|---|---|---|---|---|
| var | 函数作用域 | 允许 | 提升并初始化为 undefined | 允许 |
| let | 块级作用域 | 不允许 | 提升但处于 TDZ | 允许 |
| const | 块级作用域 | 不允许 | 提升但处于 TDZ | 不允许 |
这张表是基础,但真正显示内功的是对表格背后设计意图的理解:var 是 ES6 之前的粗糙默认,let 是更安全的作用域方案,const 则进一步帮你锁死变量名与值的指向关系,减少团队协作时不小心改错变量的机会。
3.3 Object.freeze 能解决深层不可变问题吗
既然 const 不保证对象内部不可变,那想要“对象属性也不能改”怎么办?最直接的工具是 Object.freeze:
javascript复制const frozen = Object.freeze({ name: '张三', age: 28 })
frozen.name = '李四' // 严格模式下报 TypeError,非严格模式静默失败
console.log(frozen.name) // '张三'
但要提醒一点:Object.freeze 是浅冻结。如果这个对象的某个属性还是对象,那层对象内部依然可以改:
javascript复制const user = Object.freeze({
name: '张三',
profile: { city: '上海', score: 88 },
})
user.profile.city = '北京' // 不会报错
console.log(user.profile.city) // '北京'
想要深层不可变,要么手动递归冻结,要么引入 immutable.js / immer 这类库。在绝大多数业务场景下,我建议的做法是“浅层冻结 + 团队约定”,因为真正的深层不可变在工程上维护成本很高,很多时候它服务于特定架构或性能优化需求,普通业务用不起也不需要用。
4. 最佳使用场景:从代码规范到团队落地实践
4.1 为什么我坚持“默认 const,必须变更才用 let”
在团队里,我定的规矩非常朴素:
能用 const 就用 const,只有当变量在后续逻辑中确实需要重新赋值时,才改用 let。var 在新增代码里不允许出现。
很多人第一次听到这个规则会疑惑:那岂不是到处都是 const,遇到要改的再改成 let,来回改很麻烦?我的回答一直很明确:麻烦是小事,这个规则真正的意义在于“给代码增加一个静态的信号”。
- 看到 const,读者立刻知道这个变量从头到尾不会被重新指向,心智负担小;
- 看到 let,读者会警觉:这个变量在生命周期里会发生变更,需要留意变更点;
- 看到 var,在今天的代码里意味着历史遗留,优先级最低。
这个信号的价值,在合作开发和 code review 里体现得淋漓尽致。我有过太多次因为一个变量被某段代码偷偷重新赋值,导致线上 bug 的排查经历。如果当时就把大部分变量定义为 const,这类 bug 会在写代码那一刻就被编译器拦下来,根本轮不到线上环境。
4.2 高频场景的选型对照与代码示例
下面这份选型对照表,是我在实践中反复用过、也推荐团队使用的清单:
| 场景 | 推荐关键字 | 原因 |
|---|---|---|
| 配置对象、常量字典、基础设施实例 | const | 语义上不允许替换,防止被二次赋值 |
| 循环遍历中的当前项(for...of、数组 map/filter) | const | 每轮迭代独立绑定,语义清晰 |
| 需要累积或变化的累加器、动态计算结果 | let | 数值会被重新赋值 |
| 计数器 for (let i = 0; ...) | let | 条件更新语句需要修改 i |
| 全局 API 入口、组件引用、DOM 元素引用 | const | 不希望在页面运行中被换掉 |
| 在 async 函数中需要逐步写入结果的暂存变量 | let | 多次赋值 |
| 历史遗留的 var 代码 | 尽量重构为 let/const | 消除函数级污染 |
举一个业务中非常常见的例子,配置对象用 const:
javascript复制const API_CONFIG = Object.freeze({
baseURL: 'https://api.example.com',
timeout: 5000,
headers: { 'Content-Type': 'application/json' },
})
这样写的好处是:即使某个同事在某个角落试图给 API_CONFIG 重新赋值,一运行就直接报错,错误发生的时间和位置都能精准定位。这就是 const 提供的一道强保护,在 JavaScript 这种动态语言里尤为珍贵。
再比如状态切换标志:
javascript复制let isSubmitting = false
async function submitForm(formData) {
if (isSubmitting) return
isSubmitting = true
try {
const resp = await fetch('/api/order', {
method: 'POST',
body: JSON.stringify(formData),
})
handleSuccess(resp)
} catch (e) {
handleError(e)
} finally {
isSubmitting = false
}
}
这里 isSubmitting 需要在请求期间频繁 toggle,必须用 let。同时,resp、formData 这些在单轮逻辑中不会重新赋值的变量,用 const 再合适不过。
4.3 用 ESLint 把规范变成肌肉记忆
口头规范是最容易失效的。我在团队里推动“默认 const”的时候,发现光靠 code review 是不现实的——人总有疏忽,而且评审者也记不住所有规则。更靠谱的办法,是用工具硬性约束。
ESLint 里两条核心规则,几乎是标配:
json复制{
"extends": "eslint:recommended",
"rules": {
"no-var": "error",
"prefer-const": "error"
}
}
no-var:代码里出现 var 直接报错,从源头杜绝新增 var;prefer-const:如果变量声明后从未被重新赋值,ESLint 会提示你改用 const。
这两条规则一开,团队里所有人都被迫按规范来。新人提上来的代码,如果用了 var,或者该用 const 的地方用了 let,保存即见红,根本不需要 reviewer 一遍遍重复“这里改成 const”。规范一旦被工具化,执行成本就降到了最低。
4.4 从 var 到 const/let 的一次重构实录
最后我想分享一个真实的重构案例。以前一个订单处理函数是这么写的:
javascript复制function processOrders(orders) {
var result = []
var totalAmount = 0
for (var i = 0; i < orders.length; i++) {
var order = orders[i]
if (order.amount > 100) {
var discount = order.amount * 0.9
var finalAmount = discount + order.shipping
} else {
var finalAmount = order.amount + order.shipping
}
totalAmount += finalAmount
result.push({ id: order.id, finalAmount: finalAmount })
}
return { result: result, totalAmount: totalAmount }
}
这个版本最大的坑在哪里?var finalAmount 在同一函数里被声明了两次。虽然 JS 不报错,但你没法一眼看出这个变量到底属于哪些分支,万一另一个同事在循环后面又声明一个同名变量,前面的计算就可能被悄悄覆盖。
重构后是这样的:
javascript复制function processOrders(orders) {
const result = []
let totalAmount = 0
for (const order of orders) {
if (order.amount > 100) {
const discount = order.amount * 0.9
const finalAmount = discount + order.shipping
totalAmount += finalAmount
result.push({ id: order.id, finalAmount })
} else {
const finalAmount = order.amount + order.shipping
totalAmount += finalAmount
result.push({ id: order.id, finalAmount })
}
}
return { result, totalAmount }
}
从 for (var i = 0; ...) 改成了 for (const order of orders),从多个 var 声明改成了块级 const,totalAmount 作为唯一会变的累积器保留 let。重构完之后,每个变量的生命周期清晰到不用看逻辑都知道它会不会变。
如果你正在维护一个老项目,我的建议不是一次性把所有 var 都改掉,而是先让 ESLint 的 no-var 在新增代码里生效,再按模块逐步清理旧代码。这个演进过程比推倒重来稳得多,也更容易在团队里落地。
