深刻JavaScript系列(三):閉包

詞法環境執行上下文不太瞭解的朋友,建議先閱讀系列文章的前兩篇,有助於理解本文,連接 -> 深刻ECMAScript系列目錄地址(持續更新中...)git

1、詞法做用域

首先咱們來看一個例子(來自冴羽大大的博客JavaScript深刻之詞法做用域和動態做用域):github

var scope = 'global scope'
function checkscope(){
    var scope = 'local scope'
    function f(){
        return scope
    }
    return f()
}
checkscope()
複製代碼
var scope = 'global scope'
function checkscope(){
    var scope = 'local scope'
    function f(){
        return scope
    }
    return f
}
checkscope()()
複製代碼

這裏就不賣關子了,兩段代碼的運行結果都是local scope。這是JavaScript做用域機制決定的。閉包

做用域:指程序源代碼中定義變量的區域。是規定代碼對變量訪問權限的規則。ecmascript

你們可能據說過JavaScript採用的是詞法做用域(靜態做用域),沒據說過也沒有關係,很好理解,意思就是函數的做用域在函數定義的時候就肯定了,也就是說函數的做用域取決於函數在哪裏定義,和函數在哪裏調用並沒有關係。異步

由以前的文章深刻ECMAScript系列(二):執行上下文咱們可知:任意的JavaScript可執行代碼(包括函數)被執行時,會建立新的執行上下文及其詞法環境函數

既然詞法環境是在代碼塊運行時才建立的,那爲何又說函數的做用域在函數定義的時候就肯定了呢?這就牽扯到了函數的聲明及調用了。post

2、函數的聲明及調用

在以前的文章深刻ECMAScript系列(二):執行上下文中說過,代碼塊內的函數聲明在標識符實例化及初始化階段就會被初始化並分配相應的函數體。ui

在這個階段還會會給函數設置一個內置屬性[[Environment]],指向函數聲明時所在的執行上下文的詞法環境。this

當聲明過的函數被調用時,會建立新的執行上下文和新的詞法環境,這個新建立的詞法環境的對外部詞法環境的引用outer屬性將會指向函數的[[Environment]]內置屬性,也就是函數聲明時所在的執行上下文的詞法環境。spa

而變量的查找又是經過詞法環境及其外部引用進行的,因此說函數的做用域取決於函數在哪裏定義,和函數在哪裏調用並沒有關係。

總結一下,兩個關鍵點:

  1. 函數聲明時會被賦予一個內置屬性[[Environment]],指向函數聲明時所在的執行上下文的詞法環境。
  2. 函數不管在什麼時候何地調用,建立的詞法環境的外部詞法環境引用outer都指向函數的內置屬性[[Environment]]

因此說函數的做用域取決於函數在哪裏定義,和函數在哪裏調用並沒有關係。

咱們回頭看文章開頭的兩個例子:

var scope = 'global scope'
function checkscope(){
    var scope = 'local scope'
    function f(){
        return scope
    }
}

// function f
f: {
    [[ECMAScriptCode]]: ..., // 函數體代碼
    [[Environment]]: { // 函數f 定義時所在執行上下文的詞法環境,也就是函數checkscope運行時建立的詞法環境
        EnvironmentRecord: { // 環境記錄上綁定了變量scope和函數f
            scope: 'local scope',
            f: Function f
        },
        outer: { // 外部詞法環境引用指向全局詞法環境
            EnvironmentRecord: { // 全局環境記錄上綁定了變量scope和函數checkscope
                scope: 'global scope',
                checkscope: Function checkscope
            },
            outer: null // 全局詞法環境無外部詞法環境引用
        }
    },
    ... // 其餘屬性
}
複製代碼

函數f定義在函數checkscope內部,因此函數f不論在函數checkscope的內部調用,仍是做爲返回值返回後在外部調用,其詞法環境的外部引用永遠是函數checkscope運行時建立的詞法環境,變量scope也只用往外尋找一層詞法環境,在函數checkscope運行時建立的詞法環境中找到,值爲'local scope',不用再往外查找。因此上面兩個例子的運行結果都是local scope

3、閉包

首先看看MDN上對閉包的定義:

閉包:閉包是函數和聲明該函數的詞法環境的組合。

從理論角度來講:全部的JavaScript函數都是閉包。 由於函數聲明時會設置一個內置屬性[[Environment]]來記錄當前執行上下文的詞法環境。

從實踐角度來講: 咱們平時所說的閉包應該叫「有意義的閉包」:

Dmitry Soshnikov的文章中描述具備如下特色的函數叫作閉包:

  1. 函數建立時所在的上下文銷燬後,該函數仍然存在
  2. 函數內引用自由變量

自由變量: 在函數中使用,但既不是函數參數也不是函數的局部變量的變量。

我本身的理解是如下兩點:

  1. 函數建立時的詞法環境已不存在於當前執行上下文的詞法環境鏈上。(換句話說,函數建立時的詞法環境內的變量已沒法在當前執行上下文內直接訪問)
  2. 函數內存在對函數建立時的詞法環境內的變量的訪問。

最簡單的閉包就是父函數內返回一個函數,返回函數內引用了父函數內變量:

var scope = 'global scope'
function checkscope(){
    var scope = 'local scope'
    function f(){
        return scope
    }
    return f
}

var closure = checkscope()
closure()
複製代碼

將開頭的第二個例子稍微變一下,調用checkscope會返回一個函數,咱們將其賦值給closure,此時closure函數就是一個閉包,因爲它是在調用checkscope時建立的,內置屬性[[Environment]]指向調用checkscope時建立的詞法環境,所以不管在何處調用closure函數,返回結果是'local scope'

4、閉包的應用

我理解閉包的本質做用就兩點,任何閉包的應用都離不開這兩點:

  1. 建立私有變量
  2. 延長變量的生命週期

關於延長變量的生命週期,本質實際上是延長詞法環境的生命週期,通常函數的詞法環境在函數返回後就被銷燬,可是閉包會保存對建立時所在詞法環境的引用,即使建立時所在的執行上下文被銷燬,但建立時所在詞法環境依然存在,以達到延長變量的生命週期的目的。

1. 模擬塊級做用域

經過閉包能夠模擬塊級做用域,很經典的例子就是for循環中使用定時器延遲打印的問題。

// ES6以前無塊級做用域,多個定時器內的回調函數引用同一個i
// for循環爲同步,定時器內函數爲異步,循環結束後i已經變爲4
// 定時期內函數觸發時訪問變量i都是4
// 理解的關鍵在於for循環內代碼是同步的,包括setTimtout自己
// 可是setTimeout定時器內的回調函數是異步的
for (var i = 1; i <= 3; i++) {
	setTimeout(function() {
		console.log(i)
	}, i * 1000)
}
複製代碼
// 使用當即執行函數,將i做爲參數傳入,可保存變量i的實時值
for(var i = 1; i <= 3; i++){
    (i => {
        setTimeout(() => {
            console.log(i)
        }, i * 1000)
    })(i)
}
// 如下代碼可達到相同效果
for(var i = 1; i <= 3; i++){
    (() => {
        var j = i
        setTimeout(() => {
            console.log(j)
        }, j * 1000)
    })()
}
// 如下代碼也可達到相同效果
for(var i = 1; i <= 3; i++){
    var closure = (function() {
        var j = i
        return () => {
            console.log(j)
        }
    })()
    setTimeout(closure, i * 1000)
}
複製代碼

閉包模擬塊級做用域瞭解便可,畢竟ES6以後咱們有了let來實現塊級做用域,實現塊級做用域的具體原理詳見深刻ECMAScript系列(二):執行上下文

2. 實現JS模塊模式

模塊模式是指將全部的數據和功能都封裝在一個函數內部(私有的),只向外暴露一個包含多個屬性方法的對象或函數。

var counter = (function() {
    var privateCounter = 0
    function changeBy(val) {
        privateCounter += val
    }
    return {
        increment: function() {
            changeBy(1)
        },
        decrement: function() {
            changeBy(-1)
        },
        value: function() {
            return privateCounter;
        }
    }
})()
複製代碼

另外例如underscore等一些js庫的實現也使用到了閉包。

(function(){
    var root = this;

    var _ = {};

    root._ = _;
    
    // 外部不可訪問的方法
    function tool() {
        // ...
    }
    
    // 外部可訪問的方法
    _.xxx = function() {
        tool()
        // ...
    }
})()
複製代碼

3. 函數的柯里化

柯里化的目的在於避免頻繁調用具備相同參數函數的同時,又可以輕鬆的重用。

// 假設咱們有一個求長方形面積的函數
function getArea(width, height) {
    return width * height
}
// 若是咱們碰到的長方形的寬總是10
const area1 = getArea(10, 20)
const area2 = getArea(10, 30)
const area3 = getArea(10, 40)

// 咱們可使用閉包柯里化這個計算面積的函數
function getArea(width) {
    return height => {
        return width * height
    }
}

const getTenWidthArea = getArea(10)
// 以後碰到寬度爲10的長方形就能夠這樣計算面積
const area1 = getTenWidthArea(20)

// 並且若是遇到寬度偶爾變化也能夠輕鬆複用
const getTwentyWidthArea = getArea(20)
複製代碼

其餘例如計數器、延遲調用、回調等閉包的應用這裏就不作過多講解,其核心思想仍是建立私有變量延長變量的生命週期

5、總結

  1. ECMAScript採用詞法做用域(也稱靜態做用域),函數的做用域取決於函數在哪裏定義,和函數在哪裏調用並沒有關係。
  2. 閉包是函數和聲明該函數的詞法環境的組合。
  3. 理論角度來講全部JavaScript函數都是閉包,由於函數會記錄其定義時所處執行上下文的詞法環境。
  4. 實踐角度來講,引用了定義時所處詞法環境的變量,而且可以在除了定義時所在上下文的其餘上下文被調用的函數,才叫閉包。
  5. 閉包的做用總結爲兩點,一是建立私有變量,二是延長變量的生命週期

6、小練習

function fun(n,o){
  console.log(o);
  return {
    fun: function(m){
      return fun(m,n);
    }
  };
}

var a = fun(0);                       // ?
a.fun(1);                             // ? 
a.fun(2);                             // ?
a.fun(3);                             // ?

var b = fun(0).fun(1).fun(2).fun(3);  // ?

var c = fun(0).fun(1);                // ?
c.fun(2);                             // ?
c.fun(3);                             // ?
複製代碼

運用咱們以前總結的知識來分析一下:

function fun(n,o){
  console.log(o);
  return {
    fun: function(m){
      return fun(m,n);
    }
  };
}

// 運行fun(0),未傳入第二個參數,故打印undefined,最後返回一個對象,內有一個fun方法
// (注意此方法與外部fun函數不一樣,下同)
var a = fun(0);                       // undefined
// 對象內fun方法爲閉包,記錄對fun(0)執行時的詞法環境,內部綁定一個參數n,值爲0
// 將返回對象賦值於a,執行a.fun(x)時,無論傳入的第一個參數是什麼
// 第二個參數n都將在以前fun(0)執行時的詞法環境內找到,值爲0
a.fun(1);                             // 0 
a.fun(2);                             // 0
a.fun(3);                             // 0

// 每次調用fun函數都會返回一個對象
// 對象內又一個fun方法,爲閉包,記錄建立該對象及對象方法時的詞法環境
// 故每次調用對象的fun方法,內部執行fun函數時的第二個參數總會在建立該對象時的詞法環境內找到
// 值即爲建立該對象的函數的第一個參數
// 因此除了第一次打印值爲undefined,其他皆爲上次調用fun時傳入的第一個參數
var b = fun(0).fun(1).fun(2).fun(3);  // undefined
                                      // 0
                                      // 1
                                      // 2

// 相似上面的分析,c爲一個對象,有一個fun方法,爲閉包
// 該閉包記錄了建立它時的詞法環境,上面有兩個綁定,{n: 1, o: 0}
// 因此c.fun(x)相似調用時,不論傳參是什麼,都將打印1
// 須要注意fun(0)調用時打印了undefined,fun(0).fun(1)調用時打印了0
var c = fun(0).fun(1);                // undefined
                                      // 0
c.fun(2);                             // 1
c.fun(3);                             // 1
複製代碼

OK,本篇文章就寫到這裏,相信你們對於閉包也有了必定本身的理解。關於深刻ECMAScript系列文章以後的主題你們也能夠在評論區留言討論。

系列文章

深刻ECMAScript系列目錄地址(持續更新中...)

歡迎前往閱讀系列文章,若是喜歡或者有所啓發,歡迎 star,對做者也是一種鼓勵。

菜鳥一枚,若是有疑問或者發現錯誤,能夠在相應的 issues 進行提問或勘誤,與你們共同進步。

相關文章
相關標籤/搜索