首頁  >  文章  >  web前端  >  前端基礎進階(四):詳細圖解作用域鏈與閉包

前端基礎進階(四):詳細圖解作用域鏈與閉包

阿神
阿神原創
2017-02-16 13:28:251210瀏覽

前端基礎進階(四):詳細圖解作用域鏈與閉包

初學JavaScript的時候,我在學習閉包上,走了很多彎路。而這次重新回過頭來對基礎知識進​​行梳理,要講清楚閉包,也是一個非常大的挑戰。

閉包有多重要?如果你是初入前端的朋友,我沒有辦法直觀的告訴你閉包在實際開發中的無處不在,但是我可以告訴你,前端面試,必問閉包。面試官們常常用對閉包的了解程度來判定面試者的基礎水平,保守估計,10個前端面試者,至少5個都死在閉包上。

可是為什麼,閉包如此重要,還是有那麼多人沒有搞清楚呢?是因為大家不願意學習嗎?還真不是,而是我們透過搜尋找到的大部分講解閉包的中文文章,都沒有清晰明了的把閉包講解清楚。要嘛淺嚐輒止,要嘛高深莫測,要嘛乾脆就直接亂說一通。包括我自己曾經也寫過一篇關於閉包的總結,回頭一看,不忍直視[摀臉]。

因此本文的目的就在於,能夠清晰明了得把閉包說清楚,讓讀者老爺們看了之後,就把閉包給徹底學會了,而不是似懂非懂。


一、作用域與作用域鏈鏈之前我已經明白這些重要概念中的重要概念。這些概念將會非常有幫助。 1.基礎資料型別與引用資料型別

2.記憶體空間

3.垃圾回收機轉

4.執行上下文

5.變數物件與活動物件

如果暫時還沒明白,可以去明白看本系列的前三篇文章,本文文末有目錄連結。為了講解閉包,我已經為大家做好了基本的鋪墊。哈哈,真是好大一齣戲。

作用域

1.在JavaScript中,我們可以將作用域定義為一套規則,這套規則用來管理引擎如何在當前作用域以及嵌套的子作用域中根據標識符名稱進行變數查找。 這裡的標識符,指的是變數名稱或函數名稱

2.JavaScript中只有全域作用域與函數作用域(因為eval我們平時開發中幾乎不會用到它,這裡不討論)。

3.作用域與執行上下文是完全不同的兩個概念。我知道很多人會混淆他們,但是一定要仔細區分。

JavaScript程式碼的整個執行過程,分為兩個階段,程式碼編譯階段與程式碼執行階段。編譯階段由編譯器完成,將程式碼翻譯成可執行程式碼,這個階段作用域規則會決定。執行階段由引擎完成,主要任務是執行可執行程式碼,執行上下文在這個階段建立。

作用域鏈前端基礎進階(四):詳細圖解作用域鏈與閉包

回顧一下上一篇文章我們分析的執行上下文的生命週期,如下圖。

我們發現,作用域鍊是在執行上下文的創建階段產生的。這個就奇怪了。上面我們剛剛說作用域在編譯階段確定規則,可是為什麼作用域鏈卻在執行階段確定呢?

之所有有這個疑問,是因為大家對作用域和作用域鏈有一個誤解。我們上面說了,作用域是一套規則,那麼作用域鍊是什麼呢?是這套規則的具體實作。所以這就是作用域與作用域鏈的關係,相信大家都應該明白了吧。 前端基礎進階(四):詳細圖解作用域鏈與閉包

我們知道函數在呼叫啟動時,會開始建立對應的執行上下文,在執行上下文產生的過程中,變數對象,作用域鏈,以及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)], // 作用域链
    this: {}
}

是的,你没有看错,我们可以直接用一个数组来表示作用域链,数组的第一项scopeChain[0]为作用域链的最前端,而数组的最后一项,为作用域链的最末端,所有的最末端都为全局变量对象。

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

前端基礎進階(四):詳細圖解作用域鏈與閉包

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

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


二、闭包

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

先直截了当的抛出闭包的定义:当一个函数可以记住并访问所在的作用域(全局作用域除外),并在定义该函数的作用域之外执行时,该函数就可以称之为一个闭包。

简单来说,假设函数A在函数B的内部进行定义了,并在函数B的作用域之外执行(不管是上层作用域,下层作用域,还有其他作用域),那么A就是一个闭包。记住这个定义,你在其他地方很难看到了。

在基础进阶(一)中,我总结了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的值。

这样,我们就可以称fn为闭包。

下图展示了闭包fn的作用域链。

前端基礎進階(四):詳細圖解作用域鏈與閉包

所以,通过闭包,我们可以在其他的执行上下文中,访问到函数的内部变量。比如在上面的例子中,我们在函数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();

上面的例子,可以很直观的感受到闭包的存在,但是还有一种情况的闭包,则更加隐蔽难以感受。我们来看一个例子。

function test() {
    function bar (str) {
        console.log(str);
    }

    function foo (fn, string) {
        fn(string);
    }

    foo(bar, 'this is closure');
}


test();

这个例子中,函数bar在函数test的作用域中定义,然后被作为参数传入了函数foo中并在foo的作用域中被执行。根据定义,我们很容易知道函数bar就是一个闭包。因为其隐蔽性,很多人并没有意识到这就是一个闭包。这种情况,就是我们常常说的回调函数。在实际开发中,我们遇到的大多数回调函数都是闭包。

很多时候,回调函数都是匿名函数,但是要注意的是,在其他一些语言中,闭包与匿名函数是有区别的,但是JavaScript在实现匿名函数的时候允许形成闭包,当匿名函数作为参数传入函数中时,匿名函数的引用会保存在改函数变量对象的arguments对象中。因此在JavaScript中,我们可以不用那么严格的区别闭包与匿名函数。

闭包的应用场景

接下来,我们来总结下,闭包的常用场景。

延迟函数setTimeout

我们知道setTimeout的第一个参数是一个函数,第二个参数则是延迟的时间。在下面例子中,

function fn() {
    console.log('this is test.')
}
var timer =  setTimeout(fn, 1000);
console.log(timer);

执行上面的代码,变量timer的值,会立即输出出来,表示setTimeout这个函数本身已经执行完毕了。但是一秒钟之后,fn才会被执行。这是为什么?

按道理来说,既然fn被作为参数传入了setTimeout中,那么fn将会被保存在setTimeout变量对象中,setTimeout执行完毕之后,它的变量对象也就不存在了。可是事实上并不是这样。至少在这一秒钟的事件里,它仍然是存在的。这正是因为闭包。

很显然,这是在函数的内部实现中,setTimeout通过特殊的方式,保留了fn的引用,让setTimeout的变量对象,并没有在其执行完毕后被垃圾收集器回收。因此setTimeout执行结束后一秒,我们任然能够执行fn函数。

回调函数

在上面的例子中,我们已经解释过了回调函数。所以就不再多说。

柯里化

在函数式编程中,利用闭包能够实现很多炫酷的功能,柯里化算是其中一种。关于柯里化,我会在以后详解函数式编程的时候仔细总结。

模块

在我看来,模块是闭包最强大的一个应用场景。如果你是初学者,对于模块的了解可以暂时不用放在心上,因为理解模块需要更多的基础知识。但是如果你已经有了很多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被作为一个闭包,对外暴露了一个公共方法。而变量a,b被作为私有变量。在面向对象的开发中,我们常常需要考虑是将变量作为私有变量,还是放在构造函数中的this中,因此理解闭包,以及原型链是一个非常重要的事情。模块十分重要,因此我会在以后的文章专门介绍,这里就暂时不多说啦。

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

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

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

关于作用域链的与闭包我就总结完了,虽然我自认为我是说得非常清晰了,但是我知道理解闭包并不是一件简单的事情,所以如果你有什么问题,可以在评论中问我,留言必回。你也可以带着从别的地方没有看懂的例子在评论中留言。大家一起学习进步。

陳述:
本文內容由網友自願投稿,版權歸原作者所有。本站不承擔相應的法律責任。如發現涉嫌抄襲或侵權的內容,請聯絡admin@php.cn