JS--从预编译到作用域链再翻过闭包的大山

188 阅读8分钟

闭包作为JS中的一座大山,究竟该如何理解它?

闭包是基于词法作用域书写代码时所产生的自然结果,你甚至不需要为了利用它们而有意识地创建闭包。
当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行。 -- 《你不知道的JS》

预编译


为了更好的理解闭包,我们需要先知悉作用域链的定义与生成,这里我们先通过一个例子了解一下预编译,考虑如下代码:

<script>  
		global = 100
        function fn() {
            console.log(global) // undefined
            global = 200
            console.log(global) // 200
            var global = 300
        }
        fn()
        var global;
</script>  

为什么第一次的输出会是undefined呢? 我们都知道,根据作用域的规则,当引擎进行RHS引用查询(这里是对global的值执行),会由内到外的向作用域进行查找,首先是函数fn的作用域,在fn的最后一行有一个对global变量的声明,在代码结构上它处在输出的下面,可是var的声明方式造成了变量提升,但仅限于声明,至于赋值为300的操作是不会被提升的。所以引擎在fn作用域中查找到的global值为undefined
引擎对global的查找也就到此结束了,虽然在全局的上文中有对global赋值为100的操作。但fn中var声明导致的变量提升中止了引擎继续向外查询的操作,这种现象被称为遮蔽效应

仅仅知道这些还不够,为了更好的理解作用域链,我们需要知道预编译时发生了些什么

预编译发生在全局时

  1. 创建GO对象(Global Object)
  2. 找形参和变量声明,将变量声明和形参作为GO的属性名,值为undefined
  3. 在全局里找函数声明,将函数名作为GO对象的属性名,值赋予整个函数体

预编译发生在函数执行之前

  1. 创建AO对象 (Activation Object)
  2. 找形参和变量声明,将变量声明和形参作为AO的属性名,值为undefined
  3. 将实参和形参值统一
  4. 在函数体里找函数声明,将函数名作为AO对象的属性名,值赋予整个函数体

还是之前的代码块,为了方便大家对照我把GO和AO写在代码块内

<script>  
		// GO: {
        //     global: undefined 100,
        //     fn: function () {...}
        // }
        
        global = 100
        function fn() {
            console.log(global) // undefined
            global = 200
            console.log(global) // 200
            var global = 300
        }
        
        // AO: {
        //     global: undefined 200 300,
        // }

        fn()
        var global;
</script>  

两种预编译都是发生在代码执行前的,对于全局来说:它无非包含以下四个操作

  1. global变量的声明
  2. global变量的赋值
  3. 函数fn的声明
  4. 函数fn的调用

列举完了,让我们按照预编译的逻辑来走一遍


对于全局:

首先创建GO对象:
GO: {}

找形参和变量声明,将变量声明和形参作为GO的属性名,值为undefined,这里没有形参:

GO: {
	global: undefined
}

在全局里找函数声明,将函数名作为GO对象的属性名,值赋予整个函数体:

GO: {
	global: undefined,
    fn: function () {...}
}

对于函数fn:

参照上面全局的预编译,fn预编译得到的AO应该是这样的:

AO: {
	global: undefined
}

之后代码执行,对变量进行了赋值等操作,就得到了上述代码块中的GO和AO。

作用域链


结合上面对预编译的学习,我们来了解一下作用域链的产生。在学习作用域链之前首先我们要知道,对于函数而言,它的原型是对象,所以函数也能拥有属性

function test() {

}

如 test.name 、test.prototype
特别的 test.[[scope]],scope代表作用域,其中存储了运行期上下文的集合。这是函数特有的作用域属性(隐式属性,不能被调用)

 
考虑如下代码:

  • 这里为了节省时间,我把预编译产生的GO,AO写好了,大家可以在脑海里复习一下
// GO: {
//     glob: undefined 100
//     a: function a() {...}
// }

function a() {
    function b() {
        var b = 222
    }
    var a = 111
    b()
    console.log(a)
}
var glob = 100
a()
a()

// aAO: {
//     a: undefined 111
//     b: function b() {...}
// }
// bAO: {
//     b: undefined 222
// } 

我们刚刚说过函数拥有代表作用域的scope属性,现在让我们来看看这段代码执行时函数a中scope的变化吧!

// a 定义 a.[[scope]]  ---> 0: GO:{}
// a 执行 a.[[scope]]  ---> 0: aAO: {}     1: GO:{}
// b 定义 a.[[scope]]  ---> 0: aAO: {}     1: GO:{}
// b 执行 a.[[scope]]  ---> 0: bAO: {}     1: aAO:{}    2: GO:{}

这里我稍微解释一下,js代码由上至下执行,抵达上述代码块,首先是函数a的声明,此时在 a.[[scope]] 中添加GO对象。之后 a ( ),此时函数内部代码被执行,向 a.[[scope]] 中添加函数a的AO,即 aAO: { } 。继续向下执行,遇到函数b的声明,此时函数b还未被执行,因此bAO还未产生,a.[[scope]] 保持不变。继而b被执行,此时产生 bAO: { } , 存在于 a.[[scope]] 中。

	注:后添加的AO位于作用域链的前端

紧接着,b执行完了,bAO: { } 就要销毁,这是因为引擎的垃圾回收机制,也正是这种机制,才有了闭包。而当a执行完后,aAO: { } 也要销毁,因为aAO: { } 中包含了函数b,此时函数b就会直接失效

总结:

当查找变量的时候,会先从当前上下文的变量对象中查找,如果没有找到,就会从父级(词法层面上的父级) 执行上下文的变量对象中查找,一直找到全局上下文的变量对象,也就是全局对象。这样由多个执行上下文的变量对象构成的链表就叫做作用域链。

闭包


终于,终于它来了!!!

  • 定义

结合前面学习的预编译与作用域链知识考虑如下代码:

function a() {

    function b() {
        var bbb = 234
        console.log(aaa) // 看上去应该报错,实际上有值,这就是闭包
    }
    var aaa = 123
    return b // b 出生在 a里面,但是被保存出去了
}
var glob = 100
var demo = a()
demo()

上述代码块执行阶段GO和AO:

// GO: {
//     glob: undefined 100,
//     demo: undefined,
//     a: function a() {...}
// }
// aAO: {
//     aaa: undefined 123,
//     b: function b() {...}
// }
// bAO: {
//     bbb: undefined 234
// }

输出后注释里说的本应该报错,和这就是闭包,机智的你应该已经发现了些什么吧?

前面讲作用域链时我们说过,函数b在上下文结构里处在函数a的内部,当函数a执行完后,aAO就会被回收机制销毁,而函数b自然也就失效了,但这里的输出却是123,这是为什么呢?

 

其实不难理解,当调用函数a时,函数b被当作返回值返回给了外部,此时函数a虽然执行完了,但当你在外部调用保存出去的函数b时,它应该要有可以查找的父作用域吧? 正是这样,引擎为了保证后续在外部调用函数b时有父作用域可查,就将它的父作用域保存了下来,跳过了回收机制,而这种因为函数被保存到外部而产生的父作用域保存就叫闭包

  • 缺点
    闭包最大,最致命的缺点——内存泄漏
    顾名思义,是错误的。实际上,这里内存泄漏的定义是:造成了预期之外长时间对内存的占用
    想象一下,当你对闭包不够了解时,你所书写的代码里可能存在多处闭包,当代码运行时,许多的父作用域被保存在内存当中无法释放,因为闭包的结构让引擎认为你可能随时想要调用保存出来的函数。
    解决方法:当你调用完保存出来的函数后,将它赋值为null,销毁保存在内存中的父作用域
// 用之前的代码块举例
demo = null
  • 作用
  • 1. 实现公有变量 (企业的模块化开发)

    不使用闭包 :

 // 实现一个函数执行出来的结果都是累加的
var count = 0
function test () {
    count++
    console.log(count)
}
test()
test()
test()
test()

// 不依赖外部变量,实现如上效果
// 很难,函数里面没有一个始终固定不变的变量,每次重新执行函数都会初始化函数里面的的所有东西
   

这种情况下我们只能将递增的变量定义在全局下,这样就造成了全局变量的污染,因为count变量在这里只在函数test内被使用

看看使用闭包如何解决上述需求:

function add() {
    var num = 0
    function a() {
        console.log(++num)
    }
    return a
}

var result = add()
result()
result()
result()
// result = null // 主动销毁闭包在内存中保存的add的AO

 

闭包实现共有变量的作用就展现出来了,即避免了每次重新执行函数都会初始化函数里面的的所有东西的困扰,又没有污染全局变量

  • 2. 做缓存

实现一个吃水果和添加水果的功能,这就要求有一个变量作为缓存来保存当前的水果:

function fruit() {
    var food = 'apple' // food 一直被缓存在fruit的AO中
    var obj = {
        eatFood: function() {
            if (food != '') {
                console.log('I am eating ' + food)
                food = ''
            } else {
                console.log('There is nothing')
            }
        },
        pushFood: function(myfood) {
            food = myfood
        }
    }
    return obj
}

var person = fruit()
person.eatFood()
person.eatFood()
person.pushFood('banana')
person.eatFood()

输出结果:


  • 3. 可以实现封装,属性的私有化

  • 4. 模块化开发,防止污染全局变量

第一次写文章,不知道表达的还算清楚吗,期待意见和指教,感谢