探秘JavaScript闭包:解锁高级编程技巧的秘密

240 阅读6分钟

前言

闭包是JavaScript中一个重要的概念,它允许函数访问其创建时的作用域之外的变量。闭包在很多情况下都非常有用,包括封装私有变量、实现模块化、保存状态和创建高阶函数等。对于小白来说,闭包是js中很难的一个知识点,接下来,让我们一起深入学习闭包。

什么是闭包?

在js中,根据词法作用域的规则,内部函数总是可以访问其外部函数中声明的变量的,当内部函数被返回到外部函数之外时,即使外部函数执行结束了,但是内部函数引用了外部函数的变量,那么这些变量依旧会被保存在内存中,我们把这些变量的集合称为闭包。

让我们用代码来看看,什么是闭包。首先我们需要了解的是调用栈和预编译。

调用栈

用来管理函数调用关系的一种数据结构,当一个函数执行完毕以后,它的执行上下文就会出栈。

预编译

预编译发生在全局 (三部曲)

  1. 创建GO 对象 (Global Object)全局执行上下文
  2. 找有效标识符(变量声明),将变量声明作为AO的属性名,值为undefined
  3. 在全局找函数声明,将函数名作为GO对象的属性名,值赋予函数体

预编译发生在函数执行之前 (四部曲)

  1. 创建AO对象(Action Object) 上下文对象 记录函数中有哪些标识符
  2. 找有效标识符(形参和变量声明),将变量声明和形参作为AO的属性名,值为undefined
  3. 将实参和形参值统一
  4. 在函数体内找函数声明,将函数名作为AO对象的属性名,值赋予函数体

理解闭包

接下来,我们结合调用栈,预编译,通过代码的画图来理解什么是闭包。

首先我们来看第一个例子

showName()
console.log(myName); //undefined

var myName = "dante"

function showName(){
  console.log('周杰伦'); //周杰伦
}

上述代码中,当v8引擎在预编译的过程中,会先创建一个全局执行上下文存放到调用栈中,然后查找到一个标识符myName,并赋值为undefined。查找到一个函数声明showName,值为function。在该函数执行前,创建一个函数上下文存放在调用栈中,并查找有效标识符,以及函数声明。上述代码调用栈中的执行上下文如所示:

HS96G4HM~6VKC2DUHZ_4O.png

预编译之后,代码从上到下执行,首先调用showName()这个函数,打印周杰伦后,被销毁出栈。执行console.log(myName)时,myName值为undefined,所以输出undefined,继续执行,将dante的值赋给myName。执行结束出栈。

现在我们来看如下一段代码,根据上面预编译和执行栈的规则来看看,会输出什么。

function foo() {
  var myName = 'dante'
  let test1 = 1
  let test2 = 2
  var innerBar = {
    getName: function() {
      console.log(test1)
      return myName
    },
    setName: function(newName) {
      myName = newName
    }
  }
  return innerBar
}
var bar = foo()
bar.setName('杰伦')
console.log(bar.getName())

输出的结果是1,周杰伦。我们通过调用栈来理解一下,为什么是这个结果。

O@A}G3UPBRN$EI`PUILGW.png

如图所示,首先全局上下文入栈,全局上下文变量环境中有函数体foo,变量bar,outer = null(每个执行上下文中都有outer,其作用是指明当前词法作用域在哪)。执行到16行代码时,foo执行上下文入栈,并执行foo中的代码后如上图所示,当foo执行完毕后,foo执行上下文出栈,接下来执行bar.setName(),setName执行上下文入栈,其变量环境中包含newName,以及outer。编译setName中的代码,发现变量myName在哪呢?无法找到,那为什么getName()可以输出周杰伦。

I1AX6[{((({JD_(FAQV%J[J.png

foo确实是出栈了,但是其留下了一个小背包,包含了test = 1, myName = 周杰伦,这个小背包就叫做闭包。

闭包的缺点

尽管JavaScript闭包是一个强大的功能,但它也有一些潜在的缺点,需要开发者注意:

  1. 内存占用:闭包会保留对外部函数的作用域的引用,这可能导致内存占用增加,特别是在创建大量闭包时。如果不小心处理闭包,可能会导致内存泄漏问题,因为被闭包引用的变量不会被垃圾回收。
  2. 性能开销:由于闭包涉及访问外部作用域,它们通常比普通函数调用要慢一些。这种性能开销在某些性能敏感的应用中可能会有所影响。
  3. 难以理解和调试:复杂的嵌套闭包结构可能难以理解和调试。追踪闭包中的变量引用和作用域链可能会导致代码维护困难。
  4. 变量共享:当多个闭包共享相同的外部变量时,可能会导致意外的行为,因为一个闭包的修改会影响其他闭包的状态。这可能会引发不可预测的错误。
  5. 过度使用:有些开发者可能过度使用闭包,导致不必要的复杂性和性能问题。在简单情况下,使用普通函数可能更合适。
  6. 可维护性问题:在复杂应用中,滥用闭包可能导致代码难以维护。正确使用闭包需要良好的代码组织和文档,以避免混乱和混乱的代码。

要避免这些潜在问题,开发者应当谨慎使用闭包,并确保了解它们的工作原理以及何时使用它们。使用适度的闭包来实现特定目标,同时注意内存管理和性能问题,可以减轻闭包带来的潜在缺点。

总结

JavaScript闭包(Closure)是一个重要概念,它包含了函数和其相关的词法环境,允许函数访问在其外部定义的变量。以下是对JavaScript闭包的总结:

  1. 定义:闭包是指函数和其包含的词法环境的组合,使函数能够访问外部作用域的变量。

  2. 工作原理:当函数定义时,它会创建一个闭包,保存了函数内部的代码和它所能访问的外部变量。这些外部变量在函数执行时仍然可用。

  3. 访问外部变量:闭包可以访问外部函数的局部变量,即使外部函数已经执行完成。这使得闭包可以“记住”外部环境的状态。

  4. 应用

    • 封装:使用闭包可以创建私有变量和函数,实现封装和模块化。
    • 回调函数:常用于处理异步操作、事件处理和模块化开发,通过闭包可以传递函数和数据。
    • 高阶函数:能够返回函数的函数,经常使用闭包来创建高阶函数。
  5. 内存管理:需要注意,闭包中的变量会一直保存在内存中,直到不再被引用。滥用闭包可能导致内存泄漏,因此需要小心处理。

  6. 实例:一个典型的闭包示例是将内部函数从外部函数返回,然后在外部函数执行后调用内部函数,它仍然可以访问外部函数的变量。