原文鏈接:Understanding Scope in JavaScript
原文作者:Hammad Ahmed (@shammadahmed)
前言:
JavaScript有一個特性叫做作用域。誠然,對于開發(fā)新手來說作用域的概念不那么好理解,我將會盡我所能用最淺顯易懂的方式向你們解釋作用域的含義。理解作用域的用法可以讓你的代碼錯誤率降低、形成優(yōu)雅的代碼風(fēng)格,使你編寫代碼起來更加的得心應(yīng)手。
什么是作用域:
作用域決定了在你代碼運行時,變量、函數(shù)以及對象是否可以被訪問。換言之,作用域決定了這些變量和某些資源的可見性。
相關(guān)課程: Getting Started with JavaScript for Web Development
為什么要使用作用域?什么是最低訪問原則
那么限制變量可訪問性、不讓變量可隨時隨地被訪問使用的意義在哪里呢?一個好處是,作用域為你的代碼保證了一定程度上的安全性。計算機安全的一個公共原則就是用戶每次操作只能訪問到他們當(dāng)時需要的東西。
試想作為電腦管理員,他們擁有公司系統(tǒng)的很多控制權(quán)限,看起來為他們開通完整的訪問用戶帳戶似乎是可以的。假設(shè)這個公司有三位管理員,他們每個人都有全部的系統(tǒng)權(quán)限并且一切都運行正常。但是突然發(fā)生了一些異常,公司的系統(tǒng)之一被感染了惡意病毒。這時候就無法斷定這是誰導(dǎo)致的問題。現(xiàn)在你應(yīng)該知道你應(yīng)該為他們分配基礎(chǔ)賬戶,并只在需要的時候才賦予他們完全的訪問權(quán)限。這將會幫助你追蹤變化,并記錄下誰做了什么。這就被稱為最少訪問原則。是不是很直觀?這個原則同樣被應(yīng)用于程序語言設(shè)計,在包括JavaScript的眾多程序語言中,都被稱為是作用域,我們將在接下來的學(xué)習(xí)中遇到。
在你繼續(xù)你的編程之路的時候,你會發(fā)現(xiàn)使用作用域會讓你的代碼運行更加有效率,讓你更好的減少bug以及debug。作用域的存在還可以讓你在不同作用域下用同名的變量。記住,不要將作用域(scope)與上下文(context)混淆,他們是JavaScript的不同特性。
JavaScript的作用域
在JavaScript中有兩種作用域類型:
- 全局作用域
- 局部作用域
在函數(shù)內(nèi)部定義的變量就是在局部作用域中;
在函數(shù)外部定義的變量就是在全局作用域中;
當(dāng)函數(shù)被調(diào)用時會產(chǎn)生一個新的作用域。
全局作用域
當(dāng)你在文檔中編寫JavaScript時,你就已經(jīng)在全局作用域中。在一個js文檔中有且僅有一個全局作用域。如果在函數(shù)外定義一個變量,那么它的作用域就是全局的(全局都可訪問到該變量)。
// 默認(rèn)作用域為全局作用域
var name = 'Hammad';
在全局作用域中的變量可以在頁面任何的作用域里被訪問和更改。
var name = 'Hammad';
console.log(name); // logs 'Hammad'
function logName() {
console.log(name); // 'name' 可在任何作用域中被訪問
}
logName(); // logs 'Hammad'
局部作用域
在函數(shù)內(nèi)部定義的變量的作用域為局部作用域。當(dāng)函數(shù)被不同的方法調(diào)用時,變量也會獲得不同的作用域。這意味著我們可以在不同的函數(shù)中使用擁有同樣變量名的的變量。這是因為這些變量被綁定到了各自的函數(shù)中,有著自己獨立的作用域,彼此之間沒有聯(lián)系、不能互相訪問到。
// 全局作用域
function someFunction() {
// 局部作用域 #1
function someOtherFunction() {
// 局部作用域 #2
}
}
// 全局作用域
function anotherFunction() {
// 局部作用域 #3
}
// 全局作用域
塊語句
塊語句,例如 if 和 switch 這樣的條件語句,或是 for 和 while這樣的循環(huán)語句,不同于函數(shù)的是,它們不會創(chuàng)建出新的作用域。在塊語句內(nèi)定義的變量將會保留在全局作用域內(nèi)。
[譯者注]:由于JavaScript的變量作用域?qū)嶋H上是函數(shù)內(nèi)部,我們在for循環(huán)等語句塊中是無法定義具有局部作用域的變量的。
if (true) {
// if條件語句不會創(chuàng)建新的作用域
var name = 'Hammad'; // name依舊在全局作用域中
}
console.log(name); // logs 'Hammad'
ECMAScript 6 引入了 let 和 const 關(guān)鍵字,這些關(guān)鍵字可以替代var關(guān)鍵字。
var name = 'Hammad';
let likes = 'Coding';
const skills = 'Javascript and PHP';
不同于 var 的是,在塊語句中以let 和 const 代替 var聲明變量,可以在塊語句中為其創(chuàng)建出局部作用域。``
if (true) {
// 使用if的條件語句不會創(chuàng)建出局部作用域
// 由于使用了var聲明變量,name的作用域是全局作用域
var name = 'Hammad';
// likes由let聲明,所以在塊語句的局部作用域中
let likes = 'Coding';
// skills由let聲明,所以在塊語句的局部作用域中
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 引用錯誤
Global scope lives as long as your application lives. Local Scope lives as long as your functions are called and executed.
只要你的應(yīng)用在運行,全局作用域就一直存在。而局部作用域只會在函數(shù)被調(diào)用執(zhí)行時才會存在。
上下文
許多開發(fā)者都會將作用域和上下文混淆,就好像它們引用相同的概念一樣。但并非如此,作用域我們在上面進(jìn)行了闡述,上下文是在代碼的某些特定部分中引用到的一些外部參數(shù)值。([譯者注]:也就是代碼的執(zhí)行環(huán)境)。
作用域是指變量是否可讀,上下文是指在同一作用域內(nèi)的值。我們也可以。在全局作用作用域中,上下文永遠(yuǎn)是window對象。
每一段程序都有很多外部變量。只有像Add這種簡單的函數(shù)才是沒有外部變量的。一旦你的一段程序有了外部變量,這段程序就不完整,不能獨立運行。你為了使他們運行,就要給所有的外部變量一個一個寫一些值進(jìn)去。這些值的集合就叫上下文。
// logs: Window {speechSynthesis: SpeechSynthesis, caches: CacheStorage, localStorage: Storage…}
console.log(this);
function logFunction() {
console.log(this);
}
// logs: Window {speechSynthesis: SpeechSynthesis, caches: CacheStorage, localStorage: Storage…}
// 因為logfunction()不是一個對象的屬性
logFunction();
如果作用域在一個方法或者對象中,它的上下文將會是這個方法所在的對象。
class User {
logName() {
console.log(this);
}
}
(new User).logName(); // logs User {}
(new User).logName() 是一種簡潔的調(diào)用方式,在一個變量中存儲對象,然后調(diào)用指定的函數(shù)就可以了。。這樣的話就不需要創(chuàng)建一個新的變量了。
你會注意到的一點是,如果你用 new 關(guān)鍵字調(diào)用函數(shù),它的上下文環(huán)境將會有所改變,它會被設(shè)置為被調(diào)用函數(shù)的實例的上下文。考慮上面的示例,通過 new
關(guān)鍵字調(diào)用的函數(shù)。
function logFunction() {
console.log(this);
}
new logFunction(); // logs logFunction {}
在嚴(yán)格模式下調(diào)用函數(shù),它的上下文將會默認(rèn)為
undefined。
執(zhí)行上下文
上面我們提到了作用域與上下文,為了消除歧義,“執(zhí)行上下文”這個詞中的“上下文”意指“作用域”而不是我們上面提到的“上下文”。這是一個怪異的命名規(guī)范,但是基于JavaScript的規(guī)范,我們得把它們聯(lián)系到一塊兒去。
JavaScript是一個單線程語言,所以一次只能執(zhí)行一個任務(wù)。其余的任務(wù)要在執(zhí)行上下文中排隊等待。正如我們上面說到的,當(dāng)JavaScript編譯器開始執(zhí)行代碼時,它的上下文環(huán)境將被設(shè)置為全局(也就是說它的作用域是全局作用域)。這個全局上下文被插入到執(zhí)行上下文中,實際上也是第一個啟動執(zhí)行上下文的上下文。
再之后,每個函數(shù)在調(diào)用時都會將它的上下文注入到執(zhí)行上下文中。當(dāng)另一個函數(shù)在其他地方或是在該函數(shù)中調(diào)用后,也會發(fā)生同樣的事情。
一個函數(shù)在被調(diào)用時都會創(chuàng)建出它自己的執(zhí)行上下文。
一旦瀏覽器執(zhí)行完那段上下文中的代碼后,那段上下文將會從執(zhí)行上下文中銷毀掉,且它在執(zhí)行上下文中的當(dāng)前狀態(tài)會被傳遞到它的父級上下文中。瀏覽器總是執(zhí)行執(zhí)行上下文堆棧頂部的上下文(這實際上是代碼中層次最深的作用域)。
無論函數(shù)的上下文有多少個,代碼中有且僅有一個全局上下文。
執(zhí)行上下文分為創(chuàng)建和執(zhí)行兩個階段。
創(chuàng)建階段
創(chuàng)建階段是執(zhí)行上下文的第一個階段,它發(fā)生在函數(shù)被調(diào)用但尚未執(zhí)行的時候。以下是創(chuàng)建階段會做的操作:
- 創(chuàng)建變量對象
- 創(chuàng)建作用域鏈
*設(shè)置上下文環(huán)境。(this的值)
變量對象
變量對象,也被稱為是激活對象,它囊括了所有的變量、函數(shù)以及定義在執(zhí)行上下文其他分支中的聲明。當(dāng)一個函數(shù)被調(diào)用,瀏覽器解析器會預(yù)先加載所有資源,包括函數(shù)、變量和其他聲明。這些在被包裝成一個單獨的對象后,就會成為變量對象。
'variableObject': {
// 包含函數(shù)、參數(shù)、內(nèi)部變量和函數(shù)聲明
}
作用域鏈
在執(zhí)行上下文的創(chuàng)建階段,作用域鏈的創(chuàng)建在變量對象創(chuàng)建之后。作用域鏈本身包含了變量對象。作用域鏈用于解析這些變量。當(dāng)要解析一個變量時,JavaScript會從代碼嵌套的最內(nèi)層開始并逐步向父級作用域查找,知道找到它要解析的變量或是相關(guān)的資源為止。作用域鏈可以被簡單的定義為一個包含了變量對象和它的執(zhí)行上下文、以及他們的父級的執(zhí)行上下文,作用域鏈?zhǔn)且粋€擁有其他很多對象的對象。
'scopeChain': {
// 包含了它自己的變量對象以及它父級的變量對象的執(zhí)行上下文
}
執(zhí)行上下文對象
執(zhí)行上下文可以被抽象成這樣的一個對象:
executionContextObject = {
'scopeChain': {}, // 包含了它自身的變量對象和其父級執(zhí)行上下文內(nèi)的變量對象
'variableObject': {}, // 包含了函數(shù)、內(nèi)部變量和函數(shù)聲明
'this': this 的值
}
代碼執(zhí)行階段
代碼執(zhí)行階段為執(zhí)行上下文創(chuàng)建的第二階段,代碼和數(shù)值的操作將會被執(zhí)行。
詞法作用域
詞法作用域表示在一組嵌套的函數(shù)中,內(nèi)部函數(shù)可以訪問到它外層父級函數(shù)中的變量和其他一些資源。這意味著子函數(shù)的詞法作用域綁定了它父級函數(shù)的執(zhí)行上下文。詞法作用域也被稱為靜態(tài)作用域。
function grandfather() {
var name = 'Hammad';
// likes 無法被訪問
function parent() {
// name 可以被訪問
// likes 無法被訪問
function child() {
// 這是嵌套里的最深層級
// name 可以被訪問
var likes = 'Coding';
}
}
}
你會發(fā)現(xiàn)詞法作用域只能向前(自外而內(nèi))作用,name可以被嵌套的內(nèi)部函數(shù)的執(zhí)行上下文訪問。但是這是單向的,不能向后(自內(nèi)而外),likes 不能被它的父級函數(shù)訪問到。這也告訴我們不同的執(zhí)行上下文中的同名的變量會按照在執(zhí)行堆棧中的順序自上而下執(zhí)行。一個變量若和另一個變量同名,嵌套中最深層的函數(shù)(、執(zhí)行堆棧中最上層的上下文)會有更高的優(yōu)先權(quán)(先被賦值或使用)。
閉包
閉包的概念和我們上面說過的詞法作用域很相似,閉包是在一個內(nèi)部函數(shù)試圖訪問作用域鏈中它的外層函數(shù)、也就是詞法作用域之外的變量時產(chǎn)生的。閉包里包含它自己的作用域鏈,而作用域鏈里有它們父級以及全局的作用域。
閉包不僅可以訪問它的外部函數(shù)中定義的變量,還可以訪問外部函數(shù)的參數(shù)。
即使閉包的外部函數(shù)被返回了,閉包依舊可以訪問外部函數(shù)中的變量。這使得被返回的函數(shù)也可以訪問它外部函數(shù)中的資源。
當(dāng)從一個函數(shù)中返回它的一個內(nèi)部函數(shù),當(dāng)你試圖調(diào)用外部函數(shù)時,那個被返回的函數(shù)不會被調(diào)用。我們必須先將要調(diào)用的外部函數(shù)保存在一個單獨的變量中,然后再調(diào)用這個函數(shù),可參考下面的例子:
function greet() {
name = 'Hammad';
return function () {
console.log('Hi ' + name);
}
}
greet(); // 什么也不會發(fā)生,也沒有錯誤
// greet()中返回的函數(shù)被保存在greetLetter中
greetLetter = greet();
// 調(diào)用的greetLetter 函數(shù)調(diào)用了greet()函數(shù)中被返回的函數(shù)
greetLetter(); // logs 'Hi Hammad'
我們要注意的一點是,greetLetter這個函數(shù)可以訪問到 greet 這個函數(shù)中的 name 變量,及時它被返回了。一種不需要靠重新聲明變量就從 greet 函數(shù)中調(diào)用其返回的函數(shù)的方法是使用兩次括號 () ,用 ()() 進(jìn)行調(diào)用,如下:
function greet() {
name = 'Hammad';
return function () {
console.log('Hi ' + name);
}
}
greet()(); // logs 'Hi Hammad'
公共作用域和私有作用域
在其他很多編程語言中,你可以設(shè)置變量、方法或者類是否可被訪問,例如使用 public ,private,protected等關(guān)鍵詞去聲明??蓞⒖既缦碌腜HP代碼:
// 公共作用域
public $property;
public function method() {
// ...
}
// 私有作用域
private $property;
private function method() {
// ...
}
// 被保護(hù)的作用域
protected $property;
protected function method() {
// ...
}
在公共作用域(全局作用域)中封裝函數(shù)可以讓它們免于被攻擊。而在JavaScript中,并沒有公共作用域和私有作用域的概念。但是我們可以用閉包模擬這一特性。為了保持不被全局的資源訪問的狀態(tài),我們需要像下面這樣封裝函數(shù):
(function () {
// 私有作用域
})();
函數(shù)末尾的圓括號會告訴瀏覽器,即使在沒有被調(diào)用的情況下,讀取完成后就立即執(zhí)行函數(shù)。我們可以在其中添加函數(shù)和變量,且它們不會被外界訪問到。但如果我們想要在外面訪問它,也就是說我們希望它們中的一部分是公共的一部分是私有,要怎么辦呢?有一種閉包是我們可以使用的,它叫做模塊模式。這種方式允許我們在一個對象里既可以用公共的方式定義變量,又可以用私有的方式定義變量。
模塊模式
模塊模式的寫法如下:
var Module = (function() {
function privateMethod() {
// 主代碼
}
return {
publicMethod: function() {
// 可以調(diào)用 privateMethod();
}
};
})();
這個模塊里返回的語句中就包含了公共函數(shù),而私有的函數(shù)并沒有被返回,這就保證了在該模塊的命名空間下的外部函數(shù)無法訪問沒有被返回的函數(shù)。但是公共函數(shù)可以訪問私有函數(shù),這些私有函數(shù)可以做一些其他的操作輔助公共函數(shù),比如ajax請求等。
Module.publicMethod(); // 可執(zhí)行
Module.privateMethod(); // Uncaught ReferenceError: privateMethod is not defined 引用錯誤,privateMethod并未定義
私有函數(shù)的命名有一個慣例,它們通常以下劃線_作為開頭,然后返回一個包含公共函數(shù)的匿名對象。這使得它們在一個很長的對象中非常便于管理。比如下面這樣:
var Module = (function () {
function _privateMethod() {
// 代碼
}
function publicMethod() {
// 代碼
}
return {
publicMethod: publicMethod,
}
})();
立即執(zhí)行函數(shù)(IIFE)
閉包還有一種類型,就是立即執(zhí)行函數(shù)(IIFE)。這是一個在window這個全局上下文中自執(zhí)行的匿名函數(shù),也就是說它的this指向的是window。它會暴露出一個可供交互的單一接口。如下所示:
(function(window) {
// 代碼
})(this);
用.call(), .apply() and .bind()來更改上下文
call 函數(shù)和 apply 函數(shù)被用來在調(diào)用一個函數(shù)時更改該函數(shù)的上下文。這可以讓你更好的編程(可擁有一些終極權(quán)限來駕馭代碼)。你只需要在要調(diào)用的函數(shù)名后使用 call 和 apply 去調(diào)用,并為 call 和 apply 傳入你指定的上下文作為第一個參數(shù)即可。而這個函數(shù)自身所需要的參數(shù)放在以上下文作為第一參數(shù)的參數(shù)之后即可。
function hello() {
// 代碼
}
hello(); // 我們常用的調(diào)用方式
hello.call(context); //在這里你可以將context(this的指向)作為第一個參數(shù)傳值
hello.apply(context); // 在這里你可以將context(this的指向)作為第一個參數(shù)傳值
.call() and .apply() 的區(qū)別在于,在 .call() 中,傳遞其余的參數(shù)時,以逗號 '作為間隔的字符串即可;而在 .apply() 中,其余的參數(shù)以數(shù)組的形式傳遞。
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'); // 我們通常調(diào)用函數(shù)的方式
introduce.call(window, 'Batman', 'to save Gotham'); // 以逗號分隔的字符串形式傳遞參數(shù)
introduce.apply('Hi', ['Bruce Wayne', 'businesses']); // 以數(shù)組的形式包裝參數(shù)傳參
// 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的執(zhí)行要比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>
// 將列表中所有項目的NodeList保存在listItems中
var listItems = document.querySelectorAll('ul li');
// 遍歷listItems NodeList中的每個節(jié)點,并記錄下它的內(nèi)容
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中全部選出。列表會被循環(huán)遍歷知道所有的條目都被記錄。在循環(huán)中,我們將每個條目的內(nèi)容都打印在控制臺中。
log語句被包裝在一個函數(shù)中,這個函數(shù)又被包裝在被調(diào)用的函數(shù)的括號中。相符合的列表項會被傳遞到調(diào)用的函數(shù)中,所以會在控制臺中打印出正確的結(jié)果。
Objects can have methods, likewise functions being objects can also have methods. In fact, a JavaScript function comes with four built-in methods which are:
對象可以擁有方法,就像函數(shù)作為對象擁有方法一樣。事實上,一個JavaScript函數(shù)擁有四種內(nèi)置方法:
- Function.prototype.apply()
- Function.prototype.bind() (ECMAScript 5 (ES5)中被引入)
- Function.prototype.call()
- Function.prototype.toString()
Function.prototype.toString()以字符串形式返回該函數(shù)的源代碼。
現(xiàn)在,我可以來聊聊 .call() , .apply() 以及 toString()。不像Call和Apply,Bind自身不會調(diào)用函數(shù),他只能在函數(shù)被調(diào)用之前用來為其綁定新的上下文。上述例子用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函數(shù)相同,它可以用以逗號為分隔的字符串的方式進(jìn)行傳參,而不是和Apply一樣用數(shù)組的方式傳參。
結(jié)語
這些都是JavaScript中很基本的概念,如果你想要對JavaScript有更深層次的認(rèn)識與運用,一定要好好理解。很希望你可以通過這篇文章對JavaScript中的作用域有更好的理解。如果你有任何問題,都可以在下面的評論。
愿你們都能擁有美好的編程體驗。
[譯者注:最后上一張原文作者帥照]

<h1 style="text-align:center">Hammad Ahmed </h1>