JavaScript 闭包深度解析:从调用栈到词法作用域,一文彻底搞懂!

200 阅读9分钟

JavaScript 闭包深度解析:从调用栈到词法作用域,一文彻底搞懂!

大家好!今天带大家深入 JavaScript 的核心机制——闭包(Closure)。闭包是面试高频考点,也是很多同学觉得“玄学”的地方:为什么函数执行完了,里面的变量还没被回收?为什么能记住“出生时的环境”?

别急!本文基于 V8 引擎的真实执行过程,结合生动的手绘调用栈图、详细代码示例、执行步骤拆解,以及常见易错点提醒,带你从调用栈执行上下文词法作用域一步步推导到闭包的形成、存储和销毁。全程干货,图文并茂,读完保证你能自信地向面试官画出闭包的内存结构!

一、基础铺垫:JS 是怎么运行的?

在讲闭包之前,我们先快速过一遍 JS 的底层运行机制(不了解的同学这里一定要认真看!):

  1. V8 引擎两大阶段

    • 编译阶段:解析代码,生成 AST,确定作用域(词法作用域就在这里决定!)
    • 执行阶段:以函数为单位,把执行上下文压入调用栈(Call Stack)
  2. 调用栈(Call Stack)
    就是一个“栈”结构,函数调用时入栈,执行完出栈。先入后出。
    栈底永远是全局执行上下文,栈顶是当前正在执行的函数。

  3. 执行上下文(Execution Context)
    每个函数执行时都会创建一个执行上下文,包含三部分:

    • 变量环境(Variable Environment):var 声明的变量
    • 词法环境(Lexical Environment):let/const 声明的变量 + 函数声明
    • outer 指针:指向外层词法环境(这就是作用域链的核心!)
  4. 词法作用域(Lexical Scope)
    关键点:作用域由函数声明的位置决定,不是调用位置!
    这就是为什么叫“词法”——写代码时就定死了。

二、作用域链到底是怎么查找变量的?

我们先看两个经典例子,帮你建立直觉。

示例 1

function bar() {
  console.log(myName);
}
function foo() {
  var myName = '极客邦'
  bar()
}
var myName = '极客时间'
foo();

输出:极客时间

为什么不是“极客邦”?
很多人以为 bar 是 foo 里调的,所以应该找 foo 的 myName。错!
作用域链查找规则:

  1. 先查当前函数的词法环境
  2. 找不到就沿着 outer 指针 往上找
  3. 一直找到全局

bar 函数是在全局声明的,它的 outer 指向全局,所以直接找到全局的 myName = '极客时间'。

示例 2——块级作用域 + 遮蔽

function bar () {
  var myName = "极客世界";
  let test1 = 100;
  if (1) {
    let myName = "Chrome 浏览器"
    console.log(test)
  }
}
function foo() {
  var myName = "极客邦";
  let test = 2;
  {
    let test = 3;
    bar()
  }
}
var myName = "极客时间";
let myAge = 10;
let test = 1;
foo();

输出:1

易错提醒

  • let/const 有块级作用域,var 没有。
  • test 是全局的 1,bar 里 if 块的 let myName 只遮蔽了块内,对外层无影响。
  • 很多人以为会报错,其实不会,因为查找时先找块级,再往外走。

作用域链查找路径
bar 的 if 块 → bar 函数 → 全局

58b551d53782168f8145004f1090ec4f.png

这张图说明了:变量查找只认出生地,不认调用栈。 很多人一看到 bar() 是在 foo() 里调的,就以为会打印“极客邦”,其实完全错了。作用域链在编译阶段就定死,bar 的 outer 永远指向全局

三、闭包到底是什么?形成条件有哪些?

官方定义(MDN):

当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行。

通俗理解(背包理论):

函数执行完后,本该被销毁的执行上下文没有被销毁,而是被“背”在了返回的内部函数身上。这个“背包”就是闭包,里面装着自由变量(外部函数的变量)。

闭包形成的三个必要条件(必须同时满足):

  1. 函数嵌套函数(内层函数)
  2. 内层函数被返回到外部(或者以其他方式暴露出去),在外部可以访问
  3. 内层函数引用了外层函数的变量(自由变量)

经典闭包代码

function foo(){
    var myName = '极客时间'
    let test1 = 1
    const test2 = 2
    var innerBar = {
        getName: function(){
            console.log(test1)  // 引用了 test1
            return myName
        },
        setName: function(newName){
            myName = newName
        }
    } 
    return innerBar  // 关键!暴露到外部
}
var bar = foo()     // foo 执行完,出栈
bar.setName('极客邦')
bar.getName()       // 1
console.log(bar.getName())  // "极客邦"

为什么能访问到 myName 和 test1?
因为 getName/setName 引用了它们,V8 就不会回收 foo 的执行上下文,而是把它挂在闭包里。

d70143c661ed9c209cdc5991f27fcab9.png

91647c77a8494223e939c23c77bb79ff.png 调用栈弹出那一刻,普通变量本该被回收,但因为 return innerBar,V8 发现有人还想用 myName 和 test1,于是把它们打包成一个“专属背包”挂在了返回的对象上,和全局执行上下文并列存在

536a315a83aa48b870d03dd921b6c02a.png setName执行时,闭包还在!

四、五大灵魂追问

问题 1:为什么闭包形成条件之一是要在外部可以被访问?

答案:如果内部函数没有被外部访问到,JS 引擎会认为“没人会用到这些变量”,于是直接回收整个执行上下文,闭包根本不会形成。

形象比喻
你写了个函数,里面有个秘密日记(myName),如果你只在函数里读,从不拿出去给别人看,函数一执行完,垃圾车就来收走了。
只有你把日记本(innerBar)递给了外面的人(return),别人还能翻看、修改,垃圾车才不敢收——这就是闭包!

不返回就无闭包示例

function foo(){
    var secret = '我很贵';
    function inner(){ console.log(secret); }
    // 没 return,inner 永远没人能调用
}
foo(); // 执行完,secret 立刻被回收

易错提醒:很多人以为只要函数嵌套就一定有闭包,其实必须满足“外部可访问”!

问题 2:闭包存放在哪?和 outer 指针什么关系?

答案

  • 闭包本身不是一个独立的对象,而是被内部函数持有的一个 Scope 对象,具体挂在函数对象的隐藏属性 [[Scopes]] 数组里。

text

内部函数对象
├── [[Scopes]](隐藏属性,只有引擎能访问)
   ├── 0: Closure (foo)          ← 这就是真正的闭包!保存了 foo 的变量环境
   ├── 1: Script / Global Scope
   └── 2: ...

外层函数 foo 执行完出栈后:

  • 它的执行上下文本该被销毁
  • 但因为返回的 innerBar 里面的 getName/setName 这两个函数的 [[Scopes]][0] 仍然指向 foo 的词法环境
  • 所以 V8 把 foo 的词法环境从「活跃的执行上下文」升级成了「Closure 对象」,挂在这些内部函数的 [[Scopes]] 上

所以严格说: 闭包不是放在 outer 指针里,而是 outer 所指向的那个词法环境被“升级”成了 Closure 对象,然后被所有引用它的内部函数共用。

  • 它包含了外层函数的词法环境(也就是那些自由变量)。
  • outer 指针正是构建作用域链的关键,它指向外层词法环境。
  • 当形成闭包时,V8 会把 outer 指向的整个词法环境“打包”进闭包背包。

内存结构图解

  • 调用栈:全局 → foo → 出栈
  • foo 出栈后,普通变量会被回收,但闭包保留了 foo 的词法环境
  • bar(innerBar 对象)持有闭包引用
  • 闭包里保存:myName = "极客时间"(可修改)、test1 = 1、test2 = 2
  • 查找变量时:先查自身 → 查闭包(foo 的词法环境)→ 再沿着 outer 往上(全局)

和 outer 的关系
outer 是静态的词法指针,闭包是动态的“背包”。outer 决定了查找路径,闭包则是为了让已经出栈的上下文“复活”而存在的缓存。

问题 3:闭包什么时候销毁?

答案:闭包只有在没有任何引用指向内部函数时才会被销毁。
不是程序执行完,也不是内部函数执行完,而是引用计数为 0 时,垃圾回收器才会清理。

举例说明

function createCounter() {
    let count = 0;
    return function() {
        return ++count;
    };
}
let counter = createCounter();
console.log(counter()); // 1
console.log(counter()); // 2
counter = null; // 手动解除引用
// 这时闭包才会被回收,count 变量被销毁

易错提醒:很多人以为函数执行完闭包就没了,其实只要外部还有引用,它就一直活着!

问题 4:多个内部函数引用同一个闭包,是共用一个还是各自一个独立的?

共用!100% 共用同一个 Closure 对象!

JavaScript

function foo() {
  var money = 100;
  let name = '极客时间';

  function a() { console.log(money++, name); }
  function b() { console.log(money++, name); }
  function c() { console.log(money++, name); }

  return { a, b, c };
}

const obj = foo();

这三个函数 a、b、c 的 [[Scopes]][0] 指向的是同一个 Closure (foo) 对象!

所以你会看到:

JavaScript

obj.a(); // 100 "极客时间"
obj.b(); // 101 "极客时间"
obj.c(); // 102 "极客时间"

所有人都操作的是同一个 money 和 name,典型的共享状态!

问题 5:全局变量持有闭包导致内存泄漏?

最经典的内存泄漏场景:

JavaScript

// 全局变量泄漏经典案例
let leaks = [];

function createBigClosure() {
  let bigArray = new Array(1000000).fill('我很占内存');
  
  return function inner() {
    // 只用了一点点,但把整个 bigArray 都拽住了
    console.log(bigArray.length);
  };
}

for (let i = 0; i < 10000; i++) {
  leaks.push(createBigClosure()); // 全局数组一直持有闭包引用
}

后果:

  • 10,000 个闭包,每个都拽着一个 100w 元素的数组
  • 这些闭包永远不会被回收 → 几百 MB 甚至上 GB 内存常驻
  • 页面越来越卡,最终崩溃

真实项目中常见的三种全局持有方式:

  1. 全局变量 / window.xxx 直接引用
  2. DOM 元素的事件监听器没解绑(事件回调是闭包!)
  3. setInterval/setTimeout 没 clear

五、闭包的实际应用场景(面试必问)

  1. 数据隐藏 / 私有变量(上面例子)
  2. 防抖节流(timer 变量被闭包记住)
  3. 柯里化
  4. 模块化(立即执行函数 IIFE)
  5. React Hooks(useState 的闭包)

六、总结

  1. 闭包 = 函数 + 词法环境(背包)
  2. 形成条件:嵌套 + 外部可访问 + 引用外部变量
  3. 存储:内部函数的 [[Scopes]] 属性,和 outer 指针共同构成查找链
  4. 销毁:没有任何引用时(手动设为 null 或超出作用域)
  5. 记住:作用域看“出生地”(声明位置),不是“调用地”

希望这篇文章能帮你彻底征服闭包!