关于闭包和作用域链的个人理解

729 阅读8分钟

术语表

执行环境执行环境(execution context,为简单起见,有时也称为“环境”)是 JavaScript 中最为重要的一个概 念。执行环境定义了变量或函数有权访问的其他数据,决定了它们各自的行为。每个执行环境都有一个 与之关联的变量对象(variable object),环境中定义的所有变量和函数都保存在这个对象中。虽然我们 编写的代码无法访问这个对象,但解析器在处理数据时会在后台使用它。每个函数都有自己的执行环境。当执行流进入一个函数时,函数的环境就会被推入一个环境栈中。 而在函数执行之后,栈将其环境弹出,把控制权返回给之前的执行环境。
作用域链当代码在一个环境中执行时,会创建变量对象的一个作用域链(scope chain)。作用域链的用途,是保证对执行环境有权访问的所有变量和函数的有序访问。作用域链的前端,始终都是当前执行的代码所在环境的变量对象。

文章概述

通过观察示例代码执行时函数属性[[Scopes]]的变化,总结作用域链的生成过程

示例代码

//创建father函数
let father = function () {
    //执行father函数
    let name = 'shan';
    //创建child函数
    let child = function () {
        //执行child函数
        let age = 18;
        console.log(name, age);
    }
    return child;
}
let child = father();
child();

单步执行代码,主要观察四个地方

1.创建father函数

2.执行father函数

2.创建child函数

3.执行child函数

创建father函数

father函数里面有个[[Scopes]],这个属性可以看成是father父函数的作用域链

father是在全局环境执行的,全局环境的作用域就是Global对象,可以看成father函数在创建的时候将全局环境的作用域链copy到了[[Scopes]]里,所以[[Scopes]]里面会有一个Global对象

执行father函数

father函数执行的时候会创建一个当前执行环境关联的变量对象,这个对象里面包含了当前函数father声明的所有变量,

执行father函数的时候,会创建father作用域链,这个新的作用域链其实就是通过将执行father函数时创建的变量对象插入到[[Scopes]]数组里面的最前方生成的

Tip: 函数执行时应该是通过当前函数变量对象和[[Scopes]]创建了一个作用域链,但是这个作用域链应该存在于后台中,我们无法看到实际的显示,只能观察子函数[[Scopes]]的变化进行推断

创建child函数

观察可以发现,child函数创建的时候[[Scopes]]数组里面就有两个变量对象Closure和Global了,Closure其实就是father函数的变量对象,保存着father函数中声明的变量

child函数创建的时候,会将father函数执行时创建的作用域链保存到[[Scopes]]中

Tip: Closure的中文翻译就是闭包,也就是说闭包其实是父函数的变量对象

执行child函数

child函数执行的时候会创建child作用域链,主要是由child函数变量对象,和father函数执行时创建的作用域链两部分组成。

child作用域链最前端是child函数执行时创建的变量对象,包含child函数内部声明的所有变量,后面是父函数的作用域链,包含父函数变量对象和Global对象。

在child函数中访问变量的顺序是

child变量对象 -> father变量对象 -> Global变量对象

总结:

子函数的[[Scopes]]属性可以看成是父函数执行时创建的作用域链的具现化。

1.只有在函数执行时,才会创建作用域链,函数创建(声明)时不会生成作用域链。

2.函数创建(声明)时会将父函数的作用域链保存到自己的[[scopes]]属性中。

3.函数执行时会从[[scopes]]中拿到父函数的作用域链,然后将自身的变量对象插入到作用域链的最前方,生成新的作用域链,函数中的所有变量都是通过作用域链进行访问的。

4.作用域链可以看成是一个数组,里面保存了一个一个的变量对象,最前面是当前函数的变量对象,最后面是全局变量对象,我们在函数内访问的变量是变量对象的一个属性,如果我们要访问某个变量,需要拿着变量名作为key,去变量对象里面查找,如果所有的变量对象里都不存在这个属性,那说明这个变量没有被声明,就会报错。

子函数执行时作用域链生成过程

1.父函数执行时首先会创建变量对象,父函数内声明的所有变量都会保存到这个对象中

2.将创建的变量对象插入到父函数[[Scopes]]的最前方,形成父函数执行时作用域链

3.在父函数内创建子函数,子函数创建的时候会将父函数执行时作用域链保存到自己的[[Scopes]]属性中,[[Scopes]]里面第一个对象是父函数的变量对象,也称为闭包

4.子函数执行,创建子函数自己的变量对象,然后再结合子函数自己的[[Scopes]]生成子函数的作用域链,子函数作用域链里面分别是:

a.子函数变量对象,包含子函数里面声明的所有变量

b.父函数变量对象,包含父函数里面声明的所有变量

c.Global变量对象,包含全局变量

子函数里面变量的访问顺序依次是a -> b -> c

根据这个结论可以想通很多问题

什么是闭包?

对于子函数来说,闭包就是父函数的变量对象,包含父函数里声明的所有变量。

子函数在创建的时候,会将父函数的作用域链保存在[[Scopes]]中,

子函数执行的时候,会将当前函数的变量对象插入到父函数的作用域链的最前方,形成自己的作用域链。

父函数的作用域链的第一个对象是父函数的变量对象,保存着父函数内部声明的变量。

闭包有什么用?

通过闭包可以访问父函数的变量对象,父函数的变量对象里面保存了父函数内部声明的变量,就是说通过闭包可以访问到父函数内部声明的变量

为什么变量重名采用的是就近原则?

因为子函数的变量对象在作用域链的前端,作用域链其实就是个数组,里面存储着变量对象,子函数变量对象角标为0,角标的值比较小,所以先访问子函数里的变量

为什么有时候函数执行时,获取不到外部函数的变量?

根据作用域链的生成原理,作用域链是由函数内部变量对象和创建函数时父函数作用域链组合而成的,也就是说函数在任何地方执行时,外部函数不会影响函数作用域链的创建。

简单来说函数执行时的作用域链,在创建函数的时候就已经决定了。

如果函数不是在外部函数里面创建的,那么函数的[[Scopes]]里面就不会有外部函数的变量对象,自然访问不到外部函数的变量

闭包的缺陷?

因为父函数的变量对象保存在子函数的[[Scopes]]数组中,所以无法被垃圾回收器回收,如果大量使用闭包会导致大量的父函数变量对象无法被垃圾回收器回收,可能造成内存溢出

为什么闭包无法被回收?

跟垃圾回收机制有关,v8引擎采用的是标记清除法

标记清除法

1.标记内存中所有的变量

2.从根对象开始往下遍历,如果一个变量可以访问到,就去掉标记

3.清理掉标记的变量

简单说就是如果变量可以从根变量到达,就不会被清理

对于闭包来说,只要子函数不销毁,那么因为子函数可到达,那么子函数的[[Scopes]]里面存储的闭包也会可到达,所以闭包没有办法被清理

所以销毁闭包的方法也很简单

child = null;

将child变量置为空,那么子函数将无法访问,闭包也无法访问,就可以被垃圾回收器清除掉了