本文詳細討論了 JavaScript 中作用域、執行上下文、不同作用域下變量提升與函數提升的表現、頂層對象以及如何避免創建全局對象等內容。
變量作用域與提升
在 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 閉包特性的重要保證,一般來説,在編程語言裏我們常見的變量作用域就是詞法作用域與動態作用域(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 是單線程語言,同時刻僅有單任務在執行,而其他任務則會被壓入執行上下文隊列中;每次函數調用時都會創建出新的上下文,並將其添加到執行上下文隊列中。
執行上下文
每個執行上下文又會分為內存創建(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(); // 使用模塊
});