ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

let 和 const 为什么取代 var?从作用域到迁移的 ES6 实战解析

let 和 const 为什么取代 var?从作用域到迁移的 ES6 实战解析

不少同学在写 JavaScript 的时候都有过这种疑问:明明var用得好好的,为什么 ES6 非要整出let和const?我之前在一段老代码里排查一个诡异的 bug,查到最后发现就是var的作用域把值给"串"了。从那次以后,我在新项目里基本不再写var,能上const就用const,实在要重新赋值再用let。这篇文章就把我理解的let和const为什么会出现、解决了哪些实际问题、以及日常使用时的边界沉淀一下,希望能给正在学 ES6 或者准备重构老代码的同学一个比较完整的参考。

先说结论:let和const不是给 JS 凭空加新功能,而是把原来 ES5 里var那套"过于宽松"的声明规则收紧,让变量作用域、声明时机、赋值语义都变得可控。理解这点之后,再去看let和const的每一项特性,都会发现它对应着一个实实在在的坑。

1. var 的三个历史包袱:为什么 ES5 时代的声明方式总出问题

在正式聊let和const之前,得先把var的老底翻出来。很多刚从其他语言转过来学 JavaScript 的同学,第一反应是var看着挺正常的,声明变量嘛,哪个语言没有。但var在实际运行时的表现,和大多数人的直觉偏差非常大。

1.1 函数作用域造成的"变量越狱"

var的作用域是函数级别的,不是块级别的。这意味着你在一个if块、for循环、while循环里面用var声明的变量,在块外面照样能访问到。

function testScope() { if (true) { var message = "hello"; } console.log(message); // "hello" —— 在 if 外面居然还能访问 }

这个行为初看好像无所谓,但在真实项目里会造成很多麻烦。比如你在for循环里定义了一个临时索引i,按理说循环结束i就该"消失"了,但var会把它留在函数作用域里。如果这个函数里后面还有一个for循环也用了i,两个循环就共享同一个变量,稍不注意就会出现循环嵌套或者异步回调引用索引值错乱的问题。

1.2 变量提升:声明"浮"到了顶部

var声明的变量会被提升到当前作用域的顶部,赋值操作留在原地。这里有个很经典的认知误区:很多人以为"变量提升"就是变量在顶部已经被定义了,实际上只是"声明"被提上去了,初始化并没有。

console.log(a); // undefined,不是报错 var a = 10;

这段代码在大多数编程语言里都是 ReferenceError,但在 ES5 的var时代,它打印的是undefined。原因是引擎在执行这段代码时,实际看到的是这样:

var a; console.log(a); a = 10;

这种"先访问后声明不报错而是给个 undefined"的行为,防住了你直接崩溃,但也掩盖了很多逻辑错误。比如某个变量依赖前面的计算,因为提升,你在这个计算之前先读取了它,结果拿到 undefined,接着带着 undefined 继续跑,最后算出一堆 NaN 或者错误数据,而且很难排查。

1.3 重复声明不报错:后写的覆盖先写的

var允许在同一作用域内多次声明同一个变量名,编译器和运行时都不管,直接覆盖。

var count = 1; var count = 2; var count = 3; console.log(count); // 3

在小项目里,这种覆盖可能碰巧是你想要的效果。但项目一大,多人协作,一个公共函数里你声明的变量和别人声明的变量重名了,后加载的那个直接覆盖先加载的,数据就被悄悄篡改了。更可怕的是它不报错,你根本不知道哪一行覆盖了哪一行。

1.4 这三个包袱叠加的后果

把函数作用域、变量提升、允许重复声明这三个特性放在一起,就会看到一段典型的"老式 JavaScript"代码很容易出现这种组合:函数内多个var声明被提升到顶部,所有块内变量全部共享,重名变量互相覆盖。代码的静态可读性非常差,你在一个函数里从上往下读,根本不能确定某个变量在这个位置到底是原始值还是已经被某个if块修改过。

我当时排查那个老代码 bug,就是在循环过程中var声明的临时值被另一个异步回调给改了,回调还没执行完,循环下一次迭代用到临时值时已经是错误值。这类问题本质上就是var的作用域和声明方式太"松"导致的。

2. 块级作用域:let 和 const 的"圈地运动"

let和const引入了真正的块级作用域。所谓"块",就是{}包裹的区域,包括if块、for循环、while循环,以及单纯用{}圈起来的代码段。这个特性看起来只是把作用域粒度变细了,但实际影响非常深远。

2.1 变量不再跑出块外

同样是刚才的例子,用let声明:

function testScope() { if (true) { let message = "hello"; } console.log(message); // ReferenceError: message is not defined }

message在if块外面直接无法访问,访问就报错。这种"越界立刻报错"的行为,比var的"外面能访问但可能拿到 undefined"要健康得多。你在块内定义的变量,块一结束,作用域就干干净净地收缩,后面的代码不会受到任何影响。

2.2 for 循环闭包问题:一个彻底翻篇的历史难题

在 ES5 时代,下面这个写法是无数新手和老手都踩过的经典坑:

for (var i = 0; i < 5; i++) { setTimeout(function() { console.log(i); }, 100); }

这段代码不会有同学以为会打印 0 1 2 3 4,实际上打印的是 5 5 5 5 5。因为var i是函数作用域的同一个变量,循环结束之后i的值已经变成 5,所有定时器回调共享同一个i,所以拿到的都是 5。

之前解决这个问题要借助 IIFE(立即执行函数表达式)或者用let包一层。现在直接用let写:

for (let i = 0; i < 5; i++) { setTimeout(function() { console.log(i); }, 100); } // 0 1 2 3 4

注意,这个"每次循环声明一个独立的i"的行为是let在for循环中的特殊机制:每一轮迭代都会创建一个新的绑定,每个回调拿到的都是自己那一轮的i。这个机制不是块级作用域的天然属性,而是语言规范特意为for循环里的let定制的。理解这一点很关键,因为普通的{}块不会这样的"按迭代复制"行为。

2.3 块级作用域带来的临时命名空间

除了解决循环闭包,块级作用域还有一个很实用的用法:在函数任意位置创建一个临时命名空间,把一批局部变量圈在块里,用完就走。

function processData(data) { { const normalized = data.map(item => item.value).filter(Boolean); console.log(normalized); } // 这里访问 normalized 会报错 // 继续处理其他逻辑 }

这种做法虽然不算常见,但在长函数里可以把一些临时变量、中间计算结果隔离起来,避免污染函数的整个作用域。对于代码阅读者来说,作用域越窄,推断变量状态就越容易。

3. 暂时性死区与"不提升":ES6 的严格纪律

let和const声明的变量不是没有提升过程,而是提升了声明但在初始化之前被锁在一个叫"暂时性死区"(Temporal Dead Zone,简称 TDZ)的状态。这个概念听起来晦涩,其实理解起来很简单,也很重要。

3.1 TDZ 是什么

在变量声明语句执行之前,这个变量处于一个"存在但不可访问"的状态。在这个状态下访问变量,会直接抛出 ReferenceError,而不是返回 undefined。

console.log(b); // ReferenceError: Cannot access 'b' before initialization let b = 5;

这个行为与var形成鲜明对比。var是在作用域一开始就初始化为 undefined,let是进入了块但未初始化,访问就报错。换句话说,let和const依然存在提升,只是把提升后的未初始化状态暴露成了引用错误。

3.2 TDZ 的执行顺序依赖

TDZ 最直观的价值在于:它强迫变量先声明后使用,而不是"反正 undefined 也能跑",把逻辑错误提前暴露出来。

function calculate(...args) { const first = args[0]; // 如果这里访问 helper,会因为 TDZ 报错 const helper = createHelper(); // 正确的用法 }

如果你习惯在函数里"边用边声明",TDZ 会直接拦住你。这不是语言在和你作对,而是在帮你理清依赖关系:一个变量必须初始化后才能被使用,这种顺序依赖本来就不该靠"运行时碰巧没报错"来维持。

3.3 TDZ 对 typeof 的影响

typeof也有一个因为 TDZ 产生的怪行为:对处于 TDZ 的变量使用typeof,同样会抛 ReferenceError。

console.log(typeof x); // ReferenceError let x;

这个细节很多资料不会提,但在调试时容易踩到。因为typeof在 ES5 时代对未声明的变量特别宽容,返回 "undefined"。但到了 TDZ,它就不宽容了。这不是缺陷,而是为了保持"TDZ 内任何访问都不安全"的语义一致性。

3.4 TDZ 的边界:块入口与循环条件

TDZ 的判定边界是整个块级作用域。你在一段{}的中间声明let,那么在声明语句之前的所有代码里访问该变量都会报错。包括for循环的初始化子句、条件判断里的访问。

for (let i = 0; i < 10; i++) { // 在循环体里可以访问 i } // 循环外访问 i 报错

这里有个非常容易忽略的点:for循环的括号部分(初始化和条件更新)和循环体并不完全属于同一个作用域。标准里说的是,循环括号内有一个"每迭代绑定",循环体内的let声明又是另一个块级作用域。实际开发中,你只要记住for循环内声明的变量在循环外不可访问就够了,太细节的规范具体实现不用记。

3.5 TDZ 的实际价值:从"容错"转向"纠错"

在我个人看来,TDZ 是 ES6 声明体系里最有"态度"的一个设计。它取消了 JavaScript 对"先使用后声明"的纵容,把这个错误从运行时的 undefined 悄悄传播,变成了开发阶段的直接报错。这种"让错误尽早暴露"的设计哲学,和 TypeScript 的静态检查其实是一脉相承的。虽然两者层面不同,但目标一致:在代码跑起来之前发现更多问题。

4. const 到底"常量"在哪里:约束与误区的边界

const可能是误解最多的一个关键字。很多人看到const就以为是"常量不可变",会下意识认为声明之后值就不能改了。但实际上,const约束的是"绑定"的不可变性,不是"值"的不可变性。

4.1 绑定不可变 vs 值不可变

先看一个最简单的例子:

const PI = 3.14; PI = 3; // TypeError: Assignment to constant variable.

这个报错说明绑定不可重新赋值。但如果你声明一个对象:

const config = { theme: "dark", lang: "zh" }; config.theme = "light"; // 允许 config.lang = "en"; // 允许

config指向的这块内存中的内容可以被随意修改,只要不把config本身重新指向其他对象就不会报错。很多人把这个现象叫"const 是浅不变的",听起来好像 const 做了些什么,其实它只是把"我能改绑定的引用"这个能力去掉了。

4.2 const 数组也能改

数组同理:

const list = [1, 2, 3]; list.push(4); // 允许 list[0] = 9; // 允许 // list = [5]; // TypeError

所以严格来说,const声明的是"指向不可变"的引用变量。它适合所有"声明之后不打算重新赋值"的场景。而对象内部和数组元素是否允许变,取决于你用的是普通对象还是Object.freeze/ 不可变数据结构。

4.3 为什么这个边界会导致踩坑

我在代码评审里见到过不少这样的问题:有人把const声明的对象当成"绝对不可变",然后在另一个模块里试图修改它的某个字段,结果行为表现得和预期不一样。这不是const的错,而是对语义理解有偏差。如果你真的需要不可变数据,应该配合Object.freeze:

const frozenConfig = Object.freeze({ theme: "dark", lang: "zh" }); frozenConfig.theme = "light"; // 严格模式下会报错,非严格模式静默失败

注意Object.freeze也是浅冻结,嵌套对象内部的属性还是可以改。要想深层冻结,得自己写递归或者引入处理不可变数据的工具库。所以const+Object.freeze并不是"绝对不可变"的银弹,得理解到哪一层。

4.4 const 的推荐度其实是三个里面最高的

虽然const有这些边界,但它恰恰是现代 JavaScript 项目里推荐度最高的声明方式。原因很简单:绝大多数变量在声明之后都不会被重新赋值。函数参数、配置对象、模块导入、临时计算值,这些 80% 以上的场景都属于"只赋值一次"。用const的好处是,以后某个重构场景里如果有人不小心重新赋值了,代码会直接在那一行报 TypeError,让你意识到这里不该改。

const result = fetchData(); // 如果后面有人写 result = somethingElse,就是明显的逻辑错误

let只留给真正需要重新赋值的场景:计数器的累加、循环变量、状态切换开关等。这种基于语义的区分,让代码一眼看上去就知道哪些变量是会变的,哪些是稳定的。对阅读者来说,变量稳定性的信息就是免费的文档。

5. 从 var 到 let/const 的迁移踩坑:我的实操记录

把老代码里的var全部替换成let或者const,不是一个机械的字面替换过程。这个迁移我做过不止一遍,每次都能遇到一些看起来没问题、换完就炸的场景。

5.1 全局作用域的 var 和 let/const 行为差异

在浏览器环境下,全局声明的var会挂到window上,变成全局对象的一个属性:

var globalVar = 1; console.log(window.globalVar); // 1

但全局声明的let和const不会挂到window上:

let globalLet = 2; console.log(window.globalLet); // undefined

如果你在老代码里依赖window.xxx来访问某个全局变量,把var改成let或const之后,这个访问路径就断了。这种问题的隐蔽之处在于它不会直接报错,而是返回 undefined,让后续逻辑跟着崩。迁移前先用grep搜一下全局变量的使用方式,确认没有依赖window属性访问的地方,再动手替换。

5.2 闭包场景的 var → let 变更,行为可能完全反转

前面提到过for循环里的var会造成闭包共享同一个变量。如果你把var改成let,闭包捕获的值会变成每轮独立的副本。这通常是你期望的结果,但如果老代码里的闭包逻辑本来就依赖了"共享同一个变量"这个特性,替换后行为就会变化。

for (var i = 0; i < 3; i++) { arr.push(function() { return i; }); } // 执行结果是 [3, 3, 3] for (let i = 0; i < 3; i++) { arr.push(function() { return i; }); } // 执行结果是 [0, 1, 2]

老代码里如果故意靠共享最终值来做某种计数逻辑(虽然很怪但确实存在),替换成let之后它的行为会变。所以迁移时遇到闭包就要格外小心,最好是逐个确认业务逻辑,而不是批量替换。

5.3 变量提升习惯的代码,替换后立即报错

有些老代码为了利用变量提升,会在函数顶部先var声明一堆变量,然后在下面各个分支里赋值,最后统一使用。这种代码迁移到let后,如果某个分支赋值跑在访问之前,就没问题;但如果某个访问发生在声明之前,直接 ReferenceError。

console.log(config); // ReferenceError let config = loadConfig();

遇到这种结构,正确做法是重构:把声明和初始化尽量放到使用前一步,或者干脆提取成小函数,而不是强行保留原结构然后被 TDZ 打哭。

5.4 从类型检查工具和编译工具的角度看迁移

现代开发基本都会用编译工具把 ES6+ 转成 ES5 来兼容老浏览器,或者用类型检查工具来静态约束。在迁移时,要注意有些编译配置里对"变量声明被转换"的行为有开关,比如 Babel 的loose模式。

在loose模式下,let会被直接转成var,TDZ 检查会丢失。虽然产物体积更小、兼容性更好,但也丢了let/const的约束价值。我的建议是能开spec模式就不要图省事开loose,除非项目有严格的兼容性体积要求。

5.5 我的迁移规则

经过几次迁移,我总结了一套比较稳的替换策略:

  1. 先把所有const能覆盖的变量(声明后永不重新赋值)标记出来,优先替换。
  2. 剩下的变量里面有明确重新赋值需求的,改成let。
  3. 三个坑重点看:全局作用域、闭包捕获、变量提升依赖。
  4. 替换完跑一遍完整测试 + 静态检查 + 类型检查,不要只跑功能测试。
  5. 注意编译目标,别在 loose 模式下丢掉保护。

我在实际项目里的体会是,let和const的核心价值不在于"新增了两个声明方式",而在于给了 JavaScript 开发者一种表达意图的语法。以前所有变量都是var,你分不清哪些是只读的、哪些会变化。现在const明确说"我不打算变",let明确说"我后面会重新赋值"。这种意图让代码的自解释性大幅提升。最后再分享一个小技巧:新手可以先强制自己所有变量默认写const,遇到需要重新赋值再改成let。坚持一两个项目之后,你会发现自己写代码之前会多思考一步"这个变量真的会变吗",这个思考本身就是收获。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进