硬核教程 | 你不知道的Javascript(上卷) | 第五章难点与细节解读(作用域闭包)

1,013 阅读18分钟

写在前面

作为《你不知道的Javascript》忠实读者,多次拜读该著作,本专栏用来分享我对该书的解读,适合希望深入了解这本书的读者阅读 电子书下载网址:zh.101-c.online

从我开始构思这个系列专栏的时候就在思考,闭包这部分应该如何写才能通俗易懂且不失深度

在我的眼中,O'Reilly在该部分的文笔并不是面向Javascript初学者写的,部分表述过于术语化,理解难度较大,所以这部分的内容我将采用一种新的方式去解读,我将大量去引用O'Reilly的内容(并不是细枝末节),并用我自己的理解去用一种更加通俗的语言帮助大家理解闭包这部分的内容

我会尝试先让大家掌握闭包的概念作用等之后再去一一解析《你不知道的Javascript》中的表述

第五章——作用域闭包(本人解释)

一、闭包的具体形态是什么?

换句话讲,就是我们如何去直观的用肉眼去看到闭包的代码,而不只是通过脑子去臆想,这就是我写这一部分的目标,我们可以先尝试去理解原文中关于闭包的定义

image.png 上述是O'Reilly给出的闭包的实质,我认为这个定义是生涩难懂的。至少,通过这段话我们并不能看出闭包在代码中具体体现在哪一部分,或者闭包包括了什么内容,甚至不知道闭包是一个名词还是动词或者是形容词

我认为作为Javascript学习者,不应将闭包的概念认知停留在给个定义上,下面我尝试用我的理解去给大家描述什么是闭包(当然,我会保证我描述的内容是正确的,可以放心“食用”)

1.什么条件下会产生闭包,产生闭包的核心条件是什么

该部分我们不谈闭包具体是什么,我们只谈什么情况下会产生闭包,这只是我们全面理解闭包的第一部分,就好像谈论一个人什么情况下会吃饭(比如饿的时候),不去谈论他具体吃的什么,希望这个比喻可以让各位理解

下面给大家一个最简单的闭包的例子,接下来的初步讲解会围绕这个例子来讲解

function foo() {
  var a = 2;
  // ------------------------
     function bar() {
       console.log( a );
     }
  // ------------------------
   return bar;
}
  // ------------------------
var baz = foo();
baz();

上述代码中,我用横线对代码进行了分层,方便大家去逐步理解闭包,下面我直接给出闭包形成的三个条件


条件 1:函数嵌套(Function Nesting)

必须有一个内部函数定义在另一个函数内部

  • 外部函数(Outer Function)提供作用域环境。
  • 内部函数(Inner Function)是闭包的主体。

在我们所给的例子中外部函数是foo() 内部函数是bar(),其中foo()提供了作用域环境,bar()构成了闭包的主体


条件 2:内部函数引用外部变量(Variable Capture)

内部函数必须引用外部函数作用域中的变量(或参数)

在我们的例子中,内部函数bar()引用了外部函数foo()中的变量a,即console.log( a )


条件 3:内部函数逃逸(Escape)

内部函数必须通过某种方式逃逸到外部函数的作用域之外

如被 return 返回 本例中return barbar()放回给了外界,即是一种逃逸,这里我们暂且不聊其他的逃逸方式


这样,我们便完成了掌握闭包的第一步——了解闭包产生的条件,到这里如果大家云里雾里是很正常的,不妨继续往下看,接下来我们将具体讨论闭包的具体形态是什么样的

2.闭包的词性

读者可以随我的文字进行思考,去重新定位自己心中的闭包是哪一种词性

为了方便阅读,我将例子放在这里一份,免得各位去上下翻动文章

function foo() {
  var a = 2;
  // ------------------------
     function bar() {
       console.log( a );
     }
  // ------------------------
   return bar;
}
  // ------------------------
var baz = foo();
baz();

有人认为闭包是一个动词,是函数执行过程中的一种行为,即某一个部分代码的执行过程称之为闭包,如内部函数访问外部函数的变量的过程

如果这样理解,闭包就是baz(),即访问变量a的过程

也有人认为闭包是一个形容词/副词,即某一个代码的执行是“闭包的”某部分代码是“闭包的”,如内部函数访问外部函数的变量的过程是“闭包的”

如果这样理解,我们可以说整个例子中的代码是闭包的


还望各位理解上述两个观点的具体内涵,便于与我们正确的理解做对比

各位应该已经发现我的暗示了,闭包既不是动词,也不是形容词/副词,那它是什么词性?

名词!

闭包的具体形态

为了方便阅读,我再将例子放在这里一份

function foo() {
  var a = 2;
  // ------------------------
     function bar() {
       console.log( a );
     }
  // ------------------------
   return bar;
}
  // ------------------------
var baz = foo();
baz();

诸位,我们来到了理解闭包最关键的一步了,请千万不要分神

上文,我们已经知道了闭包是一个名词,那么接下来我们就要讨论,这个名词包括什么内容

我这里直接给出闭包的三部分组成,请谨记


1. 内部函数(bar
function bar() {
  console.log(a); 
}
  • 这是闭包的 主体,负责访问外部变量。

2. 被引用的外部变量(a
var a = 2; // 
  • bar 引用了外部变量 a,这是闭包能持久化的关键。

3. 外部函数的作用域(foo 的词法环境)
function foo() { 
  // 整个 foo 的作用域构成闭包的词法环境
  var a = 2;
  function bar() { ... }
  return bar;
}
  • 即使 foo() 执行完毕,其作用域仍被 bar 保留。

我们可以用伪代码去表示闭包

Closure = {
  Function: bar,                 //内部函数
  CapturedVariable: a,           //引用的外部变量
  LexicalEnvironment: fooScope   //外部词法环境
};

各位要理解这个词法环境是什么含义,它并不像代码一样直观,这个词法环境我们并不能用具体某一行代码去表示,为了防止各位不理解何为词法环境,我这里啰嗦两句

词法环境是 JavaScript 引擎在编译阶段创建的内部数据结构,用于存储变量、函数声明和当前作用域的引用关系。它由两部分组成:

  • 环境记录(Environment Record):存储当前作用域内的变量和函数声明(如 varletconstfunction)。
  • 对外部词法环境的引用(Outer Lexical Environment):指向父级作用域(实现作用域链)。

在我们本例中的具体词法环境是var 定义的 a以及foo()引用的外部词法环境(这里是全局作用域的词法环境)


各位,我们现在回顾一下前文我们的表述,我们提到了什么?

  • 闭包产生的三个条件(函数嵌套、外部作用域引用、函数逃逸)
  • 闭包的词性(名词)
  • 闭包包括的及具体内容(内部函数、引用的外部变量、外部词法环境)

我们可以发现,闭包其实并不是一个很罕见的用法,在我们写的很多代码中都有体现,那么我们有没有考虑过,为何要使用闭包是什么原因使Javascript使用者对闭包如此狂热?

诸位不妨继续往下看

二、使用闭包的实际意义

我们先来看看闭包的作用(闭包造成了什么),之后再来递进的讲解这个作用有什么实际的意义(这样有什么用)

接下来我们来讨论闭包的作用,其实闭包的作用只有一个,正如闭包的定义所讲

使函数可以记住并访问所在的词法作用域!

这个作用直接的表述比较生涩、乏味,其实这就像数学题里面的一个公理,虽然公理简单,但是其可以推出很多定理,下面使衍生出的一个最主要的“定理”——

image.png

首先,让我们的思维拉回 “闭包是什么词性” 上,原文表述“这个引用叫做闭包”,这把闭包归类为了动词,这个表述是不准确的,准确来讲闭包是一个名词(包括内部函数、引用的外部变量、外部词法环境),这里已经经过验证,可“放心食用

我们通过原文的表述可以清晰的认识到,闭包可以防止内部作用域被回收,那么是什么原因使内部作用域可以长久的保存下来呢?这就不得不提到Javascript的垃圾回收机制了——

这里我们只通俗来讲,并不深入了解“垃圾回收机制的标记算法”等比较生涩的内容,我们讲的一切内容为理解闭包服务

想象你有一个房间(内存),里面放了很多玩具(变量和对象)。垃圾回收就像是一个自动打扫的机器人,它会定期检查哪些玩具没人玩了(不再被引用),然后把这些玩具扔掉(释放内存)。


通过上述的例子我们可以很容易的理解为什么内部作用域没有被回收(为什么玩具没有被扔掉)

因为这些变量和对象还在被引用(这些玩具还有人玩)!

这里我们还是通过我们之前的例子去印证一下这个说法

function foo() {
  var a = 2;
  // ------------------------
     function bar() {
       console.log( a );
     }
  // ------------------------
   return bar;
}
  // ------------------------
var baz = foo();
baz();

在这个例子中baz引用了foo() returnbar,而barbar 函数引用了 a,所以 bar 会死死拽住 foo 的作用域不让回收,即使 foo() 执行完毕,只要 baz(即 bar)还存在,a 就不会被回收。


承上

上述便是闭包的作用,那么这种作用有什么实际意义呢?

启下


闭包渗透在Javascript的方方面面是列举不完的,这里我只表述两种比较常见的闭包实际使用场景

一、闭包实现数据私有化

1. 私有变量的本质问题

JavaScript 没有原生的私有变量语法(ES6 的 # 私有字段仅限于 class),闭包是模拟私有变量的最佳方案。

2. 实现原理

通过闭包的词法作用域,将变量隐藏在函数内部,只暴露特定接口:

function createCounter() {
  let count = 0; // 私有变量

  return {
    increment() {
      count++;
    },
    get() {
      return count;
    }
  };
}

const counter = createCounter();
counter.increment();
console.log(counter.get()); // 1
console.log(count); // 报错:count is not defined
3. 技术细节
  • 作用域隔离count 只能在 createCounter 内部访问
  • 接口控制:返回的对象是操作 count 的唯一通道
  • 内存安全:没有闭包引用时,私有变量会被自动回收
4. 对比 ES6 Class 的私有字段
class Counter {
  #count = 0; // ES6 私有字段

  increment() {
    this.#count++;
  }

  get() {
    return this.#count;
  }
}

闭包的优势

  • 兼容所有 JavaScript 版本
  • 支持更灵活的访问控制(如条件暴露)
  • 不依赖 this,避免绑定问题

二、闭包实现缓存与记忆化

闭包是实现缓存和函数记忆化的完美工具,它通过「记住」先前计算过的结果,避免重复执行耗时操作。

原理:利用闭包保存一个缓存对象,存储输入参数与计算结果的映射。

function memoize(fn) {
  const cache = {}; // 闭包保存缓存

  return function(arg) {
    if (arg in cache) {
      console.log('读取缓存');
      return cache[arg];
    }
    console.log('首次计算');
    return cache[arg] = fn(arg);
  };
}

// 使用示例
const expensiveCalc = num => {
  console.log('正在计算...');
  return num * 2;
};

const memoizedCalc = memoize(expensiveCalc);
console.log(memoizedCalc(5)); // 首次计算 → 10
console.log(memoizedCalc(5)); // 读取缓存 → 10

关键点

  1. cache 通过闭包持久化,不会被垃圾回收
  2. 相同输入直接返回缓存结果,跳过计算

至此,我们对闭包的基础讲解到此结束,接下来我们会深入《你不知道的Javascript》第五章,去解读O'Reilly笔下的闭包

第五章——作用域闭包(O'Reilly)

对于我们讲过的内容如果书上有重复的我们直接跳过,如果有难懂的地方我会讲解

一、为什么下面的代码不是闭包?

先来看原文的表述

image.png

那么为什么下面的代码确切来说不算闭包呢?原文的表述较为生涩,但是通过我前文的解释便很容易理解——并不满足条件三内部函数逃逸bar()并没有通过return等方式逃逸出来,所以确切来说其并不是闭包,尽管很相像

二、更多的逃逸方式

O'Reilly为我们提供了另外两种逃逸的方式,如下

image.png 这两种逃逸方式其实不难理解,都使内部函数baz()逃逸到了外界,这里不做解释,只作为前文我解释的补充

三、为何下面的代码是闭包?

先来看一下原文的表述

image.png

这段代码貌似并没有引用外部变量,而且并没有函数逃逸,那么为什么这是一个闭包呢?

这里我分两个问题为何为讲解这段代码为何是一个闭包

1.引用外部变量

timer 函数内部使用了 wait 函数的参数 message,这个参数也是外部变量的一部分

2.函数逃逸

诸位是否还记得我前文所提到的函数逃逸方式,当时我只是举了一个return的例子,但其实函数逃逸并不只有这一种情况,虽然这种方式是最常见的,借助O'Reilly的文笔,我们再来详细了解一下函数逃逸

当一个 内部函数(如 timer)通过某种方式 脱离其定义时的作用域(如 wait 函数),并被外部环境持有时,就发生了 函数逃逸

逃逸的常见方式

  1. 被 return 返回(如模块模式)
  2. 传递给其他函数(如 setTimeout、事件监听)
  3. 赋值给外部变量(如全局变量)

第一和第三种(第三种情况详见二、更多的逃逸方式)情况我们先前已经讨论过了,但是第二种我们并没有讲解过

为了方便阅读,我将例子放在这里一份,免得各位去上下翻动文章

function wait(message) {
   setTimeout( function timer() {
       console.log( message );
   }, 1000 );
}
wait( "Hello, closure!" );

第二种逃逸具体解释如下

  1. wait 函数执行

    • 创建 message = "Hello, closure!"
    • 定义 timer 函数(此时 timer 还未执行)
    • 关键动作:将 timer 传递给 setTimeout(这是逃逸的核心)
  2. setTimeout 的底层行为

    • 浏览器(或Node.js)的定时器模块会 持有 timer 的引用
    • 即使 wait 函数执行完毕,timer 仍然被定时器模块保留
  3. 1秒后

    • 定时器触发,调用 timer()
    • 此时 wait 早已执行完毕,但 timer 仍能访问 message
    • 这就是闭包的力量timer 记住了 wait 的作用域

四、IIFE到底是不是闭包?

先看原文的表述

image.png

不知各位看完原文表述是何种感觉,本人是云里雾里,在经过慎重的考虑过后,我将原文的表述和我的理解总结为下面的结论

IIFE 可以用于实现闭包,但它本身不是闭包。只有当它 返回或传递的函数引用了 IIFE 的内部变量 时,才属于闭包模式。

为何有此结论?诸位,听我慢慢道来

我们还是聚焦于闭包形成的三个条件(函数嵌套、外部作用域引用、函数逃逸)

1.非闭包的IIFE

先来看一个非闭包的IIFE,就用原文的例子

var a = 2;
(function IIFE() {
    console.log( a );
})();

1. 函数嵌套

不满足

  • 此代码中只有 IIFE 自身,没有内部函数嵌套在 IIFE 里面。
  • 闭包需要至少 两层函数嵌套(外部函数 + 内部函数)。

2. 内部函数引用外部变量

部分满足

  • IIFE 引用了全局变量 a,但这只是普通的 ​作用域链查找,不是闭包。
  • 闭包要求的是 ​内部函数引用外部函数的变量,而这里没有内部函数。

3. 内部函数逃逸

不满足

  • IIFE 立即执行,没有返回任何函数,也没有传递函数给外部。
  • 没有函数逃逸到外部作用域。

不论从哪个角度看,这个IIFE都不是闭包

2.闭包的IIEF

下面是一个闭包的IIEF例子

const counter = (function() {
  let count = 0; // 被闭包引用的变量

  return function() {
    return ++count; // 内部函数引用外部变量
  };
})();

console.log(counter()); // 1
console.log(counter()); // 2
  1. 函数嵌套:返回的函数定义在 IIFE 内部。
  2. 引用外部变量:返回的函数引用了 count
  3. 函数逃逸:返回的函数被赋值给 counter,逃逸到全局。

不论从哪个角度看,这个IIFE都是闭包

那这两个IIEF有何区别呢?

没错,区别就是我给的结论——

IIFE 可以用于实现闭包,但它本身不是闭包。只有当它 返回或传递的函数引用了 IIFE 的内部变量 时,才属于闭包模式。

不知道各位有没有发现,在描述IIEF时我使用了**“闭包的”和“非闭包的”我将闭包当作了形容词来看,这时不准确的,这也在机缘巧合下印证了我们讨论闭包词性对掌握闭包的意义之大**


看似我们对IIFE是不是闭包的讨论已经结束了,但是真的结束了吗,一个优秀的Javascript学习者不会止步于此,我们接下来要进一步讨论,IIEF为何于闭包有这么强大的联系,连O'Reilly都在讲闭包时提及IIFE

追溯Javascript的历史,我们会发现IIEF+闭包是一个多么强大的组合

在没有let/const之前没有块级作用域,这时IIFE+闭包可以快速进行变量隔离,它们的强大联系源于 互补的特性 和 共同解决的核心问题

  1. 作用域互补

    • IIFE ​提供临时作用域,闭包 ​延长作用域生命周期
  2. 模块化需求

    • IIFE 隔离代码,闭包控制访问权限(私有/公有)。
  3. 历史背景

    • 在 ES6 之前,这是唯一可靠的模块化和变量隔离方案。

五、闭包下的循环

先看原文的部分代码

image.png

image.png

image.png

正常情况下,我们对这段代码行为的预期是分别输出数字 1~5,每秒一次,每次一个

原文的代码前两个是错误的,最后一个是正确的,原文的文字表述过于冗余,这里我给大家用我的语言去解释这三段代码的正确与否

1.简单循环 错误

for (var i=1; i<=5; i++) {
    setTimeout( function timer() {
        console.log( i );
    }, i*1000 );
}

(1) var 的全局作用域问题

  • var 声明的 i ​属于全局作用域​(或函数作用域),而非每次循环的独立作用域。
  • 循环结束后 i = 6,所有 timer 回调共享同一个 i

(2) 事件循环机制

  • setTimeout 是异步的,回调函数会在循环结束后才执行
  • 此时 i 已是 6,所以所有回调都输出 6

2.加入IIFE的循环 错误

for (var i=1; i<=5; i++) {
    (function() {
        setTimeout( function timer() {
            console.log( i );
        }, i*1000 );
    })();
}
  • IIFE 未接收 i 作为参数,timer 仍引用全局 i

3.完美的闭包循环 正确

for (var i=1; i<=5; i++) {
    (function(j) {
        setTimeout( function timer() {
            console.log( j );
        }, j*1000 );
    })( i );
}
  1. 每次循环调用 IIFE,传入当前的 i(保存为参数 j)。
  2. timer 闭包引用 IIFE 的 j,形成独立作用域。

六、闭包实现模块

先来看看原文的表述

形成模块的条件如下

image.png 这两个条件其实很简单,我们可以通过分析原文中的例子来理解,如下

var foo = (function CoolModule() {
    var something = "cool";
    var another = [1, 2, 3];
    function doSomething() {
        console.log( something );
    }
    function doAnother() {
        console.log( another.join( " ! " ) );
    }
    //---------------------------

    return {
    doSomething: doSomething,
    doAnother: doAnother
    };
})();
//---------------------------
foo.doSomething(); 
foo.doAnother(); 

条件一、调用函数

foo.doSomething(); foo.doAnother(); 这两段代码即调用函数

条件二、返回函数

    doSomething: doSomething,
    doAnother: doAnother
    };

这里返回了函数,如此便形成了模块

原文对于模块的讲述并不难理解,我这里不再赘述

结语

闭包是JavaScript中最优雅也最容易被误解的特性之一。通过本文的层层剖析,我们不仅揭开了闭包的神秘面纱,更看到了它在模块化、数据封装等场景中的强大威力。理解闭包不仅是掌握一个语法特性,更是打开JavaScript高级编程之门的钥匙。

从IIFE到模块模式,从循环陷阱到动态API,闭包贯穿了JavaScript开发的方方面面。希望本文能帮助你跳出"闭包就是函数返回函数"的刻板印象,真正理解其本质——它是作用域、函数和变量引用的完美结合。当你下次看到闭包时,不再感到困惑,而是能会心一笑:"原来这就是JavaScript的精妙所在!"