JavaScript 作用域与变量提升:从编译到执行的完整过程

69 阅读12分钟

JavaScript 变量声明机制详解:var、let、const 与执行机制的深度剖析

JavaScript 是一门动态类型语言,变量的声明和使用方式在代码中扮演着非常关键的角色。随着 ES6 的引入,letconst 成为了主流的变量声明方式,而传统的 var 虽然仍在使用,但其行为特性也常常引发误解。

本文将从 JavaScript 的执行机制出发,深入讲解变量的声明提升(hoisting)作用域规则、以及 varletconst 的本质区别,帮助你真正理解它们之间的差异及其背后的工作原理。


一、JavaScript 执行机制概览

要理解变量的声明和作用域,我们首先需要了解 JavaScript 是如何执行代码的。

当浏览器运行一段 JavaScript 代码时,大致会经历以下几个阶段:

  1. 从硬盘读入内存:JavaScript代码执行始于硬盘加载: 文件从磁盘/网络缓存读取到内存,转换为可执行数据。V8引擎接管后,先进行词法分析(Scanner)和语法分析(Parser),生成抽象语法树(AST)。解释器Ignition将代码转为字节码,热点代码经TurboFan编译器优化为机器码。内存分为新生代(频繁回收短期对象)和老生代(存长期对象),通过JIT动态优化性能。浏览器与Node.js环境加载机制差异在于网络请求与直接文件I/O,但核心执行流程相同。

  2. 由 V8 引擎解析并执行

    V8 是 Chrome 浏览器的核心引擎,V8 引擎是 JavaScript 执行的核心,负责将代码转换为机器可执行的指令。其编译阶段分为几个关键步骤:

  • 词法分析(Scanner) :将源代码拆分为 Token(如变量名、运算符)。

  • 语法分析(Parser) :基于 Token 生成抽象语法树(AST) ,检查语法错误。

  • 字节码生成(Ignition 解释器) :将 AST 转为字节码,快速启动执行。

  • 优化编译(TurboFan) :对热点代码(频繁执行部分)进行即时编译(JIT),生成高效机器码。

    V8 采用分代垃圾回收(新生代/老生代)管理内存,并通过内联缓存(IC) 加速属性访问。编译阶段兼顾启动速度(字节码)与长期性能(机器码优化),是 JavaScript 高效运行的关键。

  1. 编译阶段

    JavaScript 引擎在正式执行代码之前,会进入一个叫做 编译阶段 的过程。在这个阶段,引擎会做以下几件事:

  • 创建执行上下文(Execution Context)
  • 扫描整个函数体或全局代码
  • 收集所有变量和函数声明
  • 为这些声明分配内存空间,并设置初始值
  • 建立作用域链(Scope Chain)

⚠️ 注意:这里的“编译”不是像 C++ 或 Java 那样生成机器码的过程,而是 JS 引擎对代码结构的预处理。


3.1、执行上下文与编译阶段的关系

在 JavaScript 中,每一个函数调用都会创建一个新的执行上下文,而全局代码也会有一个全局执行上下文。

执行上下文的生命周期分为两个主要阶段:

阶段描述
创建阶段(Creation Phase)编译阶段,变量和函数声明被处理,作用域链和 this 被确定
执行阶段(Execution Phase)代码实际被执行,变量赋值、函数调用等

我们重点来看创建阶段,也就是编译阶段。


3.2、编译阶段做了什么?

1. 变量声明收集(Variable Environment)

引擎会扫描当前作用域内的所有变量声明(varletconst),并为其分配内存空间。

  • 对于 var 声明的变量,初始化为 undefined
  • 对于 let 和 const 声明的变量,初始化为一个特殊状态 uninitialized(未初始化)
  • 这些变量会被存储在一个叫 词法环境(Lexical Environment)  或 变量对象(Variable Object)  的地方
示例 1:var 的变量提升

javascript

console.log(name); // undefined
var name = 'Alice';

在编译阶段,引擎已经知道 name 存在,并将其初始化为 undefined,所以第一行不会报错。

示例 2:let 和 const 的暂时性死区(TDZ)

javascript

console.log(age); // ReferenceError
let age = 20;

尽管 age 在编译阶段就被识别了,但它处于 未初始化状态(uninitialized) ,访问它会抛出错误。


2. 函数声明提升(Function Declaration Hoisting)

函数声明会在编译阶段被完整地提升到作用域顶部。

示例 3:函数声明可以提前调用

javascript

sayHello(); // 输出: Hello!
function sayHello() {
    console.log("Hello!");
}

在编译阶段,函数定义就会被完整地放入变量环境中,所以在调用时可以直接使用。

示例 4:函数表达式不会提升函数体

javascript

sayHi(); // TypeError: sayHi is not a function
var sayHi = function () {
    console.log("Hi!");
};

这里 sayHi 是一个变量,它的声明(var sayHi)被提升了,但赋值(函数表达式)是在执行阶段才完成的。


3. 作用域链构建(Scope Chain Creation)

在编译阶段,JS 引擎还会构建当前执行上下文的作用域链。

作用域链决定了变量查找的路径:从当前作用域开始,依次向上查找父级作用域,直到全局作用域。

示例 5:嵌套作用域中的变量查找

javascript

var x = 10;
function outer() {
    var y = 20;
    function inner() {
        var z = 30;
        console.log(x); // 查找路径:inner -> outer -> 全局
    }
    inner();
}
outer();

在编译阶段,inner 函数的作用域链就已经确定,包括它自己的作用域、外层 outer 的作用域和全局作用域。


3.3、编译阶段 vs 执行阶段对比

阶段主要任务特点
编译阶段收集变量和函数声明
建立作用域链
为变量分配内存
不执行任何赋值操作
var 初始化为 undefined
let/const 状态为 uninitialized
执行阶段执行代码逻辑
给变量赋值
调用函数
实际运行语句
变量真正获得值
函数体被调用执行

3.4、严格模式("use strict")下的影响

在严格模式下,LHS 查找失败不会自动创建全局变量,这会影响我们在编译阶段的行为判断。

示例 6:严格模式下的 LHS 错误

javascript

"use strict";
a = 10; // ReferenceError: a is not defined

在非严格模式下,这段代码会在全局作用域中自动创建一个变量 a。但在严格模式下,引擎会在编译阶段发现 a 没有被声明,于是直接抛出错误。


在 JavaScript 中,LHS(Left-Hand Side)查找RHS(Right-Hand Side)查找是变量在作用域中被引用的两种基本方式。它们帮助我们理解 JavaScript 引擎是如何在执行代码时处理变量赋值与变量访问的。


3.5、什么是 LHS 和 RHS 查找?

这两个术语来源于赋值操作的形式:

javascript

a = 2;

在这个表达式中:

  • a 是 左值(LHS) :表示“谁是赋值的目标?”
  • 2 是 右值(RHS) :表示“谁是赋值的来源?”

但在 JavaScript 中,LHS 和 RHS 更准确地指的是变量的查找方式,而不是字面上的左右位置。

🔍 简单定义:

  • LHS 查找:查找变量是为了对其进行赋值
  • RHS 查找:查找变量是为了获取它的

3.6、LHS 和 RHS 的区别

我们来看几个例子来说明两者的不同。

示例 1:基本赋值

javascript

var a = 10;
  • 对 a 的查找是 LHS,因为是在给 a 赋值。
  • 如果在当前作用域中找不到 a,引擎会在作用域链中继续向上查找;如果最终没找到,并且处于非严格模式下,JavaScript 会自动在全局作用域中创建一个全局变量 a

⚠️ 注意:在严格模式("use strict")下,如果 LHS 查找失败,JavaScript 不会自动创建变量,而是抛出一个 ReferenceError。


示例 2:使用变量

javascript

console.log(a);
  • 这里对 a 的查找是 RHS,因为我们要读取 a 的值。
  • 如果找不到 a,就会抛出一个 ReferenceError

示例 3:函数参数传入

javascript

function foo(b) {
    console.log(b); // RHS
}
foo(20);
  • 在调用 foo(20) 时,参数 b 的赋值是一个 LHS 查找(将 20 赋给 b)。
  • 函数内部的 console.log(b) 是对 b 的 RHS 查找(读取其值)。

示例 4:嵌套作用域中的查找

javascript

function bar() {
    var c = 30;

    function baz() {
        console.log(c); // RHS 查找
    }

    baz();
}

bar();
  • 在 baz() 中对 c 的查找是 RHS,从当前作用域开始查找,直到父级作用域 bar 找到 c

3.7、LHS 与 RHS 的行为差异总结

特性LHS 查找RHS 查找
目的找到变量用于赋值找到变量用于读取值
未找到的行为非严格模式:自动创建全局变量
严格模式:报错
报错(ReferenceError)
常见场景变量赋值、函数参数赋值使用变量、作为函数返回值、条件判断等

3.8、LHS 和 RHS 与作用域链的关系

无论 LHS 还是 RHS 查找,都是沿着作用域链进行的。

作用域链的查找顺序是从当前作用域向外扩展,逐层向上查找,直到全局作用域为止。

例如:

javascript

var x = 100;

function outer() {
    var y = 200;

    function inner() {
        var z = 300;
        console.log(x); // RHS 查找,跨作用域链查找
        y = 500; // LHS 查找,在 outer 中找到 y 并修改
    }

    inner();
}

outer();
  • console.log(x) 是 RHS 查找,从 inner → outer → 全局,找到 x
  • y = 500 是 LHS 查找,同样从 inner → outer 找到 y

3.9、LHS 和 RHS 的错误类型

当查找失败时,JavaScript 会抛出不同的错误类型,这也有助于我们在调试时快速定位问题。

1. ReferenceError

  • 表示变量不存在或无法访问。
  • 通常发生在 RHS 查找失败 或 LHS 查找失败且在严格模式下

javascript

console.log(notExist); // ReferenceError: notExist is not defined

2. TypeError

  • 表示变量存在,但对其进行了不合法的操作。
  • 通常不是查找问题,而是类型错误。

javascript

var foo = 42;
foo(); // TypeError: foo is not a function

3.10、总结:编译阶段的核心要点

内容说明
发生时间在代码执行前,属于执行上下文的创建阶段
变量提升var 声明的变量被提升并初始化为 undefined
函数提升函数声明被完整提升,可以在定义前调用
let/const被提升但未初始化,访问它们会进入“暂时性死区”
作用域链构建编译阶段就确定了变量查找路径
目的提高执行效率,避免重复解析代码结构

二、执行上下文与变量环境

在执行代码前,JavaScript 引擎会构建一个执行上下文(Execution Context) ,它包含了以下核心内容:

  • 变量对象(Variable Object, VO)
  • 作用域链(Scope Chain)
  • this 值

在全局环境中,VO 就是全局对象(window 或 globalThis)。而在函数内部,VO 包含了函数参数、局部变量、函数声明等。

例如:

javascript

var showName = 'Tom';
function myName() {
    var name = 'Jerry';
}

在这个例子中,全局执行上下文的 VO 会包含:

javascript

{
    showName: undefined,
    myName: function reference to myName()
}

📌 这就是所谓的变量提升(hoisting)


三、变量提升(Hoisting)

变量提升是指 JavaScript 引擎在编译阶段自动将变量和函数的声明提升到当前作用域顶部的行为。

1. var 的变量提升

对于 var 声明的变量,其声明会被提升,但赋值不会

看下面的例子:

javascript

console.log(name); // 输出: undefined
var name = 'Alice';

这段代码在执行前会被处理成:

javascript

var name;
console.log(name); // undefined
name = 'Alice';

也就是说,变量 name 的声明被提前到了作用域顶部,但赋值操作仍保留在原地。

2. 函数声明的提升

函数声明也会被提升,并且整个函数体都会被提升

javascript

sayHello(); // 输出: Hello!
function sayHello() {
    console.log("Hello!");
}

这在执行前会被处理为:

javascript

function sayHello() {
    console.log("Hello!");
}
sayHello();

四、let 和 const 的声明提升吗?

这是很多开发者容易混淆的地方:letconst 是否也被提升?

答案是:是的,它们确实被提升了,但不会被初始化!

这种现象被称为 暂时性死区(Temporal Dead Zone, TDZ)

1. let 的行为

javascript

console.log(name); // 报错: Cannot access 'name' before initialization
let name = 'Bob';

虽然 name 被提升了(即变量已经在内存中存在),但它在赋值前处于“未初始化状态”,访问它会抛出错误。

执行前的伪代码如下:

javascript

let name; // 提升了,但未赋值
console.log(name); // 报错
name = 'Bob';

2. const 的行为

constlet 类似,但必须在声明时就赋值,否则会报错。

javascript

const PI; // 报错: Missing initializer in const declaration

javascript

console.log(PI); // 报错: Cannot access 'PI' before initialization
const PI = 3.14;

同样,const 的声明也被提升,但在赋值前不能访问。


五、作用域(Scope)与作用域链(Scope Chain)

变量的作用域决定了它的可访问范围。JavaScript 支持三种作用域:

  • 全局作用域(Global Scope)
  • 函数作用域(Function Scope)
  • 块级作用域(Block Scope)

1. 全局作用域

在全局定义的变量可以在任何地方访问。

javascript

var globalVar = "I'm global";
function test() {
    console.log(globalVar); // 正常输出
}
test();

2. 函数作用域

函数内部用 var 声明的变量只能在该函数内访问。

javascript

function foo() {
    var localVar = "I'm local";
    console.log(localVar);
}
foo();
console.log(localVar); // 报错: localVar is not defined

3. 块级作用域(ES6 新增)

letconst 引入了块级作用域的概念。

javascript

if (true) {
    let blockVar = "I'm block scoped";
}
console.log(blockVar); // 报错: blockVar is not defined

4. 作用域链(Scope Chain)

当查找一个变量时,JavaScript 引擎会从当前作用域开始查找,如果没有找到,就向上一层作用域查找,直到全局作用域为止。

javascript

var a = 10;

function outer() {
    var b = 20;

    function inner() {
        var c = 30;
        console.log(a + b + c); // 60
    }

    inner();
}

outer();

在这个例子中,inner 函数能访问到 abc,是因为作用域链的存在:

javascript

inner -> outer -> global

这就是所谓的冒泡查找机制。


六、var、let、const 的对比总结

特性varletconst
是否提升是(仅声明)是(仅声明)是(仅声明)
初始值undefined未初始化必须赋值
是否允许重复声明
是否有块级作用域
是否可修改值否(引用地址不可变)

七、实际开发中的选择建议

1. 避免使用 var

由于 var 存在变量提升和函数作用域的限制,容易导致变量污染和逻辑混乱。除非在兼容旧项目中有必要,否则不推荐使用。

2. 使用 const 作为默认选项

如果你知道变量不会被重新赋值,优先使用 const。这样可以避免意外修改变量,提高代码的可维护性和健壮性。

3. 使用 let 当变量需要重新赋值

如果变量会在后续过程中被多次修改,使用 let

javascript

let count = 0;
count++; // 合法