深入理解 JavaScript 词法作用域链与闭包:从编译到运行的完整图景

109 阅读6分钟

前言

lQLPJxL0AJudZkPNAl_NBHawlYutKE04moUJAvzH9W4rAA_1142_607.png

lQLPJwCC0KWlAbPNA03NBHawINj2y-qMdT0JAv8hJbJKAA_1142_845.png 在 JavaScript 的世界里, “变量在哪里定义,就去哪里找” 这一朴素原则背后,隐藏着一套精密而优雅的底层机制——词法作用域(Lexical Scoping)闭包(Closure) 。它们不是魔法,而是 V8 引擎在编译阶段就已确定的静态规则,决定了变量的查找路径、生命周期乃至内存管理。

本文将带你穿越 JavaScript 的执行全流程:

  • 从 V8 引擎的编译与执行阶段 出发
  • 揭秘 执行上下文、调用栈、词法环境 的协作关系
  • 彻底厘清 词法作用域链 vs 动态作用域 的本质区别
  • 深度剖析 闭包的形成条件、内存模型与实际应用

无论你是被“闭包”概念困扰的新手,还是想夯实底层认知的进阶者,这篇文章都将为你构建一幅清晰、准确、可落地的知识地图。


一、JavaScript 执行全景:编译 + 执行

1. V8 引擎的两阶段模型

JavaScript 并非纯解释型语言,而是 “先编译,后执行”

阶段核心任务
编译阶段解析代码 → 构建 AST → 生成字节码 → 确定词法作用域链
执行阶段创建执行上下文 → 管理调用栈 → 变量赋值与函数调用

关键洞察
作用域链在编译时就已固定,与函数如何被调用无关!


二、执行上下文与调用栈:变量的“家”

1. 执行上下文(Execution Context)

每次进入全局代码或函数,V8 会创建一个执行上下文,包含:

  • 变量环境(Variable Environment) :存放 var 声明
  • 词法环境(Lexical Environment) :存放 let/const/函数声明
  • this 绑定
  • 外部引用(Outer) :指向父级词法环境 → 构成作用域链

2. 调用栈(Call Stack)

  • 函数调用时,其执行上下文压入栈顶
  • 函数返回后,上下文出栈并回收(但闭包例外!
js
编辑
function foo() {
    bar();
}
function bar() {
    console.log("hello");
}
foo(); // 调用栈:[全局] → [全局, foo] → [全局, foo, bar]

⚠️ 注意:调用栈决定执行顺序,但不决定变量查找路径!


三、词法作用域:静态的查找规则

1. 什么是词法作用域?

变量的查找范围,由函数在源代码中声明的位置决定,而非调用位置。

这是 JavaScript 的核心设计,也是闭包存在的基础。

2. 经典案例:为什么输出 “极客时间”?

js
编辑
function bar() {
    console.log(myName); // 输出 "极客时间"
}
function foo() {
    var myName = '极客邦';
    bar(); // 在 foo 内部调用 bar
}
var myName = '极客时间';
foo();

🔍 分析过程:

  • bar 函数声明在全局作用域
  • 编译阶段,V8 已确定:bar 的词法环境 → 全局环境
  • 尽管 bar 在 foo 中被调用,变量查找仍沿词法作用域链向上
  • 最终在全局找到 myName = "极客时间"

❌ 常见误区:
“函数在哪里调用,就用哪里的变量” —— 这是动态作用域(如 Bash),JS 不是!


四、作用域链:变量的查找路径

1. 作用域链的构建

每个词法环境都有一个 [[Outer]] 指针,指向其外层词法环境,形成链式结构:

js
编辑
globalEnv
  ↑
fooEnv → [[Outer]] = globalEnv
  ↑
innerBar.getNameEnv → [[Outer]] = fooEnv

2. 查找规则(LEGB 规则)

当访问变量 x 时,引擎按顺序查找:

  1. Local(当前函数)
  2. Enclosing(外层函数)
  3. Global(全局)
  4. Built-in(内置对象,如 undefined

静态性:这条链在函数声明时就已确定,永不改变。


五、闭包:跨越执行上下文的生命延续

1. 什么是闭包?

当一个内部函数被返回并在外部调用时,它仍能访问其定义时所在作用域的变量,这种现象称为闭包。

2. 闭包的形成条件

  • 函数嵌套函数
  • 内部函数引用了外部函数的变量(自由变量
  • 内部函数在外部被调用(通常通过 return

3. 经典闭包示例解析

js
编辑
function foo() {
    var myName = "极客时间";
    let test1 = 1;
    const test2 = 2;
    
    var innerBar = {
        getName: function () {
            console.log(test1); // 自由变量
            return myName;      // 自由变量
        },
        setName: function (name) {
            myName = name;      // 修改闭包变量
        }
    };
    return innerBar; // 返回内部函数
}

var bar = foo(); // foo 执行完毕,上下文应出栈
bar.setName("极客邦");
console.log(bar.getName()); // "极客邦"

🔬 底层发生了什么?

  1. foo() 执行,创建执行上下文
  2. getName 和 setName 的词法环境记录:[[Outer]] = foo 的词法环境
  3. foo 返回后,其执行上下文本应销毁
  4. 但由于 bar.getName 仍持有对 foo 词法环境的引用 → V8 不会回收这些变量
  5. myNametest1 等变量被保存在一个“闭包背包”中,随函数对象常驻内存

💡 闭包的本质
函数对象 + 其定义时的词法环境快照


六、闭包的内存模型:自由变量与垃圾回收

1. 自由变量(Free Variables)

  • 在函数内部使用,但未在该函数内声明的变量
  • 通过作用域链从外层捕获

2. 垃圾回收(GC)规则

  • 只要闭包函数未被销毁,其引用的自由变量就不会被 GC
  • 当闭包函数失去所有引用,整个闭包环境才会被回收
js
编辑
var bar = foo();
// 此时 foo 的变量仍在内存中

bar = null; // 解除引用
// 下次 GC 时,foo 的变量将被回收

七、块级作用域与闭包

ES6 的 let/const 引入了块级作用域,进一步丰富了闭包场景:

js
编辑
function foo() {
    var myName = "极客邦";
    let test = 2;
    {
        let test = 3; // 块级作用域
        bar();        // 调用全局 bar
    }
}
function bar() {
    console.log(myName); // "极客时间"(全局变量)
}
var myName = "极客时间";
foo();

✅ 即使在块中调用函数,词法作用域仍由声明位置决定


八、大厂面试高频问题

  1. 闭包是什么?如何形成?
    → 内部函数访问外部变量,且在外部被调用。

  2. 闭包会导致内存泄漏吗?
    → 不会!只要合理管理引用,GC 会自动回收。但意外持有大对象引用可能造成问题。

  3. for 循环中的闭包陷阱如何解决?

    js
    编辑
    // 错误:所有回调共享 i
    for (var i = 0; i < 3; i++) {
        setTimeout(() => console.log(i), 0);
    }
    
    // 正确:用 let 创建块级作用域
    for (let i = 0; i < 3; i++) {
        setTimeout(() => console.log(i), 0);
    }
    
  4. 词法作用域和动态作用域的区别?
    → 词法看定义位置,动态看调用位置。JS 是词法作用域。


结语

词法作用域与闭包,是 JavaScript 区别于其他语言的灵魂特性。它们不是缺陷,而是强大抽象能力的体现:

  • 词法作用域 提供了可预测的变量查找规则
  • 闭包 实现了状态封装、模块化、回调等高级模式

理解它们,意味着你不再“猜”变量的值,而是知道它一定在哪里;你不再害怕“内存泄漏”,而是掌控数据的生命周期

记住
“闭包不是 bug,而是 feature;
作用域不是限制,而是秩序。”
—— 掌握底层机制,方能写出真正可靠的代码。