ES6 變數作用域與提升:變數的生命週期詳解

王下邀月熊發表於2017-08-13

ES6 變數作用域與提升:變數的生命週期詳解從屬於筆者的現代 JavaScript 開發:語法基礎與實踐技巧系列文章。本文詳細討論了 JavaScript 中作用域、執行上下文、不同作用域下變數提升與函式提升的表現、頂層物件以及如何避免建立全域性物件等內容;建議閱讀前文 ES6 變數宣告與賦值

變數作用域與提升

在 ES6 之前,JavaScript 中只存在著函式作用域;而在 ES6 中,JavaScript 引入了 let、const 等變數宣告關鍵字與塊級作用域,在不同作用域下變數與函式的提升表現也是不一致的。在 JavaScript 中,所有繫結的宣告會在控制流到達它們出現的作用域時被初始化;這裡的作用域其實就是所謂的執行上下文(Execution Context),每個執行上下文分為記憶體分配(Memory Creation Phase)與執行(Execution)這兩個階段。在執行上下文的記憶體分配階段會進行變數建立,即開始進入了變數的生命週期;變數的生命週期包含了宣告(Declaration phase)、初始化(Initialization phase)與賦值(Assignment phase)過程這三個過程。

傳統的 var 關鍵字宣告的變數允許在宣告之前使用,此時該變數被賦值為 undefined;而函式作用域中宣告的函式同樣可以在宣告前使用,其函式體也被提升到了頭部。這種特性表現也就是所謂的提升(Hoisting);雖然在 ES6 中以 let 與 const 關鍵字宣告的變數同樣會在作用域頭部被初始化,不過這些變數僅允許在實際宣告之後使用。在作用域頭部與變數實際宣告處之間的區域就稱為所謂的暫時死域(Temporal Dead Zone),TDZ 能夠避免傳統的提升引發的潛在問題。另一方面,由於 ES6 引入了塊級作用域,在塊級作用域中宣告的函式會被提升到該作用域頭部,即允許在實際宣告前使用;而在部分實現中該函式同時被提升到了所處函式作用域的頭部,不過此時被賦值為 undefined。

作用域

作用域(Scope)即程式碼執行過程中的變數、函式或者物件的可訪問區域,作用域決定了變數或者其他資源的可見性;電腦保安中一條基本原則即是使用者只應該訪問他們需要的資源,而作用域就是在程式設計中遵循該原則來保證程式碼的安全性。除此之外,作用域還能夠幫助我們提升程式碼效能、追蹤錯誤並且修復它們。JavaScript 中的作用域主要分為全域性作用域(Global Scope)與區域性作用域(Local Scope)兩大類,在 ES5 中定義在函式內的變數即是屬於某個區域性作用域,而定義在函式外的變數即是屬於全域性作用域。

全域性作用域

當我們在瀏覽器控制檯或者 Node.js 互動終端中開始編寫 JavaScript 時,即進入了所謂的全域性作用域:

// the scope is by default global
var name = 'Hammad';複製程式碼

定義在全域性作用域中的變數能夠被任意的其他作用域中訪問:

var name = 'Hammad';

console.log(name); // logs 'Hammad'

function logName() {
    console.log(name); // 'name' is accessible here and everywhere else
}

logName(); // logs 'Hammad'複製程式碼

函式作用域

定義在某個函式內的變數即從屬於當前函式作用域,在每次函式呼叫中都會建立出新的上下文;換言之,我們可以在不同的函式中定義同名變數,這些變數會被繫結到各自的函式作用域中:

// Global Scope
function someFunction() {
    // Local Scope #1
    function someOtherFunction() {
        // Local Scope #2
    }
}

// Global Scope
function anotherFunction() {
    // Local Scope #3
}
// Global Scope複製程式碼

函式作用域的缺陷在於粒度過大,在使用閉包或者其他特性時導致異常的變數傳遞:

var callbacks = [];

// 這裡的 i 被提升到了當前函式作用域頭部
for (var i = 0; i <= 2; i++) {
    callbacks[i] = function () {
            return i * 2;
        };
}

console.log(callbacks[0]()); //6
console.log(callbacks[1]()); //6
console.log(callbacks[2]()); //6複製程式碼

塊級作用域

類似於 if、switch 條件選擇或者 for、while 這樣的迴圈體即是所謂的塊級作用域;在 ES5 中,要實現塊級作用域,即需要在原來的函式作用域上包裹一層,即在需要限制變數提升的地方手動設定一個變數來替代原來的全域性變數,譬如:

var callbacks = [];
for (var i = 0; i <= 2; i++) {
    (function (i) {
        // 這裡的 i 僅歸屬於該函式作用域
        callbacks[i] = function () {
            return i * 2;
        };
    })(i);
}
callbacks[0]() === 0;
callbacks[1]() === 2;
callbacks[2]() === 4;複製程式碼

而在 ES6 中,可以直接利用 let 關鍵字達成這一點:

let callbacks = []
for (let i = 0; i <= 2; i++) {
    // 這裡的 i 屬於當前塊作用域
    callbacks[i] = function () {
        return i * 2
    }
}
callbacks[0]() === 0
callbacks[1]() === 2
callbacks[2]() === 4複製程式碼

詞法作用域

詞法作用域是 JavaScript 閉包特性的重要保證,筆者在基於 JSX 的動態資料繫結一文中也介紹瞭如何利用詞法作用域的特性來實現動態資料繫結。一般來說,在程式語言裡我們常見的變數作用域就是詞法作用域與動態作用域(Dynamic Scope),絕大部分的程式語言都是使用的詞法作用域。詞法作用域注重的是所謂的 Write-Time,即程式設計時的上下文,而動態作用域以及常見的 this 的用法,都是 Run-Time,即執行時上下文。詞法作用域關注的是函式在何處被定義,而動態作用域關注的是函式在何處被呼叫。JavaScript 是典型的詞法作用域的語言,即一個符號參照到語境中符號名字出現的地方,區域性變數預設有著詞法作用域。此二者的對比可以參考如下這個例子:

function foo() {
    console.log( a ); // 2 in Lexical Scope ,But 3 in Dynamic Scope
}

function bar() {
    var a = 3;
    foo();
}

var a = 2;

bar();複製程式碼

執行上下文與提升

作用域(Scope)與上下文(Context)常常被用來描述相同的概念,不過上下文更多的關注於程式碼中 this 的使用,而作用域則與變數的可見性相關;而 JavaScript 規範中的執行上下文(Execution Context)其實描述的是變數的作用域。眾所周知,JavaScript 是單執行緒語言,同時刻僅有單任務在執行,而其他任務則會被壓入執行上下文佇列中(更多知識可以閱讀 Event Loop 機制詳解與實踐應用);每次函式呼叫時都會建立出新的上下文,並將其新增到執行上下文佇列中。

執行上下文

每個執行上下文又會分為記憶體建立(Creation Phase)與程式碼執行(Code Execution Phase)兩個步驟,在建立步驟中會進行變數物件的建立(Variable Object)、作用域鏈的建立以及設定當前上下文中的 this 物件。所謂的 Variable Object ,又稱為 Activation Object,包含了當前執行上下文中的所有變數、函式以及具體分支中的定義。當某個函式被執行時,直譯器會先掃描所有的函式引數、變數以及其他宣告:

'variableObject': {
    // contains function arguments, inner variable and function declarations
}複製程式碼

在 Variable Object 建立之後,直譯器會繼續建立作用域鏈(Scope Chain);作用域鏈往往指向其副作用域,往往被用於解析變數。當需要解析某個具體的變數時,JavaScript 直譯器會在作用域鏈上遞迴查詢,直到找到合適的變數或者任何其他需要的資源。作用域鏈可以被認為是包含了其自身 Variable Object 引用以及所有的父 Variable Object 引用的物件:

'scopeChain': {
    // contains its own variable object and other variable objects of the parent execution contexts
}複製程式碼

而執行上下文則可以表述為如下抽象物件:

executionContextObject = {
    'scopeChain': {}, // contains its own variableObject and other variableObject of the parent execution contexts
    'variableObject': {}, // contains function arguments, inner variable and function declarations
    'this': valueOfThis
}複製程式碼

變數的生命週期與提升

變數的生命週期包含著變數宣告(Declaration Phase)、變數初始化(Initialization Phase)以及變數賦值(Assignment Phase)三個步驟;其中宣告步驟會在作用域中註冊變數,初始化步驟負責為變數分配記憶體並且建立作用域繫結,此時變數會被初始化為 undefined,最後的分配步驟則會將開發者指定的值分配給該變數。傳統的使用 var 關鍵字宣告的變數的生命週期如下:

而 let 關鍵字宣告的變數生命週期如下:

如上文所說,我們可以在某個變數或者函式定義之前訪問這些變數,這即是所謂的變數提升(Hoisting)。傳統的 var 關鍵字宣告的變數會被提升到作用域頭部,並被賦值為 undefined:

// var hoisting
num;     // => undefined  
var num;  
num = 10;  
num;     // => 10  
// function hoisting
getPi;   // => function getPi() {...}  
getPi(); // => 3.14  
function getPi() {  
  return 3.14;
}複製程式碼

變數提升只對 var 命令宣告的變數有效,如果一個變數不是用 var 命令宣告的,就不會發生變數提升。

console.log(b);
b = 1;複製程式碼

上面的語句將會報錯,提示 ReferenceError: b is not defined,即變數 b 未宣告,這是因為 b 不是用 var 命令宣告的,JavaScript 引擎不會將其提升,而只是視為對頂層物件的 b 屬性的賦值。ES6 引入了塊級作用域,塊級作用域中使用 let 宣告的變數同樣會被提升,只不過不允許在實際宣告語句前使用:

> let x = x;
ReferenceError: x is not defined
    at repl:1:9
    at ContextifyScript.Script.runInThisContext (vm.js:44:33)
    at REPLServer.defaultEval (repl.js:239:29)
    at bound (domain.js:301:14)
    at REPLServer.runBound [as eval] (domain.js:314:12)
    at REPLServer.onLine (repl.js:433:10)
    at emitOne (events.js:120:20)
    at REPLServer.emit (events.js:210:7)
    at REPLServer.Interface._onLine (readline.js:278:10)
    at REPLServer.Interface._line (readline.js:625:8)
> let x = 1;
SyntaxError: Identifier 'x' has already been declared複製程式碼

函式的生命週期與提升

基礎的函式提升同樣會將宣告提升至作用域頭部,不過不同於變數提升,函式同樣會將其函式體定義提升至頭部;譬如:

function b() {  
   a = 10;  
   return;  
   function a() {} 
}複製程式碼

會被編譯器修改為如下模式:

function b() {
  function a() {}
  a = 10;
  return;
}複製程式碼

在記憶體建立步驟中,JavaScript 直譯器會通過 function 關鍵字識別出函式宣告並且將其提升至頭部;函式的生命週期則比較簡單,宣告、初始化與賦值三個步驟都被提升到了作用域頭部:

如果我們在作用域中重複地宣告同名函式,則會由後者覆蓋前者:

sayHello();

function sayHello () {
    function hello () {
        console.log('Hello!');
    }

    hello();

    function hello () {
        console.log('Hey!');
    }
}

// Hey!複製程式碼

而 JavaScript 中提供了兩種函式的建立方式,函式宣告(Function Declaration)與函式表示式(Function Expression);函式宣告即是以 function 關鍵字開始,跟隨者函式名與函式體。而函式表示式則是先宣告函式名,然後賦值匿名函式給它;典型的函式表示式如下所示:

var sayHello = function() {
  console.log('Hello!');
};

sayHello();

// Hello!複製程式碼

函式表示式遵循變數提升的規則,函式體並不會被提升至作用域頭部:

sayHello();

function sayHello () {
    function hello () {
        console.log('Hello!');
    }

    hello();

    var hello = function () {
        console.log('Hey!');
    }
}

// Hello!複製程式碼

在 ES5 中,是不允許在塊級作用域中建立函式的;而 ES6 中允許在塊級作用域中建立函式,塊級作用域中建立的函式同樣會被提升至當前塊級作用域頭部與函式作用域頭部。不同的是函式體並不會再被提升至函式作用域頭部,而僅會被提升到塊級作用域頭部:

f; // Uncaught ReferenceError: f is not defined
(function () {
  f; // undefined
  x; // Uncaught ReferenceError: x is not defined
  if (true) {
    f();
    let x;
    function f() { console.log('I am function!'); }
  }

}());複製程式碼

避免全域性變數

在計算機程式設計中,全域性變數指的是在所有作用域中都能訪問的變數。全域性變數是一種不好的實踐,因為它會導致一些問題,比如一個已經存在的方法和全域性變數的覆蓋,當我們不知道變數在哪裡被定義的時候,程式碼就變得很難理解和維護了。在 ES6 中可以利用 let 關鍵字來宣告本地變數,好的 JavaScript 程式碼就是沒有定義全域性變數的。在 JavaScript 中,我們有時候會無意間建立出全域性變數,即如果我們在使用某個變數之前忘了進行宣告操作,那麼該變數會被自動認為是全域性變數,譬如:

function sayHello(){
  hello = "Hello World";
  return hello;
}
sayHello();
console.log(hello);複製程式碼

在上述程式碼中因為我們在使用 sayHello 函式的時候並沒有宣告 hello 變數,因此其會建立作為某個全域性變數。如果我們想要避免這種偶然建立全域性變數的錯誤,可以通過強制使用 strict mode 來禁止建立全域性變數。

函式包裹

為了避免全域性變數,第一件事情就是要確保所有的程式碼都被包在函式中。最簡單的辦法就是把所有的程式碼都直接放到一個函式中去:

(function(win) {
    "use strict"; // 進一步避免建立全域性變數
    var doc = window.document;
    // 在這裡宣告你的變數
    // 一些其他的程式碼
}(window));複製程式碼

宣告名稱空間

var MyApp = {
    namespace: function(ns) {
        var parts = ns.split("."),
            object = this, i, len;
        for(i = 0, len = parts.lenght; i < len; i ++) {
            if(!object[parts[i]]) {
                object[parts[i]] = {};
            }
            object = object[parts[i]];
        }
    return object;
    }
};

// 定義名稱空間
MyApp.namespace("Helpers.Parsing");

// 你現在可以使用該名稱空間了
MyApp.Helpers.Parsing.DateParser = function() {
    //做一些事情
};複製程式碼

模組化

另一項開發者用來避免全域性變數的技術就是封裝到模組 Module 中。一個模組就是不需要建立新的全域性變數或者名稱空間的通用的功能。不要將所有的程式碼都放一個負責執行任務或者釋出介面的函式中。這裡以非同步模組定義 Asynchronous Module Definition (AMD) 為例,更詳細的 JavaScript 模組化相關知識參考 JavaScript 模組演化簡史

//定義
define( "parsing", //模組名字
        [ "dependency1", "dependency2" ], // 模組依賴
        function( dependency1, dependency2) { //工廠方法

            // Instead of creating a namespace AMD modules
            // are expected to return their public interface
            var Parsing = {};
            Parsing.DateParser = function() {
              //do something
            };
            return Parsing;
        }
);

// 通過 Require.js 載入模組
require(["parsing"], function(Parsing) {
    Parsing.DateParser(); // 使用模組
});複製程式碼

相關文章