1. 程式人生 > >從遊戲的角度看作用域

從遊戲的角度看作用域

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

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

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

遊戲存檔

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

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

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

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

幾個知識點

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

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

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

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

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

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

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

如果我們把以上的幾個知識點串起來, 這就是所謂的作用域鏈規則了. 上圖解釋一波.( 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