先跟大家说句大实话:JavaScript这门语言,就像小区门口的智能门禁——表面看就刷个卡的事,背后却藏着识别、验证、开门一整套逻辑。尤其是“变量提升”“块级作用域”这些词,新手看了头大,老程序员偶尔也会踩坑。
今天咱们彻底抛弃黑话,用“备课上课”“租房住”这种接地气的例子,把这些知识点嚼碎了讲。目标是:就算你刚学会console.log,看完也能拍桌子说“原来这玩意儿是这么回事!”
阅读提示:文中所有专业术语,都会先给“人话翻译”,再配“生活案例”,最后上“代码验证”。全程不搞虚的,看不懂你找我!
一、核心前提:JS代码不是“从上到下直接跑”的
很多人以为JS代码是“读一行执行一行”,就像看小说从头翻到尾。但实际上,浏览器里的JS引擎(你可以理解成“执行JS的小机器人”),在执行代码前会先“偷偷做准备”——就像老师上课前要先备课,厨师做菜前要先备菜。
简单说:JS执行分两步走,编译备课阶段和执行上课阶段。这是理解后面所有知识点的根基,务必吃透!
1.1 第一步:编译备课阶段——小机器人先“划重点”
小机器人拿到代码后,不会直接执行,先快速扫一遍,干两件关键事:
-
找“声明” :把代码里的变量声明(比如
var、let、const开头的)和函数声明(function开头的)全挑出来,记在小本本上。 -
建“工作区” :创建一个“执行上下文”(相当于小机器人的“工作区”),里面分两个抽屉:
- 变量环境抽屉:放
var声明的变量和函数声明——这是“旧款抽屉”,功能简单,兼容性好。 - 词法环境抽屉:放
let、const声明的变量——这是“新款抽屉”,支持分区收纳,功能更全。
- 变量环境抽屉:放
举个例子,看这段代码小机器人会怎么备课:
console.log(myname);
var myname = "路明非";
小机器人备课的时候会想:“哦,有个var声明的myname,先记到变量环境抽屉里,暂时不给值;后面那句console.log和赋值先不管,等执行阶段再说。”
1.2 第二步:执行上课阶段——小机器人按顺序“干活”
备课结束后,小机器人才会一行一行执行代码,执行的时候会随时翻“工作区”的抽屉找东西:
- 执行第一行
console.log(myname):小机器人去变量环境抽屉里找myname,发现有这个名字但没赋值,就输出“undefined”。 - 执行第二行
var myname = "路明非":这时候才给myname赋值“路明非”,变量环境抽屉里的myname终于有了具体值。
关键提醒:正是因为有“编译备课阶段”,才有了后面的“变量提升”。要是没有这个阶段,第一行代码直接就报错“找不到myname”了!
二、变量提升:JS里的“先上车后补票”(历史遗留坑)
“变量提升”听着高级,其实就是:用var声明的变量和函数声明,会被小机器人在备课阶段“提到”当前作用域的最前面——但赋值的部分还留在原地不动。
就像你坐公交车,先上车找好座位(声明被提升),等过了两站才补票(赋值留在原地)。这种操作在生活里可能没问题,但在代码里全是坑!
2.1 两种提升场景:变量和函数不一样
场景1:var变量的提升——只提“声明”,不提“赋值”
再看刚才的例子,小机器人备课之后,代码其实被“偷偷改写”成了这样(你看不到改写后的代码,但引擎会这么处理):
var myname; // 备课阶段提升的声明,没赋值
console.log(myname); // 执行时找得到变量,值是undefined
myname = "路明非"; // 原来的赋值留在原地
这就是为什么第一行输出“undefined”而不是报错——因为声明被提前了,但赋值没动。
场景2:函数声明的提升——“声明+函数体”全提走
函数声明比var变量更“夸张”,小机器人会把整个函数的“声明+函数体”都提到作用域最前面。比如:
showName(); // 执行前调用,居然不报错
function showName() {
console.log('函数执行了');
}
正常逻辑应该是“先定义函数再调用”,但因为函数声明被完全提升了,小机器人备课的时候就把showName函数放进了变量环境抽屉,所以执行第一行调用的时候能直接找到,不会报错。
避坑提示:函数表达式不会提升!比如var showName = function() {}这种,本质是var变量,只会提升变量声明,不会提升函数体。要是写成showName()在前面调用,就会报错“showName不是函数”。
2.2 为什么说变量提升是“坑”?三个反人类问题
问题1:违反“先声明后使用”的直觉
正常人写代码都习惯“先定义变量,再使用变量”,就像先买手机再打电话。但var允许你“先用再定义”,就像没买手机先拨号码,结果还不报错,只提示“手机未开机”(undefined)。
// 反人类操作,但var允许
console.log(age); // undefined
var age = 18;
这种代码读起来特别费劲,谁能想到第一行用的变量,第二行才声明?
问题2:变量被“偷偷覆盖”,bug藏得深
这是最常见的坑!比如下面这段代码,你以为会输出“李水磊”,结果输出“undefined”:
var name = "李水磊"; // 外部变量
function showName() {
console.log(name); // 输出undefined,不是“李水磊”
if (true) {
var name = "大厂的苗子"; // 内部变量
}
}
为啥会这样?因为var没有块级作用域,if里的var name会被提升到函数的最前面,函数里的代码被小机器人改写成了这样:
function showName() {
var name; // 把if里的声明提升到函数顶部
console.log(name); // undefined(还没赋值)
if (true) {
name = "大厂的苗子"; // 赋值留在原地
}
}
函数里的name把外部的name“遮住”了(这叫“变量遮蔽”),而提升后的声明没赋值,所以输出“undefined”。这种bug新手根本找不到原因!
问题3:变量“活太久”,不该存在的时候还在
用var声明的变量,只有“全局作用域”和“函数作用域”,没有“块级作用域”(比如if、for的大括号里不算独立范围)。意思是:在if里声明的变量,出了if还能用。
if (true) {
var age = 18; // 用var声明
}
console.log(age); // 18,居然能拿到!
这就像你在电影院卫生间放了个包(if块里声明),出来后包还在手里(出了块还能用)——虽然偶尔方便,但很容易和其他变量搞混,造成命名冲突。
2.3 历史小八卦:为啥JS要搞变量提升?
不是开发者想搞复杂,是当年的历史原因逼的:
- 开发时间只有10天:1995年,网景公司要跟微软抢浏览器市场,让程序员Brendan Eich 10天内搞出一门脚本语言,用来做表单验证。时间太紧,只能怎么简单怎么来。
- 要兼容当时的开发习惯:当年主流语言(比如C)有“函数声明前置”的特性,JS为了让开发者容易上手,就借鉴了这个设计。
- 引擎实现简单:不搞块级作用域,把所有
var变量都提升到函数顶部,引擎处理起来特别简单,不用复杂逻辑。
说白了,变量提升是“为了赶工期,牺牲严谨性”的产物——就像为了快速盖房,先搭个简易框架,细节以后再补,结果留下一堆安全隐患。
三、ES6救场:let/const登场,把坑全填了
2015年,ES6(ECMAScript 2015)标准发布,专门针对var的坑,推出了let和const两个新关键字。这俩玩意儿带来了两个核心改进,直接把变量提升的坑全堵上了:
- 真正的块级作用域:变量只在大括号(
{})里有效,出了大括号就“消失”——相当于给变量建了个“专属房间”。 - 暂时性死区(TDZ) :变量声明前不能用,一用就报错——彻底杜绝“先上车后补票”。
现在开发的铁律:永远用let/const,彻底抛弃var! 记住这句话,能避免90%的作用域相关bug。
3.1 块级作用域:变量的“专属房间”
块级作用域的意思是:用let/const声明的变量,只在它所在的大括号({})里生效,出了这个大括号就找不到了——就像你把手机放在卧室(if块里),出了卧室(大括号外)就拿不到了。
咱们用let重写之前的if例子:
if (true) {
let age = 18; // 用let声明
}
console.log(age); // 报错:age is not defined
这次就报错了!因为age是用let在if块里声明的,属于“块级作用域”,出了if的大括号就不存在了。这才是正常人理解的“变量范围”!
块级作用域的实现原理:“抽屉叠叠乐”
之前说过,let/const变量存在“词法环境抽屉”里。这个抽屉是“栈结构”的,就像叠起来的饭盒,遵循“先进后出”规则:
- 进入一个块(比如
if、for、{}),就新叠一个饭盒(新的词法环境),把块里的let/const变量放进去。 - 找变量的时候,先从最上面的饭盒(当前块)找,找不到再往下翻。
- 出了这个块,最上面的饭盒就被收走(销毁),里面的变量也就没了。
看个复杂点的例子,感受一下“饭盒叠叠乐”:
function foo() {
var a = 1; // 变量环境抽屉(函数作用域)
let b = 2; // 词法环境第一层(函数块)
{
let b = 3; // 词法环境第二层(内层块)
var c = 4; // 变量环境抽屉(函数作用域)
let d = 5; // 词法环境第二层(内层块)
console.log(a); // 1(从变量环境找到)
console.log(b); // 3(从最上面的词法环境找到)
}
console.log(b); // 2(从词法环境第一层找到)
console.log(c); // 4(从变量环境找到)
console.log(d); // 报错(第二层词法环境被收走了)
}
执行到内层大括号时,词法环境有两个“饭盒”,翻的时候先翻上面的;出了内层块,上面的饭盒被收走,d就找不到了。
3.2 暂时性死区:变量的“禁止提前使用”警告
“暂时性死区”(TDZ)听着吓人,其实就是:用let/const声明的变量,在声明那行代码之前的区域,都是“死区”——绝对不能用这个变量,一用就报错。
就像你买了个新手机,没拆封(没声明)之前,绝对不能打电话,一打就提示“无法使用”。
{
console.log(name); // 报错:Cannot access 'name' before initialization
let name = '大厂'; // 声明在后面
}
对比var的“提前用输出undefined”,let直接报错——这其实是好事!它从根源上杜绝了“先使用后声明”的反人类操作,强迫你写规范的代码。
3.3 一张表分清var/let/const的区别
不用死记硬背,看这张表就够了:
| 特性 | var | let | const |
|---|---|---|---|
| 作用域 | 全局/函数作用域(无块级) | 块级作用域 | 块级作用域 |
| 提前使用 | 输出undefined | 报错(暂时性死区) | 报错(暂时性死区) |
| 重复声明 | 允许(会覆盖) | 不允许(报错) | 不允许(报错) |
| 赋值要求 | 可声明不赋值 | 可声明不赋值 | 必须赋值,且不能修改(针对基本类型) |
小技巧:写代码时先想“这个变量会不会改?”——不会改就用const(比如声明一个固定的URL),会改就用let(比如声明一个计数用的i),彻底不用var。
四、作用域链:变量的“查找地图”
咱们已经知道变量有不同的作用域了,那当代码用到一个变量时,小机器人怎么找到它呢?答案是:沿着“作用域链”一层一层往上找。
作用域链就是把嵌套的作用域串起来的“链条”,查找规则就一条:从当前作用域开始,往上找,找到第一个匹配的变量就用;如果找到全局作用域还没有,就报错。
就像你找一本《JS入门》:先在自己的书包(当前作用域)里找,没有就去客厅书架(外层作用域)找,再没有就去小区图书馆(全局作用域)找,还没有就说“没找到”(报错)。
4.1 作用域链的实际例子
// 全局作用域(小区图书馆)
var globalVar = "我是全局变量";
function myFunction() {
// 函数作用域(客厅书架)
var localVar = "我是局部变量";
function innerFunction() {
// 内层函数作用域(自己书包)
console.log(localVar); // 书包没有,去客厅找→输出“我是局部变量”
console.log(globalVar); // 书包没有,客厅没有,去图书馆找→输出“我是全局变量”
}
innerFunction();
console.log(localVar); // 客厅找到→输出“我是局部变量”
}
myFunction();
console.log(localVar); // 图书馆找不着→报错:localVar is not defined
这个例子的作用域链是:innerFunction作用域 → myFunction作用域 → 全局作用域。小机器人就是沿着这个链条找变量的,找到第一个就停手。
五、经典案例:var踩坑vs let救场(面试必考题)
光说理论不够,咱们看两个实际开发中最常踩的坑,对比var和let的表现——看完你就明白为啥必须抛弃var了。
案例1:循环里的定时器坑(90%的新手会踩)
先看用var的情况,你以为会输出0、1、2,结果全是3:
for (var i = 0; i < 3; i++) {
setTimeout(function() {
console.log(i); // 输出:3 3 3
}, 100);
}
这里要先搞懂一个关键:定时器里的函数是“延迟执行”的,它不会跟着循环一起跑,而是等循环跑完100毫秒后才开始干活。这就和var的作用域特性撞出了大bug:
var i是函数级作用域,整个循环共用一个i- 循环瞬间跑完,
i变成3 - 100毫秒后,3个定时器同时执行,都去拿当前的
i值(3)
解决方案:用let创建块级作用域
for (let i = 0; i < 3; i++) {
setTimeout(function() {
console.log(i); // 输出:0 1 2
}, 100);
}
这次为什么对了?因为let有块级作用域,每次循环都创建一个新的i,3个定时器各自记住自己那个i的值。
案例2:事件监听器的索引问题
假设要给3个按钮添加点击事件,显示各自的索引:
// var版本(错误)
var buttons = document.querySelectorAll('button');
for (var i = 0; i < buttons.length; i++) {
buttons[i].addEventListener('click', function() {
console.log('点击了第' + i + '个按钮'); // 永远显示“点击了第3个按钮”
});
}
// let版本(正确)
for (let i = 0; i < buttons.length; i++) {
buttons[i].addEventListener('click', function() {
console.log('点击了第' + i + '个按钮'); // 正确显示索引
});
}
var的问题还是作用域:所有点击事件共享同一个i,点击时i已经是3了。let为每次循环创建独立的i,完美解决。
六、实战建议:从此告别作用域坑
6.1 代码规范三原则
-
默认用const,需要改再用let:
const API_URL = 'https://api.example.com'; // 不会变的用const let isLoading = true; // 会变的用let -
永远不用var:把var当成“已废弃”的关键字
-
变量声明尽量靠近使用位置:
// 好:声明靠近使用 function processUser(user) { // ...其他代码 const name = user.name; // 用到的时候再声明 console.log(name); }
6.2 调试技巧:如何判断作用域问题
当你遇到“变量值是undefined”或者“变量值不对”时:
- 先问:这个变量是用var/let/const声明的?
- 再问:变量声明的位置在哪里?是不是被提升了?
- 最后问:当前代码在哪个作用域里?能访问到目标变量吗?
6.3 面试准备:必会的作用域问题
- var、let、const的区别(背熟第三节的表格)
- 暂时性死区是什么?举个例子
- 块级作用域解决了什么问题?举个var的坑和let的解决方案
- 什么是闭包?和作用域有什么关系?(这是进阶话题)
七、总结:从困惑到通透
JavaScript的作用域机制,经历了从“简单但坑多”到“复杂但严谨”的进化:
- ES5时代:只有var,变量提升、无块级作用域,写法自由但bug多
- ES6时代:引入let/const,块级作用域+暂时性死区,写法规范更安全
记住核心心法:
- 小机器人执行前先“备课”(编译阶段)
- var会“先上车后补票”(变量提升)
- let/const有“专属房间”(块级作用域)和“门禁系统”(暂时性死区)
- 变量查找沿着“作用域链”一层层往上找
现在回头看开头的例子,是不是完全能看懂了?作用域这个概念,就像学骑车——开始觉得平衡很难,一旦掌握了,就能骑得又快又稳。
下次写代码时,记得:const优先,let次之,var永不! 你的bug量会直接减半!