理解 JS 底层机制:作用域链、词法作用域与闭包的核心逻辑

156 阅读13分钟

理解 JS 底层机制:作用域链、词法作用域与闭包的核心逻辑

作为前端开发者,我们每天都在编写 JS 代码,但往往对其底层运行机制一知半解:为什么函数内部能访问全局变量,却无法访问其他函数的局部变量?为什么闭包能让变量 “常驻内存”?这些问题的答案,都藏在 V8 引擎的执行机制、作用域链与闭包的核心逻辑中。本文将结合三段经典代码案例,从底层原理到实际应用,带你彻底搞懂这些 JS 高级概念。

一、V8 引擎的核心运行机制:执行上下文与调用栈

要理解作用域和闭包,首先要搞懂 V8 引擎是如何执行 JS 代码的。V8 引擎处理 JS 代码的过程主要分为编译阶段执行阶段,核心依赖调用栈执行上下文两大核心组件。

1. 调用栈:代码执行的 “调度中心”

调用栈是一种 “先进后出” 的栈结构,用于管理函数的执行顺序:

  • 代码执行时,首先创建全局执行上下文并压入栈底;
  • 每当调用一个函数,就创建该函数的函数执行上下文并压入栈顶;
  • 函数执行完毕后,其执行上下文从栈顶弹出,控制权交还给上一层执行上下文。

2. 执行上下文:代码运行的 “环境容器”

每个执行上下文都包含三个核心部分:

  • 变量环境:存储var声明的变量和函数声明(FD),支持变量提升;
  • 词法环境:存储let/const声明的块级作用域变量,存在暂存死区(TDZ);
  • 作用域链:变量查找的路径,由当前执行上下文的词法环境和外层词法环境组成。

全局执行上下文是默认创建的,其变量环境中会包含全局变量、全局函数声明(如function foo(){})以及window(浏览器环境)等内置对象;函数执行上下文则在函数调用时动态创建,包含函数的局部变量、参数等信息。

二、作用域链:变量查找的 “导航地图”

在 JS 中,变量的访问本质是 “按路径查找” 的过程,这个路径就是作用域链。理解作用域链的核心,在于掌握其查找规则和形成逻辑。

1. 作用域链的核心规则

作用域链的查找遵循两大原则:

  • 从内到外:先查找当前执行上下文的词法环境,找不到则向上层词法环境查找,直到全局词法环境;
  • 静态绑定:作用域链的结构由函数声明时的位置决定(即词法作用域),与函数调用时的位置无关。

这就解答了很多开发者的疑惑:为什么函数在其他函数内部调用时,却无法访问该函数的局部变量?我们通过代码一和代码二的对比来具体分析。

2. 代码案例一:作用域链与词法作用域的基础验证

javascript

运行

// 全局作用域初始化:创建全局执行上下文,全局变量 myName 被声明(未赋值)
function bar() {
    // bar 函数定义在全局作用域 → 作用域链:bar 自身词法环境 → 全局词法环境
    console.log(myName); // 极客时间
}

function foo() {
    // foo 函数定义在全局作用域 → 作用域链:foo 自身词法环境 → 全局词法环境
    var myName = '极客邦'; // foo 内部变量,仅在 foo 词法环境中
    bar(); // 调用 bar(执行时)
    console.log(myName); // 极客邦
}

var myName = '极客时间'; // 全局变量赋值
foo(); // 执行 foo 函数

执行结果:

QQ20251127-233657.png

执行流程与作用域链分析:
  1. 全局执行上下文创建:barfoo函数声明和myName变量(var声明)存入变量环境,myName初始值为undefined,赋值后变为'极客时间'
  2. 执行foo():创建foo执行上下文并压入栈顶,foo内部var myName = '极客邦'存入其变量环境;
  3. 执行bar():创建bar执行上下文并压入栈顶,bar的作用域链是 “自身词法环境 → 全局词法环境”(因bar定义在全局);
  4. bar中查找myName:自身词法环境无myName,向上查找全局词法环境,找到myName = '极客时间',因此输出 “极客时间”;
  5. bar执行完毕出栈,回到foo执行上下文,查找myName时直接在foo词法环境找到'极客邦',输出 “极客邦”。

调用栈执行上下文示意图:

21D5A78B-1E80-4231-8A95-BB992D795B8A.png

关键结论:

bar虽然在foo内部调用,但由于其声明在全局作用域,作用域链中不包含foo的词法环境,因此无法访问foo的局部变量myName,使用全局执行上下文的myName。这就是词法作用域 “静态绑定” 的核心体现。

3. 代码案例二:块级作用域与作用域链的延伸

javascript

运行

// 全局作用域:声明/赋值全局变量
function bar() { // 定义在全局 → 作用域链:bar函数作用域 → 全局作用域
    var myName = "极客世界"; // bar函数作用域的var变量(函数作用域)
    let test1 = 100; // bar函数作用域的let变量(块级)
    if (1) { // if块形成独立块级作用域
        let myName = "Chrome 浏览器"; // if块内的let变量(块级作用域)
        console.log(test); // 核心:查找test变量
    }
}

function foo() { // 定义在全局 → 作用域链:foo函数作用域 → 全局作用域
    var myName = "极客邦"; // foo函数作用域的var变量
    let test = 2; // foo函数作用域的let变量(函数级块作用域)
    { // 普通块形成独立块级作用域
        let test = 3; // 该块内的let变量(块级作用域)
        bar(); // 执行bar函数(关键:bar的执行位置不影响其作用域链)
    }
}

// 全局作用域赋值
var myName = "极客时间";
let myAge = 10; // 全局块级变量
let test = 1; // 全局块级变量
foo(); // 执行foo函数

执行结果:

QQ20251127-234843.png

核心亮点:块级作用域对作用域链的影响
  • let/const声明的变量会形成块级作用域(函数、if、普通{}都可形成),而var仅支持函数作用域;
  • barif块内的let myName是块级变量,仅在if块内生效,外部无法访问;
  • 关键查找:barconsole.log(test)的作用域链查找流程为 “if块词法环境 → bar函数词法环境 → 全局词法环境”,最终在全局找到test = 1,因此输出1

调用栈执行上下文及变量查找过程示意图:

455F39F3-10BA-420B-8925-03DD9B0B4422.png

观察上面的示意图,我们谈谈变量test的查找顺序:

第一步:查「当前最内层块的词法环境」

if块属于块级作用域,其对应的词法环境(图中①)是当前最内层的变量容器,仅存储块内let/const声明的变量(这里只有let myName="Chrome浏览器"),未找到test

第二步:查「外层函数的词法环境」

块级词法环境的outer(外层指向)会关联到所在函数的词法环境(图中②,bar函数的词法环境),这里存储bar函数内let/const声明的变量(如let test1=100),仍未找到test

第三步:查「当前函数的变量环境」

函数执行上下文的变量环境(图中③,bar函数的变量环境)存储var声明的变量(这里是var myName="极客世界"),无test,继续向外。

第四步:跳过无关执行上下文(作用域链决定)

虽然foo的执行上下文在调用栈中,但bar是在全局作用域定义的,其作用域链不包含foo,因此不会查找foo的变量环境 / 词法环境。

第五步:查「全局执行上下文的词法环境」

bar函数执行上下文的outer指向全局执行上下文,先查全局的词法环境(图中④),这里存储全局let/const声明的变量(包含let test=1),成功找到test,查找终止。

总结来说:先找当前执行上下文的「块级词法环境」→ 「函数词法环境」→ 「函数变量环境」,若当前执行上下文无结果,沿作用域链(由函数定义位置决定)查外层执行上下文的「词法环境→变量环境」,直到遍历到全局执行上下文的词法环境 / 变量环境,找到则返回,否则报错。

关键结论:

块级作用域仅会影响当前作用域链的内层结构,不会改变外层作用域链的绑定关系。foo内部的let test = 2let test = 3,由于bar的作用域链不包含foo,因此bar完全无法访问。

三、词法作用域:作用域链的 “底层基石”

通过上面的案例,我们反复提到 “词法作用域”,它是 JS 作用域机制的核心,也是理解闭包的前提。

1. 词法作用域的定义

词法作用域(又称静态作用域)指:函数的作用域在代码编译阶段就已确定,完全由函数声明时的位置决定,与函数调用时的上下文无关

与之相对的是 “动态作用域”(如 Bash 脚本),其作用域由函数调用时的位置决定。JS 选择词法作用域的原因很简单:编译阶段就能确定作用域链,执行时直接按链查找,效率更高。

2. 词法作用域的核心价值

  • 可预测性:变量的查找路径固定,代码行为可预判,便于调试和维护;
  • 高效性:编译阶段提前构建作用域链,执行时无需动态计算查找路径;
  • 闭包的基础:正因为作用域链是静态绑定的,闭包才能 “记住” 定义时的环境变量。

四、闭包:词法作用域的 “高级应用”

闭包是 JS 中最强大也最易混淆的概念,本质上是词法作用域的延伸。结合代码三,我们从底层逻辑拆解闭包的形成、作用和原理。

1. 闭包的定义与形成条件

闭包的官方定义:嵌套在父函数内部的子函数,若引用了父函数的变量,且子函数能被父函数外部访问,则形成闭包。

形成闭包需满足三个条件:

  1. 函数嵌套(子函数定义在父函数内部);
  2. 子函数引用父函数的变量(自由变量);
  3. 子函数被父函数外部的变量引用(如 return 子函数或包含子函数的对象)。

2. 代码案例三:闭包的底层逻辑与内存机制

javascript

运行

function foo() {
    var myName = "极客时间"; // foo函数作用域的var变量
    let test1 = 1; // foo函数作用域的let变量(块级)
    const test2 = 2; // foo函数作用域的const变量(块级)
    var innerBar = { // 引用类型,栈存引用,堆存对象本体
        getName: function () { // 嵌套函数,定义在foo内 → 作用域链:getName → foo → 全局
            console.log(test1);
            return myName;
        },
        setName: function (newName) { // 嵌套函数,同理作用域链包含foo
            myName = newName;
        }
    }
    return innerBar; // 返回堆内存的引用,被全局变量bar接收
}

var bar = foo(); // 执行foo,创建foo执行上下文 → 执行完毕出栈,但闭包阻止foo变量回收
bar.setName("极客邦"); // 执行setName,创建执行上下文 → 操作foo内的myName
bar.getName(); // 执行getName,创建执行上下文 → 访问foo内的test1/myName
console.log(bar.getName());

执行结果:

QQ20251128-001348.png

执行流程与闭包原理分析:
  1. 执行foo():创建foo执行上下文,myNametest1test2存入对应的变量环境 / 词法环境,innerBar是引用类型,栈内存存引用,堆内存存对象(包含getNamesetName方法);
  2. foo返回innerBar:全局变量bar接收堆内存的引用,此时foo执行完毕,其执行上下文从调用栈弹出;
  3. 闭包的关键:getNamesetName定义在foo内部,其作用域链包含foo的词法环境。由于bar引用了这两个方法,foo的词法环境仍被持续引用,因此myNametest1等变量不会被垃圾回收(GC);
  4. 执行bar.setName("极客邦")setName的作用域链查找myName,找到foo词法环境中的myName并修改为"极客邦"
  5. 执行bar.getName()getName查找test1foo词法环境中test1=1)并输出,返回修改后的myName"极客邦");
  6. 最终输出:11极客邦

调用栈执行上下文示意图:

执行 var bar = foo(); 调用栈内有foo函数执行上下文全局执行上下文

663DFEFB-DA30-4BA7-B8A3-D83755FA4C24.png

执行完 var bar = foo(); foo函数执行上下文从调用栈内弹出,但 bar.setName("极客邦");bar.getName(); 仍能访问foo函数的词法环境,并修改myName、查找test1myName。这就是闭包的作用:阻止父函数执行上下文销毁后,其内部变量被垃圾回收(GC),因为子函数的作用域链仍引用这些变量。在代码案例三中表现为:foo 内的 myName/test1 被 getName/setName 的作用域链引用,而 getName/setName 作为 innerBar 的属性,又被全局变量 bar 引用 → foo 的词法环境始终有「可达引用」,因此不会被 GC 回收。

foo函数出栈后调用栈内闭包示意图:

8588FE25-CCD5-42C8-A11A-DB49E35E5586.png

执行setName函数调用栈内闭包示意图:

1088C323-FC4A-4D9B-80A8-290735B4B132.png

3. 闭包的核心特性与注意事项

  • 自由变量:被闭包引用的父函数变量(如myNametest1)称为自由变量,这些变量会被闭包 “携带”,即使父函数执行上下文出栈也不会销毁;
  • 内存机制:闭包本质是作用域链的引用保留,父函数的词法环境因被子函数引用而常驻内存;
  • 注意事项:滥用闭包可能导致内存泄漏,需在不需要时手动切断引用(如bar = null),让垃圾回收机制回收父函数的词法环境。

五、总结:JS 底层机制的逻辑闭环

通过对 V8 引擎执行机制、作用域链、词法作用域和闭包的层层拆解,我们可以梳理出 JS 底层运行的核心逻辑:

  1. V8 引擎通过执行上下文管理代码运行环境,通过调用栈调度执行顺序;
  2. 作用域链是变量查找的路径,其结构由词法作用域决定(函数声明时的位置);
  3. 闭包是词法作用域的延伸,通过保留父函数词法环境的引用,实现变量的持久化和外部访问。

理解这些底层机制,不仅能解决日常开发中的 “变量访问异常”“闭包内存泄漏” 等问题,更能帮助我们编写更高效、更可维护的 JS 代码。比如在设计模块、实现防抖节流、管理私有变量时,都能基于这些原理做出最优选择。

JS 的底层机制看似抽象,但只要结合具体代码案例,从 “执行流程→作用域链查找→内存变化” 的角度层层剖析,就能彻底掌握其核心逻辑。