从执行上下文谈论闭包

【前言】

这次从执行上下文的层面来梳理一遍闭包。

执行上下文

在讲闭包之前先梳理一遍前置知识,关于执行上下文栈和执行上下文。

执行上下文栈

首先我们知道,在JavaScript中,可执行的代码有三种:全局代码、函数代码、eval代码。

当代码自上而下执行时,会进行准备工作,也就是创建相应的执行上下文。众多的代码对应着众多的执行上下文,为了方便管理。JavaScript引擎创建了一个执行上下文栈(Execution context stack,ECS),用来管理众多的执行上下文,你可以把它当作一个数组来理解。当JavaScript开始要解释执行代码的时候,会最先遇到全局代码,所以初始化的时候会创建全局执行上下文压入ECStack,因为只有在整个应用程序结束的时候,ECStack才会被清空,所以程序执行结束之前,ECStack底部始终都会有一个全局执行上下文。而每当执行一个函数的时候,就会创建一个对应的执行上下文,并且压入执行上下文栈,当函数执行完毕的时候,就会将函数的执行上下文从执行上下文栈中弹出。

执行上下文

前面我们说到,当JavaScript执行一段可执行代码时,会创建对应的执行上下文。我的理解是执行上下文是当前执行代码的一个环境与作用域。每个执行上下文都包含以下三种属性:

  • 变量对象(Variable object,VO)

    • 变量对象是与执行上下文相关的数据作用域,储存了在上下文中定义的变量和函数声明
  • 作用域链(Scope )

    • 包含前面执行上下文的变量对象
  • this

这里我们通过一段代码来分析,此处借鉴冴羽老师的例子。

var scope = "global scope";
function checkscope(){
    var scope = "local scope";
    function f(){
        return scope;
    }
    return f();
}
checkscope();
复制代码

首先执行全局代码,创建了全局执行上下文globalContent,压入执行上下文栈

ECStack = [
        globalContext
    ];
复制代码

之后给全局上下文进行初始化

    globalContext = {
        VO: [global],
        Scope: [globalContext.VO],
        this: globalContext.VO
    }
复制代码

在全局上下文初始化的时候,checkscope函数被创建,函数就会保存所有父变量对象到函数的内部属性[[scope]],当函数执行时,将函数执行上下文压入栈并进行初始化。

    checkscope.[[scope]] = [
      globalContext.VO  //全局上下文中的变量对象,包含了全局上下文中的所有变量和函数声明
    ];
复制代码

当执行到checkscope函数时候,会创建相对应的函数执行上下文,并将其压入执行上下文栈。压入栈后开始对checkscope函数执行上下文进行初始化:

  • 复制函数的[[scope]]作用域链,创建作用域链
  • 用arguments创建活动对象并初始化活动对象,即加入形参、函数声明、变量函数(变量对象)
  • 将变量对象压入[[scope]]的顶端

此时checkscope的scope是这样的

 Scope: [AO, globalContext.VO],
复制代码

当该函数被执行完后,其执行上下文会被弹出栈。

github.com/mqyqingfeng… 有兴趣的可以看看羽哥的博客,里面有详细说明

闭包

MDN所定义的闭包为:

闭包是指那些能够访问自由变量的函数。

自由变量就是指可以在函数中使用,但既不是函数的参数也不是函数的局部变量的变量。所以闭包由函数+函数能够访问的自由变量组成。

我所知道的闭包分两种,一种从理论角度来说,一种从实践角度来说。

理论角度:

所有的函数。当全局执行上下文初始化时,函数在此时被创建,函数会将所有父级的变量对象存到函数内置属性[[scope]]里面去,此时全局变量对于该函数来说就是属于自由变量。所以函数中访问全局变量就相当于是在访问自由变量。

实践角度:

  • 即使创建它的上下文已经销毁,它仍然存在(比如:内部函数从父函数中返回)

    • 因为上下文维护了一个作用域链,即使创建它的上下文被销毁,其上下文的活动变量仍然活在内存中
    • 通常,函数的作用域以及所有变量都会在函数执行结束后被销毁。但是,在形成了闭包以后,这个函数的作用域就会一直保存到闭包不存在为止。引用关系一直存在,就可以一直访问变量。
  • 在代码中引用了自由变量的函数

我们用一段代码进行分析

function a(){
    var a = 1
    function b(){
        console.log(a);
    }
​
    return b
}
​
c = a()
c()
复制代码

这是一段经典的内部函数从父函数中返回而形成闭包的代码

当执行到c = a( )时,b函数作为返回值被return,此时的c其实就相当于函数b,所以c( )结果是1

当a( )执行完后,a函数的执行上下文从执行上下文栈中弹出,但由于b函数对a函数内部的变量a存在引用关系,不会被垃圾回收机制回收。而c对b存在引用,b对a函数的活动变量存在引用,只要这段引用关系一直存在,b函数就可以一直通过所维护的作用域链来读取到a函数的活动变量

这里看到过一段很有意思的评论:本质就是上级作用域内变量的生命周期,因为被下级作用域内引用,而没有被释放。就导致上级作用域内的变量,等到下级作用域执行完以后才正常得到释放。

//此时b函数的作用域链
Scope:[ AO , aContext.VO , globalContext.VO ]
// b函数可通过Scope中的aContext.VO来获取到变量a的值1
复制代码

被引用的活动变量存活在堆中还是栈中呢?

伴随着a函数的被调用,为了保证变量不被销毁,会在堆中先生成一个对象叫Scope,把变量作为Scope的属性存起来。所以它的活动变量并没有存在栈中,而是在堆里,用一个特殊的对象Scope保存。其实也可以这样想,如果存在栈中的话,当a函数执行完,a的执行上下文被弹出时,那么其中的活动变量不也会被弹出么?所以a的活动变量存活于堆中。

闭包存在的问题

闭包被大量使用会导致内存泄露,性能开销过大。在JavaScript中垃圾回收采用引用计数法,每当时间轮询时都会清除内存中计数为0的变量,由于闭包的存在始终存在着对活动变量的引用,垃圾回收机制无法清除。若闭包使用过多,那么在内存中无法被回收的变量也会越来越多,造成内存泄漏。

闭包的常用用途

  1. 我们希望在函数外部能够访问到函数内部的变量。通过在外部调用闭包函数,从而在外部访问到函数内部的变量,用这种方法可以创建私有变量。保证函数内部变量的安全,实现封装,防止变量流入其它环境发生命名冲突,造成环境污染。这也是为什么要创建私有变量而不是自由变量的原因。
  2. 希望已经运行结束了的函数上下文中的活动变量继续留在内存中。比如某函数的调用处理耗时,我们就可以将结果在内存中缓存起来,下次执行时,该结果在内存中可以直接返回,提高执行效率。

在这里感谢冴羽老师的文章,从中学到了很多东西。本篇文章也算是站在巨人的肩膀上写出来的。 感兴趣的兄弟可以去我的博客玩,里面有我总结的一些文章,不足之处欢迎各位批评指正。

我的博客

Guess you like

Origin juejin.im/post/7063115537013800990