“闭包不是背包,但比背包更会藏东西”——从三段诡异代码看透 JS 作用域链与闭包的本质

42 阅读8分钟

“闭包不是背包,但比背包更会藏东西”——从三段诡异代码看透 JS 作用域链与闭包的本质

副标题:别再死记“函数嵌套 + 返回内部函数 = 闭包”了!本文带你用 V8 引擎视角,拆解变量查找、词法作用域链、内存回收机制,直击大厂高频面试题核心。


🧨 开场暴击:三段代码,三个“极客”,一个答案?

先来看这三段看似独立、实则暗藏玄机的 JavaScript 代码:

// 代码片段 A
function bar() {
    console.log(myName);
}
function foo() {
    var myName = '极客帮';
    bar(); // 运行时
}
var myName = '极客时间';
foo();
// 代码片段 B
function bar() {
    var myName = "极客世界";
    let test1 = 100;
    if (1) {
        let myName = "Chrome 浏览器";
        console.log(test); // 注意:这里 log 的是 test,不是 test1!
    }
}
function foo() {
    var myName = "极客邦";
    let test = 2;
    {
        let test = 3;
        bar();
    }
}
var myName = "极客时间";
let myAge = 10;
let test = 1;
foo();
// 代码片段 C(闭包经典)
function foo() {
    var myName = "极客时间";
    let text1 = 1;
    const text2 = 2;
    var innerBar = {
        getName: function () {
            console.log(text1);
            return myName;
        },
        setName: function (name) {
            myName = name;
        }
    };
    return innerBar;
}

var bar = foo();
bar.setName("极客邦");
console.log(bar.getName()); // 输出?

问题来了

  • 片段 A 输出什么?为什么不是 “极客帮”?
  • 片段 B 会报错吗?如果会,错在哪?test 到底是谁?
  • 片段 C 中,foo() 执行完后,里面的 myNametext1 不是应该被回收了吗?为什么还能访问?

如果你能秒答且解释清楚底层机制,恭喜你,已经站在了字节/阿里 P7 的门槛上。
如果还有一丝犹豫——别慌,这篇文章就是为你准备的。


🔍 第一章:JS 引擎如何“看”你的代码?编译阶段 vs 执行阶段

很多人以为 JS 是“解释型语言”,一行一行执行。错!V8 引擎其实有编译阶段

1.1 编译阶段:变量提升(Hoisting)与词法环境构建

在代码真正运行前,V8 会做两件事:

  • 变量/函数声明提升(仅声明,不赋值)
  • 构建词法环境(Lexical Environment) —— 这就是作用域的底层实现

以片段 A 为例:

var myName = '极客时间'; // 全局变量
function foo() { ... }
function bar() { ... }

在编译阶段,V8 已经知道:

  • 全局作用域中存在 myNamefoobar
  • foo 内部有局部变量 myName
  • bar 内部没有声明 myName,所以它会去外层作用域

✅ 关键点:作用域链是在编译时静态确定的,和函数在哪里调用无关!

这就是词法作用域(Lexical Scope) 的核心:看函数写在哪,而不是在哪调用

所以当 foo() 调用 bar() 时,bar 的作用域链是:

bar 的作用域 → 全局作用域

不是 foo 的作用域!因为 bar 是在全局定义的。

因此,片段 A 输出:"极客时间"

💡 面试高频陷阱:函数作为参数传递或回调时,其作用域仍由定义位置决定


🧱 第二章:块级作用域、let/const 与词法作用域的“铁律”

我们来看这段看似混乱、实则精准体现 JavaScript 作用域机制的代码(即提供的片段 B):

function bar() {
    var myName = "极客世界";
    let test1 = 100;
    if (1) {
        let myName = "Chrome 浏览器";
        console.log(test); // 注意:这里访问的是 test,不是 test1!
    }
}

function foo() {
    var myName = "极客邦";
    let test = 2;
    {
        let test = 3;
        bar(); // 调用全局定义的 bar
    }
}

var myName = "极客时间";
let myAge = 10;
let test = 1; // 全局变量
foo();

2.1 问题:console.log(test) 会输出什么?

很多初学者会直觉认为:

bar 是在 foo 里面调用的,而 foo 里有 test = 2 和块级 test = 3,所以应该输出 3 或 2。”

这是典型的误区——混淆了“调用位置”和“定义位置”。

JavaScript 使用的是 词法作用域(Lexical Scope) ,也叫 静态作用域
这意味着:一个函数能访问哪些变量,在它被声明(写下来)的那一刻就已经决定了,和它在哪里被调用完全无关。


2.2 关键分析:bar 的作用域链是什么?

bar 函数是在全局作用域中声明的,因此它的作用域链结构为:

bar 的局部作用域
└── 全局作用域

注意:不包含 foo 的作用域!

即使 bar() 是在 foo 内部被调用的,V8 引擎在查找变量时,依然只沿着 bar 自身的作用域链向上搜索。

当执行到 console.log(test) 时,引擎按以下顺序查找 test

  1. bar 的局部作用域中查找
    → 没有 var/let/const test 声明,跳过。
  2. 进入上一级作用域:全局作用域
    → 找到 let test = 1;,且该变量已完成初始化(不在暂时性死区),
    → 于是成功读取其值:1

最终输出:1


2.3 为什么不会报错?

有人可能会担心:“bar 里没声明 test,会不会报 ReferenceError: test is not defined?”

不会!
ReferenceError 只在以下情况发生:

  • 变量从未被声明,且你试图读取它;
  • 或者变量使用 let/const 声明,但处于暂时性死区(TDZ)  中。

而在本例中:

  • 全局作用域确实声明了 let test = 1
  • 该声明在 foo() 调用前已完成(JS 代码自上而下执行,test = 1 在 foo() 之前);
  • 因此 test 是可访问的合法变量

✅ 结论:代码正常运行,输出 1,无任何错误。


2.4 对比实验:如果 bar 定义在 foo 内部呢?

为了加深理解,我们做个小实验:

let test = 'global';

function foo() {
    let test = 'local';
    function bar() { // ← 现在 bar 定义在 foo 内部!
        console.log(test);
    }
    bar();
}

foo(); // 输出 'local'

此时,bar 的作用域链变为:

bar 局部作用域
└── foo 的作用域
    └── 全局作用域

因此它会优先找到 foo 中的 test,输出 'local'

这再次验证了核心原则:

作用域链由函数的“书写位置”决定,而非“调用位置”。


2.5 高频面试陷阱总结

误区正确认知
“函数在哪调用,就能访问哪的变量”❌ JS 是词法作用域,看定义位置
“let 没提升,所以找不到变量就报错”❌ let 有提升,但有 TDZ;只要变量存在且已初始化,就能跨作用域访问
“闭包才能访问外层变量”❌ 任何内部函数(无论是否返回)都能访问其词法父作用域

💡 小结

片段 B 的设计精妙之处在于:

  • 用 bar 在 foo 中调用的“假象”,诱导你误判作用域;
  • 实际通过 console.log(test) 的变量名(与 foo 中的 test 同名但无关),测试你对词法作用域链的理解深度。

记住这条铁律:

在 JavaScript 中,函数永远带着它出生地的地图(作用域链)行走江湖,不管它后来去了哪里。


🎒 第三章:闭包——不是“背包”,而是“被捕获的词法环境”

终于来到重头戏:闭包(Closure)

很多人说:“闭包就是返回一个内部函数”。
不准确!闭包的本质是:函数 + 其定义时的词法环境的组合

看片段 C:

function foo() {
    var myName = "极客时间";
    let text1 = 1;
    return {
        getName: function() { console.log(text1); return myName; },
        setName: function(name) { myName = name; }
    };
}
var bar = foo();

foo() 执行完毕,其执行上下文(Execution Context)确实会从调用栈弹出
按理说,myNametext1 应该被垃圾回收。

但 V8 发现:有两个函数(getName/setName)引用了它们!

于是,V8 不会回收这些变量,而是将它们打包进一个“闭包环境”(Closure Scope),挂载在那两个函数的 [[Scope]] 内部属性上。

✅ 闭包形成的三个条件:

  1. 函数嵌套(内部函数)
  2. 内部函数引用了外部变量(自由变量)
  3. 内部函数在外部被引用(生命周期延长)

所以:

bar.setName("极客邦");
bar.getName(); // 输出 "极客邦"

myName 被成功修改并读取,即使 foo 已经执行完毕

💡 自由变量(Free Variables):指在函数内部使用、但未在该函数内声明的变量。它们会被“捕获”进闭包。


🧠 深度拓展:闭包与内存管理、性能优化

4.1 闭包会导致内存泄漏吗?

可能,但不是必然

  • 如果闭包引用了大型对象(如 DOM 节点、大数组),且长时间不释放,就会占用内存。
  • 解决方案:用完后置 null,断开引用。
bar = null; // 此时闭包环境可被 GC 回收

4.2 闭包 vs 模块模式

片段 C 其实是模块模式(Module Pattern) 的雏形:

const user = (function() {
    let name = '匿名';
    return {
        getName() { return name; },
        setName(n) { name = n; }
    };
})();

这是 ES6 之前实现“私有变量”的主要手段。

4.3 现代替代方案:WeakMap / Private Fields

ES2022 引入了类私有字段:

class User {
    #name = '匿名';
    getName() { return this.#name; }
    setName(n) { this.#name = n; }
}

更安全、性能更好,但闭包仍是理解 JS 作用域的基石


🧪 高频面试题关联

面试题本文对应知识点
“解释 JS 作用域链”词法作用域、编译阶段构建作用域链
“闭包是什么?如何形成?”自由变量、函数引用延长生命周期
“var/let 在 for 循环中的区别?”块级作用域 vs 函数作用域
“setTimeout 闭包陷阱”回调函数作用域链问题
“如何避免闭包内存泄漏?”引用清理、WeakMap

✅ 总结:一张图看懂 JS 作用域与闭包

[全局作用域]
   ├── myName ("极客时间")
   ├── foo()
   └── bar() → 作用域链:bar → 全局

[foo 执行上下文](执行时创建)
   ├── myName ("极客帮")
   └── 调用 bar() → 但 bar 的作用域链不变!

[闭包场景]
foo() 返回对象 → 对象方法引用 foo 内部变量
→ V8 保留 foo 的词法环境 → 形成闭包

记住

  • 作用域链 = 函数定义位置决定(静态)
  • 闭包 = 函数 + 被捕获的外部变量(动态存活)
  • 变量回收 = 是否还有引用(引用计数 + 可达性分析)

📣 结语

闭包不是魔法,也不是“背包”,而是 JS 词法作用域机制的自然产物。
理解它,就理解了 React Hooks 的 stale closure、Vue 的响应式原理、甚至 Node.js 的模块缓存。

下次面试官问:“闭包是什么?”
你可以微微一笑:“它是我函数的‘记忆’,只要我还活着,它就不会忘。”