與作用域相關的閉包、記憶體洩漏、this

夜曉宸發表於2019-01-13

作用域是 JavaScript 裡的一個非常重要和基礎的概念. 很多人認為自己理解了作用域, 但是在遇到閉包時卻說不出個所以然, 甚至不能識別出來.

閉包也是個非常重要, 且經常被誤解的概念. 然而閉包就是基於作用域書寫程式碼時所產生的自然結果. 倘若拋開作用域講閉包, 那都是耍流氓. 閉包可以說在平時的程式碼裡隨處可見, 但真正讓閉包發揮積極作用的做法是隔離作用域、模組函式等.

作用域機制是不能直接檢視的, 我們首先模擬一個場景來儘可能的說明作用域這套規則, 然後通過程式碼片段和開發者工具進行驗證.

遊戲存檔

想必大家都有玩過遊戲的經驗. 剛開始的時候, 也就是第一關, 難度比較簡單. 到了第二關的時候, 就在第一關的基礎上加些難纏的角色, 難度相應地加大了. 關卡越是往後, 難纏的角色也就會越來越多.

可在遊戲的時候, 由於各種原因, 往往我們不可能一下子通過所有的關卡, 所以遊戲提供了存檔的功能. 下次再玩的時候可以從存檔裡續上. 如果不想這樣, 完全可以從頭玩起.

為什麼我們能從存檔裡直接跳到上次的關卡, 很顯然, 這裡是有記錄儲存的. 比如第一關有個場景食人花和海王, 第二關又多了個邪惡人等等. 每個關卡都會記錄該關卡新增的角色或場景同時也會儲存之前關卡的記錄. 這樣就保證了不同的存檔的獨立性, 無論在哪個關卡存檔, 下次也定會續上之前的地方. 當然了, 我們也可以回到上一個關卡.

Aquaman
(海王之雄風&敵人之邪惡)

幾個知識點

結合上面的場景, 我們再回頭看看以下幾個知識點.

  1. 識別符號: 變數、函式、屬性的名字, 或者函式的引數.

  2. 每個函式都有自己的執行環境. 當執行流進入一個函式時, 函式的環境就會被推入一個環境棧中. 而在函式執行後, 棧將其環境彈出, 把控制權返回之前的執行環境.

  3. 執行環境定義了變數或函式有權訪問的其它資料. 每個執行環境都有一個與之關聯的變數物件, 環境中定義的所有變數和函式都儲存在這個物件中. 某個執行環境中的所有程式碼執行完畢後, 該環境被銷燬, 儲存在其中的所有變數和函式定義也隨之銷燬.

  4. 當程式碼在一個環境中執行時, 會建立變數物件的一個作用域鏈.

  5. 作用域鏈是保證對執行環境有權訪問的所有變數和函式的有序訪問. 作用域的前端始終都是當前執行的程式碼所在的變數物件. 如果這個環境是函式, 則將其活動物件作為變數物件. 活動物件在最開始只包含一個變數, 即 arguments 物件. 作用域鏈中的下一個變數物件來自包含(外部)環境. 全域性執行環境的變數物件始終都是作用域鏈的最後一個物件.

  6. 當某個環境中為了讀取或寫入而引入一個識別符號時, 必須通過搜尋來確定該識別符號來確定該識別符號實際代表什麼. 搜尋過程從作用域鏈的前端開始, 向上逐級查詢與給定名字匹配的識別符號. 如果在區域性環境中找到了該識別符號, 搜尋過程停止, 變數就緒. 如果在區域性環境中沒有找到該變數名, 則繼續沿作用域鏈向上搜尋. 搜尋過程將一直追溯到全域性環境的變數物件. 如果在全域性環境中也沒有找到這個識別符號, 則意味著該變數尚未宣告.

  7. 作用域鏈本質上時一個指向變數物件的指標列表, 它只引用但實際不包含變數物件.

如果我們把以上的幾個知識點串起來, 這就是所謂的作用域鏈規則了. 上圖解釋一波.( arguments 應該加到變數物件裡的, 圖中沒體現, 疏忽)

圖解作用域

Scope Chain

現在我們從最後兩行說起,

var outer = outerFn(10);
var inner = outer(10);
複製程式碼

執行 outer = outerFn(10) 後, outer 擁有了返回函式的引用. outer(10) 在執行的時候它會建立 屬於它自己 的作用域鏈, 這裡包含函式所處外部環境的變數物件.

在讀取 initial 變數時, 在 Inner 變數物件中沒有檢索到, 它會沿著作用域鏈向上搜尋, 在 outer 變數物件裡找到了該識別符號, 搜尋過程停止, 變數就緒.

函式在定義的時候就已經決定了之後執行時, 作用域裡將包含什麼. 這也解釋了, 即使我們把定義在函式內部的函式扔在外邊執行也能訪問到函式內部的變數. 這和內部函式在哪執行沒有半毛錢關係.

為什麼強調 屬於它自己 的呢?

function outer() {
    var num = 0;
    return function inner() {
        return num++;
    }
}
let innerFn_1 = outer();
let a_1 = innerFn_1()
let innerFn_2 = outer();
let a_2 = innerFn_2();

let a_1_1 = innerFn_1();
let a_2_2 = innerFn_2();
複製程式碼

innerFn_1 和 innerFn_2 都屬於自己的作用域鏈, 而 a_1 和 a_2 則分別在 innerFn_1 和 innerFn_2 上建立了屬於自己的作用域鏈. 所以它們函式裡的 num 是屬於不同作用域鏈裡的變數. 但對於 a_1 和 a_1_1 來說它們都是基於 innerFn_1, 擁有同一 outer 變數物件, num 自然也是同一個, 所以會累加. 同理 a_2 和 a_2_2.

如果理解了這個, 那麼面試常考的一題就小菜一碟了.

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

重點是執行的時候才會建立變數物件的一個作用域鏈.

閉包是什麼?

當函式可以記住並訪問所在的作用域, 即使函式是在當前作用域之外執行, 這時就產生了閉包. 這就和之前提到的遊戲存檔差不多.

好了, 扔幾個閉包出來鞏固一下.

function outer_1() {
    var a = 'hello world';
    function inner() {
        console.log(a)
    }
    outer_2(inner)
}
function outer_2(fn) {
    fn()
}
複製程式碼

這裡也有閉包.

var a = new array(99999999);
function b() {
    console.log(b)
}
b()
window.addEventListener('click', function() {
    console.log('hello world')
})
複製程式碼

DevTools裡直觀看閉包

還有開頭所說的可以結合開發者工具直觀地看一下, 一張動態圖解釋一切.

devToolsWithScope

記憶體洩漏

閉包之所以能成為閉包, 是因為它記錄了函式所在的作用域. 現主流的自動垃圾收集機制正因為閉包的這個特點而不能釋放記憶體. 閉包的濫用會導致導致記憶體能分配的空間變少, 最終崩潰.

正常來說, 函式在執行的過程中, 區域性變數會被分配相應的記憶體空間, 以便儲存它們的值, 直至函式執行結束. 此時區域性變數佔有的空間會被釋放以供將來使用.

常說的回收機制之一, 標記清除, 它的工作原理是, 當變數進入執行環境時, 儲存在記憶體中的所有變數都會被加上標記(至於什麼標記我們不關心), 然後找到 環境中的變數 以及 被環境中引用的變數, 把它們之前加的標記給去掉. 而剩下的被標記的變數將被視為 準備 刪除的變數. 最後, 垃圾收集器找出不再繼續使用的變數, 釋放其佔用的記憶體. 所以, 一旦資料不再被需要, 應解除引用, 將其值設定為null.

outer = null;
inner = null;
複製程式碼

內部函式的執行環境會儲存著外部環境活動物件的引用, 內部函式被扔出去後, 就意味著外部環境中的變數不能被銷燬了.

this物件

執行環境裡記錄的不只是這些, 它也記錄了函式呼叫棧、函式呼叫方式等. this 和作用域有關係, 但不是你們想象的那種關係. 每個函式在被呼叫時都會自動取得兩個特殊變數: this 和 arguments. 內部函式在搜尋這兩個變數時, 只會搜尋到其活動物件為止(即當前變數物件). 因此永遠不可能直接訪問到外部函式中的這兩個變數. 除非我們把外部作用域中的 this 物件儲存在一個閉包能夠訪問到的變數裡.

// 很常見是不是?
let obj = {
    a: function() {
        var self = this;
        return function() {
            console.log(self)
        }
    }
}
複製程式碼

函式內部的 this 在函式執行時才正式被賦予相應的值, 所以說函式的呼叫位置很關鍵. 可以這麼說, 誰 直接 呼叫了這個函式, this 就指向了誰. 如果不是物件在直接呼叫這個函式, 我們可統統認為是 undefined, 非嚴格模式下瀏覽器環境就是 window. 如果真想知道為什麼, 可以直接看規範(神煩).

'use strict'
function a() {
    console.log(this)
}
var b = {
    a: function() {
        console.log(this);
    },
    b: function() {
        return a;
    }
}
let b_a = b.a;
a();    //1. undefined;
b_a();  //2. undefined;
b.a();  //3. {a: f, b: f};
b.b()();    //4. undefined;
(true && b.a)() //5. undefined;
new a();    //6. {}
b.call(b);  //7. {a: f, b: f};
複製程式碼

從 1 ~ 6, 我們看看哪個物件直接呼叫了該函式.

第 1 個沒找到呼叫物件, 就是個普通函式呼叫. 第 2 個經過 b_a = b.a 賦值操作後, 返回的就是那個普通函式, 就是一普通的函式呼叫. 第 3 個很直接, 就是 b 這個物件了. 第 4 個是個閉包, 首先 this 只在當前活動物件裡找 this 物件, 不知道是哪個物件, 但肯定不會是 b. 第 5 個和第 2 個是一個道理. 第 6 個吧, 貌似不算是函式呼叫了吧, 不過我們知道, this 是指向新建立的空物件. 第 7個就更直接了, 人家都指名道姓就差喊出來了.

this 繫結物件的幾條準則貌似在我這裡就剩一條了?.

相關文章