闭包是JavaScript中非常重要的一个概念,MDN中提到闭包(closure)是一个函数以及其捆绑的周边环境状态(lexical environment,词法环境)的引用的组合。换而言之,闭包让开发者可以从内部函数访问外部函数的作用域。在 JavaScript 中,闭包会随着函数的创建而被同时创建。看起来非常晦涩难懂,不易理解。今天写下这篇文章就是想从原理上深入探索,彻底弄明白什么是闭包。

JavaScript执行三阶段与变量提升

先来看看这段代代码,明明console.log(a)在声明赋值之前,却没有出现语法错误而终止执行,JavaScript作为一种解释性语言,它并不只是简单的读取一行代码就执行一行代码。

 1console.log(a) 
 2var a = '赋值成功'
 3console.log(a) 

整个JavaScript的执行过程,可以简单归纳为三个步骤:语法检查、预编译、执行

语法检查阶段

当JavaScript代码被加载到浏览器环境或Node.js环境中时,首先进行语法检查,检查代码是否符合 JavaScript 的语法规则,比如括号是否正确闭合,发现并报告语法错误,阻止代码的进一步执行

预编译阶段

预编译是发生在函数执行的前一刻,‌或全局代码执行前,这里值得注意⚠️全局代码执行前和每个函数执行前都会经历预编译阶段,而不仅仅是只执行一次全局预编译。预编译阶段JavaScript引擎会创建函数作用域,‌分配变量、‌函数名以及参数等内存空间,‌并赋予默认值,此阶段涉及变量提升和函数提升。‌到这里可以解释上面的代码为什么输出会是undefined了。这个阶段的主要任务如下:

  • 函数声明提升:函数声明会被提升到它们所在作用域的顶部。这意味着即使函数在代码中被调用的位置在声明之前,调用也是有效的。
  • 变量声明提升:使用var声明的变量也会被提升到它们所在作用域的顶部,但只有声明被提升,赋值不会提升。使用letconst声明的变量不会被提升,而是被放入一个叫暂时性死区(Temporal Dead Zone, TDZ)的特殊区域,直到它们的声明被执行。
  • 作用域确定‌:为代码中的变量和函数确定其作用域。

看看这段代码,函数和var声明的变量被提升了,letconst声明的变量无法被提升:

 1console.log(a) 
 2console.log(b) 
 3
 4
 5
 6var a = 1
 7function b() {
 8  console.log('函数执行了')
 9}
10
11

执行阶段

此阶段JavaScript引擎会按照代码的顺序,‌逐行解释执行代码,‌完成代码的实际功能‌,使用 varletconst 声明的变量将被赋予实际的值

GO、AO与作用域链

搞懂了JavaScript执行的三个阶段,接下来我们来看看作用域是怎么确定的。

GO与AO

全局对象GO

全局对象GO(Global Object) 是在全局作用域中自动创建的,它代表了全局作用域。在浏览器中是window对象;在Node.js中是global对象。它包含了全局变量、内置函数和用户定义的全局变量和函数。它在代码执行之前就已经存在,其确定主要分为以下3个步骤:

  1. 创建GO对象
  2. 找变量声明,将变量声明作为GO对象的属性名,值赋予undefined
  3. 找全局里的函数声明 ,将函数名作为GO对象的属性名,值赋予函数体

举个例子:

 1console.log(a) 
 2var a = 1
 3console.log(a) 
 4function a(){} 
 5
 6console.log(a) 

这段代码中GO的创建和代码执行的过程是这样的:

创建GO

变量声明a:undefined

函数声明a:function

输出a

赋值a:1

输出1

再次输出1

激活对象AO

激活对象AO(Activation Object) 是在函数被调用时创建的,它代表了函数作用域。它包含了函数的局部变量、参数、this关键字的引用以及函数内部声明的函数。当一个函数被调用时,一个新的执行上下文被创建,其中包括一个新的AO。这个AO会随着函数的执行而存在,函数执行完毕后,AO通常会被销毁,除非函数形成了闭包。其确定主要分为以下4个步骤:

  1. 创建AO对象
  2. 到函数体作用域里找形参和变量声明,将形参和变量声明作为AO对象的属性名,值为undefined
  3. 将实参赋值给形参
  4. 在函数体内找函数声明,将函数名作为AO对象的属性名,值赋予函数体

举个例子:

 1function func(a) {
 2  console.log(a) 
 3  var a = 1
 4  console.log(a) 
 5  function a() {}
 6  console.log(a) 
 7  var b = function () {} 
 8  console.log(b) 
 9  function c() {}
10  var c = a
11  console.log(c) 
12}
13
14func(2)

AO的创建和代码执行的过程是这样的:

AO、GO和函数代码的执行总顺序

在这里,我们可以理解为JavaScript在全局代码预编译阶段创建GO,在函数调用后、函数内部代码执行前创建AO,执行完毕后决定销毁AO与否。整个流程是这样的:

这里AO的创建晚于GO,也就是说AO中的新值,在其作用域内会覆盖掉GO

作用域链

从上面的学习,我们知道,对于每个函数,JavaScript引擎会创建一个与之关联的作用域对象AO,这个作用域对象包含函数内部声明的所有局部变量、函数参数以及声明的变量(⚠️注意:letconst声明的变量具有块级作用域,而var声明的变量具有函数级作用域,这里只拿var做分析)

此外,JavaScript引擎还会构建一个作用域链(Scope Chain),它是一个从当前作用域向上查找父级作用域的链表。当在函数内部访问一个变量时,JavaScript会首先在当前作用域中查找,如果找不到,则继续向上在作用域链中查找,直到找到全局作用域。我们可以把作用域链理解为一个保存了当前函数AO、父级函数AO和GO对象的链表。

我们直接看图,这里展示的是test2执行前一刻的作用域链: 函数的作用域链可以干成一个数组,自身AO排在数组的最前面,越往下这是父级函数的AO,直到最后到达全局对象GO。

步入正题——闭包

闭包到底是什么

说白了,闭包就是一个内部函数访问了它外部函数的变量。闭包会创建一个包含外部函数作用域变量的环境,并将其保存在内存中,这意味着,即使外部函数已经执行完毕,闭包仍然可以访问和使用外部函数的变量。

看图更容易理解,还是这个图,当test1()被执行完后,其AO对象本该被销毁,但其还在test2的作用域链上,可以被test2操控,形成闭包了,不会被销毁。 来段代码看看,这里,我们通过返回的addreduce方法操作了calculateOne的局部变量num,形成闭包:

 1
 2function calculateOne(num) {
 3  var n = num
 4  
 5  function add() {
 6    n++
 7    console.log(n)
 8  }
 9  
10  function reduce() {
11    n--
12    console.log(n)
13  }
14  return {
15    add: add,
16    reduce: reduce,
17  }
18}
19
20
21var test = calculateOne(100)
22test.add() 
23test.add() 
24test.reduce() 

可见,我们常用的定时器、事件监听器、Ajax请求这些都是闭包,它们使用了回调函数访问了外部作用域。

闭包的优缺点

闭包的优点

变量封装和隐私保护JavaScript本身不支持变量私有化,通过闭包可以将变量封装在函数内部,避免全局污染,保护变量不被外部访问和修改。
变量生命周期延长闭包让函数内部的变量在函数执行完后仍然存在,可以在函数外部继续使用。
实现模块化闭包可以创建私有变量和私有方法,通过返回函数来暴露接口,隐藏内部实现细节,实现模块化的封装和隐藏,提高代码的可维护性和安全性。
保持状态闭包可以捕获外部函数的变量,并在函数执行时保持其状态。在事件处理、回调函数等场景中经常用到。
柯里化通过闭包实现函数柯里化,创建函数更加灵活。

闭包带来的问题

内存占用:闭包会导致外部函数的变量无法被垃圾回收,从而增加内存占用。如果滥用闭包,会导致内存泄漏问题。
性能损耗:闭包涉及到作用域链的查找过程,会带来一定的性能损耗。
增加代码的复杂性和调试难度‌闭包内部可以访问外部作用域的变量,这可能会增加代码复杂度,使得代码的调试变得更加困难,特别是在大型项目中,需要仔细跟踪变量的作用域和生命周期。

闭包的常见应用场景

结合自动执行函数使用: 结合使用可以用来创建私有变量、私有函数、实现模块化、配置对象等,比如下面实现了模块的封装:

 1var myModule = (function () {
 2  var privateVar = 'Private data'
 3  function privateFunction() {
 4    console.log('Private function called')
 5  }
 6  return {
 7    publicFunction: function () {
 8      privateFunction()
 9      return privateVar
10    },
11  }
12})()
13
14console.log(myModule.publicFunction())
15
16
17
18

柯里化(Currying) 柯里化是一种函数的转换,它是指将一个函数从可调用的 f(a, b, c) 转换为可调用的 f(a)(b)(c)。柯里化不会调用函数。它只是对函数进行转换。

 1function curry(f) {
 2  
 3  return function (a) {
 4    return function (b) {
 5      return f(a, b)
 6    }
 7  }
 8}
 9
10
11function sum(a, b) {
12  return a + b
13}
14
15let curriedSum = curry(sum)
16
17console.log(curriedSum(1)(2)) 

节流和防抖

 1
 2function throttle(func, delay) {
 3  let timer = null
 4  return function () {
 5    if (!timer) {
 6      timer = setTimeout(() => {
 7        func.apply(this, arguments)
 8        timer = null
 9      }, delay)
10    }
11  }
12}
 1
 2function debounce(func, delay) {
 3  let timer = null
 4  return function () {
 5    clearTimeout(timer)
 6    timer = setTimeout(() => {
 7      func.apply(this, arguments)
 8    }, delay)
 9  }
10}

另外,闭包在事件回调处理、迭代器封装、发布订阅者模式等都有着广泛的应用。

解除闭包

闭包在不需要的时候需要解除,否则会占用内存,影响性能,通常我们可以通过解除引用的方法来解除闭包:

 1
 2function calculateOne(num) {
 3  var n = num
 4  
 5  function add() {
 6    n++
 7    console.log(n)
 8  }
 9  
10  function reduce() {
11    n--
12    console.log(n)
13  }
14  return {
15    add: add,
16    reduce: reduce,
17  }
18}
19
20
21var test = calculateOne(100)
22test.add() 
23test.add() 
24test.reduce() 
25
26
27test = null
28console.log(test) 
个人笔记记录 2021 ~ 2025