JavaScript 闭包详解

配图源自 Freepik

继上一篇文章 JavaScript 脚本编译与执行过程简述,再来介绍一下 JavaScript 中神奇的“闭包”(Closure)。

闭包是基于词法作用域书写代码时所产生的自然结果。

JavaScript 语言是采用了词法作用域。一般情况下,函数、变量的作用域在编写的时候已经确定且不可改变的。除了 evalwith 之外,它们会在运行的时候“修改”词法作用域,但实际项目中,几乎很少用到它们,欺骗词法作用域会有性能问题,我们可以忽略。

还有,千万别把 this 跟作用域混淆在一起,this 与函数调用有关,可以说是“动态”的。而作用域是静态的,跟函数怎样调用没关系。词法作用域也被叫做“静态作用域”。

若对词法作用域、执行上下文、变量对象、作用域链等内容不熟悉的话,建议先学习相关知识。到时回来再看闭包的时候,就非常容易理解了。

  • JavaScript 深入系列 15 篇(冴羽)
  • JavaScript 专题(Veda)

一、概念

无论网上文章,还是各类书籍,对闭包的定义都不尽相同。列举几个:

  • MDN:闭包是指那些能够访问自由变量的函数。

  • 《JavaScript 高级程序设计》:闭包指的是那些引用了另一个函数作用域中变量的函数。

  • 《你不知道的 JavaScript》:闭包是代码块和创建该代码块的上下文中数据的结合。

讲实话,我也不知道以上哪个说法更贴切、更符合。当了解作用域链之后,就很容易理解闭包了。

上面提到了自由变量一词,

自由变量(Free Variable):是指在函数中使用的,但既不是函数参数,也不是函数的局部变量的变量。

var a = 1
function foo() {
  var b = 2 // b 不是自由变量
  console.log(a) //  a 是自由变量
}
foo()

在 ECMAScript 中,闭包指的是:

  • 从理论角度:所有的函数。因为它们都在创建的时候就将上层上下文的数据保存起来了。哪怕是简单的全局变量也是如此,因为函数中访问全局变量就相当于是在访问自由变量,这个时候使用最外层的作用域。

  • 从实践角度:以下函数才算是闭包:

    • 即使创建它的上下文已经销毁,它仍然存在(比如,内部函数从父函数中返回)。
    • 在代码中引用了自由变量。

就我个人认为,闭包不是一个函数,它是一种机制,用于访问自由变量。闭包不是 JavaScript 中专有术语,在上世纪很早就被提出来了,在其他语言(如 Ruby 语言)中,闭包可以是一个过程对象,一个 Lambda 表达式或者是代码块。

二、Chrome 眼中的闭包

其实上面概念可能很多人都不理解,但问题不大,我们先看看 Chrome 眼中的闭包是长怎么样的。

举个例子:

function foo() {
  var a = 1
  function bar() {
    console.log(a)
  }
  return bar
}

var f = foo()
f() // 1

相信很多人都知道,函数 foo 就是一个闭包,通过 Chrome 断点调试可以从视角感知。

但是我们稍微修改一下,

var a = 1
function foo() {
  function bar() {
    console.log(a)
  }
  return bar
}

var f = foo()
f()

此时 a 是全局上下文的变量,尽管对于函数 bar 来说 a 属于自由变量,但它不是 foo 函数上下文内声明的变量,因此 foo 就不是闭包。

总结:在函数 A(如 foo)中存在某个函数 B(如 bar,且必须是在 A 中定义的),且 B 内至少引用了 A 中的一个“变量”,那么函数 A 就是一个闭包。

请注意,与函数 B 的调用方式没关系。无论 B 是在 foo 内部被调用,还是作为返回值返回,然后在别处调用。

再看一个例子:

function foo() {
  var b = () => {
    // 由于 b 是箭头函数,内部没有 arguments 对象,
    // 所以这个 arguments 对象是 foo 中变量对象的一员,
    // 因此 foo 也是一个闭包。
    console.log(arguments)
  }
  return b
}

var f = foo('foo')
f() // { 0: 'foo', length: 1 }

上述这个示例,是为了提醒 BA 中的某个“变量”(指变量、函数、arguments、形参等)的引用,不仅仅是通过 varfunctionletconstclass 等关键字显式声明的,还可以是 arguments 对象、形参。换句话说,就是 AO 中的所有变量。

再看,下面示例中 foo 是闭包吗?

function foo(fn) {
  var a = 'local'
  fn()
}

function bar() {
  console.log(a)
}

var a = 'global'

foo(bar) // "global"

答案是 NO。前面总结过一个函数要成为闭包,该函数(foo)内部必须存在另外一个函数(fn),且 fn 内需要 foo 中的某个变量。那不正好引用了 foo 中的变量 a 吗?显然,这理解是错误的。

根据词法作用域可知,函数 bar 的作用域链 [[scope]] 在声明时就已确定且不可变,只含 GlobalContext.VO,因此当查找自由变量 a 时,当 bar 的 AO 内查不到,下一步是前往全局对象下查找,于是就找到了 a 其值为 "global"。所以 fn 内部对 foo 构成不了引用,因此 foo 就不是闭包。

若到这里,对闭包还是懵懵懂懂的,这块引用的内容,请跳过。

突然间,我好像明白了为什么函数内部缺省声明关键字的变量(如 a = 1),在执行时才将其视为全局变量。

假设将其作为函数上下文的变量,要怎么做:

  • 假设将其视为当前函数执行上下文的一个变量,那么 JS 引擎在进入执行上下文时,初始化工作量实在太多了,要通篇扫描当前上下文的声明语句和赋值语句,还要判别赋值语句是单纯地给已有变量赋值,还是上面提到的缺省声明情况。显然很影响效率和性能。
  • 如果不通篇扫描,在执行代码的时候再更新到 AO 上,那么又会破坏 JavaScript 的词法作用域。似乎就变成了“动态作用域”。

但如果将其视为全局上下文的一个变量,上面的额外的工作都省了。但注意,它与全局声明的变量有些区别,前者可以被删除,而后者无法删除(原因可看这里)。在严格模式下对这种“隐式”声明全局变量的行为作为禁止,并抛出 SyntaxError。不确定是不是因为这个原因而被禁的。

这个是突然灵光一闪的,所以也 Mark 下来了。

综上所述,Chrome 浏览器眼中的闭包应该是这样的:

在某个函数 A 中存在另一个函数 B(函数 B 必须是在函数 A 中定义的),而且 B 内至少引用了 A 中的一个变量,那么当 B 在任意地方被调用时,函数 A 就是一个闭包。

其实,我认为概念不是很重要的...

三、更多示例

前面的示例,都相对比较简单和清晰的。再看多几个吧。

关于 Chrome 浏览器调试,在 Source 选项卡进行断点调试时,可以看到作用域、闭包的变化。

CallStack: 调用栈
Scope: 当前执行上下文的作用域链
  Local   // 当前 AO/VO 对象,但不完全是,我们也可以看到 this 指向
  Block   // 包含块级作用域 let、const、class 的变量
  Closure // 闭包
  modules // ESM 模块
  Script  // 
                    
                    

你可能感兴趣的:(JavaScript 闭包详解)