从执行环境及作用域开始深入理解闭包及其原因

392 阅读7分钟

对闭包的概念总是有种一知半解的感觉,结合JavaScpript高级程序设计第四章和第七章后发现对闭包有了一定的理解。

执行环境

执行环境是JavaScript中最为重要的一个概念。执行环境定义了变量或函数有权访问的其他数据,决定了它们各自的行为。每个执行环境都有一个与之关联的变量对象,也就是我们通常所说的vo,环境中定义的所有变量和函数都保存在这个对象中。虽然我们编写的代码无法访问这个对象,但解析器在处理数据时会在后台使用它。

而每个函数都有自己的执行环境。当执行流进入一个函数时,函数的环境就会被推入一个环境栈中。而在环境执行之后,栈将其环境弹出,然后将控制权返回给之前的执行环境。所以通过执行流不断的进出执行环境就实现了代码环境

function fun() {
    var a = 1;
    var b = 2;
    function insidefun()
}

当执行流进入这个函数后, 该函数的变量对象中就会存在ab变量以及insidefun函数

某个执行环境中所有的代码执行完毕后,该环境会被销毁,对应的保存其中的所有变量和函数定义也会被销毁。但全局执行环境只用应用程序退出,比如关闭网页或者浏览器时才会被销毁。

作用域链

因为每个函数都有自己的执行环境,所以当代码在一个环境中执行时,会创建变量对象的一个作用域链。作用域链的用途是保证对执行环境有权访问的所有变量和函数的有序访问,即在某个执行环境中的代码想要访问某个变量或者函数的时候,引擎就会在其作用域链中寻找改变量或者函数,若未找到,则报错undefined

那么作用域链是什么样子呢?

作用域链的前端,始终都是当前执行的代码所在的环境的变量对象。如果这个环境是函数,则将其活动对象作为变量对象。活动对象在最开始时只包含一个变量,即参数arguement对象,不过这个对象在全局环境中是不存在的。作用域链中的下一个对象来自于外部的包含环境,而再下一个变量对象则来自其包含环境的包含环境,一直延续到全局执行环境。

全局执行环境的变量对象始终都是任何一个执行环境的作用域链中最后一个对象

举个例子

var name = 'Jerry'

function getName() {
    var newName = 'Tom'
    
    function changeName() {
        var tempName = name
        name = newName
        newName = tempName
    }
   
    changeName()
}

getName()

上面的代码一共涉及了3个执行环境

  • 全局环境
  • getName的局部环境
  • changeName的局部环境
全局环境

全局环境中有一个变量name和一个函数getName()

所以在全局执行环境中的代码可以直接使用name变量和getName函数

getName局部环境

getName局部环境的活动对象中有一个名为newName的变量和changeName的函数

由于全局环境是它的父执行环境,所以该执行环境可以使用的变量

  • newName
  • changeName
  • name
changeName局部环境

changeName局部环境活动对象中有一个名为tempName的变量和changeName的函数

由于全局环境是和getName局部环境是他的包含环境,所以该执行环境可以使用的变量

  • tempName
  • newName
  • name

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-TBj9pJwC-1594983194312)(C:\Users\asus\AppData\Roaming\Typora\typora-user-images\image-20200717130117791.png)]

上图展示了代码的执行环境。其中,内部环境可以通过作用域链访问所有的外部环境,但外部环境不能访问内部环境中的任何变量或者函数。

对于这个例子中的changeName()而言,其执行环境的作用域包含3个对象,分别为:

  • changeName()的变量对象
  • getName()的变量对象
  • 全局变量对象

个人觉得有点类似于原型链的寻找变量方法,这里贴出一篇我总结的原型链相关的链接: 原型链详细图解

还有可以延长作用域链的方法,但由于不属于文章重点,这里就不过多赘述

在上面我们已经了解了函数执行时执行流如何工作,需要注意的一点是**某个执行环境中所有的代码执行完毕后,该环境会被销毁,对应的保存其中的所有变量和函数定义也会被销毁。**这个对下面的理解闭包会有一定的帮助

闭包

闭包是值有权访问另一个函数作用域中的变量的函数

通过上面的执行环境和作用域的介绍,可以发现如果一个执行环境要想访问非自身活动对象的变量或者函数,只能通过其包含环境去寻找,所以创建闭包的常见方式就是在一个函数内部创建另一个函数。

举个例子:

function A() {
    var name = 'Jerry'
    
    function B() {
        var newName = name
        return newName
    }
    return B
}

var temp = A()
var res = temp()

在这个例子中,B()被返回了,但是在外部仍然可以访问变量name。之所以还能访问这个变量,是因为b()作用域链中包含了A()的作用域。

但是我们在介绍执行环境和作用域的时候说到:

某个执行环境中所有的代码执行完毕后,该环境会被销毁,对应的保存其中的所有变量和函数定义也会被销毁。

为什么A()执行完之后没有将内部保存的所有变量和函数定义销毁呢?

一般来讲,当一个函数执行完毕后,局部活动对象就会被销毁,内存中仅保存全局作用域,但是闭包的情况比较特殊,在A()中定义的B()的作用域链中会包含A()的活动对象。

下图为temp()函数执行过程中产生的作用域链之间的关系

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-m0UwSHZg-1594983194314)(C:\Users\asus\AppData\Roaming\Typora\typora-user-images\image-20200717182310328.png)]

B()A()函数中被返回后,它的作用域链被初始化为包含A()函数的活动对象全局变量对象。这样,接受B()temp就可以访问A()中定义的所有变量。此外,在A()执行完成之后,其活动对象也不会被销毁,因为B()的作用域链仍然在引用这个活动对象。即当A()函数返回后,其执行环境的作用域链会被销毁,但它的活动对象仍然会留在内存中,直到B()被销毁,A()的活动对象才会被销毁。

temp = null 

下面的代码即可解除对B()的引用

闭包经典例子

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

乍一看这段代码返回的函数数组中的每个函数都应该返回自己的索引值,即result[0] () = 0. result[1] () = 1 ,但实际上每个函数都返回的是 10

解释:

按照我们上面说的,实际上result数组中每一个元素都是一个闭包函数,因为其都引用了createFunctions的活动对象中的 i,并且数组中的每一个函数都引用的时同一个变量i, 当createFunctions返回后,变量i的值已经变为了 10, 所以 在每个函数内部 i 的值都是10, 那么如何解决呢,我们只需要将i的值从createFunctions的活动对象添加到每一个函数的活动对象中,方法使用传参即可

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

最后

闭包在我平常的编程中基本没有用到过,但是为了防止以后编程中不小心出现闭包的情况,还是有必要了解一下闭包的,并且由于闭包会携带其包含它的函数的作用域,因此会比其他函数占用更多的内存,特别是如果函数嵌套层级过深则会更加耗费内存。