上周帮一位朋友看代码,一个列表渲染的组件,里面十几次声明全用var,循环里绑了点击回调,点了永远拿到最后一项。他第一反应是框架的锅,排查半小时才发现是变量声明的问题。这类事我在过往的项目里见过太多次——var、let、const这三个关键字,语法层面五分钟就能背下来,但真正理解它们背后的作用域模型、提升机制、绑定方式,再落到实际项目里做出一致的选择,是需要踩过一些坑之后才能形成的判断力。这篇内容就把这三者的差异掰开讲透:从语言设计的初衷讲到执行上下文和词法环境,从暂时性死区讲到循环里的特殊处理,再落到工程化配置和代码审查的落地做法。不管你是刚学 JavaScript 一两周、还在纠结什么时候用const的新手,还是写了几年代码、想让团队规范更硬一点的老手,下面的内容应该都能找到能直接拿去用的东西。
1. 三种声明方式的本质差异:先弄清楚它们各自在干什么
1.1 从一段不起眼的老代码说起
我手上有一份很典型的遗留代码,简化后大致长这样:
var handlers = []; for (var i = 0; i < 3; i++) { handlers.push(function () { console.log(i); }); } handlers.forEach(function (fn) { fn(); });三个函数依次打印,输出是3 3 3,不是0 1 2。很多人第一次遇到会觉得莫名其妙,但只要把var换成let,输出立刻变成0 1 2。同样的循环体、同样的回调,差别只在一个关键字上。
这个现象背后牵扯的东西远超"关键字替换"本身:var声明的变量绑定在函数作用域上,整个循环共用一份i;而let在每次迭代时都会创建一个新的绑定,每个回调捕获的是各自那一轮的副本。前者是函数级作用域的必然结果,后者是块级作用域加上循环语义的特殊设计。理解了这个差异,你就明白这三个关键字不是"新旧替代"的关系,而是三套不同的绑定规则。
1.2 三个关键字的核心差异对照
先把结论层面能对比的东西列清楚,后面再逐个展开原理。下面这张表是我自己整理常用的一张,基本覆盖了日常判断时会用到的维度:
| 对比维度 | var | let | const |
|---|---|---|---|
| 作用域 | 函数作用域(或全局) | 块级作用域 | 块级作用域 |
| 是否提升 | 是,初始化为 undefined | 是,但不初始化(TDZ) | 是,但不初始化(TDZ) |
| 重复声明 | 允许 | 报错 | 报错 |
| 必须初始化 | 否 | 否 | 是 |
| 可否重新赋值 | 可以 | 可以 | 不可以(绑定不可变) |
| 全局声明时挂到全局对象 | 是 | 否 | 否 |
| 循环中创建独立绑定 | 否 | 是 | 是 |
这张表里最容易被误解的是const那一行的"绑定不可变"。很多人以为const声明的对象就完全冻结了,实际上它约束的是变量与值之间的那条引用线,对象内部的属性想怎么改都行。这一点我后面会单独用一节讲清楚,因为围绕它产生的 bug 相当高频。
另一个容易被忽略的是最后两行。全局声明时var会把变量挂成全局对象的属性,而let和const不会——它们存在于全局词法环境里。这个差异在浏览器里会直接影响window.xxx的判断,在做特性检测或者和老脚本共存的时候会咬人。
1.3 语言为什么要设计成三套规则并存
var是 JavaScript 诞生之初就有的,那个年代的脚本都很短,作用域混乱带来的问题不明显。等到前端工程体量上来,函数作用域导致的变量泄漏、循环闭包、重复声明覆盖等问题集中爆发,语言层面才在 ES2015 引入了let和const。
let和const的设计目标很明确:把作用域收窄到块,让变量的生命周期跟代码结构对齐;同时用"声明前不可用"的规则堵住提升带来的隐式错误。而const更进一步,用编译期的约束表达"这个绑定我只赋值一次"的意图。这三者不是互相替代的竞争关系,var保留是为了向后兼容,新代码里基本没有它的位置。
我个人的判断标准很朴素:新写的代码,默认const;确实需要重新赋值时才降级为let;var只在维护老代码、或者明确的兼容场景下出现。这个顺序不是教条,而是让"这个变量会不会变"成为写代码时必须先想清楚的一件事,长期看对可读性的提升非常明显。
2. 作用域机制拆解:函数作用域与块级作用域的真实边界
2.1 作用域在代码执行前就已经确定
很多人以为作用域是代码运行到哪算到哪,实际上 JavaScript 采用词法作用域,作用域链在代码编译阶段就已经确定,跟函数在哪里被调用没关系,只跟它在哪里被定义有关系。这一点是理解三种声明方式差异的地基。
拿下面的例子来说:
const name = 'outer'; function sayName() { console.log(name); } function run() { const name = 'inner'; sayName(); } run(); // 输出 outersayName定义在全局,它的作用域链就指向全局的name,即使在run内部调用也不会看到run里的局部变量。这个特性跟声明关键字无关,但对理解提升和死区很重要——因为引擎在进入一段代码时,就已经把该段作用域里的声明都登记好了,只是初始化的时机不同。
函数作用域和块级作用域的区别就落在这个"登记"的粒度上。函数作用域意味着只要在函数体内,无论嵌多深的if、for、try,var声明的变量都登记在函数这一层;块级作用域则是每一对花括号都是一个独立的登记单元,let和const跟着块走。
2.2 变量提升:提升的是声明,不是赋值
"提升"这个翻译容易让人以为变量被物理地移到了代码顶部,实际情况是引擎在进入作用域时先扫描一遍声明并登记,赋值操作仍然留在原位置执行。对于var,登记时会被初始化为undefined,所以下面这段代码不会报错:
console.log(a); // undefined var a = 1; console.log(a); // 1let和const同样被登记了,但登记后处于"未初始化"状态,访问它会抛ReferenceError。这个"已登记但未初始化"的状态区间,就是后面要讲的暂时性死区。所以准确的说法是:三者的声明都被提升了,区别在于初始化时机——var在进入作用域时初始化为undefined,let/const要等到声明语句执行时才初始化。
我见过不少简历上写"了解变量提升"的人,问一句"let有没有提升"就答不上来。这个问题的正确答案是"有提升,但不初始化",答"没有提升"其实是错的理解,会影响到后面理解 TDZ 和函数声明的位置关系。
2.3 一个容易忽略的差异:全局声明落在哪
在浏览器环境的全局代码里,var声明的变量会成为全局对象的属性,let和const不会:
var a = 1; let b = 2; const c = 3; console.log(window.a); // 1 console.log(window.b); // undefined console.log(window.c); // undefined这个差异在实际项目里有两种典型影响。一是判断某个变量是否已经声明时,用'a' in window只对var有效;二是引入多个脚本时,如果两个文件都用var在全局声明同名变量,后加载的会覆盖前面的且不报错,而用let/const会直接抛重复声明错误——从工程角度讲,后者反而是好事,问题能暴露在启动阶段而不是运行到一半才出诡异结果。
提示:在模块化环境里(ES Module),顶层声明不再是全局对象的属性,这个差异的影响会小很多。写老式的多脚本页面时才需要特别注意。
3. 暂时性死区:let 和 const 最容易被误解的行为
3.1 TDZ 的准确定义与触发范围
暂时性死区(Temporal Dead Zone,简称 TDZ)指的是从进入作用域到声明语句执行之间的那段区间,在这段区间里访问变量会抛ReferenceError。它不是什么特殊机制,就是"已登记但未初始化"这个状态的直接结果。
看这段代码:
function demo() { console.log(x); // ReferenceError: Cannot access 'x' before initialization let x = 1; }关键在于,报错不是发生在let x那一行,而是发生在访问的那一行。这跟var的行为形成了鲜明对比——var在同样位置会安静地输出undefined,把问题拖到后面才以"值莫名其妙是 undefined"的形式暴露出来。TDZ 的价值就在这里:把时序错误变成显式报错,越早失败越好。
TDZ 的范围严格以块为单位。同一个变量在外层块里正常存在时,进入内层块后如果内层有同名let声明,内层块从头到尾都是 TDZ,连外层那个变量也访问不到:
let x = 'outer'; { console.log(x); // ReferenceError let x = 'inner'; }这个行为第一次见会觉得反直觉,但它保证了一件事:块内的变量名在整个块内语义一致,不会出现前半段指外层、后半段指内层的混乱。
3.2 typeof 在死区里的表现
typeof对未声明的变量一直很宽容,不会报错,返回'undefined'。但遇到 TDZ 里的let/const变量,它会照样抛错:
console.log(typeof notDeclared); // 'undefined' console.log(typeof inTDZ); // ReferenceError let inTDZ = 1;这个细节值得记一笔,因为很多老代码用typeof someVar !== 'undefined'来做安全判断,如果那个变量是用let声明但在当前位置还处于死区,这个判断会直接炸掉。我遇到过一回,某个按需加载的模块里用typeof检测全局配置对象,那个对象改成let声明之后判断逻辑在特定加载顺序下就崩了,排查花了不小功夫。
3.3 死区其实是免费的错误防线
从使用体验上看,TDZ 像是个麻烦制造者,但换个角度它是语言给你的免费防线。它强制你把声明写在首次使用之前,避免了"变量先用了,值从哪来全靠猜"的情况。
我的习惯是把一条规则刻进肌肉记忆:声明尽可能靠近首次使用的位置,不要为了"看起来整齐"把一堆声明堆在函数开头。const尤其适合这样写,变量在用到的地方才出现,读者从上往下读不需要来回跳。这样写出来的代码,TDZ 永远不会成为问题,因为它根本不会被触发。
4. var 的经典坑位与老代码兼容策略
4.1 循环变量的闭包陷阱
前面开头提到的那个例子,是var最出名的坑。根源在于var只有函数作用域,循环体里的回调共享同一个变量。在 ES5 时代大家的解法是套一层立即执行函数,人为造一个函数作用域把值存下来:
var handlers = []; for (var i = 0; i < 3; i++) { (function (index) { handlers.push(function () { console.log(index); }); })(i); }这种写法现在看起来已经很有年代感了,但维护老项目时还是会大量遇到。我更推荐的处理方式是:在改造范围内,把这个var直接换成let,然后把 IIFE 拆掉。改完逻辑等价、代码量砍掉一半,可读性提升非常直接。判断能不能直接换的标准是——这个循环变量有没有在循环外被引用;如果循环外还要用,就另外声明一个变量承接。
4.2 重复声明与作用域泄漏
var允许在同一作用域内重复声明同一个变量,后一次声明不报错,赋值直接覆盖:
var count = 1; var count = 2; console.log(count); // 2在几百行的函数里,这种覆盖很容易造成"我明明在上面赋过值了"的困惑。更麻烦的是作用域泄漏——在for或if块里用var声明的变量,出了块还能访问:
for (var j = 0; j < 5; j++) { /* ... */ } console.log(j); // 5这种泄漏在函数体很长的时候,会让变量名冲突的概率大幅上升。我接手过一个两千多行的工具函数,里面有三处不同逻辑都用了var result,互相覆盖的结果是某个边界条件下返回值莫名其妙变成null,查了大半天。
4.3 接手老项目时的现实处理手法
完全不碰var是不现实的,尤其是一些年代较久的库和打包产物。我的处理顺序是这样的:业务代码里自己新写的部分,一律不用var;改动到某段老逻辑时,顺手把该函数内的var换成let/const,但控制改动范围,不要一次重构整个文件;第三方库和构建产物保持原样,不去动。
判断换不换的一个实用技巧是看这个变量有没有被闭包捕获。如果它在循环里被回调引用,那几乎肯定有坑,优先处理;如果只是普通的临时变量,风险低,跟着改动顺手换掉就行。这样渐进式推进,既控制了回归风险,又不会让技术债无限堆积。
注意:替换
var为let时,要确认没有依赖"变量提升后值为 undefined"的行为。有些老代码会先判断if (flag === undefined)再赋值,换成let后这段会直接抛 TDZ 错误,需要连同判断逻辑一起调整。
5. let 与 const 的选型逻辑与落笔规范
5.1 const 约束的是绑定,不是值
这是const最需要说清楚的一点。const声明的变量不能在之后被重新赋值,但它指向的对象内部完全可以变:
const user = { name: 'A' }; user.name = 'B'; // 合法 user.age = 18; // 合法 user = { name: 'C' }; // TypeError: Assignment to constant variable.用一句话概括:const锁定的是"这个变量名永远指向这块内存",不是"这块内存里的内容不许动"。理解这一点之后,const的适用面就宽得多了——绝大多数你声明后不再重新赋值的变量,包括对象、数组、函数、类,都可以用const。
我审代码时看到有人写let config = {...}然后全程没重新赋值,会直接建议改成const。这种改动零风险,但能明确表达意图:读者看到const就知道往下不用再找"这个值什么时候被改的"了。
5.2 对象和数组的"真不可变"怎么写
如果业务上确实需要连内部属性都锁死,const不够用,得靠Object.freeze:
const config = Object.freeze({ apiBase: '/api', timeout: 5000 }); config.timeout = 10000; // 静默失败(非严格模式),严格模式下抛 TypeErrorObject.freeze是浅冻结,嵌套对象里的属性还是能改。要深冻结就得递归处理,或者干脆用不可变数据的方式——每次修改都产生新对象,而不是改原对象。这一套在状态管理里用得很多,核心思路就是"永远不原地改,永远返回新副本"。
我的经验是:普通业务配置用const加约定就够了,不必层层freeze,性能上也有开销;只有在跨模块共享、容易被误改的核心配置上,才值得上冻结。过度使用冻结反而会让调试变麻烦,因为改动会静默失败。
5.3 let 的使用边界与命名习惯
let就应该留给"确实要重新赋值"的场景,典型的有:循环计数器、累加器、需要先声明后按条件赋值的变量、状态切换标记。这些场景用let语义准确,读代码的人一眼就知道它会被改。
有个小习惯我坚持了很多年:能在声明时就确定值的,绝不写成"先let声明、再在下面几行赋值"。这样写除了多两行代码,还容易在中间插入逻辑时忘记赋值导致undefined。如果确实要先声明后赋值(比如要包在try里),我会给一个明确的初始值,比如let result = null;,而不是留空。
6. 常见报错与排查技巧实录
6.1 报错信息速查表
实际开发中遇到的报错其实就那么几种,整理成表之后排查速度会快很多:
| 报错信息 | 典型原因 | 处理方向 |
|---|---|---|
Cannot access 'x' before initialization | 在声明语句之前访问了let/const变量 | 把声明上移,或检查是否有同名变量遮蔽 |
Identifier 'x' has already been declared | 同一作用域内重复声明 | 检查是否与参数、其他块内声明重名 |
Assignment to constant variable | 对const变量重新赋值 | 改用let,或改成修改对象属性 |
x is not defined | 变量根本没声明,或拼写错误 | 检查拼写与作用域范围 |
Missing initializer in const declaration | const声明没给值 | 补上初始值,或改用let |
这张表里前两个最容易被混淆:一个是时序问题,一个是重名问题。看报错里的关键字就能区分,before initialization是死区,already been declared是重复声明。
6.2 for 循环里 let 的特殊行为
let在for循环里的行为值得单独说。规范里的处理是:每次迭代都会创建一个新的词法环境,把上一轮的值复制过去,所以每个回调捕获的是各自那一轮的变量。这就是为什么把var换成let之后闭包问题自动消失了。
但有个细节很多人不知道:在for循环体的开始处,引擎会再做一次复制,所以即使在循环体内修改循环变量,也不影响for语句头部的更新逻辑:
for (let i = 0; i < 3; i++) { i = 100; console.log(i); // 100, 100, 100 }这段的输出是三次100,循环仍然只跑三轮。原因是循环体里改的是当轮的那个副本,i++作用在循环头自己的变量上。这个行为平时基本用不到,但面试里会问,理解了它说明你对"每轮独立绑定"这件事是真的搞清楚了。
6.3 几处容易踩的语法细节
第一个是switch的case里声明变量。整个switch块共享一个块级作用域,在case A里写let x,在case B里又写let x,会直接报重复声明。解法是用花括号把每个case包起来,形成独立的块。
第二个是try/catch的参数名。catch (e)里的e是一个独立的绑定,在catch块里不能再用let e声明。改用catch (err)或者直接改内部变量名就行。
第三个是函数参数与块内声明重名。非严格模式下var可以和参数同名,let不行,会报错。这类报错信息很直白,但出现在压缩后的代码里时定位会麻烦,建议开发阶段就开严格模式,让问题在最前面暴露。
7. 工程化落地:把规则交给工具而不是靠自觉
7.1 ESLint 规则该怎么配
靠人肉审查保证声明规范不现实,交给工具更稳。我常用的几条规则如下:
{ "rules": { "no-var": "error", "prefer-const": ["error", { "destructuring": "all" }], "no-redeclare": "error", "block-scoped-var": "error", "no-use-before-define": ["error", { "variables": true }] } }no-var直接禁掉var,是最省事的一条;prefer-const会在变量从未重新赋值时提示改成const;block-scoped-var用来发现把var当块级变量用的写法。这几条组合起来,基本能覆盖掉绝大多数声明相关的隐患。
prefer-const的destructuring: "all"这个配置值得说一下。解构赋值里如果只有部分字段会变,默认配置可能不会提示改成const,设成all之后要求所有被解构的变量都满足条件才提示,避免误报引发的频繁打断。刚接入时可能会冒出一大堆提示,建议分模块逐步处理,别一次性全改,否则 review 成本会压垮进度。
7.2 团队约定与代码审查清单
工具管语法,约定管语义。我在团队里推过一份很短的清单,就四条:新代码不允许var;默认const,需要重赋值才用let;声明位置尽量靠近首次使用;循环里的回调如果引用了循环变量,必须确认用的是块级声明。这四条贴在 review 模板里,新人上手两周基本就能形成习惯。
最后分享一个我自己一直在用的小技巧:当你犹豫某个变量该用let还是const时,先写const,等编辑器或 ESLint 报错了再改let。这个顺序的好处是,每一次从const改成let都是一次有意识的决定,你会被迫问自己"它真的需要变吗",而反过来先写let再想改成const,往往就懒得改了。半年下来,代码里const的比例会明显上升,函数体也会跟着变短。