JavaScript 闭包

概念

闭包(closure)是 Javascript语言 的一个难点,也是它的特色,很多高级应用都需要依靠闭包实现。有不少的开发人员总是分不清匿名函数和闭包这两个的概念,因此经常混用。匿名函数 是指创建一个函数并将它赋值给变量,这种情况下创建的函数叫做匿名函数(anonymous function。),因为 function 关键字后面没有标识符。匿名函数的 name 属性是空字符串。闭包是指有权访问另一个函数作用域(当某个函数被调用时,会创建一个执行环境及相应的作用域链。)中的变量的函数。换言之,闭包是指在 Javascript 中,内部函数总是可以访问其所在外部函数中申明的参数和变量,即使在其外部函数被返回(寿命终结)之后。它是一种特殊的对象,由两部分构成:函数,以及创建该函数的环境。创建闭包的常见方式,就是在一个函数内创建个另一函数。

变量(variable)

变量 是用于存储信息的"容器"。要理解闭包,首先必须理解 Javascript 特殊的变量作用域。JavaScript 变量可以是局部变量或全局变量。在web页面中全局变量属于 window 对象, 全局变量可应用于页面上的所有脚本。局部变量只能用于定义它函数内部,对于其他的函数或脚本代码是不可用的。 全局和局部变量即便名称相同,它们也是两个不同的变量。修改其中一个,不会影响另一个的值。对此我们需要知道的是Javascript语言的特殊之处,就在于函数内部可以直接读取全局变量。而正常在函数外部却无法读取函数内的局部变量。

变量对象(variable object) 是一个抽象的对象,用于储存执行环境中的变量、函数声明、函数参数。在全局作用域下变量对象的值指向全局对象,在浏览器中为 window。我们编写的代码无法访问这个对象,但解析器在处理数据时会在后台使用它。

执行环境(execution context)

执行环境 是 Javascript 中最为重要的一个概念。执行环境(也称 执行上下文)始终是this关键字的值。其定义了变量或函数有权访问的其他数据,决定了他们各自的行为。每个执行环境都有一个与之关联的变量对象,环境中定义的所有变量和函数都保存在这个对象中。

全局执行环境是最外围的一个执行环境。在Web浏览器中,全局环境被认为是 window对象,因此所有全局变量和函数都是作为window对象的属性和方法创建的。

当 JavaScript解释器 初始化执行代码时,它首先默认进入全局执行环境,从此刻开始,函数的每次调用都会创建一个新的执行环境。Javascript 引擎以推栈的方式(后进先出)处理执行环境,栈底永远是全局环境,栈顶是当前执行的上下文。某个执行环境中的所有代码执行完毕后,该环境被销毁,保存在其中的所有变量和函数定义也会随之被销毁。全局执行环境直到应用程序退出(例如关闭网页或浏览器)时才会被销毁。

执行环境分为两个阶段:

  • 执行上下文初始阶段,变量对象按如下的顺序填充和:
    1. this 赋值
    2. 函数参数(若未传入,初始化值为 undefined)。
    3. 函数声明(每找到一个函数声明,就在变量对象中用函数名建立一个属性,值指向该函数在内存中的地址的一个引用人,若以函数名为属性名已经存在在变量对象中,则会被覆盖)。
    4. 变量声明(初始化值为 undefined,若命名重复则忽略)。
  • 代码执行阶段:执行函数中的代码,给变量对象中的变量属性赋值。

this 关键字

与其他语言相比,函数的 this 关键字在 JavaScript 中的表现略有不同,在绝大多数情况下,函数的调用方式决定了 this 的值。this 不能在执行期间被赋值,并且在每次函数被调用时 this 的值也可能会不同。ES5 引入了 bind方法 来设置函数的this值,而不用考虑函数如何被调用的,ES2015 引入了支持 this 词法解析的箭头函数(它在闭合的执行上下文内设置this的值)。此外,在严格模式和非严格模式之间也会有一些差别。下面主要就非严格模式下this关键字的值做下讲解。

  • 全局上下文
    1. 在全局执行上下文中(在任何函数体外部)。无论是否在严格模式下,this 都指代全局对象。在浏览器中即 window。
  • 函数上下文 在函数内部,this的值取决于函数被调用的方式
    1. 直接调用。因为 this 的值不是通过调用设置的,所以 this 的值默认指向全局对象。在严格模式下,如果 this 未在执行的上下文中定义,那它将会默认为 undefined
    2. 作为对象的一个方法。当以对象里的方法的方式调用函数时,它们的 this 是调用该函数的对象。
    3. 构造函数。当一个函数用作构造函数时(使用new关键字),它的 this 被绑定到正在构造的新对象。构造函数的函数名第一个字母大写(规则约定)。
    4. 原型链中的 this。相同的概念在定义在原型链中的方法也是一致的。如果该方法存在于一个对象的原型链上,那么 this 指向的是调用这个方法的对象,就好像该方法本来就存在于这个对象上。
    5. call 和 apply方法。如果要想把 this 的值从一个 context 传到另一个,就要用 call,或者 apply方法。call()方法:调用一个函数,其具有一个指定的 this 值和分别地提供的参数。apply()方法:其具有一个指定的 this 值和作为一个数组(或类数组的对象)提供的参数。call() 和 apply()方法属于间接调用(indirect invocation)。当一个函数的函数体中使用了 this 关键字时,通过 call()方法 和 apply()方法调用,this 的值可以绑定到一个指定的对象上。如果传递的 this 值不是一个对象,JavaScript 将会尝试使用内部 ToObject 操作将其转换为对象。
    6. bind 方法。ECMAScript 5 引入了 Function.prototype.bind。调用 demo.bind(某个对象)会创建一个与 demo 具有相同函数体和作用域的函数,但是在这个新函数中,this 将永久地被绑定到了 bind 的第一个参数,无论这个函数是如何被调用的。
    7. getter 与 setter 中的 this。相同的概念也适用时的函数作为一个 getter 或者 一个 setter 调用。用作 getter 或 setter 的函数都会把 this 绑定到正在设置或获取属性的对象。
    8. 箭头函数。箭头函数表达式的语法比函数表达式更短,并且不绑定自己的 this,arguments,super 或 new.target。这些函数表达式最适合用于非方法函数,并且它们不能用作构造函数。在箭头函数中,this 是根据当前的词法作用域来决定的,就是说,箭头函数会继承外层函数调用的 this 绑定(无论this绑定到什么)。在全局作用域中,它会绑定到全局对象上。
    9. 作为一个 DOM事件处理函数。当函数被用作事件处理函数时,它的 this 指向触发事件的元素(一些浏览器在使用非addEventListener的函数动态添加监听函数时不遵守这个约定)。
    10. 作为一个内联事件处理函数。当代码被内联处理函数调用时,它的 this 指向监听器所在的 DOM元素。

作用域(scope)和作用域链(scope chain)

ES6 之前 JavaScript 没有块级作用域,除了全局作用域之外,只有函数可以创建作用域 ES6 开始新增加了一个 let,可以在{}, if, for里声明。用法同 var,但作用域限定在块级,let 声明的变量不存在变量提升。在代码块内,在let声明之前使用变量都是不可用的。

每个环境能够访问的标识符集皆可称为“作用域”。当代码在一个环境中执行时,会创建变量对象的一个 作用域链作用域链的用途,是保证对执行环境有权访问的所有变量和函数的有序访问,且能隔离变量,使得不同作用域下同名变量不会有冲突。作用域链的前端,始终都是当前执行的代码所在环境的变量对象。如果这个环境是函数,则将其活动对象作为变量对象。活动对象在最开始时只包含一个变量,即 arguments对象 (这个对象在全局环境中是不存在的)。作用域链中的下一个变量对象来自包含(外部)环境。这样一直延续到全局环境;全局执行环境的变量对象始终都是作用域链中的最后一个对象。

标识符解析是沿着作用域链一级一级地搜索标识符的过程。搜索过程始终从作用域链的前端开始,然后逐级地向后回溯,直到找到标识符。如果找不到标识符,通常会导致错误发生。

作用域和执行环境的关系

作用域只是一个“地盘”,一个抽象的概念,其中没有变量。要通过作用域对应的执行上下文环境来获取变量的值。同一个作用域下,不同的调用会产生不同的执行上下文环境,继而产生不同的变量的值。所以,作用域中变量的值是在执行过程中产生的确定的。

所以对于函数来说,上下文环境是在调用时创建的,而作用域却是在函数创建时就确定了。而且一个作用域下可能包含若干个上下文环境。如果要查找一个作用域下某个变量的值,就需要找到这个作用域对应的执行上下文环境,再在其中寻找变量的值。

闭包(closure)

闭包是一个函数和函数所声明的词法环境的结合。

首先我们来看下面的一段代码:

1  function demo(){
2 var name = "Anani";
3 return function displayName(){
4 console.log(name);
5 }
6 }
7
8 var demoTest = demo();
9 demoTest();

运行这段代码,将会弹出字符串“Anani",我们知道函数中的局部变量仅在函数的执行期间可用,此处 displayName 函数在执行前被从其外围函数中返回了,name 变量应该不再可用,然而因为 demoTest()是一个闭包,由 displayName 函数和闭包创建时存在的 "Anani" 字符串形成,所以它仍然可以访问 name 变量。

要理解上面的闭包首先要对前面讲解的概念要熟悉,特别是执行环境和作用域,这是参透闭包的关键。

当 JavaScript解释器 初始化执行代码时,它首先默认进入全局执行环境,此时全局环境是活动状态,并对其中的变量进行赋值。执行第 8 行代码时,调用 demo(),产生 demo()的执环环境,压栈,并设置为活动状态。当 demo()调用完成,按理说应该销毁 demo()的执行环境,但是调用 demo()时返回了 displayName 函数,函数可以创建一个独立的作用域,而且刚好返回的函数中有一个 name 自由变量要在 demo()的执行环境中取值,因此这里的 demo()的执行环境不会被销毁,依然存在于执行环境栈中。

当代码执行到第 9 行时,执行 demoTest(),即执行 displayName(),创建 displayName()的执行环境,并将其设置为活动状态。因为在 displayname()中 name 是自由变量,需要向创建该函数的作用域中查找,最终在 demo()的执行环境中找到 name 的值为“Anani”。执行完第 9 行的代码后执行环境开始反向销毁。

闭包的缺陷

  • 闭包的缺点就是常驻内存会增大内存使用量,并且使用不当很容易造成内存泄露。
  • 如果不是因为某些特殊任务而需要闭包,在没有必要的情况下,在其它函数中创建函数是不明智的,因为闭包对脚本性能具有负面影响,包括处理速度和内存消耗。