一文搞定JS中的闭包和作用域

前言

作为一名前端开发者,提起JavaScript中的语言特性,我们常常会想起闭包、Event Loop以及原型链等其他特性。最近为了彻底理清闭包,我翻开了一本黄色的JS武林秘籍——《你不知道的JavaScript(上卷)》。

为什么需要闭包?

首先我先来思考一个问题,那么闭包到底有什么用呢?知道了它的用处,我才会有更多的动力去学习它。

闭包的左右有以下几点:

  • 变量长期驻扎在内存中
  • 避免全局变量的污染
  • 私有成员的存在
  • 模块化封装,以及HOC等

什么是闭包?

根据书中所讲,一句话概括闭包的话就是:“当函数在当前词法作用域之外执行但可以记住并访问所在的词法作用域时,就产生了闭包”。那么什么是“词法作用域”呢?什么又是“作用域”呢?别慌,且听我一一道来。

作用域

负责收集并维护由所有声明的标识符(变量)组成的一系列查询,**并实施一套非常严格的规则,确定当前执行的代码对这些标识符的访问。**作用域又分为词法作用域(静态作用域)和动态作用域两种,两者最重要的区别在于作用域规则是在什么时候确定的?在函数定义时就确定的被称为静态作用域(词法作用域),相对的,在函数调用时就确定的则被称为动态作用域。JavaScript的作用域(包括大多数主流的语言如C、C++、Java等)就是词法作用域。

静态作用域为什么又叫词法作用域呢?

大部分标准语言编译器的第一个工作阶段叫作词法化(也叫单词化)。词法化的过程会对源代码中的字符进行检查,如果是有状态的解析过程,还会赋予单词语义。静态作用域是由你在写代码时将变量和块作用域写在哪里来决定的,它是定义在词法阶段的作用域。当词法分析器处理代码时会保持作用域不变(大部分情况下是这样的),因此静态作用域也被称为词法作用域。

闭包是怎样产生的?

明白了词法作用域之后,我们知道词法作用域是在函数定义时就基本能够知道全部标识符在哪里以及是如何声明的。那么我们还需要满足书中所说的另外一个必要条件——“在当前词法作用域之外执行”。这个执行过程到底是什么样的呢?接下来,我门来介绍一下,执行过程中六个重要的角色。

1、EC(Execution Context)执行上下文

先上一波官方的抽象概念:执行上下文是ECMA-262规范用来对可执行代码进行类型化和区分的抽象概念。

Execution context (abbreviated form — EC) is the abstract concept used by ECMA-262 specification for typification and differentiation of an executable code.

更通俗地将,当我们的代码执行时,会进入到不同的上下文环境(Context)。在不同的环境中,有着不同的 作用域链、this指向,代码所能访问到的资源也就不同。在JS中,执行环境有如下三种情况:

  1. 全局环境

    代码默认运行的环境,代码执行时会首先进入全局环境。它是最外围的一个执行环境,根据 ECMAScript 实现所在的宿主环境的不同,表示全局环境的对象也不一样。在 web 浏览器中,全局环境就是 window 对象。全局变量和函数都是作为全局对象 window 的变量和方法来创建的。在Node环境中,全局环境则为global对象。

  2. 函数环境

    函数被调用执行时,所创建的执行环境。

  3. eval环境

    使用 eval 会进入一个新的执行环境,它的变量对象为全局变量对象或调用者的变量对象。由于 eval 的毒瘤属性,一般不推荐使用,可忽略。

    一个执行上下文的生命周期可以分为两个阶段:

    • 创建阶段 在这个阶段中,执行上下文会分别
  • 创建变量对象
    • 建立作用域链
    • 以及确定this的指向
  • 代码执行阶段 创建完成之后,就会开始执行代码,这个时候,会完成变量赋值,函数引用,以及执行其他代码。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-W7dL6Ywi-1599641252242)(\images\ec.png)]

有了执行上下文,则需要需要一个空间来存放并执行它,这个时候就需要用到我的ECStack执行上下文环境栈,顾名思义执行上下文环境栈是一个栈内存空间,当我们执行不同的函数时,都会创造新的执行上下文,同时需要压入执行上下文环境栈。执行完毕后,则需要进行出栈操作(全局执行上下文,始终都在栈底部,一般情况下,只有浏览器关闭时,才会出栈)。

下一节将详细介绍ECStack执行上下文环境栈 (Execution Context Stack)。

2、ECStack执行上下文环境栈 (Execution Context Stack)

浏览器在内存中专门开辟了一块内存为JS提供了代码执行环境,这个环境被称作 ECStack执行上下文环境栈 。 浏览器中的JavaScript解释器是单线程实现的,这意味着在浏览器中一次只能发生一件事情,其它动作或事件在所谓的执行上下文环境栈中排队。

在debugger的过程中,我可以在开发者工具—sources这一栏中看到Call Stack,它会显示ECStack执行上下文环境栈的函数调用栈信息

一文搞定JS中的闭包和作用域_第1张图片
关于执行上下文环境栈有五个关键点:

  1. 单线程
  2. 同步执行,只有栈顶的上下文处于执行中,其他上下文需要等待
  3. 1个全局上下文,它在浏览器关闭时出栈
  4. 无限的函数上下文
  5. 每个函数调用都会创建一个新的执行上下文,甚至是调用自身

3、变量对象VO(Varibale Object)和 活动对象AO(Activation Object)

在当前的上下文中,用来存放创建变量的和值的地方。就被称作变量对象VO(Varibale Object)。 AO(Activation Object)是特殊的VO(Varibale Object),它存在于函数的上下文中,当该函数被执行也就是出于调用栈栈顶时,函数中的变量对象VO就会被激活成为活动对象AO。

变量对象的创建,依次经历了以下几个过程。

  1. **建立arguments对象。**检查当前上下文中的参数,建立该对象下的属性与属性值。
  2. 检查当前上下文的函数声明,也就是使用function关键字声明的函数。在变量对象中以函数名建立一个属性,属性值为指向该函数所在内存地址的引用。如果函数名的属性已经存在,那么该属性将会被新的引用所覆盖。
  3. 检查当前上下文中的变量声明,每找到一个变量声明,就在变量对象中以变量名建立一个属性,属性值为undefined。如果该变量名的属性已经存在,为了防止同名的函数被修改为undefined,则会直接跳过,原属性值不会被修改。

4、全局对象GO(Global Object)

除了上述两个对象外,还存在一个特殊的全局对象GO(Global Object),浏览器会把一些内置的方法单独地存放在其中。用伪代码可以进行如下的表示:

var globaObject = {
     
    Math:{
     },
    Strign:{
     },
    ...
    window:this
}

5、作用域

  • JavaScript中只有全局作用域和函数作用域以及ES6中添加的块级作用域(因为eval我们平时开发中几乎不会用到它,这里不讨论)。

  • 作用域与执行上下文是完全不同的两个概念。我知道很多人会混淆他们,但是一定要仔细区分。

    JavaScript代码的整个执行过程,分为两个阶段,代码编译阶段与代码执行阶段。编译阶段****由编译器完成,将代码翻译成可执行代码,这个阶段作用域规则会确定。执行阶段由引擎完成,主要任务是执行可执行代码,执行上下文在这个阶段创建

一文搞定JS中的闭包和作用域_第2张图片

我们可以在debugger时,在控制台中查看到这个[[Scope]]属性。

一文搞定JS中的闭包和作用域_第3张图片

一文搞定JS中的闭包和作用域_第4张图片

**6、作用域链(Scope Chain)**和[[Scopes]]属性

执行上下文中的词法环境中含有外部词法环境的引用,我们可以通过这个引用获取外部词法环境的变量、声明等,这些引用串联起来一直指向全局的词法环境,因此形成了作用域链。它是由当前环境与上层环境的一系列变量对象组成,它保证了当前执行环境对符合访问权限的变量和函数的有序访问。

实际上JS中的函数都存在一个虚拟出来的[[Scope]]属性,这个属性保存着函数在执行上下文时创建的变量对象和函数的自身的变量对象,在创建函数的时候,就会赋予其[[Scope]]属性,它可以显示当函数的作用域链。

示例:

介绍完了六个重要的角色,我们再来通过详细的例子结合Chrome的开发者工具,来一步步介绍闭包产生的过程:

function foo() {
     
    var a = 20;
    var b = 30;

    function bar() {
     
        return a + b;
    }

    return bar;
}

var bar = foo();
bar();

1、首先在调用所有的函数执行前,我们可以看到这是Call Stack存在一个匿名的函数,我可以把我们正在执行的全局代码,看作一个大的匿名函数。同时,我们可以看到在Scope中存在一个Global全局执行上下文,它始终存在ECStack执行上下文环境栈的栈底,当进入浏览器的JS代码执行环境,它就存在了。

一文搞定JS中的闭包和作用域_第5张图片
一文搞定JS中的闭包和作用域_第6张图片

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Kng4ejp8-1599641252249)(images\Chrome(3)].png)

2、在执行到var bar = foo();之前,我们声明并创建了foo函数,此时foo函数就会被添加到全局作用域中。如图所示,我们可以在foo函数中找它的[[Scopes]]属性,由于JS的作用域是词法作用域,函数的作用域和作用域链在定义时就被确定,foo函数是在全局作用域中创建的,所以此时他[[Scopes]]中存放了一个Global对象,即上文提到的GO

一文搞定JS中的闭包和作用域_第7张图片

3、在下图中,我们可以看到当我们执行foo函数时,创建了执行上下文,并被压入栈顶。由于变量提升的原因,bar函数被提前声明和创建。和上面foo函数一样,我们在bar函数创建时,我就可以确定它的[[Scopes]],此时我们可以看到bar函数的[[Scopes]]已经存在了存在了一个Closure对象,也就是本文所说的闭包。Closure对象中存放了foo函数中的变量a和b以及它们的值。

一文搞定JS中的闭包和作用域_第8张图片
4、当foo函数执行完成之后,执行上下文会被弹出栈顶。

![在这里插入图片描述](https://img-blog.csdnimg.cn/20200909164906390.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLm

5、此时bar变量会指向foo函数中返回的bar函数的内存地址,我们接着执行这个函数。bar函数需要返回变量a和变量b的和,但是此时本来声明变量a和变量b的foo函数的可执行上下文已经出栈了。但是神奇的闭包在此时就发挥了它的作用,并成功返回了正确的结果。

一文搞定JS中的闭包和作用域_第9张图片
6、foo函数完毕之后按照常理,其执行环境生命周期会结束,所占内存被垃圾收集器释放。但是由于全局上下文中的bar变量被赋予了指向bar函数的指针,bar函数仍然在被引用,所以它并不会被释放。此时,如果bar函数需要用到变量a和变量b,那么它就会到自身的作用链中去寻找它们的值,那么在[[Scopes]]的closure中bar函数就能得到它想要的结果。

一文搞定JS中的闭包和作用域_第10张图片

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-p5xWKtzX-1599641252252)(images\Chrome(8)].png)

闭包的应用

好了,到了这里我们已经一步步地了解了闭包产生的经过和原理。既然我们有了闭包这样的工具,那我们应该怎么去应用它呢?下面介绍两个闭包常用的应用场景:

  1. 柯里化

    柯里化利用了闭包的功能绑定了参数的作用域,使得每次调用函数时可以访问上次所传入的参数。

    function add(a, b, c) {
           
        return a + b + c;
    }
    console.log(add(1,2,3));  // 6
     
    //柯里化
    function newAdd(a) {
           
        return function(b) {
           
            return function(c) {
           
                return a + b + c;  //闭包中包含了对a,b的引用
            }
        }
    }
    console.log(newAdd(1)(2)(3));  // 6
    
  2. 模块

function CoolModule() {
     
var something = "cool";
var another = [1, 2, 3];
function doSomething() {
     
console.log( something );
}
function doAnother() {
     
console.log( another.join( " ! " ) );
}
return {
     
doSomething: doSomething,
doAnother: doAnother
};
}
var foo = CoolModule();
foo.doSomething(); // cool
foo.doAnother(); // 1 ! 2 ! 3

CoolModule() 返回一个用对象字面量语法 { key: value, … } 来表示的对象。这个返回的对象中含有对内部函数而不是内部数据变量的引用

附录

参考链接:

《你不知道的JavaScript(上卷)》

你可能感兴趣的:(JS底层原理,闭包,javascript,函数闭包)