JavaScript之理清闭包

400 阅读7分钟

作用域

当我们在理解闭包的时候,首先要明确的是JavaScript中作用域及作用域链的概念。

当代码在一个环境中执行时,会创建变量对象的一个作用域链。作用域链的前端是当前环境的变量对象,下一个变量对象来自于包含的外部环境,逐级往上到全局执行环境。在读取或者写入一个变量标识的时候,会从作用域链前端开始,逐层向后查找,直到找到为止,找不到则会抛出错误。

// 全局环境,只能访问color
var color = 'blue'
function changeColor() {
  // changeColor环境,这里可以访问color 和anotherColor,但不能访问tempColor
  var anotherColor = 'red'
  function swapColors() {
    // swapColors环境,这里可以访问color、anotherColor 和tempColor
    var tempColor = anotherColor
    anotherColor = color
    color = tempColor
  }
  swapColors()
}
changeColor()

上述代码涉及到三个逐级嵌套的作用域:swapColors()的局部环境、changeColor()的局部环境、全局环境。在swapColors环境中访问color变量时,首先从自身环境开始检查,没有找到时继续到changeColor环境中查找,一直逐级向上在全局环境中找到了该变量。

这里需要区分的一点是,向上查找变量是从创建这个函数的那个作用域中取值——是“创建”,而不是“调用”。

var x = 100
function fn() {
  console.log(x)
}

function show(f) {
  var x = 10
  f()
}
show(fn) // 100

通常,当函数执行完成后,其执行环境会被销毁,而在闭包中,其外部环境的作用域链会被保留下来。

闭包的概念

简单来说,闭包指的是能够访问另一个函数作用域中变量的函数。在闭包内部可以访问外部环境的变量对象。

创建闭包

通常,在一个函数内部创建另一个函数就是一个创建闭包的过程。

function add(base) {
  return function(num) {
    return num + base
  }
}
var foo = add(5)
foo(5) // 10

闭包的作用域链包含着自己的作用域,以及包含它的函数和全局的作用域。理论上,在add函数运行完毕后,整个函数内部的作用域会被销毁。然而内部的匿名函数持有add函数的内部作用域,使得add函数的作用域一直被引用,以供该匿名函数在其余的地方别调用时可以访问。

闭包的特性

保留外部环境变量对象

闭包即使是在其他地方被调用了,仍然能够访问外层函数的变量,这是因为它的作用域链中包含了外层函数的作用域。

var outerParam = 'outer'
var temp
function foo() {
  var innerParam = 'inner'
  return function (param) {
    console.log(outerParam, innerParam, param, temp)
  }
}

var inner = foo()
inner('insert')
var temp = 'hello'
inner('insert')

// outer inner insert undefined
// outer inner insert hello

从上述代码中可以看出,我们在全局环境中调用了闭包后,在其内部仍然保存了定义时的词法作用域。而对于未声明的变量,不能够提前进行引用。

保存外部变量的最后一个值

由于闭包保存的是整个变量对象,而不是某个特定的值,因此闭包只能够取到函数中任何变量的最后一个值。典型的例子是在for循环中使用闭包。

function test(){
  var arr = [];
  for(var i = 0; i < 3; i++){
    arr[i] = function(){
      return i;
    };
  }
  for(var a = 0; a < 3; a++){
    console.log(arr[a]());
  }
}
test(); // 2 2 2

上述代码打印出来的是3个2 ,由于for循环没有块级作用域,而闭包取得的是其变量的最后一个 i 的值,即为2。那么如果我们想要让函数输出从0~2的数字,可以使用let或是立即执行函数。

// 使用let
function test(){
  var arr = [];
  for(let i = 0; i < 3; i++) { // 可看作是形成了块级作用域
    arr[i] = function(){
      return i;
    };
  }
  for(var a = 0; a < 3; a++){
    console.log(arr[a]());
  }
}
test(); // 0 1 2

// 使用立即执行函数
function test(){
  var arr = [];
  for(var i = 0; i < 3; i++){
    arr[i] = (function(num){
      return num;
    })(i);
  }
  for(var a = 0; a < 3; a++){
    console.log(arr[a]);
  }
}
test(); // 0 1 2

this的指向

var name = "The Window";

var obj = {
  name: "My Object",
  getName: function(){
    return function(){
      return this.name;
    };
  }
};

console.log(obj.getName()());  // The Window

匿名函数的执行环境具有全局性,通常我们会使用匿名函数来创建闭包,因此此时的this对象通常会指向window。我们可以通过缓存obj内部的this来达到访问obj内部name属性的目的。

var name = "The Window";

var obj = {
  name: "My Object",
  getName: function(){
    var that = this
    return function(){
      return that.name;
    };
  }
};

console.log(obj.getName()());  // My Object

内存空间的释放

函数的作用域及其所有的变量会在函数执行结束之后销毁。但是在创建了闭包之后,这个函数的作用域会一个保存到闭包不存在为止。

function handler() {
    let ele = document.getElementById('app')
    ele.onclick = function() {
        console.log(ele.id)
    }
}

上述代码中onClick事件创建了一个闭包,保存了对于外部ele的引用,使得其引用数至少为1,因此无法被回收。我们可以通过改造代码来将其释放。

function handler() {
    const ele = document.getElementById('app')
    const id = ele.id
    ele.onclick = function() {
        console.log(id)
    }
    ele = null
}

上述代码中,即使闭包不直接引用ele,包含函数的活动对象中也仍然会保留一个引用,因此我们有必要手动将ele变量设置为null。这样就能够解除对DOM 对象的引用,顺利地减少其引用数,确保正常回收其占用的内存。

由于闭包的这种特性会使得其携带包含它函数的作用域,因此会比其他函数占用更多的内存,在过度使用闭包的情况下可能会导致内存占用过多。

应用

私有作用域

function outputNumbers(count){
	for (var i=0; i < count; i++){
		alert(i);
	}
	alert(i); // 计数
}

变量 i 在for循环结束后没有被销毁

使用闭包可以在JavaScript中模仿块级作用域,通常可以使用匿名函数来模仿块级作用域。

function outputNumbers(count) {
  ;(function() {
    for (var i = 0; i < count; i++) {
      alert(i)
    }
  })()
  alert(i) // error
}

上述代码中,for循环外部加入了私有作用域。匿名函数的任何变量都会在执行结束的时候销毁。同时这个匿名函数是一个闭包,它能够访问外部作用域中的变量count

这种做法不会在内存中留下对于该函数的引用,同时也可以避免往全局作用域中添加过多的变量与函数,不必担心搞乱全局作用域。

创建私有变量

闭包的主要应用场景是设计私有方法与变量。

在任何函数内部定义的变量,都可以认为是私有变量,因为不能够在函数外部访问这些变量。这里的私有变量包括函数的参数、局部变量与函数内定义的函数。

而如果在函数内创建闭包,那么闭包可以通过自己的作用域链来访问到这些变量。利用这一点,我们可以创建用于访问私有变量的公有方法,我这类方法我们称为特权方法

var calc = (function() {
  var goodsList = [] // 私有变量
  goodsList.push(new Good()) // 初始化操作
  return {
    add: function(val) {
      goodsList.push(val)
    },
    getTotal: function() {
      return goodsList.length
    }
  }
})()

上面的这种方式也叫做模块模式。所谓的模块模式,指的是能为单例模式添加私有变量与方法并减少全局变量的使用。

上述代码中,模块模式使用了一个返回对象的匿名函数,在这个函数内部,首先定义了私有变量goodsList。返回的对象字面量中只可包含公开的属性与方法。这种模式在需要对单例进行初始化,同时又需要维护其私有变量的时候非常有用。

总结

什么是闭包

当在函数内部定义了其他函数时,就创建了闭包。闭包有权访问包含函数内部的所有变量。闭包的作用域链包含着自己的作用域、包含(创建该函数)函数的作用域与全局作用域。

通常函数的作用域及其变量会在函数执行结束后销毁,但是当函数反悔了一个闭包时,这个函数的作用域会一直保存在内存中直到闭包被释放。

闭包的应用

  • 模仿块级作用域
  • 创建私有变量