看到这个标题点进来的,多半是在var、let、const之间纠结过的前端新人。JavaScript变量声明这个事,说小很小,三句话就能把语法列完;说大也真大,项目里一半以上的隐性bug、面试里一半以上的基础题,都能绕回作用域和声明方式上。尤其最近不少同学在群里聊前端面试题时提到var和let的区别、javascript:void(0)这类运行时报错,还有一堆看着像是变量声明引起的控制台报错,我觉得有必要把这套东西从头到尾捋一遍。
这篇不会只给你列语法,我会把变量提升、暂时性死区、块级作用域、闭包陷阱这些“为什么”讲透,再配合实际项目中真正会用到的场景、高频报错和排查思路,最后附上几个适合新人自查的题目。不管你是刚开始接触前端,还是已经在写业务代码但偶尔被变量问题坑到,这篇都值得花十分钟看完,能帮你省下后面很多排查时间。
1. 变量声明的前世今生:为什么一个声明能写出三套语法
1.1 三种声明方式的基本定位
JavaScript里的变量声明经历了两个阶段:ES5时代只有var,到了ES6(也就是ES2015)正式引入了let和const。很多新人第一次看到这个会觉得“是不是多此一举”,实际上不是,let和const的出现是为了解决var在作用域和变量提升上埋下的各种坑。
先说最基础的语法差异。var声明一个变量,你可以反复对同一个名字声明多次,后面一次会覆盖前面一次,不会报错。let和const则不允许在同一个作用域里重复声明同一个变量,一旦重复,控制台会直接抛出Uncaught SyntaxError: Identifier 'x' has already been declared。
javascript复制var a = 1;
var a = 2; // 完全没问题,a 变成 2
let b = 1;
let b = 2; // 报错:Uncaught SyntaxError: Identifier 'b' has already been declared
这个差异看起来简单,但背后反映的是设计思路的变化:var比较宽松,写起来随意,但也正因为随意,很容易在复杂项目里不小心覆盖掉别人的变量或者自己之前定义的变量。let和const则是一种更“克制”的设计——同一个作用域里,名字就是唯一的,强迫你在命名时想清楚。
const和let的区别只有一个字面层面的差异:const声明时必须赋值,而且声明后不能再对这个变量本身重新赋值。注意“变量本身”这个说法,后面我会专门讲const对象到底能不能改的问题,这里先记住最基础的一条——const并不是让你声明一个完全不可变的值。
1.2 从一份报错说起:变量提升与暂时性死区
新手最容易在控制台看到这样的现象:用var声明的变量,在声明语句之前就能访问,值还是undefined,不报错。很多人第一次遇到时一脸懵,以为代码写错了,其实这里涉及的是“变量提升”(hoisting)。
变量提升指的是:var声明的变量,在代码执行之前会被“提升”到当前作用域的顶部。不过提升的只是声明,不是赋值。所以代码执行到声明语句之前时,变量已经存在了,但值还是undefined。
javascript复制console.log(a); // undefined,不报错
var a = 10;
上面这段代码实际执行时等价于:
javascript复制var a;
console.log(a); // undefined
a = 10;
这就是为什么很多老前辈会说“var的声明被提升了”。听着很玄,其实可以理解成一个“先占坑后填值”的过程:变量名先出现在作用域里,赋值操作原地不动。
let和const本质上也会被提升,但和var不一样的是:它们在声明语句执行之前,会处在一个“暂时性死区”(Temporal Dead Zone,简称TDZ)。在这个阶段访问变量,会直接抛错,而不是返回undefined。
javascript复制console.log(b); // Uncaught ReferenceError: Cannot access 'b' before initialization
let b = 10;
为什么var是undefined而let是报错?关键差异在于:var在提升时会被初始化为undefined,而let和const在提升时不会初始化,进入暂时性死区。强调一点:暂时性死区从作用域开始就生效,一直到声明语句执行完才结束。哪怕这个变量是全局变量,在声明前访问同样会报错。
面试题里经常出现一道经典题目:
javascript复制var a = 10;
if (true) {
console.log(a); // 这里会输出什么?
let a = 20;
}
很多人认为会输出10,实际结果是直接报错。原因就是块级作用域内的let a让这个块从一开始就进入了暂时性死区,所以在声明前访问a,不会去外层找那个var a,而是直接抛Cannot access 'a' before initialization。这个坑在真实代码里很容易踩到,尤其是你把某个变量从var改造成let时,如果有一行代码放在声明语句前面,本来悄悄拿到undefined的程序会瞬间崩溃。从某种角度看,这其实是好事——让问题暴露得更早,而不是留到后面产生诡异结果。
1.3 一张表理清三者的核心差异
为了让你快速建立全局认知,我把三者的核心差异整理成一张表,建议保存下来当备忘。
| 特性 | var | let | const |
|---|---|---|---|
| 作用域 | 函数作用域 | 块级作用域 | 块级作用域 |
| 变量提升 | 提升,且初始化为 undefined | 提升,但不初始化(TDZ) | 提升,但不初始化(TDZ) |
| 重复声明 | 允许 | 不允许 | 不允许 |
| 声明时赋值 | 可以不赋值 | 可以不赋值 | 必须赋值 |
| 修改绑定 | 允许重新赋值 | 允许重新赋值 | 不允许重新赋值 |
| 使用建议 | 尽量避免 | 需要改值时使用 | 默认首选 |
表格里的“函数作用域”和“块级作用域”是两个关键概念,接下来我重点展开。这里先记住一个结论:从项目实践角度,const优先、let兜底、var收手。理由后面会讲。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 作用域到底怎么算:从函数级到块级的关键变化
2.1 函数作用域 vs 块级作用域
先来解释两个概念的区别,我尽量用大白话讲。
var的作用域是“函数作用域”,意思是:只要你在一个函数内部用var声明变量,这个变量在整个函数内部的任何位置都能访问到,跟你在哪个代码块内部没太大关系。一个直观的例子:
javascript复制function test() {
if (true) {
var x = 1;
}
console.log(x); // 1,因为 if 的花括号不是 var 的边界
}
test();
if里的花括号对var而言形同虚设,x照样能穿透出来。这看起来挺方便,但同时也意味着变量很容易“泄漏”到不该出现的地方。
let和const的作用域是“块级作用域”,这里的“块”指的是由花括号{}包裹起来的所有区域,包括if、for、while、switch甚至单独的{}。块级作用域的意思是:变量只能在当前这个块内部访问,出了花括号就访问不到。
javascript复制function test() {
if (true) {
let y = 1;
}
console.log(y); // 报错:y is not defined
}
test();
打个生活化的比方:函数作用域像整个房子,你进了房子以后,其他房间的门都开着,你能随意走动;块级作用域更像是按房间隔离的出租屋,你在卧室里放的东西,客厅里看不到,出了卧室门也进不了客厅。var是第一种,所有东西都堆在公共区域,而let和const是第二种,东西放在哪间房就在哪间房。
2.2 闭包与循环变量:最经典的let应用场景
块级作用域给日常开发带来的最大变化之一,就是解决了循环变量和闭包配合使用时那个经典的“输出错误值”问题。
看这个老生常谈的题目:
javascript复制for (var i = 0; i < 5; i++) {
setTimeout(function () {
console.log(i);
}, 100);
}
输出的是5个5,而不是0、1、2、3、4。原因就是var i是函数作用域(这里就是全局作用域),循环结束以后i已经变成了5,等到定时器回调执行时,访问到的只能是最新的i。
在ES6之前,解决办法是借助IIFE(立即执行函数表达式)给每次循环创建一个独立作用域:
javascript复制for (var i = 0; i < 5; i++) {
(function (j) {
setTimeout(function () {
console.log(j);
}, 100);
})(i);
}
传参j就是利用函数参数把每一次的i值“锁”住。这个方法当然有效,但写起来很啰嗦。有了let以后,一切都变得直接:
javascript复制for (let i = 0; i < 5; i++) {
setTimeout(function () {
console.log(i);
}, 100);
}
这次输出的就是0、1、2、3、4。原因很简单:let是块级作用域,这就相当于for循环每次迭代都重新声明了一个独立的i,每个i的值都被单独绑定到那一轮的块上,定时器回调只能访问到自己那轮的i。
这个场景是前端面试题里的超高频考点,而且也是实际项目中真实会遇到的坑。比如你用for循环批量给DOM绑定事件时,如果用了var,点击每个元素拿到的索引永远是最后一个;换成let,一切都符合直觉。所以我建议所有新人只要写循环,直接默认用let,不要给自己留“下一行再改”的余地。
2.3 块级作用域在日常开发中的实用场景
除了循环以外,块级作用域在业务代码里还有一个很容易被忽略的实用性:它可以帮你隔离临时变量,避免命名冲突。
举个例子,你在一个函数里两次处理不同类型的数据,都可能用到result这个名字。如果用var,那这两个result就是同一个变量,第二段代码会覆盖第一段的值。如果用let,并且把每段逻辑包在合适的花括号块里,两个result互不干扰:
javascript复制function process(data) {
{
let result = data.filter((item) => item.active);
console.log(result.length);
}
{
let result = data.map((item) => item.name);
console.log(result.length);
}
}
这个风格在业务代码里其实很少被刻意使用,但当你需要临时定义一个只在几行内有效的中转变量时,块级作用域可以避免变量污染。
我还踩过一个很实在的坑:早期写代码时,在一个大函数里用var声明了一个循环变量i,后面另一个循环也用了i,结果两个循环嵌套时相互覆盖,整个逻辑彻底乱套。后来排查了很久才发现只是变量名冲突。如果当初用了let,把循环变量限制在各自的for块内部,问题根本不会发生。所以我的经验是:只要在一个逻辑块里声明变量,优先问自己“这个变量需不需要出这个块”,不需要就一律用let或const。
3. const并不等于不可变:对象与数组的正确使用姿势
3.1 “常量”认识的三个误区
const这个词听起来是“常量”,很多新人会顺手把“const声明的值是不可变的”挂在嘴边,但这是对const最常见的误解。我至少见过三次有同事因为这个问题在代码评审时被问住。
误区一:const必须在声明时赋值。这一点是对的,属于硬性语法要求,不赋值直接报错。
javascript复制const a; // 报错:Missing initializer in const declaration
误区二:const声明的对象不能修改属性。这是错的。const限制的是“变量指向哪个值的绑定关系”,而不是“值本身的内部结构”。
javascript复制const obj = { name: '张三' };
obj.name = '李四'; // 不报错,obj 变成 { name: '李四' }
为什么?因为obj这个变量存的是对象的内存引用地址,改obj.name改的是内存地址指向的那个对象的内容,并没有改变obj变量持有什么地址。只有当你试图让obj指向另一个对象时,才会报错:
javascript复制const obj = { name: '张三' };
obj = { name: '李四' }; // 报错:Assignment to constant variable.
误区三:const声明的数组完全静态。同理,数组的push、pop、splice等方法都是修改数组内部元素,不是重新赋值,所以完全合法。
javascript复制const arr = [1, 2, 3];
arr.push(4); // 合法,arr 变成 [1, 2, 3, 4]
arr = [5]; // 报错:Assignment to constant variable.
3.2 引用可变带来的实际影响与解决思路
既然const只是限制重新绑定,那它在实际项目中到底有什么意义?答案在于“代码可读性和可维护性”。
当你看到别人代码里声明了一个const config = {...},你立刻就知道:这个变量在整个生命周期里不会被重新赋值,它的值只可能在声明处确定。这提高了代码的可预期性。相反,如果统一用let,你没法从声明上看出这个变量后面会不会被改,必须在整个作用域里搜索所有对它的赋值才能判断,阅读成本高不少。
所以在项目里,我推荐的默认策略是:能用const就用const,只有当你确定某个变量后续需要被重新赋值时,才改用let。比如:
javascript复制const BASE_URL = '/api';
const STATUS_MAP = {
1: '待处理',
2: '处理中',
3: '已完成',
};
let count = 0;
count = count + 1;
如果你真的需要一个“深度冻结”的对象,也就是连属性都不能被修改,那const本身做不到,需要借助Object.freeze()。但Object.freeze()是浅冻结,只能冻结对象的第一层属性,如果属性值是嵌套对象,那层嵌套对象依然可以改:
javascript复制const obj = Object.freeze({ name: '张三', info: { age: 18 } });
obj.name = '李四'; // 不生效,非严格模式下静默失败,严格模式下报错
obj.info.age = 20; // 可以,嵌套对象没有被冻结
真正要做深冻结,得自己递归遍历或者使用第三方工具库。但说实话,我一般在业务代码里不会随便用Object.freeze,因为它的冻结语义在非严格模式下的静默失败很容易把人绕晕。const加上团队约定“不要修改对象内部结构”,在日常开发中已经足够。
3.3 面试中的经典const问题
面试官特别喜欢围绕const出一些“看结果”的题目。比如:
javascript复制const obj = { x: 1 };
obj.x = 2;
console.log(obj.x);
答案是输出2,不报错。原因我刚才讲了,obj指向的内存地址没变,变的只是内存地址里那个对象的一个属性。
再比如:
javascript复制const arr = [1, 2, 3];
arr.length = 0;
console.log(arr);
输出[],同样不报错。手动修改length是数组的合法操作,它修改的是数组内部长度属性,不是重新绑定arr变量。
还有一个常见追问:let和const都能声明块级变量,什么时候必须用const?我的回答有两个层次。语法层面,如果一个变量从头到尾都没有被重新赋值,就适合用const。实践层面,默认优先用const,遇到需要重新赋值的场景再改成let。这个回答能让面试官知道你不仅有语法知识,还有工程习惯。
4. 实战场景选择:何时用var、let、const
4.1 推荐“const优先,let兜底,var收手”的原则
学了这么多理论,落到实际项目里到底怎么选?我给团队定的规矩就一条:默认const,需要改值才用let,基本不考虑var。
为什么默认const?因为代码里大部分变量只是用来保存一个“中途计算结果”或者“固定配置”,它们不需要被重新赋值。用const的好处是给自己和阅读代码的人一个明确信号:这个变量的值不会变,看到它就知道可以放心。而let意味着“这里有个会变的变量”,读到的时候自然会小心一点。
什么时候用let?最简单的判断标准是:这个变量会不会出现在赋值符号的左边,也就是会不会被重新赋值。只要会,就用let。典型场景包括循环计数器、累加器、循环中临时缓存、需要动态更新的状态值等。
var在什么情况下还会出现?一般只有三种情况:
- 在维护一个多年前的旧项目,项目里大量代码已经用了
var,为了风格统一先沿用,但新代码尽量不用。 - 在浏览器控制台调试时,偶尔用
var把某个变量挂到全局方便反复查看(不过现在直接用let也能调试)。 - 在极老的代码中需要兼容某些古老运行环境时,但这些场景在现代前端开发里已经非常少见了。
4.2 面试题与项目中的典型场景
我在给同事做Code Review时,发现新人最容易在三个场景里选错声明方式,这里展开说一下。
场景一:定义配置和枚举映射。这种值一旦写在代码里就不应该变,比如接口地址、状态枚举、错误码映射。用const能最大程度避免后人“不小心重新赋值”的问题。
javascript复制// 推荐
const API_BASE = 'https://api.example.com/v1';
const ERROR_CODE = {
NOT_FOUND: 404,
SERVER_ERROR: 500,
};
场景二:循环里的累加器。比如你需要计算购物车里商品总价,这个total会不断累加,必须用let:
javascript复制let total = 0;
for (const item of cart) {
total += item.price * item.count;
}
这个例子里有个细节:for...of循环里的item每轮都是新的绑定,用const声明完全没问题。很多新人会习惯性写let item,其实是没必要的。只有for循环传统的计数器才必须用let,因为计数器要自增。
场景三:闭包捕获循环变量。前文已经详细说过,for (let i = 0; ...)能在异步回调里正确输出每轮的值。这里再补一个从业务里提炼的真实例子——批量注册事件:
javascript复制const buttons = document.querySelectorAll('.btn');
for (let i = 0; i < buttons.length; i++) {
buttons[i].addEventListener('click', function () {
console.log('点击了第' + i + '个按钮');
});
}
如果写成var i,不管点击哪个按钮,输出的都是buttons.length,那这个“第几个按钮”就永远不对了。
4.3 旧代码改造与兼容性:什么时候保留var
偶尔有人会问:项目里全是老代码,能不能直接把var全部替换成let或const?我的建议是——可以,但一定要谨慎,别做“无脑批量替换”。
var和let的作用域语义不同,把var换成let后,如果原代码依赖了var的变量提升或者穿透块级作用域的行为,代码可能会直接报错。比如这种代码:
javascript复制function test() {
console.log(flag); // 原来输出 undefined,换成 let 后报错
if (true) {
var flag = 1;
}
}
原因就是原代码里flag是在if块里用var声明的,但在整个函数作用域内都能访问,而且因为变量提升,声明前访问到的是undefined。一旦改成let,块级作用域生效,函数顶部访问flag直接进入暂时性死区,程序崩溃。这种改动不是“优化”,而是“破坏”。
所以我的改造建议是:
- 先把
var改成let,跑一遍完整的测试流程。 - 观察有没有报错,报错多的位置重点排查是否有块级作用域边界问题。
- 确认某个变量在整个生命周期里从未被重新赋值,再把它从
let改成const。 - 不要盲目追求“所有变量都是const”,那只会在代码审查时花大量时间解释为什么某个变量不能被重新赋值。
5. 常见报错与排查技巧实录
5.1 几个高频报错的现场重现
新人在跟变量声明相关的代码打交道时,遇到报错是家常便饭。我把控制台里最常见的几种报错样式整理成一个速查表,方便你排查时对照。
| 报错信息 | 出现原因 | 解决方案 |
|---|---|---|
Cannot access 'x' before initialization |
在暂时性死区内访问了let/const变量 |
把访问语句移到声明语句之后 |
Identifier 'x' has already been declared |
同一个作用域内重复声明了同名字的let/const变量 |
检查是否在同一个块内声明了两个同名变量 |
Assignment to constant variable |
尝试对const变量重新赋值 |
改回let,或者调整变量设计,不重新赋值 |
'x' is not defined |
访问了不存在的变量,或者变量不在当前作用域内 | 确认变量是否在当前作用域内声明,注意花括号边界 |
Missing initializer in const declaration |
const声明时没有赋初值 |
在声明时直接赋值 |
这五个报错几乎覆盖了90%以上的变量声明相关报错场景。我发现一个有趣的现象:很多新人看到Cannot access 'x' before initialization会以为变量不存在,非常慌,但其实只要把日志往下翻一翻,找到声明语句的位置,大概率就能看出是访问顺序问题。
5.2 报错排查的三个习惯
排查变量相关报错,我建议养成三个固定的步骤习惯,能帮你把排查时间缩短一大截。
第一步,先看清报错信息里提到的变量名,再去看报错信息给出的文件和行号。大多数报错信息都写得很明确,关键在于你要认得出它的类型是语法错误(SyntaxError)还是运行时错误(ReferenceError)。SyntaxError说明代码本身就不能解析,通常是重复声明或者语法结构不对;ReferenceError说明代码能解析但执行到某一行时访问不到这个变量。
第二步,回头检查变量声明的位置,确认它和访问语句的相对顺序。特别是从var改成let的代码,极容易出现“访问在声明之前”的情况。用编辑器搜索变量的整个生命周期,把所有涉及它的地方列出来,标出声明在哪一行、访问在哪一行,一目了然。
第三步,用作用域边界来判断“为什么在这个位置访问不到”。如果是let或者const,要确认当前访问语句是否在声明所在的同一个花括号块内。不在同一个块内,就访问不到,这是很正常的事情。如果你确实需要在块外使用这个变量,解决办法不是把let换成var,而是把变量声明提到外层作用域。
还有一个很实用的小技巧:把报错来源的代码在浏览器控制台里拆开执行,每次少执行一行,通过二分定位方式找出第一个抛错的位置。这个方式虽然听起来朴素,但面对上百行代码时真的有效。
5.3 给新人的调试建议
关于变量声明,我一直觉得新人最需要的不是更多理论,而是动手验证的习惯。浏览器控制台(Console)就是最好的练习场。
我建议你打开浏览器,按F12打开开发者工具,在Console面板里直接输入代码片段,看它的执行结果。比如你想验证“let到底能不能穿透花括号”,直接在控制台里输入:
javascript复制{
let inner = 42;
}
console.log(inner);
执行后会发现直接报错,因为你试图在块外访问块内的变量。这个结果比背十遍“let是块级作用域”都来得印象深刻。
控制台还支持多行编辑,你可以写一个完整的函数再调用它,观察输出。我当年学作用域时就是用这个方式,把一个函数反复改写,逐步验证。这种“试错式学习”比单纯看书效率高很多。
另外,如果你用的是VS Code,可以安装并习惯使用“调试器”功能,在代码行左侧打一个红点,代码执行到这一行就会暂停,然后你可以在左侧面板里看到当前作用域下所有变量的值和类型。这个功能对理解“变量在哪里可见”非常有帮助,尤其是看到作用域列表里出现Block和Local的区别时,你对作用域的理解会瞬间清晰起来。
6. 几个帮助新人入门的练习与自测题
6.1 三道自测题
学完上面这些内容,我建议你拿三道题自查一下。不要只看答案,先自己写出结论和理由,再对照解析。
第一题,这段代码输出什么?
javascript复制for (var i = 0; i < 3; i++) {
setTimeout(function () {
console.log(i);
}, 0);
}
答案是3个3。因为var在这里是全局作用域,循环结束后i为3,所有回调访问的都是同一个i。
第二题,这段代码输出什么?
javascript复制let a = 1;
{
let a = 2;
console.log(a);
}
console.log(a);
答案是先输出2,再输出1。外层let a声明的是全局作用域里的a,内层花括号创建了一个新的块级作用域,块内的let a和外层的a是两个不同的变量。这个例子能帮你理解“同名变量在不同作用域里可以共存”。
第三题,这段代码会报错吗?
javascript复制const obj = { a: 1 };
obj.a = 2;
obj = { a: 3 };
第一行声明一个const对象;第二行修改对象属性合法;第三行对const变量重新赋值,直接抛Assignment to constant variable。
6.2 顺带聊聊搜索热词里的javascript:void(0)
很多新人搜索变量声明相关内容时,会连带看到javascript:void(0)这个热词。它虽然不属于变量声明,但经常出现在一些旧代码的链接里,比如:
html复制<a href="javascript:void(0)" onclick="doSomething()">点击</a>
这个写法的意思是:让这个a标签点击后不产生页面跳转,void(0)返回undefined,用来阻止链接默认行为。它与变量声明没有直接关系,但很多刚接触前端的同学会在处理这类旧代码时遇到困惑。我的建议是:在现代代码里,如果你需要让一个元素可点击但不跳转,更推荐使用button元素配合事件处理,或者写href="#"配合阻止默认行为。javascript:void(0)虽然还能见到,但已属于历史遗留写法,了解即可,不必优先使用。
另外,如果你在自己的代码里看到类似javascript:void(document.title=document.cookie)这种写法,这多半是恶意脚本或者安全测试代码,不要随便执行,更不要把它复制到自己的项目里。碰到可疑的脚本代码,保持警惕是对的,这也是前端开发中很重要的安全意识。
6.3 掌握变量声明的最终心法
写完这篇,我想把几句话送给刚入门前端的朋友。
第一句:变量声明不是前端最炫酷的知识,但绝对是你每天都要用的基础知识。它在项目里无处不在,理解不透彻,后面学闭包、原型、异步都会受到影响。
第二句:少背定义,多敲代码。看到var、let、const、作用域、提升这些概念时,打开浏览器控制台敲一遍,比抄十遍笔记都管用。
第三句:给自己定一个简单规则——所有新代码默认const,需要再赋值就改let,能用let就不要用var。坚持一个月以后,你对变量声明的掌控感会明显不一样。
按我自己带新人的经验,能把变量声明这块真正想明白的人,后面学什么都快。因为作用域和声明方式就像整个JavaScript语言的骨架,骨架稳了,上面的血肉才挂得住。这也是我为什么宁愿花这么大篇幅,也要把每一个“为什么”展开讲清楚的原因。
