理解 JavaScript 中的作用域、上下文、閉包

原文鏈接: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
}
// 全局作用域

塊語句

塊語句,例如 ifswitch 這樣的條件語句,或是 forwhile這樣的循環(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 引入了 letconst 關(guān)鍵字,這些關(guān)鍵字可以替代var關(guān)鍵字。

var name = 'Hammad';
let likes = 'Coding';
const skills = 'Javascript and PHP';

不同于 var 的是,在塊語句中以letconst 代替 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 ,privateprotected等關(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ù)名后使用 callapply 去調(diào)用,并為 callapply 傳入你指定的上下文作為第一個參數(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中的作用域有更好的理解。如果你有任何問題,都可以在下面的評論。
愿你們都能擁有美好的編程體驗。
[譯者注:最后上一張原文作者帥照]

image.png

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

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容