🌐 从作用域链到闭包:JavaScript 的静态作用域与动态引用机制

84 阅读8分钟

在 JavaScript 中,作用域链(Scope Chain) 是变量查找的路径,而 闭包(Closure) 则是作用域链在函数执行结束后依然“存活”的体现。理解二者之间的关系,是掌握 JavaScript 高级特性的关键。

本文将带你从 词法作用域 出发,深入剖析 作用域链如何构建,并自然过渡到 闭包的形成机制与实际意义,最终揭示:

🔑 闭包不是魔法,而是作用域链在特定条件下的延续


🧭 一、起点:词法作用域(Lexical Scope)

在学习过程中,你可能接触过 词法环境(Lexical Environment) ,但它和 词法作用域 并非同一概念:

  • 词法作用域 是一种 语言设计原则
  • 词法环境 是引擎用来 实现该原则的运行时数据结构

✅ 什么是词法作用域?

变量的可访问范围由函数或代码块在源代码中的声明位置决定,而不是由运行时的调用位置决定。

这是一个 静态(编译期) 的概念。

JavaScript 采用 词法作用域(Lexical Scope) ,也称 静态作用域,这意味着:

💡 一个函数能访问哪些变量,在它被声明(定义)的时候就已经确定了,与它在哪里被调用无关。

📌 示例:调用栈 ≠ 作用域链

function bar() {
    console.log(myname);
}
function foo() {
    var myname = '极客帮';
    bar(); // 运行时调用
}
var myname = '极客时间';
foo();

❓ 这段代码打印什么?

  • 如果按 调用栈逻辑 推测:barfoo 调用 → 应该看到 foo 中的 myname → 输出 '极客帮'

调用栈示意图

2347dd5fa2b645aa993eeb8cf4a5f107.png

  • 但这是错误的!

✅ 实际输出: '极客时间'

为什么?
因为 bar 函数是在 全局作用域中声明的,它的作用域链是:

bar 的局部作用域 → 全局作用域

无法访问 foo 的变量,哪怕是在 foo 内部调用的!因为它们两个函数都声明在全局执行上下文的变量环境,因此应该属于同级。

事实上,在函数声明时,JavaScript 引擎会为其创建一个 [[Outer]] 引用,指向其声明位置所处的词法环境;变量查找时,正是通过这个 [[Outer]] 链沿着作用域链逐层向上追溯,从而确定变量的来源。

变量查找示意图

ea01243306ed457782f2a165c4b8f30a.png


🔗 二、作用域链:变量查找的静态路径

每当创建一个执行上下文(如函数调用),JavaScript 引擎会为其建立一条 作用域链(Scope Chain) ,用于变量查找。

🏗️ 作用域链的构成

作用域链是一个 链式结构,每个节点对应一个 词法环境(Lexical Environment)

  • 当前函数的词法环境
  • 外层函数的词法环境(如果有)
  • ……
  • 全局词法环境

⚠️ 这条链在 函数声明时 就已固定,属于 编译阶段 的产物。

🔍 查找规则

  1. 从当前作用域(当前词法环境)开始查找变量
  2. 若未找到,沿 [[Outer]] 引用向上查找
  3. 直到全局作用域
  4. 若仍未找到,抛出 ReferenceError

这就是作用链的查找:

d215acebece848a293a55924868da25b.png

🧪 示例 1:标准嵌套

function bar() {
    var myName = '极客世界';
    let text1 = 100;
    if (1) {
        let myName = "Chrome 浏览器";
        console.log(text); // ← 查找 text
    }
}
function foo() {
    var myName = '极客帮';
    let text = 2;
    {
        let text = 3;
        bar(); // 调用全局声明的 bar
    }
}
var myName = '极客时间';
let myAget = 10;
let text = 1;

foo(); // 输出:1

🔎 查找过程:

  1. console.log(text)barif 块级词法环境中查找 → 无
  2. 回退到 bar 函数的词法环境 → 无
  3. bar 是在 全局作用域声明的,所以其 [[Outer]] 指向 全局词法环境
  4. 在全局词法环境中找到 let text = 1 → 输出 1

查找步骤示意图:

5d8ce78386aa4e1d8ec602d7b0d065a9.png

结论:作用域链由 代码结构(声明位置) 决定,与调用位置无关。


🧪 示例 2:块级作用域不会“穿透”

function bar() {
    console.log(text); // ← 仍查找全局
}
function foo() {
    let text = 2;
    {
        let text = 3;
        bar();
    }
}
var myName = '极客时间';
if (1) {
    let text = 4; // ← 全局块中的 text
}
foo();

❓ 输出什么?

  • bar 依然在 全局作用域声明
  • 全局作用域中 没有 let text 声明if 块中的 text 属于该块,不可被外部访问)
  • 所以:ReferenceError: text is not defined

📌 关键点:函数只能访问其声明位置可见的变量,不能“看到”调用处的块级变量。


🚪 三、转折点:当函数“逃逸”出其作用域

通常,函数执行完毕后,其执行上下文会被销毁,内部变量也会被垃圾回收。

但有一种特殊情况:内部函数被返回或传递到外部

这时,神奇的事情发生了——外层变量 没有被回收


🎒 四、闭包:作用域链的“持久化”

❓ 什么是闭包?

📘 闭包 = 函数 + 该函数声明时所在词法环境的引用

无论通过何种手段将内部函数传递到所在词法作用域以外,它都会持有对原始定义作用域的引用,无论在何处执行这个函数都会使用闭包 ——《你不知道的js》

换句话说,闭包是 作用域链在函数执行结束后依然保持活跃的状态

✅ 闭包的三个必要条件

  1. 函数嵌套:内部函数存在于外部函数中
  2. 内部函数引用外层变量(称为“自由变量”)
  3. 内部函数在外部被访问(如 return、回调、事件监听等)(任何方式对函数类型的值进行传递都可以产生闭包)

💡 示例:经典闭包

function foo() {
    var myName = "极客时间";
    let text1 = 1;
    const text2 = 2;

    var innerBar = {
        getName: function () {
            console.log(text1); // 引用外层变量
            return myName;
        },
        setName: function (newName) {
            myName = newName;
        }
    };

    return innerBar; // 返回对象,方法可被外部访问
}

var bar = foo(); // foo 执行完毕,上下文出栈
console.log(bar.getName()); // → 1, "极客时间"
bar.setName("极客帮");
console.log(bar.getName()); // → 1, "极客帮"

执行到foo时调用栈示意图:

ec7da13405014dd8ada0cc950dfb0252.png

🔍 发生了什么?

  • foo 执行结束,按理应销毁 myNametext1
  • getNamesetName 引用了这些变量
  • 引擎检测到:这些变量仍被需要 → 保留在内存中
  • 这个“被保留的词法环境”就是 闭包

闭包示意图:

6e183890bb954d8891c0f624f5fd5b09.png

🎒 你可以把闭包想象成函数背上的“专属背包”,走到哪,变量就跟到哪。


❓ 如果把 innerBarlet 声明呢?

function foo() {
    let innerBar = { /* ... */ };
    return innerBar;
}

仍然形成闭包!

区别仅在于:

  • var innerBar:存放在 变量环境(Variable Environment)
  • let innerBar:存放在 词法环境(Lexical Environment)

getName/setName 仍然是在 foo 内部 声明的函数,它们的作用域链依然是:

函数自身 → foo 的词法环境 → 全局

因此,闭包是否形成,与 var/let 无关,只取决于函数是否引用外层变量并在外部被使用


🖼️ 五、作用域链 vs 闭包:一张图看懂关系

【声明阶段(编译期)】
foo()
 └── 内部函数 f()
      └── 作用域链:f → foo → global   ← 静态确定 ✅

【执行阶段(运行时)】
1. foo() 被调用 → 创建 foo 的执行上下文
2. f() 被返回 → foo 上下文出栈
3. 但 f 仍持有对 foo 词法环境的引用 → 形成闭包 🎒
概念角色特性
作用域链设计蓝图静态、声明时确定
闭包运行时实例动态、延长变量生命周期

🔁 闭包 = 作用域链的运行时延续


🚫 六、常见误区澄清

❌ 误区1:“闭包就是返回函数”

不准确。只有当返回的函数 引用了外层变量,才形成有意义的闭包。

function noClosure() {
    let x = 100;
    return function () {
        console.log('hello'); // 未引用 x
    };
}
// 技术上存在闭包,但为空;x 会被正常回收。

❌ 误区2:“闭包会导致内存泄漏”

闭包本身是安全的。内存泄漏 通常是因为:

  • 无意中长期持有大对象引用
  • 未及时解除事件监听或定时器

合理使用闭包不会导致泄漏。

❌ 误区3:“作用域链由调用位置决定”

这是 动态作用域(如 Bash、早期 Lisp)的特点。
JavaScript 是 词法作用域,只看 声明位置


🛠️ 七、实战:用闭包解决经典问题

🔄 问题:循环中的 setTimeout

for (var i = 0; i < 3; i++) {
    setTimeout(() => console.log(i), 100); // 输出:3, 3, 3
}

原因:所有回调共享同一个 ivar 声明在全局),循环结束时 i = 3


✅ 解法1:利用闭包捕获当前值

for (var i = 0; i < 3; i++) {
    (function(j) {
        setTimeout(() => console.log(j), 100);
    })(i);
}
// 输出:0, 1, 2
  • 每次循环创建一个新函数,形成独立闭包
  • j 被“冻结”为当前 i 的值

✅ 解法2:使用 let(推荐)

for (let i = 0; i < 3; i++) {
    setTimeout(() => console.log(i), 100); // 输出:0, 1, 2
}
  • let 在每次循环创建 新的块级作用域
  • 每个 setTimeout 回调形成 独立闭包,绑定各自的 i

🌟 这正是 let 解决循环变量问题的底层原理!


📊 八、总结:从作用域链到闭包的逻辑脉络

阶段核心机制关键特性
1. 声明函数词法作用域建立作用域链静态确定 ✅
2. 调用函数执行上下文入栈变量/词法环境初始化
3. 内部函数引用外层变量自由变量产生依赖外层词法环境
4. 内部函数被传出外层上下文不应销毁引擎保留词法环境
5. 闭包形成作用域链持久化变量生命周期延长 🎒

💎 闭包不是新概念,而是作用域链在函数“逃逸”场景下的自然延伸。


🌱 延伸思考

React Hooks(如 useState)的“状态记忆”能力,本质上也是闭包的巧妙应用:

function MyComponent() {
    const [count, setCount] = useState(0);
    // 每次渲染,setCount 都通过闭包“记住”对应的状态
}

每一次组件渲染,Hook 函数都通过闭包“记住”上一次的状态——这正是 词法作用域 + 闭包 赋予 JavaScript 的强大表达力。


🧠 掌握作用域链,你就掌握了 JavaScript 的灵魂;理解闭包,你就拥有了操控状态的艺术。