拆穿 JavaScript 变量提升的"魔术"——从一段反直觉代码说起

0 阅读7分钟

拆穿 JavaScript 变量提升的"魔术"——从一段反直觉代码说起


一、开场先看一段"反直觉"的代码

showName();
console.log(myName);
console.log(add);

var myName = '极客时间';

function showName() {
    console.log('函数showName被执行了');
}

var add = function (x, y) {
    return x + y;
};

如果你刚学 JavaScript,你的大脑大概会这样运转:

"showName 还没定义怎么就调用了?myName 还没声明就 console.log?这不全得报错吗?"

来,按一下回车——

函数showName被执行了
undefined
undefined

不仅没报错,showName() 居然还正确执行了myName 也没报错,只是乖乖地输出了 undefined

这就像你在餐厅点了一份还没上菜单的菜,厨师不仅没赶你走,还真给你端了一盘"空气炒肉"——盘子来了,肉还在锅里。

这就是 JavaScript 圈里大名鼎鼎的「变量提升 (Hoisting)」。


二、变量提升到底是什么?

变量提升,是指 JavaScript 引擎(V8)在执行代码之前,把变量和函数的声明部分提前到作用域顶部,并给变量赋默认值 undefined

看一段简化后的模拟代码就清楚了:

// === 你写的代码 ===
console.log(myname);
var myname = '极客时间';

// === JS 引擎眼中的代码(模拟提升后) ===
var myname = undefined;  // 声明被"提升"到顶部,默认值是 undefined
console.log(myname);      // undefined
myname = '极客时间';      // 赋值留在原地

2fd80db316dcb9e13e4586ddf1356633.png 函数声明的待遇更高——连函数体一起提升

// === 你写的代码 ===
showName();
function showName() {
    console.log('函数被执行了');
}

// === JS 引擎眼中的 ===
function showName() {
    console.log('函数被执行了');
}
showName();  // 函数声明整体提升,所以可以先调用,再声明

但注意,函数表达式不享受这个待遇

485c667e10e3719870fdd424e58824ec.png

console.log(add);   // undefined
var add = function (x, y) { return x + y; };

add 用的是 var 声明,提升的只是变量 add 本身(值为 undefined),赋值操作(函数体)仍然在原地,所以此时调用 add() 会报错 TypeError: add is not a function


b5f80a96c8c763fe313a15fde9e095a1.png

三、JS 真的是一行一行执行的吗?

如果你真的以为 JS 是老老实实从上到下逐行执行的,变量提升就是你撞上的第一道墙。

总结三条"诡异现象":

现象结果
使用完全未声明的变量报错 ReferenceError ✓
在 var 声明之前使用该变量不报错,值是 undefined
在函数声明之前调用该函数不报错,正常执行

如果代码真的是一行一行执行的,后面两条根本说不通。

真相是:JavaScript 的执行分为「编译阶段」和「执行阶段」。

5769bf4bd42441d7b551d4aaedd11169.png

JS 是动态脚本语言,没有像 C/Java 那样独立的编译过程,但在代码执行前的"那一刹那",V8 引擎会快速完成一轮编译——生成执行上下文 (Execution Context)可执行代码。编译阶段为执行阶段铺好路,执行阶段才真正逐行跑代码。


四、编译阶段到底干了什么?

输入一段代码,编译阶段结束后,V8 吐出两样东西:

  1. 执行上下文(Execution Context) ——代码运行所需的"环境配置"
  2. 可执行代码——编好的字节码,供执行阶段使用

执行上下文就是一段代码的"户口本",记录着:

  • 变量环境 (Variable Environment)var 声明的变量和函数声明住在这里
  • 词法环境 (Lexical Environment)let / const 声明的变量住在这里
  • this 指向
  • 对外部环境的引用(作用域链)

编译阶段做了一件至关重要的事:遍历代码,把所有的声明找出来,在对应的环境中分配好内存空间。

这个过程就是变量提升的物理实现——变量和函数声明的位置没有移动一厘米,只是提前在内存里占好了坑。

所以"变量提升意味着代码被物理移动到顶部"这个说法,严格来说是不准确的。代码原地不动,但内存分配已经提前完成了。


五、变量环境 vs 词法环境——let/const 为什么不一样?

来,再跑一段代码:

console.log(myname);        // 变量环境
let myname = '极客时间';    // 词法环境

结果呢?

ReferenceError: Cannot access 'myname' before initialization

报错了!let 不是也有提升吗?

letconst 确实也有提升,但它们和 var 不同流合污。

关键区别:

varlet / const
住在哪里变量环境词法环境
提升时赋默认值吗✅ 赋值为 undefined❌ 不赋默认值
声明前访问返回 undefined报错(暂时性死区 TDZ)

let 声明的变量在编译阶段同样分配了内存,但这个内存空间位于词法环境中。从代码开头到 let 声明之间的区域,变量处于 "暂时性死区" (Temporal Dead Zone, TDZ) ——内存已分配,但你不能碰它。

这就像一个酒店房间:var 的房间提前挂好了门牌号,你可以先进去,里面是空的;let 的房间也挂好了门牌号,但门锁死了,必须等到"入住时间"(声明语句执行)才能进去,否则直接吃闭门羹(ReferenceError)。

// 暂时性死区示意
// ↓ TDZ 开始
console.log(myname);  // 💥 ReferenceError: Cannot access 'myname' before initialization
// ↑ TDZ 结束
let myname = '极客时间';

六、函数提升的"特权"

同样是声明,为什么函数声明比变量声明更"高级"?

因为函数是一等公民(first-class object)。 在编译阶段,函数声明不仅分配了内存,还把整个函数体都存了进去。

// 编译阶段的函数声明
// 变量环境中:
// showName → 指向函数对象的引用(已包含完整函数体)

所以你在函数声明之前调用它,V8 在变量环境中找到的已经是一个完整的函数对象了,执行起来自然毫无压力。

var add = function(){} 这种函数表达式,本质上只是一个 var 变量提升——addundefined,赋值操作要等到执行阶段才发生。


七、完整的编译→执行过程拆解

让我们重新审视开头那段代码,用 V8 的视角完整走一遍:

showName();
console.log(myName);
console.log(add);
var myName = '极客时间';
function showName() {
    console.log('函数showName被执行了');
}
var add = function (x, y) {
    return x + y;
};

🔧 编译阶段

变量环境:
  myName  → undefined           (var 声明,分配内存,赋默认值)
  add     → undefined           (var 声明,分配内存,赋默认值)
  showName → <function 对象>    (函数声明,整体提升)

▶️ 执行阶段(逐行执行)

1行:showName()
  → 变量环境中找到 showName 函数对象 → 执行 → 控制台输出 "函数showName被执行了"2行:console.log(myName)
  → 变量环境中找到 myName,值为 undefined → 输出 undefined3行:console.log(add)
  → 变量环境中找到 add,值为 undefined → 输出 undefined4行:myName = '极客时间'
  → 变量环境中 myName 的值从 undefined 更新为 '极客时间'5-7行:showName 函数声明 → 编译阶段已处理,执行阶段跳过

第8-10行:add = function(x,y){ return x+y }
  → 变量环境中 add 的值从 undefined 更新为一个函数对象

输出结果完美吻合:

函数showName被执行了
undefined
undefined

254406823b2a9bededf079ff7bba7381.png

八、面试高频:函数声明 vs 函数表达式

// ✅ 可以正常工作
foo();  // "hello"
function foo() {
    console.log('hello');
}

// ❌ 报错:TypeError: bar is not a function
bar();
var bar = function () {
    console.log('world');
};
// 此时 bar 是 undefined,不能当函数调用

本质还是提升的差异——函数声明整体提升,函数表达式只提升了变量名,函数体还在原地。


九、一句话总结

  1. 变量提升不是物理移动代码,而是编译阶段的内存分配行为
  2. var 和函数声明 住在变量环境里,编译后就可用(var → undefined,函数声明 → 完整函数对象)
  3. let 和 const 住在词法环境里,编译后内存已分配但被 TDZ 锁住,声明前访问直接报错
  4. JS 不是逐行执行的——编译阶段做"准备工作",执行阶段才真正跑代码
  5. 函数是一等公民,提升权重最高——声明+定义一起提

写完最大的感受是:变量提升不是什么玄学,它就是 JS 引擎在编译阶段分配内存的一种策略。 理解了执行上下文、变量环境和词法环境的分工,所谓的"提升"不过是一个自然的结果。

正如太阳升起不是太阳在绕着地球转,代码"看起来"被提升到了顶部,也不是代码真的移动了——而是引擎在你看不见的地方,提前做好了准备。