变量提升:JS里的“先上车后补票”(历史遗留坑)

78 阅读14分钟

先跟大家说句大实话:JavaScript这门语言,就像小区门口的智能门禁——表面看就刷个卡的事,背后却藏着识别、验证、开门一整套逻辑。尤其是“变量提升”“块级作用域”这些词,新手看了头大,老程序员偶尔也会踩坑。

今天咱们彻底抛弃黑话,用“备课上课”“租房住”这种接地气的例子,把这些知识点嚼碎了讲。目标是:就算你刚学会console.log,看完也能拍桌子说“原来这玩意儿是这么回事!”

阅读提示:文中所有专业术语,都会先给“人话翻译”,再配“生活案例”,最后上“代码验证”。全程不搞虚的,看不懂你找我!

一、核心前提:JS代码不是“从上到下直接跑”的

很多人以为JS代码是“读一行执行一行”,就像看小说从头翻到尾。但实际上,浏览器里的JS引擎(你可以理解成“执行JS的小机器人”),在执行代码前会先“偷偷做准备”——就像老师上课前要先备课,厨师做菜前要先备菜。

简单说:JS执行分两步走,编译备课阶段执行上课阶段。这是理解后面所有知识点的根基,务必吃透!

1.1 第一步:编译备课阶段——小机器人先“划重点”

小机器人拿到代码后,不会直接执行,先快速扫一遍,干两件关键事:

  1. 找“声明” :把代码里的变量声明(比如varletconst开头的)和函数声明(function开头的)全挑出来,记在小本本上。

  2. 建“工作区” :创建一个“执行上下文”(相当于小机器人的“工作区”),里面分两个抽屉:

    • 变量环境抽屉:放var声明的变量和函数声明——这是“旧款抽屉”,功能简单,兼容性好。
    • 词法环境抽屉:放letconst声明的变量——这是“新款抽屉”,支持分区收纳,功能更全。

举个例子,看这段代码小机器人会怎么备课:

console.log(myname); 
var myname = "路明非";

小机器人备课的时候会想:“哦,有个var声明的myname,先记到变量环境抽屉里,暂时不给值;后面那句console.log和赋值先不管,等执行阶段再说。”

1.2 第二步:执行上课阶段——小机器人按顺序“干活”

备课结束后,小机器人才会一行一行执行代码,执行的时候会随时翻“工作区”的抽屉找东西:

  1. 执行第一行console.log(myname):小机器人去变量环境抽屉里找myname,发现有这个名字但没赋值,就输出“undefined”。
  2. 执行第二行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声明的变量,只有“全局作用域”和“函数作用域”,没有“块级作用域”(比如iffor的大括号里不算独立范围)。意思是:在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的坑,推出了letconst两个新关键字。这俩玩意儿带来了两个核心改进,直接把变量提升的坑全堵上了:

  • 真正的块级作用域:变量只在大括号({})里有效,出了大括号就“消失”——相当于给变量建了个“专属房间”。
  • 暂时性死区(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是用letif块里声明的,属于“块级作用域”,出了if的大括号就不存在了。这才是正常人理解的“变量范围”!

块级作用域的实现原理:“抽屉叠叠乐”

之前说过,let/const变量存在“词法环境抽屉”里。这个抽屉是“栈结构”的,就像叠起来的饭盒,遵循“先进后出”规则:

  1. 进入一个块(比如iffor{}),就新叠一个饭盒(新的词法环境),把块里的let/const变量放进去。
  2. 找变量的时候,先从最上面的饭盒(当前块)找,找不到再往下翻。
  3. 出了这个块,最上面的饭盒就被收走(销毁),里面的变量也就没了。

看个复杂点的例子,感受一下“饭盒叠叠乐”:

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的区别

不用死记硬背,看这张表就够了:

特性varletconst
作用域全局/函数作用域(无块级)块级作用域块级作用域
提前使用输出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救场(面试必考题)

光说理论不够,咱们看两个实际开发中最常踩的坑,对比varlet的表现——看完你就明白为啥必须抛弃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:

  1. var i是函数级作用域,整个循环共用一个i
  2. 循环瞬间跑完,i变成3
  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 代码规范三原则

  1. 默认用const,需要改再用let

    const API_URL = 'https://api.example.com'; // 不会变的用const
    let isLoading = true; // 会变的用let
    
  2. 永远不用var:把var当成“已废弃”的关键字

  3. 变量声明尽量靠近使用位置

    // 好:声明靠近使用
    function processUser(user) {
        // ...其他代码
    
        const name = user.name; // 用到的时候再声明
        console.log(name);
    }
    

6.2 调试技巧:如何判断作用域问题

当你遇到“变量值是undefined”或者“变量值不对”时:

  1. 先问:这个变量是用var/let/const声明的?
  2. 再问:变量声明的位置在哪里?是不是被提升了?
  3. 最后问:当前代码在哪个作用域里?能访问到目标变量吗?

6.3 面试准备:必会的作用域问题

  1. var、let、const的区别(背熟第三节的表格)
  2. 暂时性死区是什么?举个例子
  3. 块级作用域解决了什么问题?举个var的坑和let的解决方案
  4. 什么是闭包?和作用域有什么关系?(这是进阶话题)

七、总结:从困惑到通透

JavaScript的作用域机制,经历了从“简单但坑多”到“复杂但严谨”的进化:

  • ES5时代:只有var,变量提升、无块级作用域,写法自由但bug多
  • ES6时代:引入let/const,块级作用域+暂时性死区,写法规范更安全

记住核心心法

  • 小机器人执行前先“备课”(编译阶段)
  • var会“先上车后补票”(变量提升)
  • let/const有“专属房间”(块级作用域)和“门禁系统”(暂时性死区)
  • 变量查找沿着“作用域链”一层层往上找

现在回头看开头的例子,是不是完全能看懂了?作用域这个概念,就像学骑车——开始觉得平衡很难,一旦掌握了,就能骑得又快又稳。

下次写代码时,记得:const优先,let次之,var永不! 你的bug量会直接减半!