JS 执行机制的「一国两制」:变量提升与块级作用域的协同之道

142 阅读12分钟

一、变量提升:代码执行前的 “隐形操作”

先看一段看似 “逻辑颠倒” 的代码,感受变量提升的存在:

showName();
console.log(username);    // undefined
// 全局变量声明
var username = "张三";
function showName (){
    console.log('函数showName 执行了')
}

执行结果

函数showName 执行了
undefined

为什么函数能在定义前调用?变量未赋值却不报错?核心原因是:

JavaScript 引擎在代码实际执行前,会进行 “预编译”——var 声明的变量和 function 声明的函数,会被 “悄悄提升” 到当前作用域的顶部。这就是代码顺序颠倒却能正常运行的关键。

预编译后的 “真实执行顺序”

原始代码经过引擎处理后,会变成以下逻辑(相当于引擎的 “内部版本”):

// 1. 函数声明提升:完整提升(函数名+函数体)到作用域顶部
function showName (){
    console.log('函数showName 执行了')
}

// 2. var 变量提升:仅提升“声明”,不提升“赋值”,默认值为 undefined
var username; 

// -------------- 预编译结束,开始执行代码 --------------
// 3. 执行函数调用
showName();

// 4. 打印未赋值的变量(此时为默认值 undefined)
console.log(username); 

// 5. 给提升后的变量赋值
username = "张三";

关键细节:提升优先级与函数表达式差异

1. 为什么函数提升在 var 前面?

function 声明的提升优先级高于 var 变量:

  • 函数会被完整提升(包含函数体),直接成为可执行状态;
  • var 仅提升 “变量声明”,赋值逻辑仍留在原地,提升后默认值为 undefined
2. 函数声明 vs 函数表达式(关键区别!)

只有 function 声明式 会完整提升,而 var 函数表达式(如 var fn = function(){})本质是普通 var 变量,仅提升变量名,不提升函数体:

// 先调用函数(定义在后面)
showName(); // 报错:showName is not a function(此时 showName 是 undefined)

// 函数表达式定义(var 变量 + 匿名函数赋值)
var showName = function() {
    console.log("函数showName 执行了");
};

其真实执行顺序:

// 仅提升变量名,值为 undefined
var showName;

// 调用时 showName 是 undefined,不是函数 → 报错
showName();

// 赋值逻辑留在原地执行(此时才给变量绑定函数)
showName = function() {
    console.log("函数showName 执行了");
};

变量提升带来的问题与解决方法

问题 1:变量声明前可访问(值为 undefined),逻辑混乱;
问题 2:var 无块级作用域,变量易穿透污染;
问题 3:允许重复声明,覆盖原有变量引发 bug。
解决方法:
  • 避免使用 var,优先用 let/const(无提升、支持块级作用域);
  • 若需兼容旧代码,遵循 “变量声明置顶” 原则,手动将 var 变量和函数声明移至作用域顶部。

二、块级作用域:{} 圈起来的 “独立变量空间”

什么是块级作用域?

块级作用域指:变量 / 函数的作用范围被限制在一对花括号 {} 内,仅在该代码块(如 ifforwhiletry/catch 或直接用 {} 包裹的代码)中有效,外部无法访问。

简单理解:块级作用域 = 用 {} 圈起来的 “独立房间”,变量出了这个房间就失效。

块级作用域的核心特点

  1. 变量仅在当前代码块内可见;
  2. 避免 “变量提升穿透”,防止变量污染;
  3. 支持嵌套(内部块可访问外部块变量,反之不行)。

经典误区:var 的 “函数作用域” vs 块级作用域

先看这段代码,为什么最后打印 localVar 会报错?

var globalVar = "全局变量";
function myFunction(){
    var localVar = "局部变量";
    console.log(globalVar); // 输出:全局变量
    console.log(localVar);  // 输出:局部变量
}
myFunction();
console.log(globalVar); // 输出:全局变量
console.log(localVar);  // 报错:ReferenceError: localVar is not defined

很多人会疑惑:var 不支持块级作用域,为什么 localVar 不能在外部打印?核心是要分清 函数作用域 和 块级作用域 的区别:

  • var 不支持块级作用域 → if/for 等 {} 块内的 var 会 “穿透” 块,影响外部(比如 if 块内的 var 会成为函数作用域变量);
  • 但 var 支持函数作用域 → 函数内用 var 声明的变量,会被限制在函数内部,外部完全访问不到(这就是 localVar 报错的原因)。

用两个例子直观对比:

// 例子1:var 不支持块级作用域(if 块内的 var 穿透到函数作用域)
function testBlock() {
    if (true) {
        var x = 10; // 无块级作用域,x 属于函数作用域
    }
    console.log(x); // 输出:10(x 穿透了 if 块,函数内可访问)
}
testBlock();

// 例子2:var 支持函数作用域(函数内的 var 无法穿透到外部)
function testFunction() {
    var y = 20; // 函数作用域变量
}
testFunction();
console.log(y); // 报错:y is not defined(外部访问不到函数内的 var 变量)

实战对比:var vs let 的块级作用域差异

第一段代码(var + if (true))
var name = "张三";
function showName() {
    console.log(name); // undefined(函数内 var name 提升,遮蔽全局 var,未赋值)
    if (true) {
        var name = "李四"; // var 无块级作用域,穿透到函数作用域,给提升后的变量赋值
    }
    console.log(name); // 输出:李四(函数内变量已赋值)
}
showName();

核心逻辑var 有变量提升、无块级作用域,函数内的 var name 提升后遮蔽全局变量,if (true) 执行后赋值生效,最终输出 undefined → 李四

第二段代码(let + if (false))
let name = "张三";
function showName() {
    console.log(name); // 输出:张三(let 无提升,函数内无同名变量,直接访问全局 let)
    if (false) {
        let name = "李四"; // let 有块级作用域,但 if 不执行,变量未声明
    }
}
showName();

核心逻辑let 无变量提升、支持块级作用域,if (false) 导致块内变量未执行,函数内无同名变量遮蔽全局,最终仅输出 张三

一句话总结
  • var 提升 + 无块级作用域 → 函数内变量遮蔽全局,先 undefined 后赋值;
  • let 无提升 + 有块级作用域 → 未执行的块内变量不生效,直接访问全局值。

三、升华:JS 如何让变量提升与块级作用域和谐共存?(执行上下文视角)

ES6 引入块级作用域后,并未废除 ES5 的变量提升,而是通过执行上下文的  “双环境设计” (变量环境 + 词法环境)实现了二者的 “一国两制”—— 既兼容旧代码的 var 变量提升逻辑,又支持 let/const 的块级作用域特性,核心是通过分离存储、独立规则、协同查询,让新旧特性互不干扰、和谐工作。

1. 核心设计:执行上下文的 “双环境分工”

执行上下文是 JS 代码运行的 “环境容器”,ES6 对其扩展后,核心包含两大环境,各司其职:

环境类型存储内容核心规则(对应特性)数据结构
变量环境(VE)var 声明的变量、function 声明支持变量提升(预编译提升声明)、函数 / 全局作用域(无块级隔离)普通对象(键值对)
词法环境(LE)let/const 声明的变量支持块级作用域、暂时性死区(无变量提升)栈结构(后进先出)

核心思想:用 “变量环境” 保留 ES5 旧规则(兼容),用 “词法环境” 实现 ES6 新特性(升级),双环境独立存储但协同查询。

2. 第一步:编译阶段 —— 创建执行上下文,初始化双环境

JS 引擎执行代码前,会先编译代码并创建对应执行上下文(全局 / 函数),此时双环境会完成初始化,分别对应变量提升和暂时性死区:

(1)变量环境初始化(对应变量提升)
  • 编译时,扫描当前作用域内所有 var 变量和 function 声明:

    • 先提升 function 声明:完整提升(函数名 + 函数体)到变量环境顶部(优先级高于 var);
    • 再提升 var 变量:仅提升声明(不提升赋值),默认值设为 undefined,存入变量环境;
  • 赋值语句不提升,留在原地等待执行阶段触发。

示例(全局作用域编译)

var name = "张三";
function showInfo() {}
let age = 20;

编译后变量环境(全局):

{
  showInfo: 函数体(完整提升),
  name: undefined(仅声明提升)
}
(2)词法环境初始化(对应暂时性死区 + 块级作用域准备)
  • 编译时,扫描当前作用域内所有 let/const 变量:

    • 仅 “注册” 变量名,不赋值(状态为 uninitialized,区别于 undefined);
    • 此时变量进入 “暂时性死区”:声明前访问会直接报错,而非 undefined
    • 词法环境的核心是 栈结构:当前作用域(如全局、函数)的 let/const 变量存入栈底,后续进入块级作用域(if/for 等)时,会创建新的块级词法环境压入栈顶。

示例(全局作用域编译) :词法环境(全局)栈初始化:

[
  { age: uninitialized } // 栈底:全局块级作用域的 let 变量
]
编译阶段总结:
  • 变量环境的初始化 = 变量提升的实现(var/function 按规则提升并初始化);
  • 词法环境的初始化 = 暂时性死区的实现(let/const 注册但未赋值,不可访问);
  • 双环境初始化完成后,执行上下文进入 “执行阶段”。

3. 第二步:执行阶段 —— 块级作用域的实现(词法环境栈的操作)

执行阶段按代码顺序执行,当遇到 {} 块级作用域(如 iffor)时,词法环境的栈结构会发生 “压栈”“查询”“出栈” 操作,这正是 ES6 实现块级作用域的核心:

(1)进入块级作用域:压栈(创建块级词法环境)

当执行到 {} 块内,且块内有 let/const 声明时:

  • 创建一个新的 “块级词法环境”(独立键值对对象);
  • 将块内 let/const 变量注册到该环境中(状态 uninitialized),执行赋值语句后更新为实际值;
  • 将这个新环境压入词法环境栈顶(栈结构 “后进先出”,当前块环境为栈顶)。

示例(进入 if 块级作用域)

function showInfo() {
  if (true) {
    let age = 25; // 块内 let 变量
  }
}
showInfo();

进入 if 块后,词法环境栈(函数内):

[
  { age: 25 }, // 栈顶:if 块级词法环境(赋值后)
  { 无变量 },  // 中间:函数级词法环境
  Outer: 全局词法环境(age: 20// 栈底:外部引用
]
(2)块内变量查询:栈顶优先

执行块内代码时,访问变量会遵循 “栈顶优先” 规则:

  1. 先查询词法环境栈顶(当前块级环境):找到则直接使用;
  2. 未找到则向上回溯外层词法环境(函数级→全局级);
  3. 词法环境中未找到,再查询变量环境(var/function);
  4. 仍未找到则报错 xxx is not defined

示例(块内查询 age :块内 console.log(age) → 直接查询栈顶(if 块级环境)的 age: 25 → 输出 25。

(3)退出块级作用域:出栈(销毁块级词法环境)

当块内代码执行完毕:

  • 栈顶的块级词法环境会被弹出(销毁);
  • 块内 let/const 变量随环境销毁,外部无法访问(实现块级隔离);
  • 词法环境栈恢复到进入块前的状态,后续查询会回溯到外层环境。

示例(退出 if 块后) :词法环境栈(函数内)恢复为:

[
  { 无变量 },  // 栈顶:函数级词法环境
  Outer: 全局词法环境(age: 20// 栈底
]

此时查询 age → 回溯到全局词法环境,输出 20。

4. ES6 支持块级作用域的本质:词法环境的栈结构

从执行上下文角度看,ES6 实现块级作用域的核心是  “词法环境的栈结构设计”

  1. 块级作用域 = 词法环境栈中的一个 “栈帧”(块级环境);
  2. 进入块 → 压栈(新增栈帧,隔离块内变量);
  3. 退出块 → 出栈(销毁栈帧,释放变量,外部不可访问);
  4. 变量查询 → 栈顶优先(确保块内变量优先使用,不污染外层)。

而变量提升则通过 “变量环境的普通对象结构” 保留:var/function 存入普通对象,无块级隔离,遵循函数 / 全局作用域,实现旧规则兼容。

5. 核心总结:双环境协同的 “和谐之道”

  1. 分离存储var/function 存入变量环境(旧规则),let/const 存入词法环境(新规则),互不干扰;
  2. 独立规则:变量环境实现变量提升,词法环境通过栈结构实现块级作用域 + 暂时性死区;
  3. 协同查询:查询时词法环境栈优先(新规则优先),变量环境兜底(兼容旧规则);
  4. 最终实现:ES5 代码按旧规则运行(变量提升、无块级),ES6 代码按新规则运行(块级作用域、无提升),二者和谐共存。

一句话概括:ES6 用 “栈结构的词法环境” 实现块级作用域,用 “普通对象的变量环境” 保留变量提升,双环境协同查询,既兼容历史又满足新需求。

总结

JavaScript 执行机制的核心演进,本质是「兼容历史」与「优化特性」的平衡:ES5 时代的变量提升虽简化了早期引擎实现,却带来了变量污染、逻辑混乱等问题;ES6 并未废除旧规则,而是通过执行上下文的「双环境设计」,让变量提升与块级作用域实现了和谐共存。

变量环境承载 var 与函数声明的提升逻辑,延续函数 / 全局作用域规则,保障旧代码兼容;词法环境以栈结构为核心,实现 let/const 的块级隔离与暂时性死区,解决了变量提升的缺陷。二者通过「栈顶优先、逐级回溯」的查询规则协同工作,既让 ES5 代码正常运行,又让 ES6 代码具备更严谨的作用域控制。

理解这一机制,能从底层解释「变量为何提前可访问」「块内变量为何不污染外部」等核心问题,也为后续学习闭包、执行上下文栈等知识点奠定基础。实际开发中,优先使用 let/const 遵循块级作用域规则,可有效减少变量污染与逻辑 bug,同时理解变量提升的底层逻辑,能更精准地处理旧代码兼容问题。