谈谈JavaScript的闭包

537 阅读6分钟

前言

闭包,可以说是JavaScript里面一个无法避开的知识点。它其实并不难以理解,但令人诧异的是有很多人闭包的理解是一直半解的,大概能理解,但是说不清楚。今天我们就来谈谈到底什么是闭包,以及应该怎么使用闭包。

一、词法作用域

对于理解闭包,首先我们需要理解一下什么是词法作用域。通常我们讲作用域的时候,往往讲的就是词法作用域(还有另外一个叫语法作用域,出现得比较少)。词法作用域的定义是:

根据源代码中声明变量的位置来确定该变量在何处可用

这句话翻译成白话的意思就是,词法作用域是根据你写的代码顺序位置决定的,也就是说它在你写完代码的那一刻就决定了它能在哪些地方使用。比如你在一个function里面定义了一个变量,他的位置是在function的大括号里面,这时候大括号外面是无法访问这个变量的。

function test(){
	var a = "haha";
}
console.log(a); //undefined

然后,在JavaScript引擎里面,规定了 嵌套函数可访问声明于它们外部作用域的变量

举一个MDN里面的例子:

function init() {
    var name = "Mozilla"; // name 是一个被 init 创建的局部变量
    function displayName() {
        alert(name); // 使用了父函数中声明的变量,嵌套函数可以访问定义它的外部函数的变量
    }
    displayName();
}
init();

总结词法作用域有两点:

  1. 在一个块(大括号)里面定义的变量,可以被这个大括号内的语句使用,超出大括号无效。
  2. 嵌套函数可以访问定义它的外部函数的变量(global 可以理解成一个最大的外部函数)

二、什么是闭包

通常我们在看一个技术点的解释时,有三种方式可以让我们比较准确地了解到真正的含义。

  1. 看官方的说法,因为这是最准确的描述
  2. 追溯技术的历史发展过程,了解一下最初的定义是什么,后来怎么发展演变的
  3. 查看领域内牛人的说法,站在巨人的肩膀上

我们来看看mdn上是怎么解释闭包的。

函数和对其周围状态(lexical environment,词法环境)的引用捆绑在一起构成闭包closure)。也就是说,闭包可以让你从内部函数访问外部函数作用域。在 JavaScript 中,每当函数被创建,就会在函数生成时生成闭包。

先看例子:

function makeFunc() {
    var name = "Mozilla";
    function displayName() {
        alert(name);// displayName引用了外部的name
    }
    return displayName;
}

var myFunc = makeFunc();
myFunc();

代码运行的结果当然就是成功地弹出了Mozilla。原因在于,

JavaScript中的函数会形成闭包。 闭包是由函数以及声明该函数的词法环境组合而成的。该环境包含了这个闭包创建时作用域内的任何局部变量。在本例子中,myFunc是执行 makeFunc时创建的 displayName函数实例的引用。displayName的实例维持了一个对它的词法环境(变量 name 存在于其中)的引用。因此,当 myFunc 被调用时,变量 name仍然可用,其值 Mozilla就被传递到alert中。

总结:

函数和对其周围状态(lexical environment,词法环境)的引用捆绑在一起构成闭包closure),也就是闭包=函数+词法环境的捆绑。

三、闭包的标准用法

严格上来说,一个函数的定义就产生了一个闭包,因为这个函数内部可以访问定义它的词法环境。但其实存在着标准上的用法,看以下这个例子:

function foo() {
  var a = 2;
  function bar() {
    console.log( a ); // 2
  }
	bar();
}
foo();

bar直接在foo的内部执行完了,这是闭包吗?严格上来说是的,因为bar确实可以访问foo的词法作用域。但是这并不是个标准的用法,标准的用法其实是将bar函数return出去,在其他地方调用。这样才能深刻体现出bar捆绑了foo的词法作用域。比如:

function foo() {
  var a = 2;
  function bar() {
    console.log( a ); // 2
  }
	return bar;
}

var bar = foo();
bar();

标准的闭包用法有三个条件:

  1. 定义了内部函数(这里是bar)
  2. 内部函数引用了外部函数的变量(bar引用了a)
  3. 这个内部函数在其他地方被使用(return了bar,bar在外面被调用)

四、什么时候用到闭包

现在闭包的定义很明显了,就是函数跟词法环境的捆绑。所以我们可以利用这种捆绑的作用来达到定义私有变量,或者锁定内部变量引用的作用。所以当你想一个函数内的变量锁定某个值,你就用闭包

function makeFunc() {
    var name = "Mozilla";
    function displayName() {
        alert(name);
    }
    return displayName;
}

var myFunc = makeFunc();
myFunc();  //myFunc 里面的name由于词法作用域的原因锁定了makeFunc里面的name

闭包无处不在,本质上无论何时何地,如果将函数(访问它们各自的词法作用域)当作第一 级的值类型并到处传递,你就会看到闭包在这些函数中的应用。在定时器、事件监听器、 Ajax 请求、跨窗口通信、Web Workers 或者任何其他的异步(或者同步)任务中,只要使用了回调函数,实际上就是在使用闭包! 在es6的module中,无论你import了多少次,里面的变量总是一样的,这其实也是一种闭包。

五、闭包的性能考量

闭包很香,但是应该慎用。如果不是某些特定任务需要使用闭包,在其它函数中创建函数是不明智的,因为闭包在处理速度和内存消耗方面对脚本性能具有负面影响。

还是看上面那个例子

function makeFunc() {
    var name = "Mozilla";
    function displayName() {
        alert(name); //displayName 引用了name
    }
    return displayName;
}

var myFunc = makeFunc(); //myFunc 是对displayName的一个引用
myFunc();

通常一个函数执行完之后,JavaScript引擎会摧毁他占用的内存空间,回收垃圾。那么在 makeFunc() 执行后,通常会期待 makeFunc() 的整个内部作用域都被销毁,因为我们知道引擎有垃圾回收器用来释放不再使用的内存空间。由于看上去 makeFunc() 的内容不会再被使用,所以很自然地会考虑对其进行回收。 而闭包的“神奇”之处正是可以阻止这件事情的发生。事实上由于displayName被外部引用了,而displayName引用了内部作用域,所以导致垃圾回收器并不能判断内部作用域是否还需要,因此没有回收空间。谁在使用这个内部作用域?原来是 displayName() 本身在使用。这就是我们所说的内存泄露。

总结:

什么是闭包?闭包是指函数+词法环境的捆绑。闭包的标准用法是将这个绑定了词法环境的函数赋值到其他地方调用,从而达到了函数捆绑了词法环境的目的。

参考:

MDN闭包章节