讲解「闭包」

# 定义

闭包 是指有权访问另一个函数作用域中的变量的函数。注意别混淆匿名函数和闭包的概念。
创建闭包 需要达到两个条件,如果不满足第二条,也只能称作是匿名函数
(1)在一个函数内部创建另一个函数
(2)内部函数访问外部函数的变量

function createCompareFn(attr) {
  return function(obj1, obj2) {
    return obj1[attr] >= obj2[attr]
  }
}

  上例中,内部函数(一个匿名函数) 访问了包含函数(即外部函数)的变量attr,即使这个内部函数被返回且在其他地方被调用了,它仍然可以访问变量attr。之所以能够访问,是因为内部函数的作用域链中含有包含函数的作用域。

# 作用域链

  当某个函数被调用时,会创建一个执行环境(execution context)及相应的作用域链。然后,使用arguments和其他命名参数的值来初始化函数的活动对象(activation object)。但在作用域链中,外部函数的活动对象始终处于第二位,包含函数的活动对象处于第二位,包含函数的包含函数的活动对象处于第三位……,直到找到全局环境为止。这些活动对象使用链表来连接,形成作用域链
  在函数执行过程中,为了读取和写入变量的值,需要在作用域链中查找变量。看一个简单的函数声明及调用来解释:

function compare(val1, val2) {
  return val1 >= val2
}
var result = compare(5, 10)

  上述代码定义了compare函数,然后又在全局作用域中调用。当调用compare()时,会创建一个包含argument, val1, val2的活动对象。而全局环境的变量对象处在作用域链的第二位

A

  后台的每个执行环境都有一个标识变量的对象——变量对象。全局环境的变量对象始终存在,而像compare()这样的局部环境的变量对象,只有在函数执行过程中菜户存在。在创建compare()函数时,会创建一个包含全局变量对象的作用域链,这个作用域链被保存在内部的[[Scope]]属性中,当执行compare()函数时,回味函数创建一个执行环境,然后通过复制函数的[[Scope]]属性中的对象构建起执行的作用域链,以此类推形成作用域链。如图所示,作用域链本身只是一个指向变量对象的指针列表,它只引用但不实际包含对象
  无论什么时候在函数中访问一个白能量时,就会从作用域链中搜索具有相应名字的变量。通常当函数执行完成后,局部活动对象就会被销毁,内存中仅保存全局作用域(全局执行环境的变量对象)

# 闭包的作用域链

  由于闭包存在函数内部定义函数,内部定义的函数将包含函数的活动对象到它的作用域链中。假设有闭包如下:

function createCompareFn(attr) {
  return function(obj1, obj2) {
    return obj1[attr] >= obj2[attr]
  }
}
var compare = createCompareGn('name')
var result = compare({ name: 'Nic' }, { name: 'Goe' })

  则它的作用域链关系为


B

由于闭包会携带它包含函数的作用域链,因此会比其他函数占用更多内存,过度使用闭包容易导致占用内存过多,需谨慎。

# 闭包与变量

  由于作用域链本身只是一个指向变量对象的指针列表,它只引用并不真正存储它们。而这种配置机制引出了一个副作用,即闭包只能取到包含函数中任何变量的最终值
  因为闭包作用域链与包含函数的活动对象之间只是引用关系,当包含函数中由于某些运算导致它活动对象中的属性发生更新时,该更新会被带到闭包作用域中,当闭包再访问变量时,取到的就是被更新后的变量的值。经典例子如下:

function createFunctions() {
  var result = new Array()
  for (var i = 0; i < 10; i++) {
    result[i] = function() {
      return i
    }
  }
  return result // 10 个 10
}

  该例中,在闭包中返回包含函数的变量i并压入给结果数组。表象上看应该得到1~10的数组。打印发现是10个10。原因是:每个函数的作用域链中,都保存着createFunctions()函数的活动对象,引用的都是同一个变量i;当createFunctions()函数返回后,变量i的值是10,此时每个函数都引用者保存变量i的同一个变量对象。所以在每个函数内部i的值都是10。作用域引用关系如下:

C

  我们可以通过创建另一个匿名函数强制让闭包的行为符合预期

function createFunction() {
  var result = new Array()
  for (var i = 0; i < 10; i++) {
    result[i] = function(num) {
      return function() {
        return num
      }
    }(i)
  }
return result // 1~10
}

  经过如上改造,我们没有闭包直接赋值给数组,取而代之的是定义了一个匿名函数,并将立即执行该函数的结果赋给数组。匿名函数有个参数num,存在于匿名函数的作用域链中。程序执行for循环调用每个匿名函数时,由于函数参数是按值传递的,在其内部我们创建了一个直接访问num的闭包。这样一来,result数组中存储的值就是每次执行的num的一个副本了。

# 闭包的this变量

  我们知道,this对象是在运行时基于函数的执行环境绑定的。在全局函数中,this等于window。而当函数被某个对象的方法调用时,this等于那个对象。
  由于匿名函数的执行环境具有全局性,因此其this对象通常指向window(除使用call()apply()来改变函数执行环境外)。看以下例子:

var name = 'the window'
var object = {
  name: 'my object',
  getNameFn: function() {
    return function() {
      return this.name
    }
  }
}
console.log(object.getNameFn()())  // the window

  本例中,getNameFn是对象中的一个方法属性。object.getNameFn()返回一个函数,object.getNameFn()()立即执行该函数得到一个字符串。
  我们知道,每个函数在被调用时都会自动取得两个特殊变量:thisarguments。内部函数在搜索这两个变量时,只会搜索到它自己的活动对象为止(它们自身能获取到不必去包含函数活动对象中获取)。在本段代码执行时,程序发现需要返回逻辑想要返回的this.name,于是搜索匿名函数自身的作用域,取到自己的this对象,该对象因匿名函数执行环境全局性的特征指向了window,从而输出了"the window"

  注意本例的称呼是匿名函数而不是闭包!原因就是匿名函数内部有自身的this变量,它无需也无法获取到外部objectthis,没有达到内部函数访问外部函数的这么一个行为,因此不称呼为闭包。以下改造后就符合了闭包的特征

  如果我们想获取到object中的name,只需如下简单改造即可

var name = 'the window'
var object = {
  name: 'my object',
  getNameFn: function() {
    var that = this
    return function() {
      return that.name
    }
  }
}
console.log(object.getNameFn()()) // myobject

  经过如上改造后,但执行object.getNameFn()()调用内部闭包函数时,需要搜索that,而在自身作用域内并没有找到that,于是顺着作用域链查找包含函数的作用域,得到结果。

# 闭包与内存泄漏

  由于闭包作用域链包含着包含函数的作用域,因此会比普通函数占用更多的内存,当使用闭包不当,且未得到合适的释放情况下,就容易造成大量内存空间的占用。看一个例子

function assignHandler() {
  var element = document.getElementById('someElement')
  element.onclick = function() {
    alert(element.id)
  }
}

  以上代码实现了对某个元素进行点击时的点击响应事件。onclick是一个闭包,在这个闭包内循环引用了element.id。因此,只要匿名函数存在,element的引用数至少是1。那么,在垃圾回收机制规则中就无法判定element是一个需要被回收的元素。导致其一直占用在内存空间中。解决办法如下

function assignHandler() {
  var element = document.getElementById('someElement')
  var id = element.id
  element.onclick = function() {
    alert(id)
  }
element = null
}

  如此改造有两点:(1)对element.id保存副本目的是在闭包中取消对元素变量的循环引用。(2)由于闭包会引用包含函数的整个活动对象,其中还包含着element,因此包含函数的活动对象中也会保存有一个引用。因此有必要把element变量设置为null

# 闭包与应用场景

(1)模仿块级作用域
  在闭包中创建的变量,不受外部变量的影响。看以下函数,在for循环这个块级空间中定义了变量,但在外部依然能访问alert(i)依然能访问到该变量。

function Counter(count) {
  for (var i = 0; i < count; i ++) {
    console.log(i)
  }
  var i // 重新声明变量
  alert(i) // 计数
}

  如何让该变量私有化?当然可以使用ES6的let定义。本例笔者不想说这个,我们来看以下这个更熟悉的函数格式

(function() {
  // 块级作用域
})()

  我们都称它为自执行函数,初学者可能觉得这个格式很难理解,我们做如下拆解来帮助理解它。
一个正常函数表达式定义及调用如下

var func = function() {
  // 块级作用域
}
func()

  普通的函数调用使用函数名加圆括号来执行一个函数,如果改成函数对象,即如下

function() {
  // 块级作用域
}()  // 报错。。

  执行报错了。因为JS将 function 关键字当做一个函数声明的开始,而函数声明后面是不允许跟圆括号,而函数表达式后面可以跟圆括号,因此需要把匿名函数加一个圆括号来告诉JS这是一个函数表达式,从而形成了自执行函数的表现形式,也实现了块级作用域的目的。

  利用自执行函数的特征,我们可以改写outputNumber函数如下

function outputNumber() {
  (function() {
    for(var i = 0; i < number; i++) {
      console.log(i)
    }
  })()
  console.log(i)  // 报错
}

  以上代码中,匿名函数是一个闭包,他能够访问包含函数作用域链中的所有变量,而外部无法访问比包内的变量。

(2)创建私有变量
  创建一个可以访问私有变量和私有函数的共有方法。该方法也称作特权方法。
  创建私有变量需要从一个计数器来说起:

function Counter() {
  var count = 0
  this.clear = function () {
    this.count = 0
  }
  this.add = function() {
    this.count++
  }
  this.decrease = function () {
    this.count--
  }
}

  这个构造函数中,有一个私有变量count,它只能在函数内部被访问,函数中有三个特权方法用于清空,累加和累减计数。除了它们外,灭有别的方法可以访问到count变量。而特权方法可以被实例化的实例访问。count就是一个私有变量

【缺点】每次实例化的时候,都需要重新生成一次特权方法

以上方法为构造函数法创建私有变量,除此之外,还有通过私有作用域定义静态私有变量,为单例创建私有变脸个特权方法的模块模式等,这里不展开说明。