JavaScript 变量提升,执行上下文里的各种门道

0 阅读5分钟

明明写在后面的函数,在前面调用却不报错?
明明没定义的变量,打印出来是 undefined?
这背后藏着 JS 代码执行的秘密:编译阶段 + 执行阶段


一、先看一段“奇怪”的代码

打开浏览器控制台,输入下面这段:

js

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

你觉得输出是什么?
我一开始猜:第一行报错 showName is not defined,第二行也报错。
结果输出是:

text

函数showName被执行了
undefined

函数调用成功了,变量打印是 undefined 而不是报错。
这不符合“一行一行顺序执行”的直觉。

再改一下:

js

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

输出 undefined,而不是函数体。
同样声明函数,function showName(){} 可以提前调用,var add = function(){} 却不行。

为什么?


二、变量提升(Hoisting):编译阶段的小动作

JavaScript 代码在执行前,会先经历一个 编译阶段
这个阶段会把 变量声明 和 函数声明 提升到当前作用域的顶部。

注意:提升的是声明,不是赋值。而且函数声明的优先级高于变量声明。

2.1 模拟变量提升后的代码

上面的代码在编译后,大概变成这个样子:

js

// ----- 编译阶段:变量提升部分 -----
var myName = undefined;                 // 变量声明提升,默认值 undefined
function showName() {                   // 函数声明整体提升
    console.log('函数showName被执行了');
}

// ----- 执行阶段:可执行代码 -----
showName();                             // 此时函数已经在内存中,调用成功
console.log(myName);                    // 输出 undefined
myName = '极客时间';                    // 赋值操作留在原地

这就是 变量提升 的模拟过程。
函数 showName 被完整提升,所以可以在定义前调用。
var myName 只提升了声明(赋值为 undefined),赋值语句留在原地,所以打印时是 undefined

058ce0f2d535dba5b07b145dd814696c.png

图片里也展示了这个转换过程:左边是原始代码,右边是提升后的代码。


三、var 的声明和赋值是“两回事”

8892861e62badeaf8c26f3f2caad602c.png

js

var myName = '极客时间';

这行代码可以拆成两步:

阶段操作含义
编译阶段var myName声明变量,默认值 undefined
执行阶段myName = '极客时间'赋值,覆盖 undefined

所以写 var myName = '极客时间',在编译后等价于:

js

var myName = undefined;   // 提升到顶部
// ... 其他代码 ...
myName = '极客时间';      // 原地赋值

这就是为什么在赋值前打印变量,得到的是 undefined,而不是报错。


四、函数声明 vs 函数表达式

ee43fc3beefe60eda2e62b34b7a22560.png

写法类型提升方式
function foo() {}函数声明整体提升(函数体一起提升)
var bar = function() {}函数表达式只提升 var bar(值为 undefined),赋值留在原地

所以:

js

foo();                 // 正常执行
function foo() { console.log('foo'); }

bar();                 // TypeError: bar is not a function
var bar = function() { console.log('bar'); };

编译后等价于:

js

// 提升部分
function foo() { console.log('foo'); }
var bar = undefined;

// 执行部分
foo();
bar();                // bar 还是 undefined,调用报错
bar = function() { console.log('bar'); };

图片中有一张对比图,明确标注了函数声明是“完整的函数声明”,函数表达式是“声明 + 赋值”两步。


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

当浏览器(比如 Chrome 的 V8 引擎)拿到一段 JS 代码时,会先编译,再执行。

编译阶段产出

  1. 执行上下文(Execution Context)
    这是代码执行所需的环境,里面包含:

    • 变量环境(Variable Environment) :存放 var 声明的变量和函数声明
    • 词法环境(Lexical Environment) :存放 letconst 声明的变量
    • 其他(如 this 绑定)
  2. 可执行代码
    去掉声明部分后,剩下的赋值、函数调用等语句。

举个例子,输入这段代码:

js

showName();
console.log(myName);
var myName = '极客时间';
function showName() {
    console.log('showName被调用');
}

编译后:

  • 执行上下文 中:
    变量环境:myName = undefinedshowName = function...
    词法环境:空(因为没有 let/const

  • 可执行代码 为:

    js

    showName();
    console.log(myName);
    myName = '极客时间';
    

然后进入 执行阶段,一行一行运行可执行代码。

图片中有流程图:输入一段 JavaScript 代码 → 编译 → 执行上下文 + 可执行代码 → 执行 → 输出结果。


六、let 和 const 怎么不一样?

let 和 const 也会提升,但处于 暂时性死区(TDZ) ,在声明前访问会报错。

js

console.log(myname);   // ReferenceError: Cannot access 'myname' before initialization
let myname = '极客时间';

为什么?
因为 let 声明的变量不在 变量环境 中,而在 词法环境 中。
词法环境里的变量在声明前是不可访问的,这叫暂时性死区。

adfbc813b22cb07cf1592d72316b58e0.png

图片中展示了“变量环境”和“词法环境”的分工:
var 和函数声明放变量环境,let/const 放词法环境。


七、整个流程回顾

  1. 编译阶段

    • 创建执行上下文(变量环境 + 词法环境)
    • 将 var 声明和函数声明放入变量环境,初始值 undefined
    • 将 let/const 声明放入词法环境,但处于 TDZ,不可访问
    • 生成可执行代码
  2. 执行阶段

    • 按顺序执行可执行代码
    • 遇到变量赋值,修改变量环境或词法环境中的值
    • 遇到函数调用,去执行上下文里找函数

26d7da52d4011f93b416cf6080d683d6.png


八、总结与建议

声明方式提升初始值能否提前访问
varundefined可以(值是 undefined)
function 声明函数对象可以(正常调用)
let是(TDZ)未初始化不可以(报错)
const是(TDZ)未初始化不可以(报错)
函数表达式 (var f = function)只提升 var fundefined不可以(调用报错)

我的习惯

  • 默认用 let 和 const,别用 var
  • 函数声明放在文件顶部,变量声明也尽量靠近顶部
  • 不要在声明前使用变量,即使对 var 可以,也不利于理解

变量提升是 JS 设计早期的一个“特性”,虽然理解它有点绕,但一旦搞懂了编译阶段和执行阶段的区别,后面看代码就清晰多了。