ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

JavaScript变量声明:var、let、const的作用域与提升机制详解

JavaScript变量声明:var、let、const的作用域与提升机制详解 我刚入行那阵子写JavaScript基本靠猜今天用var明天用let遇到const更是稀里糊涂。直到有一次在团队代码评审里被前辈一句你知道这段代码里的变量为什么会是undefined吗问得哑口无言才痛下决心把变量声明这件事彻彻底底搞明白。后来我带过不少新人发现几乎所有人都会在var、let、const这三兄弟身上栽跟头。说到底JavaScript变量声明是整个语言最基础也最容易出错的环节它牵扯到作用域、提升、闭包、内存存储等一系列底层机制搞不懂它后面学什么都像在沙地上盖楼。这篇文章不讲虚的就围绕变量声明这条主线从内存原理讲到实际开发中的选型策略再把你早晚会遇见的报错和坑一个个拆开揉碎。无论是刚入门的新手还是写了两年想补基础的前端看完都能直接用到项目里。1. 变量声明到底在声明什么先理顺存储与命名的基本逻辑1.1 变量只是名字真正干活的是内存里的容器很多初学者最大的误区是把变量当成一个盒子往里装东西就行。实际上更准确的理解是变量是一个命名引用它指向内存中的某块存储区域。你写let age 18本质上是做了两件事在内存里划分一块区域存下数字18然后给这个区域贴上一个叫age的标签。之后你操作age其实是在操作那块内存里的数据。这个理解特别重要因为var和let看似只是关键字不同底层行为差异极其大。var声明的变量会绑定到最近的函数作用域而let和const绑定到最近的块级作用域。再往深了说变量声明还涉及一个创建-初始化-赋值的三阶段过程。var的提升会把声明和初始化同时提升而let/const只提升声明不提升初始化这就导致了一个非常著名的机制——暂时性死区。先记住这个结论后面我会详细展开。1.2 三种声明方式的出生时间线JavaScript从ES6开始正式把变量声明分成三套体系varES5时代的产物函数作用域存在变量提升允许重复声明。letES6引入块级作用域不存在变量提升准确说是存在但受暂时性死区限制不允许重复声明。constES6引入块级作用域声明时必须初始化且绑定不可重新赋值。你可以把这三兄弟理解成三种租房合同var是那种先住进去再说手续后补的宽松合同let是按房间块划分签了才生效的规范合同const则是只租不买钥匙只有一把而且不能换锁的严格合同。日常开发中到底选哪种取决于你对这个变量后续如何使用的预期。2. var全面拆解函数作用域、变量提升与重复声明的历史功过2.1 var的三大特性每个都是把双刃剑先看一段真实的代码function testVar() { console.log(a); // undefined不是报错 var a 10; if (true) { var b 20; } console.log(b); // 20if块没困住var变量 } testVar();这里有几个关键行为要拆开讲第一个特性函数作用域。var声明的变量作用域范围是当前函数。你在if、for、while这些块里用var声明变量它照样能穿透到函数外层。上面代码里b在if块内声明函数内任何地方都能访问。这个特性在ES5时代是唯一选择但到了ES6它就成了隐患来源块级逻辑里临时用的变量一不小心就污染了整个函数。最常见的坑是for循环里的索引变量i循环结束后i依然存在于函数作用域中。第二个特性变量提升。这是让无数新手困惑的机制。JavaScript引擎在执行函数时会先把所有var声明提升到函数顶部但仅仅是提升声明赋值还留在原处。所以上面代码里第一行打印a得到undefined而不是报ReferenceError。这个过程非常像先贴标签后放东西标签在函数一开始就贴好了但东西还在后面才搬进来。第三个特性允许重复声明。var可以反复声明同一个变量后一个声明会覆盖前一个。这在老代码中比较常见但也带来麻烦你不知道这个变量到底在第几行被赋了值调试起来像大海捞针。2.2 var的经典危害场景循环与闭包全军覆没var最著名的问题就是在循环里配合异步函数或者事件回调时var的共享绑定问题。核心原因在于var的作用域是函数级的循环并不会为每次迭代创建新的作用域所以每次迭代里的变量i指向的都是同一块内存。for (var i 0; i 3; i) { setTimeout(function () { console.log(i); // 输出 3 3 3 }, 100); }输出结果为什么是3、3、3而不是0、1、2因为循环结束后i已经被加到3三个setTimeout回调执行时访问的都是同一个i拿到的自然是最终值3。这在开发中造成的后果非常隐蔽——你以为是每个回调捕获了当时的i其实它们全抓着一个共享变量。这个坑的经典解法有两种。第一种是用立即执行函数IIFE把i锁进每次迭代的作用域for (var i 0; i 3; i) { (function (j) { setTimeout(function () { console.log(j); // 输出 0 1 2 }, 100); })(i); }第二种更直接——把var换成let利用块级作用域为每次迭代创建独立绑定。这也是ES6之后最推荐的写法。3. let与const块级作用域和暂时性死区如何重塑变量声明3.1 块级作用域{} 圈出来的安全区let和const把变量的可见范围限制在最近的{}内这个特性彻底解决了var的穿透污染问题。看个对比if (true) { let x 1; var y 2; } console.log(y); // 2 console.log(x); // ReferenceError: x is not definedx的声明被锁在if块里块外访问直接报错。这个行为符合绝大多数编程语言对局部变量的直觉。我在实际项目中用过let之后最直观的感受是再也不用担心临时变量跑到函数外面去跟别的变量撞车了。同时块级作用域让循环里的变量每次迭代都有了自己的副本for (let i 0; i 3; i) { setTimeout(function () { console.log(i); // 输出 0 1 2 }, 100); }这里的关键机制是let在每次循环迭代时都会重新绑定一个全新的i对应的内存区域不同回调分别捕捉到了不同迭代的i值。理解了这个你就明白了为什么社区里一致推荐循环中使用let。3.2 暂时性死区TDZ运行时报错的第一凶手暂时性死区是let/const最容易被忽略、却又是最常见报错的根源。什么叫TDZ简单说从代码块一开始到变量声明被执行之前的这段区域该变量都处于不可访问状态。在这段区域里访问变量会直接抛出ReferenceError。console.log(a); // ReferenceError: Cannot access a before initialization let a 5;我第一次看到这个报错时很费解明明是有提升的因为引擎知道a的存在报错信息里明确提示了before initialization为什么不能像var一样先拿到undefined原因在于ES6对let/const的规定声明会被提升但初始化不会提升在初始化之前访问就进入死区。这个设计的本意是好的——让开发者不要在变量还没准备好之前就使用它从而减少隐性bug。实际开发中TDZ最容易出现在两个位置一个是区块顶部访问后续才声明的let变量另一个是在函数内部访问外层同名变量时被内层声明提前遮蔽shadowing导致的外层变量不可见。后者非常隐蔽let value outer; function fn() { console.log(value); // ReferenceError let value inner; } fn();这里因为内层存在同名的let声明从函数开头起外层value就进入了TDZ范围所以打印报错。解决方式是调整变量名或者先访问再声明但最根本的还是要养成变量声明靠前的习惯。3.3 const绑定不可变不等于内容不可变const有一个高频误解好多人以为const声明对象之后对象就完全不能改了。实际上const约束的是绑定本身也就是不能对这个变量重新赋值但它指向的对象内部属性是可以修改的。const person { name: 张三 }; person.name 李四; // 合法 person.age 20; // 合法 // person {}; // TypeError: Assignment to constant variable.这个设计有它的合理性如果连对象内容都不能改那const的应用范围就太窄了。实践中我推荐把const当作默认选项凡是不会被重新赋值的变量一律用const等到确实需要重新赋值时再改成let。这样能大幅减少变量被意外覆盖的可能。4. 三兄弟正面交锋var/let/const的全维度对比与选型决策4.1 一张表看清所有关键差异特性varletconst作用域函数作用域块级作用域块级作用域变量提升声明提升且初始化为undefined声明提升但存在暂时性死区同let重复声明允许不允许不允许重新赋值允许允许不允许声明时必须赋值否否是全局声明时挂到window是否否最后一行也值得注意用var在全局作用域声明的变量会成为window对象的属性而let/const不会。如果你在浏览器环境里做全局变量管理这个差异会带来实际影响。4.2 从项目实际出发的选型策略我自己的编码规范非常固定也是目前团队在用的const优先能确定不会被重新赋值的一律用const。这包括所有引用类型变量数组、对象、函数、导入的模块、配置常量。let按需只有确实需要重新赋值的变量才用let比如循环计数器、累加器、状态切换值。var禁用新代码里根本不写var。老项目维护时看到var除非必须改逻辑否则顺手改成let或const。为什么这么激进因为const和let能把变量是否会被改变这个信息直接写入代码读代码的人不需要去追踪每一行赋值就知道某个变量从头到尾的语义。这种隐式约束对于团队协作非常友好。另外一个实际建议如果你在维护一个ES5老项目没法用let/const那至少在函数顶部集中做var声明不要写到一半突然冒出个var这对后续维护者的阅读体验是灾难性的。5. 变量声明与函数、循环、闭包最容易踩坑的三个真实场景5.1 函数声明与函数表达式提升机制完全不同很多人没有意识到函数声明与变量声明在提升机制上的优先级是不同的。当同名变量和同名函数同时出现时到底谁说了算看代码console.log(foo); // 输出函数本身 function foo() { console.log(1); } var foo bar;输出了函数而不是undefined也不是字符串。原因是函数声明在提升阶段的优先级高于var变量声明函数会被完整提升到作用域顶部之后var的var foo声明因重复被忽略赋值阶段才覆盖所以此处foo还是函数。这个面试题几乎百考不厌但实际开发中更重要的教训是不要故意写同名的函数和变量。这样写也许能通过运行但维护成本极高任何人看到都会头疼。5.2 循环里的let/var不只是换一个关键字那么简单很多文章说把var改成let就行这话没错但如果你不理解原理遇到复杂情况还是会懵。for循环里let的机制可以拆成三步理解每轮迭代引擎会创建一个新的词法环境并且把上一轮迭代的i复制一份到新的环境里再执行增量表达式。这意味着每个setTimeout回调捕获的i都是各自迭代环境里的那个i互不干扰。而var只有函数级作用域整个循环共享同一个i所以最终全部指向最后一次赋值。如果把这件事引申到更复杂的场景比如双层循环、循环里动态创建DOM节点绑定事件、循环里发起异步请求你会发现let的块级绑定理念贯穿始终。写代码时只要记住一条循环内产生延迟执行的回调优先用let声明索引变量。5.3 闭包捕获与外层变量的相互关系闭包是JavaScript里绕不开的概念它和变量声明的绑定关系特别紧密。闭包的本质是内部函数保持对外部变量的引用。而变量的生命周期是否被延长取决于变量是用var、let还是const声明的——更准确地说取决于它所在的作用域。一个实用的场景你写了一个计数器函数希望每次调用加一function createCounter() { let count 0; return function () { count 1; return count; }; } const counter createCounter(); counter(); // 1 counter(); // 2这里count不会被垃圾回收因为返回的函数在闭包中持有它的引用。如果你把let换成var效果一样因为关键不在于var还是let而在于闭包的存在。但如果你不小心在createCounter内部用var声明了一个同名变量可能因为作用域不同产生完全不同的行为。这个例子也提醒我们闭包捕获的永远是被声明的那个变量不是值。5.4 全局变量与window对象声明方式决定身份我们在浏览器环境里用var在全局声明变量会直接挂到window上用let/const则不会。这意味着什么假设你有一段代码var globalCount 0; // 另一个文件 window.globalCount 5;两个文件之间通过window隐式共享全局状态在小型项目里图省事还行项目一复杂这种隐式耦合就是调试地狱。所以规范的团队会统一要求全局环境不允许用var声明所有全局状态都放入显式的模块或配置对象里。使用let/const之后全局变量不会污染window这也是一种语言层面的进步。6. 实战排查变量声明相关的运行时报错与开发规范6.1 分清ReferenceError、TypeError与SyntaxError实战开发中与变量声明相关的报错主要有三类很多人拿到报错信息就懵了。先说最简单的区分ReferenceError变量根本没声明或处于暂时性死区。例如访问了不存在的变量或TDZ内访问let变量。TypeError变量存在但你试图做的操作不符合它的类型约束。例如给const重新赋值Assignment to constant variable、读取undefined的属性。SyntaxError代码根本没法解析常见于重复声明同一块作用域内的let/const比如let a 1; let a 2;。这里有一个容易混淆的点typeof对未声明变量是安全操作返回undefined不会报错但对TDZ中的let变量使用typeof却会直接报ReferenceError。这也是一个很冷门但面试可能会问的细节console.log(typeof undeclaredVar); // undefined console.log(typeof tdzVar); // ReferenceError let tdzVar 1;6.2 常见报错情景速查表报错信息触发场景修复方法ReferenceError: xxx is not defined访问了从未声明的变量检查拼写确认变量是否在可访问作用域内ReferenceError: Cannot access xxx before initializationTDZ内访问let/const变量将变量的使用移到声明之后TypeError: Assignment to constant variable给const绑定重新赋值改用let或重构逻辑确保不重新赋值SyntaxError: Identifier xxx has already been declared同一块作用域内重复声明let/const改名或把重复声明合并ReferenceError: xxx is not definedtypeof触发对处于TDZ的let变量使用typeof把typeof操作移到声明之后6.3 严格模式与ESLint把变量声明问题提前扼杀JavaScript的严格模式use strict对变量声明有额外的约束最主要是两点禁止给未声明的变量赋值否则静默创建全局变量以及禁止删除变量。没有严格模式下a 1会自动创建一个全局变量这种隐性全局变量是bug温床。而在严格模式下直接抛ReferenceError。ESLint是比严格模式更强的执法者。我们团队配置了这么几条核心规则no-var禁止使用var强制走let/const。prefer-const如果变量从未被重新赋值强制使用const。no-redeclare禁止重复声明变量。no-unused-vars声明但从未使用的变量直接报错这个能帮你清理大量死代码。配置好之后把ESLint挂到CI流程或者pre-commit钩子里很多变量声明的问题根本走不到运行时。我带新人的时候特别强调这一点与其花一小时排查这个变量为什么是undefined不如提前让构建工具把你拦下来。6.4 我自己踩过的一个真实坑最后分享一个我早期项目中真实遇到过的案例。当时写一个图片懒加载组件需要记录当前已加载的图片索引。我图省事在模块里直接写了一个全局varvar loadedIndex 0; function loadNext() { loadedIndex 1; // 加载逻辑 }这个组件被同时用在页面上的两个独立轮播图区域结果两个区域共用同一个loadedIndex导致图片切换逻辑完全错乱一会儿跳图一会儿不加载。排查了很久才发现是全局变量互相污染。后来改成函数内部用let管理状态再通过闭包各自持有独立的计数器问题瞬间消失。这个教训告诉我变量声明从来不只是语法选择它直接决定了你的代码在复杂场景下是稳如泰山还是脆如薄纸。在实际项目中变量声明的选择还会影响代码的可读性、可维护性和bug率。每次review同事代码时我第一眼扫的就是声明方式。坚持const优先、let按需、var清零时间久了你会发现很多棘手的运行时报错从一开始就不会出现。
RELATED READING

延伸阅读

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