深入拆解 JavaScript 作用域:从执行机制到 ES6 优化的完整知识体系

153 阅读19分钟

深入拆解 JavaScript 作用域:从执行机制到 ES6 优化的完整知识体系

在 JavaScript 开发中,作用域是决定变量 “生存逻辑” 的核心概念 —— 它定义了变量的声明位置、查找规则、可见范围与生命周期,也是多数 “反直觉” 代码 Bug 的根源。从 ES5 时代令人困惑的变量提升,到 ES6 引入块级作用域的革新,作用域的演进始终与 JavaScript 的执行机制深度绑定。本文将从 V8 引擎的执行逻辑出发,逐层拆解作用域的本质,分析历史设计缺陷的成因,结合实例代码详解 ES6 如何实现作用域机制的升级,最终形成一套完整的作用域知识体系。

一、JS 执行机制:理解作用域的 “底层基石”

要掌握作用域,必须先厘清 JavaScript 代码的执行逻辑。主流浏览器的V8 引擎处理 JS 代码并非 “逐行直译”,而是通过 “编译→执行” 双阶段流程,配合调用栈执行上下文完成运行,这是作用域规则生效的前提。

1. V8 引擎的 “编译 - 执行” 双阶段

V8 引擎执行代码时,会先通过编译器完成预处理,再通过解释器逐行执行,两个阶段分工明确:

  • 编译阶段:引擎扫描代码,完成三件核心工作:① 识别var变量声明与函数声明,执行 “变量提升”(将声明提前到当前作用域顶部);识别let/const变量声明,完成作用域绑定(确定变量所属的块级作用域),但不执行变量提升,也不初始化变量;② 分析函数嵌套关系与块级结构,确定变量的查找路径(作用域链);③ 创建执行上下文(Execution Context),将var变量 / 函数声明存入 “变量环境”,将let/const变量声明存入 “词法环境”(仅绑定作用域,未初始化),并标记let/const的 “暂时性死区(TDZ)”:从作用域开始到变量声明语句为止,该区域内变量不可访问。。
  • 执行阶段:引擎创建调用栈,按 “先进后出” 原则管理执行上下文:① 函数调用时,对应的执行上下文入栈,成为 “当前执行上下文”;② 逐行执行代码,完成变量赋值、函数调用等操作(遇到let/const声明时,才完成变量初始化);③ 函数执行完毕后,执行上下文出栈,其内部局部变量被回收(垃圾回收机制介入)。

2. 调用栈:执行上下文的 “管理工具”

调用栈以 “函数” 为单位管理执行上下文,核心规则与生命周期如下:

  • 全局代码执行前,先创建 “全局执行上下文” 并入栈,这是调用栈的初始状态;
  • 每调用一个函数,引擎立即创建该函数的 “函数执行上下文” 并入栈,覆盖当前上下文;
  • 函数执行完后,其执行上下文出栈,全局执行上下文重新成为 “当前上下文”;
  • 页面关闭或进程退出时,全局执行上下文出栈,调用栈清空,变量彻底回收。

代码二为例,可直观看到调用栈的工作流程:

javascript

运行

// 代码二:全局与函数作用域的变量访问
var globalVar = "我是全局变量"; // 全局执行上下文创建时,存入变量环境
function myFunction() {
    var localVar = "我是局部变量"; // myFunction执行上下文创建时,存入其变量环境
    console.log(globalVar); // 查找顺序:myFunction变量环境→全局变量环境,输出“我是全局变量”
    console.log(localVar); // 查找:myFunction变量环境,输出“我是局部变量”
}
myFunction(); // myFunction执行上下文入栈
console.log(globalVar); // 全局执行上下文为当前上下文,输出“我是全局变量”
console.log(localVar); // 全局变量环境无localVar,报错“localVar is not defined”

调用栈流程:全局执行上下文入栈→myFunction()调用,函数执行上下文入栈→函数执行完出栈→全局执行上下文继续执行→报错后终止。

3. 执行上下文:作用域的 “数据容器”

每个执行上下文包含两个核心部分,这是 ES6 实现 “兼容旧代码 + 优化作用域” 的关键设计,也是下文讲述的 “一国两制论” 的基础:

  • 变量环境(Variable Environment) :专门存储var声明的变量与函数声明,保留 ES5 的 “变量提升” 特性 ——var变量会被初始化为undefined,函数声明直接指向函数体;
  • 词法环境(Lexical Environment) :专门存储let/const声明的变量与函数表达式(如 const fn = function() {}),支持 “块级作用域” 与 “暂时性死区”—— 变量在声明前不初始化,访问时直接报错。

二、变量提升:ES5 的设计缺陷与历史成因

“变量提升(Hoisting)” 是 ES5 时代最具争议的特性,指var变量和函数声明在编译阶段被 “提前” 到当前作用域顶部,但赋值操作仍保留在原位置。这一特性并非 “设计漏洞”,而是 JavaScript 早期 “快速落地” 的妥协。

1. 变量提升的历史背景

1995 年,网景公司为对抗微软 IE 浏览器,需要一款能给网页添加简单动态效果的脚本语言,Brendan Eich 仅用 10 天就设计出 JavaScript。当时的核心目标是 “快速适配浏览器”,而非 “语言严谨性”,因此做出两个关键选择:

  • 放弃块级作用域:C、Java 等语言的块级作用域(if/for块形成独立作用域)需要复杂的语法分析,为简化实现,ES5 仅支持 “全局作用域” 和 “函数作用域”;
  • 引入变量提升:没有块级作用域后,为避免 “变量未声明就使用” 的错误,引擎将作用域内所有var变量和函数声明统一提升到顶部,成为最快的预处理方案。

此外,早期 JavaScript 的面向对象设计也因 “简化需求” 妥协:本应复杂的classconstructorsuper等语法被简化为 “大写函数(构造函数)+ prototype”,进一步体现 “快速落地优先” 的设计思路。

2. 变量提升的具体表现(代码一解析)

变量提升对 “函数声明” 和 “var变量” 的处理存在差异:函数声明 “完全提升”(声明 + 函数体),var变量 “部分提升”(仅声明,赋值保留)。以代码一为例:

javascript

运行

// 代码一:变量提升的直观表现
showNmae(); // 输出:“函数showNmae 执行了”(函数声明完全提升,可提前调用)
console.log(myname); // 输出:undefined(var变量仅提升声明,未赋值)
var myname = "路明非"; // 赋值操作在执行阶段执行
function showNmae() { // 函数声明完全提升到全局作用域顶部
    console.log("函数showNmae 执行了");
}

编译阶段,function showNmae()被整体提升到全局作用域顶部,因此提前调用能正常执行;var myname仅声明被提升,执行阶段赋值前访问,值为undefined—— 这是变量提升最典型的特征。

3. 变量提升的两大核心缺陷

变量提升的 “简便性” 背后,是两个无法忽视的问题,也是 ES6 必须解决的设计缺陷:

(1)变量无感知覆盖(代码三解析)

由于var仅受 “函数作用域” 限制,块内声明的var变量会提升到函数作用域顶部,覆盖外层同名变量,导致逻辑错误。以代码三为例:

javascript

运行

// 代码三:变量提升导致的无感知覆盖
var name = "张三"; // 全局作用域的name
function showNmae() {
    console.log(name); // 输出:undefined(被块内提升的name覆盖)
    if(false) { // ES5中if块不形成独立作用域
        var name = "大厂的苗子"; // var变量提升到showNmae函数作用域顶部
    }
    console.log(name); // 输出:undefined(提升后的name无法进入if语句内,未赋值)
}
showNmae();

编译阶段,if块内的var name会提升到showNmae函数作用域顶部,覆盖全局的name。即便if条件为false(代码不执行),提升后的name仍存在,导致两次打印均为undefined,与开发者直觉完全不符。

(2)变量泄露(本该销毁的变量未销毁,代码六解析)

var变量不受块级作用域限制,块内变量会泄露到外层作用域,导致 “变量本该销毁却保留”。以代码六foo函数为例:

javascript

运行

// 代码六(foo函数):变量提升导致的变量泄露
function foo() {
    for(var i = 0;i < 7;i++) { // var i提升到foo函数作用域顶部
        // 循环逻辑:i从0递增到6
    }
    console.log(i); // 输出:7(i泄露到foo函数作用域,未被销毁)
}
foo();

for 循环块在 ES5 中不形成作用域,var i提升到foo函数作用域顶部。循环结束后,i本应随循环块销毁,却因作用域提升保留在函数内,造成 “变量泄露”,违背 “变量按需销毁” 的原则。

三、作用域的本质与分类:变量的 “生存规则”

作用域的核心定义是:程序中定义变量的区域,该位置决定了变量的可见性(能否被访问)、生命周期(何时创建 / 销毁),以及变量的查找路径(作用域链) 。JavaScript 的作用域分为三类,其中 “块级作用域” 是 ES6 对 ES5 的核心补全。

1. 全局作用域

全局作用域是最顶层的作用域,在浏览器中对应window对象(Node.js 中为global对象),具备两个核心特性:

  • 可见性:全局变量可在任何地方访问(函数内、块内均可);
  • 生命周期:与页面 / 进程一致,页面关闭或进程退出时才会被销毁。

代码二中的globalVar,声明在全局作用域,因此在myFunction内和全局代码中均可访问;但全局作用域的缺陷也很明显:滥用会导致 “变量污染”,多个脚本文件的全局变量可能重名,引发冲突。

2. 函数局部作用域

函数内部声明的变量属于 “函数局部作用域”,具备两个核心特性:

  • 可见性:仅能在函数内部访问,外部无法访问;
  • 生命周期:函数执行时创建,执行完毕后被回收(垃圾回收机制回收未被引用的变量)。

代码二中的localVar,声明在myFunction内部,全局代码访问时直接报错 —— 这是 ES5 时代实现变量 “私有性” 的唯一方式,也是隔离变量、避免全局污染的关键。

3. 块级作用域:ES5 缺失,ES6 补全

块级作用域指{}包裹的区域(如ifforwhile块,或独立{}块),核心特性是 “块内变量仅在块内有效”:

  • ES5 不支持块级作用域if/for块内的var变量会泄露到外层作用域(如代码三、六);
  • ES6 支持块级作用域:通过let/const声明变量,变量被限制在块内,块外无法访问(如代码四、六的foo1函数)。

代码五展示了块级作用域的常见场景,其中 ES6 中仅let/const能让这些块形成独立作用域:

javascript

运行

// 代码五:块级作用域的常见场景
if(1) { // ES6中let/const可让此块形成块级作用域,ES5不行
}
while(true) { // 同上,ES5中块不独立
}
function foo() { // 函数本身是函数作用域,与块级作用域独立
}
for(i = 0;i < 100;i++) { // ES5中var i泄露,ES6中let i形成块级作用域
}

四、ES6 的 “一国两制”:兼容与革新的平衡

ES6 既要解决 ES5 的作用域缺陷,又要向下兼容海量旧代码(如使用var的项目),因此设计了 “变量环境 + 词法环境” 的双环境机制 —— 即 “一国两制”,既保留变量提升,又支持块级作用域。

1. “一国两制” 的核心:双环境分工

ES6 的执行上下文仍包含 “变量环境” 和 “词法环境”,但分工明确,确保新旧代码兼容:

  • var变量 / 函数声明:存入 “变量环境”,保留 ES5 的变量提升特性,旧代码正常运行;
  • let/const变量:存入 “词法环境”,支持块级作用域和暂时性死区,解决 ES5 的缺陷。

这种分工的关键在于:变量环境的变量会被初始化为undefined(变量提升),词法环境的变量在声明前不初始化(触发暂时性死区),两者独立工作,互不干扰。

2. 词法环境的栈结构:块级作用域的底层实现

ES6 的词法环境内部维护一个小型栈结构,这是块级作用域生效的核心。当执行到块级作用域时,引擎按以下逻辑处理:

  1. 块开始时:将块内let/const声明的变量压入词法环境的 “栈顶”;
  2. 变量查找时:优先从栈顶查找(当前块的变量),找不到再向下查找栈底(外层块 / 函数的变量);
  3. 块结束时:栈顶的变量出栈,确保块外无法访问,实现 “块级隔离”。

代码七为例,可直观看到双环境的共存与词法环境的栈结构:

javascript

运行

// 代码七:执行上下文角度的var/let融合(一国两制实践)
function foo() {
    var a = 1; // 变量环境:a=1(提升到函数作用域,无块级限制)
    let b = 2; // 词法环境栈底:b=2(函数作用域的let变量)
    {
        let b = 3; // 词法环境栈顶:b=3(块级作用域的let变量)
        var c = 4; // 变量环境:c=4(提升到函数作用域,无块级限制)
        let d = 5; // 词法环境栈顶:d=5(块级作用域的let变量)
        console.log(a); // 输出:1(从变量环境查找)
        console.log(b); // 输出:3(从词法环境栈顶查找)
    }
    // 块结束:栈顶的b=3、d=5出栈,词法环境仅保留栈底的b=2
    console.log(b); // 输出:2(从词法环境栈底查找)
    console.log(c); // 输出:4(从变量环境查找)
    // console.log(d); // 报错:d is not defined(d已出栈,无法访问)
}
foo();

foo函数的执行上下文示意图如下:

第一阶段:

块级作用域外部:变量环境:a=1(提升到函数作用域,无块级限制);词法环境栈底:b=2(函数作用域的let变量);变量环境:c=4(提升到函数作用域,无块级限制)。

11645361-1FAD-4E1C-B03A-8557F2DB6C0C.png

第二阶段:

块级作用域内部:词法环境栈顶:b=3(块级作用域的let变量);词法环境栈顶:d=5(块级作用域的let变量)。

67F3FB8C-13CF-4F1A-8934-AC15CCA9358C.png

第三阶段:

块结束:栈顶的b=3、d=5出栈,词法环境仅保留栈底的b=2;函数未结束,变量环境仍保存着a=1、c=4.

320C54AF-A1CE-48E1-8265-D297A0087492.png

你知道 console.log(a);执行时 a 的查找过程吗?先在当前块级作用域里查找,当前块级作用域中没有 a ,再到变量环境中找到了 a ,打印 a 。示意图如下:

9625B41F-066C-4BD2-AF8B-44B93C395CF9.png

通过词法环境的栈结构,let变量实现了 “块内隔离”,而var变量因存于变量环境,不受块级作用域影响,完美兼顾兼容性与革新。

3. 暂时性死区:词法环境的安全机制

“暂时性死区(TDZ)” 是let/const的核心特性,指变量在声明语句之前的区域为 “死区”,访问时会报错 —— 这是为了避免 ES5 变量提升的 “提前访问” 问题。以代码八为例:

javascript

运行

// 代码八:暂时性死区问题(TDZ)
let name = "张三"; // 全局作用域的name
{
    console.log(name); // 报错:Cannot access 'name' before initialization
    let name = "大厂的苗子"; // let声明触发死区,声明前无法访问
}

块内的let name会在块开始时绑定词法环境,形成 “死区”。即便全局有同名name,块内声明前访问仍报错,彻底杜绝 “变量无感知覆盖”,让变量访问规则更符合直觉。

4. ES6 块级作用域的验证(代码四、六解析)

(1)避免变量覆盖(代码四解析)

javascript

运行

// 代码四:ES6块级作用域避免变量覆盖
let name = "张三"; // 全局作用域的name
function showNmae() {
    console.log(name); // 输出:张三(函数作用域无name,向上查全局)
    if (false) { // 块级作用域,let name仅在块内有效
        let name = "大厂的苗子"; 
    }
}
showNmae();

if块内的let name被限制在块内,且因条件为false未执行,函数内访问的是全局name,无覆盖问题,与 ES5 的代码三形成鲜明对比。

(2)解决变量泄露(代码六 foo1 函数解析)

javascript

运行

// 代码六(foo1函数):ES6块级作用域解决变量泄露
function foo1() {
    for(let i = 0;i < 7;i++) { // let i受块级作用域限制,仅在for块内有效
    }
    console.log(i); // 报错:i is not defined(i随for块销毁)
}
foo1();

let i被限制在 for 块内,循环结束后块级作用域销毁,i也随之销毁,彻底解决 ES5 的变量泄露问题。

五、总结与实践建议:从理论到应用

JavaScript 作用域的演进,本质是 “从简化设计到工程化优化” 的过程:ES5 为快速落地放弃块级作用域,引入变量提升;ES6 通过 “双环境机制” 实现兼容与革新,解决了变量覆盖、泄露等问题。结合学习笔记与代码案例,可总结出以下实践建议:

  1. 优先使用let/const,放弃var:利用块级作用域避免变量覆盖和泄露,const还能强制变量只读,减少意外修改(如循环变量、临时变量用let,常量用const);
  2. 理解作用域链的查找规则:变量查找遵循 “当前块→外层块→函数作用域→全局作用域”,找到第一个同名变量即停止,避免冗余查找;
  3. 警惕暂时性死区let/const变量必须在声明后访问,避免触发 TDZ 错误(如不要在let a前写console.log(a));
  4. 减少全局变量:通过 “函数作用域” 或 “ES 模块(import/export)” 封装变量,避免全局污染(如将初始化逻辑放入函数,内部变量私有化);
  5. 维护旧代码时注意变量提升:若需修改 ES5 代码,需先梳理var的提升范围,避免新增变量意外覆盖旧变量。

六、结语

作用域是 JavaScript 的底层逻辑支柱,从 V8 引擎的 “编译 - 执行” 双阶段,到 ES5 的变量提升,再到 ES6 的 “一国两制”,每一个设计都体现了 “兼容性” 与 “开发体验” 的平衡。掌握作用域的核心知识(执行上下文、变量提升、块级作用域、词法环境栈结构),不仅能避开代码陷阱,更能为后续学习闭包、模块化、React Hooks 等高级概念打下基础 —— 这也是前端工程师从 “会用” 到 “精通” 的关键一步,更是理解 JavaScript 语言设计哲学的重要窗口。

七、代码一 至 代码八 源码汇总

代码一:

// 代码一:变量提升的直观表现
showNmae(); // 输出:“函数showNmae 执行了”(函数声明完全提升,可提前调用)
console.log(myname); // 输出:undefined(var变量仅提升声明,未赋值)
var myname = "路明非"; // 赋值操作在执行阶段执行
function showNmae() { // 函数声明完全提升到全局作用域顶部
    console.log("函数showNmae 执行了");
}

代码二:

// 代码二:全局与函数作用域的变量访问
var globalVar = "我是全局变量"; // 全局执行上下文创建时,存入变量环境
function myFunction() {
    var localVar = "我是局部变量"; // myFunction执行上下文创建时,存入其变量环境
    console.log(globalVar); // 查找顺序:myFunction变量环境→全局变量环境,输出“我是全局变量”
    console.log(localVar); // 查找:myFunction变量环境,输出“我是局部变量”
}
myFunction(); // myFunction执行上下文入栈
console.log(globalVar); // 全局执行上下文为当前上下文,输出“我是全局变量”
console.log(localVar); // 全局变量环境无localVar,报错“localVar is not defined”

代码三:

// 代码三:变量提升导致的无感知覆盖
var name = "张三"; // 全局作用域的name
function showNmae() {
    console.log(name); // 输出:undefined(被块内提升的name覆盖)
    if(false) { // ES5中if块不形成独立作用域
        var name = "大厂的苗子"; // var变量提升到showNmae函数作用域顶部
    }
    console.log(name); // 输出:undefined(提升后的name无法进入if语句内,未赋值)
}
showNmae();

代码四:

// 代码四:ES6块级作用域避免变量覆盖
let name = "张三"; // 全局作用域的name
function showNmae() {
    console.log(name); // 输出:张三(函数作用域无name,向上查全局)
    if (false) { // 块级作用域,let name仅在块内有效
        let name = "大厂的苗子"; 
    }
}
showNmae();

代码五:

// 代码五:块级作用域的常见场景
if(1) { // ES6中let/const可让此块形成块级作用域,ES5不行
}
while(true) { // 同上,ES5中块不独立
}
function foo() { // 函数本身是函数作用域,与块级作用域独立
}
for(i = 0;i < 100;i++) { // ES5中var i泄露,ES6中let i形成块级作用域
}

代码六:

// 代码六(foo1函数):ES6块级作用域解决变量泄露
function foo1() {
    for(let i = 0;i < 7;i++) { // let i受块级作用域限制,仅在for块内有效
    }
    console.log(i); // 报错:i is not defined(i随for块销毁)
}
foo1();

代码七:

// 代码七:执行上下文角度的var/let融合(一国两制实践)
function foo() {
    var a = 1; // 变量环境:a=1(提升到函数作用域,无块级限制)
    let b = 2; // 词法环境栈底:b=2(函数作用域的let变量)
    {
        let b = 3; // 词法环境栈顶:b=3(块级作用域的let变量)
        var c = 4; // 变量环境:c=4(提升到函数作用域,无块级限制)
        let d = 5; // 词法环境栈顶:d=5(块级作用域的let变量)
        console.log(a); // 输出:1(从变量环境查找)
        console.log(b); // 输出:3(从词法环境栈顶查找)
    }
    // 块结束:栈顶的b=3、d=5出栈,词法环境仅保留栈底的b=2
    console.log(b); // 输出:2(从词法环境栈底查找)
    console.log(c); // 输出:4(从变量环境查找)
    // console.log(d); // 报错:d is not defined(d已出栈,无法访问)
}
foo();

代码八:

// 代码八:暂时性死区问题(TDZ)
let name = "张三"; // 全局作用域的name
{
    console.log(name); // 报错:Cannot access 'name' before initialization
    let name = "大厂的苗子"; // let声明触发死区,声明前无法访问
}