再談JS中的閉包

語言: CN / TW / HK

theme: hydrogen highlight: agate


先説觀點

1. 什麼是閉包?

現象説: 如果在一個函數內定義了另一個函數,在外層函數執行完畢後,再執行內部函數,這個內部函數也能訪問外層函數執行時的變量

函數説: 如果在一個函數內定義了另一個函數,且內部函數中訪問了來自外層函數的變量,就把這個內部函數 稱為閉包函數

2. 產生閉包的條件

從上面兩種定義中,我們至少可以找出產生閉包的三個關鍵要素:

  • 要素1:兩個函數有嵌套關係,即在 一個函數中,定義另一個函數
  • 要素2:內部函數是在外部函數執行結束後才執行。 表現形式可以是,內部函數做為返回值返回
  • 要素3:內部函數內訪問了外部函數中的變量。 可以是實參或內部聲明的變量

那我們來舉個栗子🌰

```js function createFunc(a, b) { let a2 = aa; let b2 = bb;

return function () {
    return a2+b2; // 訪問外層函數的局部變量
}

}

let fn = createFunc(3, 4); // 我們執行了外層函數 fn() // 輸出 25 這個例子,滿足了,我們剛才提到的三個要素 ,再看看這個!js function sumsqu(a, b) {

let fn = function() {
    return a*a + b*b;
}
return fn();

}

sumsqu(3, 4) // 25 ```

雖然這段代碼有兩個嵌套的函數 ,但內部函數 fn 定義 後就立即執行了,所以並不會形成閉包。

再深入一點

那有的小夥伴説,你説的這三個要素 我早就知道了!閉包瞭解這麼多應該夠了吧!曾經我也是這麼理解的,直到有一天,面試官問我閉包的原理😂 !

要了解閉包的原理,我們要引入一些概念, 作用域, 靜態作用域,執行上下文,有了這些概念,我們再分析代碼的執行流程,就更加清晰了

1. 作用域

一句描述作用域:作用域是標識符的查找範圍。 什麼是標識符:變量名和函數名。 在JS中支持三種作用域 分別是 全局作用域函數作用域塊級作用域

舉個栗子🌰: js let a = 100; // 1 function log() { // 2 { let b = 10; // 3 } let c = 12; // 4 console.log(a+b+c); // 5 } log(); 我在代碼中加上 數字的註釋,方便説明執行的流程:

  1. 這段代碼中我們 定義了a 和 函數 log 他們是位於全局作用域中的,可以把全局作用域 想象成一個大的盒子
  2. 最後一行我們調用了 log 函數 ,會產生一個 標識符查找 ,因為這個foo()位於代碼最外層, 所以他的查找的範圍是 全局作用域,可以很快的找到 函數 foo
  3. 下面執行流程進入 log 函數內部 ,在 3 的地方 我們定義了一個 b = 10 ,它位於一對大括號中,這樣會形成一個新的作用域(塊級作用域),就是就是一個新的小盒子,這個盒子中,只有一個 b , 這個 b 對盒子外的代碼 是不可見的
  4. 在 4 的地方, 我們定義了一個變量 c ,很明顯,他定義在 函數log 作用域內, c的可訪問範圍就是這個函數
  5. 在 5 的地方,稍微有一點複雜,首先這是一個函數調用 ,js 引擎 要先查找 console 標識符,在 函數 foo 中並不能找到,於 js 引擎會向上查找,進入 全局作用域, 在全局作用域,console是內置對象,接着查找 log 屬性, a 的 查找過程 和 console 類似
  6. 注意 b 是無法找到的,這段代碼最終是會報錯,因為標識符查找,只會向上級查找

image.png

2. 靜態作用域與作用域鏈

通過上面的分析中,我們不難發現:我們通過分析代碼中的函數和變量定義的位置,就可以清晰的知道代碼中的作用域。 也就是説,不論我們何時 調用函數 log,函數中變量的訪問規則就已經確定 函數的作用域只和函數的代碼結構有關係 。而且作用域是相互嵌套的。

標識符的查找正是基於這些嵌套的作用域進行由內向外進行的,最後到達全局作用域!

關於作用域的嵌套請看下圖:

image.png

來個小結:

  1. 我們把這種基於代碼結構分析 得到作用域的機制,叫 靜態作用域也叫詞法作用域, 它是由JS引擎,按ECMAScript-262 標準來實現的
  2. 我們把作用域嵌套和標識符由內向外的查找規則 ,叫 作用域鏈, 也就是標識符的每次查找都會由內向外在作用域鏈上查找

3. 靜態作用域與閉包的關係

那你講了半天的作用域和我閉包有什麼關係🙄!

在我看來:閉包,是用來實現靜態作用域的一種手段! JavaScript 的標準TC39委員會那波人制定的,而具體實現是寫 JavaScript 引擎的那波人! 我彷彿聽到他們的對話,“標準我們已經制定出來了,怎麼實現就看你們的了!”

我們回到文章開頭,閉包的例子!

```js function createFunc(a, b) { let a2 = aa; let b2 = bb;

function getSum() {
    return a2+b2; // 訪問外層函數的局部變量
}

console.dir(getSum) // 打印 函數  getSum

return getSum;

}

let fn = createFunc(3, 4); // 我們執行了外層函數 fn() // 輸出 25 `` 基於我們剛提到的靜態作用域的知識 ,簡單分析一下 1. 這塊代碼中有兩個作用域createFunc函數 作用域,和getSum函數作用域,兩個作用域相互嵌套 2. getSum 函數中訪問了 外層作用域中的a2b2兩個變量 3. 依據 作用域鏈的規則 ,在函數getSum 中 就應該可以訪問a2b24. 當createFunc執行結束後,getSum並沒有執行 5. 一般來講,函數中的變量,會在函數執行完成後,釋放掉!但有一種情況例外 6. 為了實現第三點,我們必需找一個地方把a2b2存在起來,在函數getSum` 調用時使用,只有這樣才符合 JavaScript 靜態作用域的規則

那到底存在哪兒呢?其實在函數 getSum 存在一個內部屬性 [[Scopes]],在Chrome 中運行運行上面這段代碼 瞅瞅:

image.png

可以看到圖片的 Scopes 是一個類似數組的東西, 第一項是 Closure(createFunc) 裏邊兩個值 a2=9 b2=16, 看這名字難道是由 createFunc() 函數創建的閉包,掛在了 getSum 函數上。

對沒錯!是就是這樣

image.png

感覺越來越接近原理了呢!

那我們給閉包重新 下一個定義 吧:

閉包是實際上是 掛在函數上的一組沒有釋放的變量(或內存區域),在這個函數執行時使用!

還有幾點要説: 1. 閉包的是在對 函數進行 詞法分析 時創建的,為了節約內存,閉包中只保留必要的值,也就是在這個函數執行時要用到的變量!

閉包的運用

1. 防抖和節流

貼一代碼 ,大家感受一下 ```js // 防抖 function debounce(fn, dealy = 300) { let timer; return function () { const args = arguments; if (timer) { clearTimeout(timer); } timer = setTimeout(() => { fn.apply(this, args); }, delay); }; }

// 節流 function throttle(fn, delay) { let flag = true; return function () { let args = arguments; if (!flag) return; flag = false; timer = setTimeout(() => { fn.apply(this, args); }, delay); }; }

```

2. 生成連續ID

js let uniqueId = function() { let start = 0; return function (prefix = 'id_') { let id = ++start; if(prefix === 'id_') { return `${id}`; } return `${prefix}${id}` } }();

閉包的使用場景 還有很多,要注意的是如一個閉包函數 ,就用不了就及時釋放掉,以免過多消耗內存, 將閉包函數 賦值為 null 或可以釋放!

總結

  1. 閉包的本質是一組沒有釋放的變量(或內存區域),並且在函數被執行時,加到入執行上下文中!
  2. 閉包產生原因:因為 JavaScript 遵循靜態作用域規則, 為了保證函數在執行時可以訪問外層作用域的變量,而形成的一種實現機制

參考

  1. 《你不知道的 JavaScript 上》
  2. Closures - JavaScript | MDN
  3. 面試官:説説作用域和閉包吧
  4. JavaScript 的靜態作用域鏈與“動態”閉包鏈

最後

感謝你讀到這裏, 希望你讀完這篇文章對閉包有更進一步的瞭解 !

如果覺得有所收穫 ,點個贊,再走吧~~

「其他文章」