理解 JavaScript 作用域
簡介
JavaScript 有個特性稱為作用域。儘管對於很多開發新手來說,作用域的概念不容易理解,我會盡可能地從最簡單的角度向你解釋它們。理解作用域能讓你編寫更優雅、錯誤更少的程式碼,並能幫助你實現強大的設計模式。
什麼是作用域?
作用域是你的程式碼在執行時,各個變數、函式和物件的可訪問性。換句話說,作用域決定了你的程式碼裡的變數和其他資源在各個區域中的可見性。
為什麼需要作用域?最小訪問原則
那麼,限制變數的可見性,不允許你程式碼中所有的東西在任意地方都可用的好處是什麼?其中一個優勢,是作用域為你的程式碼提供了一個安全層級。電腦保安中,有個常規的原則是:使用者只能訪問他們當前需要的東西。
想想計算機管理員吧。他們在公司各個系統上擁有很多控制權,看起來甚至可以給予他們擁有全部許可權的賬號。假設你有一家公司,擁有三個管理員,他們都有系統的全部訪問許可權,並且一切運轉正常。但是突然發生了一點意外,你的一個系統遭到惡意病毒攻擊。現在你不知道這誰出的問題了吧?你這才意識到你應該只給他們基本使用者的賬號,並且只在需要時賦予他們完全的訪問權。這能幫助你跟蹤變化並記錄每個人的操作。這叫做最小訪問原則。眼熟嗎?這個原則也應用於程式語言設計,在大多數程式語言(包括 JavaScript)中稱為作用域,接下來我們就要學習它。
在你的程式設計旅途中,你會意識到作用域在你的程式碼中可以提升效能,跟蹤 bug 並減少 bug。作用域還解決不同範圍的同名變數命名問題。記住不要弄混作用域和上下文。它們是不同的特性。
JavaScript中的作用域
在 JavaScript 中有兩種作用域
-
全域性作用域
-
區域性作用域
當變數定義在一個函式中時,變數就在區域性作用域中,而定義在函式之外的變數則從屬於全域性作用域。每個函式在呼叫的時候會建立一個新的作用域。
全域性作用域
當你在文件中(document)編寫 JavaScript 時,你就已經在全域性作用域中了。JavaScript 文件中(document)只有一個全域性作用域。定義在函式之外的變數會被儲存在全域性作用域中。
// 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
塊語句
塊級宣告包括if和switch,以及for和while迴圈,和函式不同,它們不會建立新的作用域。在塊級宣告中定義的變數從屬於該塊所在的作用域。
if (true) {
// this 'if' conditional block doesn't create a new scope
var name = 'Hammad'; // name is still in the global scope
}
console.log(name); // logs 'Hammad'
ECMAScript 6 引入了let和const關鍵字。這些關鍵字可以代替var。
var name = 'Hammad';
let likes = 'Coding';
const skills = 'Javascript and PHP';
和var關鍵字不同,let和const關鍵字支援在塊級宣告中建立使用區域性作用域。
if (true) {
// this 'if' conditional block doesn't create a scope
// name is in the global scope because of the 'var' keyword
var name = 'Hammad';
// likes is in the local scope because of the 'let' keyword
let likes = 'Coding';
// skills is in the local scope because of the 'const' keyword
const skills = 'JavaScript and PHP';
}
console.log(name); // logs 'Hammad'
console.log(likes); // Uncaught ReferenceError: likes is not defined
console.log(skills); // Uncaught ReferenceError: skills is not defined
一個應用中全域性作用域的生存週期與該應用相同。區域性作用域只在該函式呼叫執行期間存在。
上下文
很多開發者經常弄混作用域和上下文,似乎兩者是一個概念。但並非如此。作用域是我們上面講到的那些,而上下文通常涉及到你程式碼某些特殊部分中的this值。作用域指的是變數的可見性,而上下文指的是在相同的作用域中的this的值。我們當然也可以使用函式方法改變上下文,這個之後我們再討論。在全域性作用域中,上下文總是 Window 物件。
// logs: Window {speechSynthesis: SpeechSynthesis, caches: CacheStorage, localStorage: Storage…}
console.log(this);
function logFunction() {
console.log(this);
}
// logs: Window {speechSynthesis: SpeechSynthesis, caches: CacheStorage, localStorage: Storage…}
// because logFunction() is not a property of an object
logFunction();
如果作用域定義在一個物件的方法中,上下文就是這個方法所在的那個物件。
class User {
logName() {
console.log(this);
}
}
(new User).logName(); // logs User {}
(new User).logName()是建立物件關聯到變數並呼叫logName方法的一種簡便形式。通過這種方式你並不需要建立一個新的變數。
你可能注意到一點,就是如果你使用new關鍵字呼叫函式時上下文的值會有差異。上下文會設定為被呼叫的函式的例項。考慮一下上面的這個例子,用new關鍵字呼叫的函式。
function logFunction() {
console.log(this);
}
new logFunction(); // logs logFunction {}
當在嚴格模式(strict mode)中呼叫函式時,上下文預設是 undefined。
執行環境
為了解決掉我們從上面學習中會出現的各種困惑,“執行環境(context)”這個詞中的“環境(context)”指的是作用域而並非上下文。這是一個怪異的命名約定,但由於 JavaScript 的文件如此,我們只好也這樣約定。
JavaScript 是一種單執行緒語言,所以它同一時間只能執行單個任務。其他任務排列在執行環境中。當 JavaScript 解析器開始執行你的程式碼,環境(作用域)預設設為全域性。全域性環境新增到你的執行環境中,事實上這是執行環境裡的第一個環境。
之後,每個函式呼叫都會新增它的環境到執行環境中。無論是函式內部還是其他地方呼叫函式,都會是相同的過程。
每個函式都會建立它自己的執行環境。
當瀏覽器執行完環境中的程式碼,這個環境會從執行環境中彈出,執行環境中當前環境的狀態會轉移到父級環境。瀏覽器總是先執行在執行棧頂的執行環境(事實上就是你程式碼最裡層的作用域)。
全域性環境只能有一個,函式環境可以有任意多個。
執行環境有兩個階段:建立和執行。
建立階段
第一階段是建立階段,是函式剛被呼叫但程式碼並未執行的時候。建立階段主要發生了 3 件事。
-
建立變數物件
-
建立作用域鏈
-
設定上下文(this)的值
變數物件
變數物件(Variable Object)也稱為活動物件(activation object),包含所有變數、函式和其他在執行環境中定義的宣告。當函式呼叫時,解析器掃描所有資源,包括函式引數、變數和其他宣告。當所有東西裝填進一個物件,這個物件就是變數物件。
'variableObject': {
// contains function arguments, inner variable and function declarations
}
作用域鏈
在執行環境建立階段,作用域鏈在變數物件之後建立。作用域鏈包含變數物件。作用域鏈用於解析變數。當解析一個變數時,JavaScript 開始從最內層沿著父級尋找所需的變數或其他資源。作用域鏈包含自己執行環境以及所有父級環境中包含的變數物件。
'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
}
程式碼執行階段
執行環境的第二個階段就是程式碼執行階段,進行其他賦值操作並且程式碼最終被執行。
詞法作用域
詞法作用域的意思是在函式巢狀中,內層函式可以訪問父級作用域的變數等資源。這意味著子函式詞法繫結到了父級執行環境。詞法作用域有時和靜態作用域有關。
function grandfather() {
var name = 'Hammad';
// likes is not accessible here
function parent() {
// name is accessible here
// likes is not accessible here
function child() {
// Innermost level of the scope chain
// name is also accessible here
var likes = 'Coding';
}
}
}
你可能注意到了詞法作用域是向前的,意思是子執行環境可以訪問name。但不是由父級向後的,意味著父級不能訪問likes。這也告訴了我們,在不同執行環境中同名變數優先順序在執行棧由上到下增加。一個變數和另一個變數同名,內層函式(執行棧頂的環境)有更高的優先順序。
閉包
閉包的概念和我們剛學習的詞法作用域緊密相關。當內部函式試著訪問外部函式的作用域鏈(詞法作用域之外的變數)時產生閉包。閉包包括它們自己的作用域鏈、父級作用域鏈和全域性作用域。
閉包不僅能訪問外部函式的變數,也能訪問外部函式的引數。
即使函式已經return,閉包仍然能訪問外部函式的變數。這意味著return的函式允許持續訪問外部函式的所有資源。
當你的外部函式return一個內部函式,呼叫外部函式時return的函式並不會被呼叫。你必須先用一個單獨的變數儲存外部函式的呼叫,然後將這個變數當做函式來呼叫。看下面這個例子:
function greet() {
name = 'Hammad';
return function () {
console.log('Hi ' + name);
}
}
greet(); // nothing happens, no errors
// the returned function from greet() gets saved in greetLetter
greetLetter = greet();
// calling greetLetter calls the returned function from the greet() function
greetLetter(); // logs 'Hi Hammad'
值得注意的是,即使在greet函式return後,greetLetter函式仍可以訪問greet函式的name變數。如果不使用變數賦值來呼叫greet函式return的函式,一種方法是使用()兩次()(),如下所示:
function greet() {
name = 'Hammad';
return function () {
console.log('Hi ' + name);
}
}
greet()(); // logs 'Hi Hammad'
共有作用域和私有作用域
在許多其他程式語言中,你可以通過 public、private 和 protected 作用域來設定類中變數和方法的可見性。看下面這個 PHP 的例子
// Public Scope
public $property;
public function method() {
// ...
}
// Private Sccpe
private $property;
private function method() {
// ...
}
// Protected Scope
protected $property;
protected function method() {
// ...
}
將函式從公有(全域性)作用域中封裝,使它們免受攻擊。但在 JavaScript 中,沒有 共有作用域和私有作用域。然而我們可以用閉包實現這一特性。為了使每個函式從全域性中分離出去,我們要將它們封裝進如下所示的函式中:
(function () {
// private scope
})();
函式結尾的括號告訴解析器立即執行此函式。我們可以在其中加入變數和函式,外部無法訪問。但如果我們想在外部訪問它們,也就是說我們希望它們一部分是公開的,一部分是私有的。我們可以使用閉包的一種形式,稱為模組模式(Module Pattern),它允許我們用一個物件中的公有作用域和私有作用域來劃分函式。
模組模式
模組模式如下所示:
var Module = (function() {
function privateMethod() {
// do something
}
return {
publicMethod: function() {
// can call privateMethod();
}
};
})();
Module 的return語句包含了我們的公共函式。私有函式並沒有被return。函式沒有被return確保了它們在 Module 名稱空間無法訪問。但我們的共有函式可以訪問我們的私有函式,方便它們使用有用的函式、AJAX 呼叫或其他東西。
Module.publicMethod(); // works
Module.privateMethod(); // Uncaught ReferenceError: privateMethod is not defined
一種習慣是以下劃線作為開始命名私有函式,並返回包含共有函式的匿名物件。這使它們在很長的物件中很容易被管理。向下面這樣:
var Module = (function () {
function _privateMethod() {
// do something
}
function publicMethod() {
// do something
}
return {
publicMethod: publicMethod,
}
})();
立即執行函式表示式(IIFE)
另一種形式的閉包是立即執行函式表示式(Immediately-Invoked Function Expression,IIFE)。這是一種在 window 上下文中自呼叫的匿名函式,也就是說this的值是window。它暴露了一個單一全域性介面用來互動。如下所示:
(function(window) {
// do anything
})(this);
使用 .call(), .apply() 和 .bind() 改變上下文
Call 和 Apply 函式來改變函式呼叫時的上下文。這帶給你神奇的程式設計能力(和終極統治世界的能力)。你只需要使用 call 和 apply 函式並把上下文當做第一個引數傳入,而不是使用括號來呼叫函式。函式自己的引數可以在上下文後面傳入。
function hello() {
// do something...
}
hello(); // the way you usually call it
hello.call(context); // here you can pass the context(value of this) as the first argument
hello.apply(context); // here you can pass the context(value of this) as the first argument
.call()和.apply()的區別是 Call 中其他引數用逗號分隔傳入,而 Apply 允許你傳入一個引數陣列。
function introduce(name, interest) {
console.log('Hi! I'm '+ name +' and I like '+ interest +'.');
console.log('The value of this is '+ this +'.')
}
introduce('Hammad', 'Coding'); // the way you usually call it
introduce.call(window, 'Batman', 'to save Gotham'); // pass the arguments one by one after the contextt
introduce.apply('Hi', ['Bruce Wayne', 'businesses']); // pass the arguments in an array after the context
// Output:
// Hi! I'm Hammad and I like Coding.
// The value of this is [object Window].
// Hi! I'm Batman and I like to save Gotham.
// The value of this is [object Window].
// Hi! I'm Bruce Wayne and I like businesses.
// The value of this is Hi.
Call 比 Apply 的效率高一點。
下面這個例子列舉文件中所有專案,然後依次在控制檯打印出來。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Things to learn</title>
</head>
<body>
<h1>Things to Learn to Rule the World</h1>
<ul>
<li>Learn PHP</li>
<li>Learn Laravel</li>
<li>Learn JavaScript</li>
<li>Learn VueJS</li>
<li>Learn CLI</li>
<li>Learn Git</li>
<li>Learn Astral Projection</li>
</ul>
<script>
// Saves a NodeList of all list items on the page in listItems
var listItems = document.querySelectorAll('ul li');
// Loops through each of the Node in the listItems NodeList and logs its content
for (var i = 0; i < listItems.length; i++) {
(function () {
console.log(this.innerHTML);
}).call(listItems[i]);
}
// Output logs:
// Learn PHP
// Learn Laravel
// Learn JavaScript
// Learn VueJS
// Learn CLI
// Learn Git
// Learn Astral Projection
</script>
</body>
</html>
HTML文件中僅包含一個無序列表。JavaScript 從 DOM 中選取它們。列表項會被從頭到尾迴圈一遍。在迴圈時,我們把列表項的內容輸出到控制檯。
輸出語句包含在由括號包裹的函式中,然後呼叫call函式。相應的列表項傳入 call 函式,確保控制檯輸出正確物件的 innerHTML。
物件可以有方法,同樣函式物件也可以有方法。事實上,JavaScript 函式有 4 個內建方法:
-
Function.prototype.apply()
-
Function.prototype.bind() (Introduced in ECMAScript 5 (ES5))
-
Function.prototype.call()
-
Function.prototype.toString()
Function.prototype.toString()返回函式程式碼的字串表示。
到現在為止,我們討論了.call()、.apply()和toString()。與 Call 和 Apply 不同,Bind 並不是自己呼叫函式,它只是在函式呼叫之前繫結上下文和其他引數。在上面提到的例子中使用 Bind:
(function introduce(name, interest) {
console.log('Hi! I'm '+ name +' and I like '+ interest +'.');
console.log('The value of this is '+ this +'.')
}).bind(window, 'Hammad', 'Cosmology')();
// logs:
// Hi! I'm Hammad and I like Cosmology.
// The value of this is [object Window].
Bind 像call函式一樣用逗號分隔其他傳入引數,不像apply那樣用陣列傳入引數。