前端基础进阶(四):详细图解做用域链与闭包

攻克闭包难题
初学JavaScript的时候,我在学习闭包上,走了不少弯路。而此次从新回过头来对基础知识进行梳理,要讲清楚闭包,也是一个很是大的挑战。javascript

闭包有多重要?若是你是初入前端的朋友,我没有办法直观的告诉你闭包在实际开发中的无处不在,可是我能够告诉你,前端面试,必问闭包。面试官们经常用对闭包的了解程度来断定面试者的基础水平,保守估计,10个前端面试者,至少5个都死在闭包上。前端

但是为何,闭包如此重要,仍是有那么多人没有搞清楚呢?是由于你们不肯意学习吗?还真不是,而是咱们经过搜索找到的大部分讲解闭包的中文文章,都没有清晰明了的把闭包讲解清楚。要么浅尝辄止,要么高深莫测,要么干脆就直接乱说一通。包括我本身曾经也写过一篇关于闭包的总结,回头一看,不忍直视[捂脸]。java

所以本文的目的就在于,可以清晰明了得把闭包说清楚,让读者朋友们看了以后,就把闭包给完全学会了,而不是似懂非懂。面试

1、做用域与做用域链

在详细讲解做用域链以前,我默认你已经大概明白了JavaScript中的下面这些重要概念。这些概念将会很是有帮助。算法

  • 基础数据类型与引用数据类型
  • 内存空间
  • 垃圾回收机制
  • 执行上下文
  • 变量对象与活动对象

若是你暂时尚未明白,能够去看本系列的前三篇文章,本文文末有目录连接。为了讲解闭包,已经为你们作好了基础知识的铺垫哦。chrome

做用域编程

  • 在JavaScript中,咱们能够将做用域定义为一套规则,这套规则用来管理引擎如何在当前做用域以及嵌套的子做用域中根据标识符名称进行变量查找。
这里的标识符,指的是变量名或者函数名
  • JavaScript中只有全局做用域与函数做用域(由于eval咱们平时开发中几乎不会用到它,这里不讨论)。
  • 做用域与执行上下文是彻底不一样的两个概念。我知道不少人会混淆他们,可是必定要仔细区分。
JavaScript代码的整个执行过程,分为两个阶段,代码编译阶段与代码执行阶段。编译阶段由编译器完成,将代码翻译成可执行代码,这个阶段做用域规则会肯定。执行阶段由引擎完成,主要任务是执行可执行代码,执行上下文在这个阶段建立。

过程

做用域链segmentfault

回顾一下上一篇文章咱们分析的执行上下文的生命周期,以下图。
执行上下文生命周期设计模式

咱们知道函数在调用激活时,会开始建立对应的执行上下文,在执行上下文生成的过程当中,变量对象,做用域链,以及this的值会分别被肯定。以前一篇文章咱们详细说明了变量对象,而这里,咱们将详细说明做用域链。数组

做用域链,是由当前环境与上层环境的一系列变量对象组成,它保证了当前执行环境对符合访问权限的变量和函数的有序访问。

为了帮助你们理解做用域链,我咱们先结合一个例子,以及相应的图示来讲明。

var a = 20;

function test() {
    var b = a + 10;

    function innerTest() {
        var c = 10;
        return b + c;
    }

    return innerTest();
}

test();

在上面的例子中,全局,函数test,函数innerTest的执行上下文前后建立。咱们设定他们的变量对象分别为VO(global),VO(test), VO(innerTest)。而innerTest的做用域链,则同时包含了这三个变量对象,因此innerTest的执行上下文可以下表示。

innerTestEC = {
    VO: {...},  // 变量对象
    scopeChain: [VO(innerTest), VO(test), VO(global)], // 做用域链
}

咱们能够直接用一个数组来表示做用域链,数组的第一项scopeChain[0]为做用域链的最前端,而数组的最后一项,为做用域链的最末端,全部的最末端都为全局变量对象。

不少人会误解为当前做用域与上层做用域为包含关系,但其实并非。以最前端为起点,最末端为终点的单方向通道我认为是更加贴切的形容。如图。

做用域链图示

注意,由于变量对象在执行上下文进入执行阶段时,就变成了活动对象,这一点在上一篇文章中已经讲过,所以图中使用了AO来表示。Active Object

是的,做用域链是由一系列变量对象组成,咱们能够在这个单向通道中,查询变量对象中的标识符,这样就能够访问到上一层做用域中的变量了。

2、闭包

对于那些有一点 JavaScript 使用经验但从未真正理解闭包概念的人来讲,理解闭包能够看做是某种意义上的重生,突破闭包的瓶颈可使你功力大增。

闭包是一种特殊的对象。

它由两部分组成。执行上下文(代号A),以及在该执行上下文中建立的函数(代号B)。

当B执行时,若是访问了A中变量对象中的值,那么闭包就会产生。

在大多数理解中,包括许多著名的书籍,文章里都以函数B的名字代指这里生成的闭包。而在chrome中,则以执行上下文A的函数名代指闭包。

所以咱们只须要知道,一个闭包对象,由A、B共同组成,在之后的篇幅中,我将以chrome的标准来称呼。

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

    function bar() {
        return a + b;
    }

    return bar;
}

var bar = foo();
bar();

上面的例子,首先有执行上下文foo,在foo中定义了函数bar,而经过对外返回bar的方式让bar得以执行。当bar执行时,访问了foo内部的变量a,b。所以这个时候闭包产生。

基础进阶(一)中,我总结了JavaScript的垃圾回收机制。JavaScript拥有自动的垃圾回收机制,关于垃圾回收机制,有一个重要的行为,那就是,当一个值,在内存中失去引用时,垃圾回收机制会根据特殊的算法找到它,并将其回收,释放内存。

而咱们知道,函数的执行上下文,在执行完毕以后,生命周期结束,那么该函数的执行上下文就会失去引用。其占用的内存空间很快就会被垃圾回收器释放。但是闭包的存在,会阻止这一过程。

先来一个简单的例子。

var fn = null;
function foo() {
    var a = 2;
    function innnerFoo() {
        console.log(a);
    }
    fn = innnerFoo; // 将 innnerFoo的引用,赋值给全局变量中的fn
}

function bar() {
    fn(); // 此处的保留的innerFoo的引用
}

foo();
bar(); // 2

在上面的例子中,foo()执行完毕以后,按照常理,其执行环境生命周期会结束,所占内存被垃圾收集器释放。可是经过fn = innerFoo,函数innerFoo的引用被保留了下来,复制给了全局变量fn。这个行为,致使了foo的变量对象,也被保留了下来。因而,函数fn在函数bar内部执行时,依然能够访问这个被保留下来的变量对象。因此此刻仍然可以访问到变量a的值。

这样,咱们就能够称foo为闭包。

下图展现了闭包foo的做用域链。

闭包foo的做用域链,图中标题写错了,请无视

咱们能够在chrome浏览器的开发者工具中查看这段代码运行时产生的函数调用栈与做用域链的生成状况。以下图。

关于如何在chrome中观察闭包,以及更多闭包的例子,请阅读基础系列(六)

从图中能够看出,chrome浏览器认为闭包是foo,而不是一般咱们认为的innerFoo

在上面的图中,红色箭头所指的正是闭包。其中Call Stack为当前的函数调用栈,Scope为当前正在被执行的函数的做用域链,Local为当前的局部变量。

因此,经过闭包,咱们能够在其余的执行上下文中,访问到函数的内部变量。好比在上面的例子中,咱们在函数bar的执行环境中访问到了函数foo的a变量。我的认为,从应用层面,这是闭包最重要的特性。利用这个特性,咱们能够实现不少有意思的东西。

不过读者朋友们须要注意的是,虽然例子中的闭包被保存在了全局变量中,可是闭包的做用域链并不会发生任何改变。在闭包中,能访问到的变量,仍然是做用域链上可以查询到的变量。

对上面的例子稍做修改,若是咱们在函数bar中声明一个变量c,并在闭包fn中试图访问该变量,运行结果会抛出错误。

var fn = null;
function foo() {
    var a = 2;
    function innnerFoo() {
        console.log(c); // 在这里,试图访问函数bar中的c变量,会抛出错误
        console.log(a);
    }
    fn = innnerFoo; // 将 innnerFoo的引用,赋值给全局变量中的fn
}

function bar() {
    var c = 100;
    fn(); // 此处的保留的innerFoo的引用
}

foo();
bar();
关于这一点,不少同窗把函数调用栈与做用域链没有分清楚,因此有的大神看了我关于介绍执行上下文的文章时就义正言辞的说个人例子有问题,而这些评论有很大的误导做用,为了帮助你们本身拥有可以辨别的能力,因此我写了基础(六),教你们如何在chrome中观察闭包,做用域链,this等。固然我也不敢100%保证我文中的例子就必定正确,因此教你们如何去辨认我认为才是最重要的。

闭包的应用场景

除了面试,在实践中,闭包有两个很是重要的应用场景。分别是模块化与柯里化。

  • 柯里化

在函数式编程中,利用闭包可以实现不少炫酷的功能,柯里化即是其中很重要的一种。点击了解更多关于柯里化的知识

  • 模块

在我看来,模块是闭包最强大的一个应用场景。若是你是初学者,对于模块的了解能够暂时不用放在心上,由于理解模块须要更多的基础知识。可是若是你已经有了不少JavaScript的使用经验,在完全了解了闭包以后,不妨借助本文介绍的做用域链与闭包的思路,从新理一理关于模块的知识。这对于咱们理解各类各样的设计模式具备莫大的帮助。

(function () {
    var a = 10;
    var b = 20;

    function add(num1, num2) {
        var num1 = !!num1 ? num1 : a;
        var num2 = !!num2 ? num2 : b;

        return num1 + num2;
    }

    window.add = add;
})();

add(10, 20);

在上面的例子中,我使用函数自执行的方式,建立了一个模块。add是模块对外暴露的一个公共方法。而变量a,b被做为私有变量。在面向对象的开发中,咱们经常须要考虑是将变量做为私有变量,仍是放在构造函数中的this中,所以理解闭包,以及原型链是一个很是重要的事情。模块十分重要,所以我会在之后的文章专门介绍,这里就暂时很少说啦。

此图中能够观看到当代码执行到add方法时的调用栈与做用域链,此刻的闭包为外层的自执行函数

为了验证本身有没有搞懂做用域链与闭包,这里留下一个经典的思考题,经常也会在面试中被问到。

利用闭包,修改下面的代码,让循环输出的结果依次为1, 2, 3, 4, 5

for (var i=1; i<=5; i++) {
    setTimeout( function timer() {
        console.log(i);
    }, i*1000 );
}

点此查看关于此题的详细解读

关于做用域链的与闭包我就总结完了,虽然我自认为我是说得很是清晰了,可是我知道理解闭包并非一件简单的事情,因此若是你有什么问题,能够在评论中问我。你也能够带着从别的地方没有看懂的例子在评论中留言。你们一块儿学习进步。

前端基础进阶系列目录

clipboard.png

相关文章
相关标签/搜索