(譯)理解javascript中的作用域

飛翔的大象發表於2019-03-08

#簡介

JavaScript有一個特性叫做作用域。儘管作用域的概念對於許多初學者是不容易理解的,我會盡力在最簡單的範圍內解釋。理解作用域會是你的程式碼更加清楚,減少錯誤,幫助你使用它製作強大的設計模式。

#什麼是作用域

作用域是執行時程式碼中某些特定部分中變數,函式和物件的可訪問性。換句話說,作用域確定了程式碼中的變數和其他資源的可見性。

#作用域為何存在--最小訪問性原則

因此,限制變數的可見性的重點是什麼,而不是所有的程式碼不是隨處可見的。一個優點是作用域為你的程式碼提供了一定級別的安全性,電腦保安的一個常見的原則是使用者應該一次只能訪問他們需要的東西。

想想電腦的管理員,由於他們對公司的電腦有很多控制權,向他們的賬戶授予全部許可權是沒問題的。假設你有一個含有三個管理員的公司,他們都可以訪問系統,一切都很順利。但是突然,發生了一件壞事,其中的一個系統感染了病毒。現在你不知道到底是誰的錯誤導致的。你意識到你應該給他們基本的使用者賬戶,只有在需要的時候才賦予完全訪問的特權。這會幫助你追蹤變化,記錄誰做了什麼。這叫做最小訪問性原則。看起來很直觀?這個原則也適用於程式語言的設計。它在大多數程式語言中稱作作用域,包括我們接下來要研究的JavaScript。

隨著你的程式設計之旅,你會意識到程式碼的作用域有助於提高效率,追蹤bug並且減少bug。作用域也解決了在不同作用域中相同變數名的命名問題。切記不要吧作用域和上下文弄混淆了,它們是不同的特性。

#JavaScript中的作用域

在JavaScript中有兩種型別的作用域

  • 全域性作用域
  • 區域性作用域

函式內部的變數是在區域性作用域,外部的是在全域性作用域。每一個函式在呼叫的時候會建立一個新的作用域。

#全域性作用域

在文件中開始寫JavaScript時,你已經在全域性作用域中了。整個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
複製程式碼

#塊語句

塊語句類似ifswitch條件或者forwhile迴圈中,不像函式那樣建立新的作用域。定義在塊語句中的變數將保留在它們已經存在的作用域中。

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關鍵字相反,letconst關鍵字支援在塊語句中宣告區域性作用域。

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 {}
複製程式碼

在嚴格模式下呼叫函式時,上下文預設為undefined

#執行上下文

要消除我們上面學的內容的混淆,執行上下文中的上下文指的是作用域而不是上下文。這是一個奇怪的命名約定,但是由於JavaScript的規範,我們與之相關。

JavaScript是一個單執行緒的語言,所以它一次只能執行一個任務。其餘的任務在執行上下文中排隊。正如我之前告訴你的,當JavaScript直譯器開始執行你的程式碼時,預設情況下,上下文(作用域)設定成全域性。此全域性上下文附加到您的執行上下文,該上下文實際上是啟動執行上下文的第一個上下文。

之後,每個函式呼叫都會將其上下文附加到執行上下文。當在該函式內部或其他地方呼叫另一個函式時,會發生同樣的事情。

每個函式都建立自己的執行上下文

一旦瀏覽器完成該上下文中的程式碼,那麼該上下文將從執行上下文中彈出,並且執行上下文中的當前上下文的狀態將被傳送到父上下文。 瀏覽器總是執行位於執行堆疊頂部的執行上下文(實際上是程式碼中最內層的範圍)。

只能有一個全域性上下文,但有任意數量的函式上下文。

執行上下文有兩個建立階段和程式碼執行階段。

建立階段

當呼叫函式但其程式碼尚未執行時,存在建立階段的第一個階段。在建立階段發生的三件主要事情是:

  • 建立可變物件
  • 建立作用域鏈
  • 設定上下文的值(this)
可變物件

可變物件(也稱為啟用物件)包含在執行上下文的特定分支中定義的所有變數,函式和其他宣告。 呼叫函式時,直譯器會掃描所有資源,包括函式引數,變數和其他宣告。 當打包到單個物件中時,所有內容都將成為可變物件。

'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不能被它的父函式訪問。這也告訴我們在不同執行上下文中具有相同名稱的變數從執行堆疊的頂部到底部優先獲得。一個變數和其他變數具有相同的名稱,在最裡面的函式(執行堆疊的最頂部的上下文)具有最高的優先權。

#閉包

閉包的概念和我們上面學習的語法作用域密切相關。當內部函式嘗試訪問其外部函式的作用域鏈時建立Closure,這意味著語法作用域之外的變數。閉包包含自己的範圍鏈,父母的範圍鏈和全域性範圍。

閉包不僅可以訪問外部函式中定義的變數,還可以訪問外部函式的引數。

即使在函式返回後,閉包也可以訪問其外部函式的變數。這允許返回的函式維護對外部函式的所有資源的訪問。

當您從函式返回內部函式時,這時候您嘗試呼叫外部函式時,將不會呼叫返回的函式。你必須首先將外部函式的呼叫儲存在單獨的變數中,然後將該變數作為函式呼叫。思考這個例子:

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'
複製程式碼

這裡要注意的關鍵點是函式greetLettergreet函式返回的情況下任然可以訪問變數name。在沒有變數賦值的情況下從greet函式呼叫返回函式的一種方法是使用括號兩次,如下所示:

function greet() {
    name = 'Hammad';
    return function () {
        console.log('Hi ' + name);
    }
}

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

#public作用域和private作用域

在很多其他的程式語言中,你可以使用privatepublicprivateprotected設定類方法和類屬性的可見性。使用PHP語言思考這個例子:

// Public Scope
public $property;
public function method() {
  // ...
}

// Private Sccpe
private $property;
private function method() {
  // ...
}

// Protected Scope
protected $property;
protected function method() {
  // ...
}
複製程式碼

封裝來自public作用域的函式可以使他們免受易攻擊。但是在JavaScript中,共有和私有的概念都沒有。然而,我們可以使用閉包來模擬這個特性。為了使所有內容與全域性分離,我們必須首先將函式封裝在如下的函式中:

(function () {
  // private scope
})();
複製程式碼

函式的尾部告訴直譯器無需呼叫立即就可以執行它。我們向其中增加函式和變數,在外部是不可以訪問的。但是,如果我們想在外部訪問它們,意味著我們希望一部分是public一部分是private?我們可以使用的另一種閉包叫做模組模式,這允許我們使用物件中的public和private作用域來界定我們的函式。

模組模式

模組模式看起來像這樣:

var Module = (function() {
    function privateMethod() {
        // do something
    }

    return {
        publicMethod: function() {
            // can call privateMethod();
        }
    };
})();
複製程式碼

模組的返回宣告中包括我們的public函式,private函式並不會返回。不反悔的函式是在模組名稱空間外不能訪問。但是我們的共有方法是可以訪問我們的私有函式,這些函式一般是輔助函式,例如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)

另一種閉包的型別是立即執行函式。這是在window的上下文中自呼叫的匿名函式,這意味著this的值是window。這暴露了一個與之互動的全域性介面。它看起來是這樣:

(function(window) {
    // do anything
})(this);
複製程式碼

#使用.call(), .apply() 和 .bind()改變上下文

callapply函式用於在呼叫函式時更改上下文,這為您提供了令人難以置信的程式設計能力(以及統治世界的一些終極能力)。要使用callapply函式,只需要在函式上呼叫它,而不是使用一對括號呼叫函式,並將上下文作為第一個引數傳遞。函式自己的引數可以在上下文之後傳遞。

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中選擇所有的列表。迴圈列表。在迴圈內部,我們將列表項的內容記錄到控制檯。

此日誌語句包含在括在括號中的函式中,在該函式中呼叫呼叫函式。相應的列表項將傳遞給呼叫函式,以便控制檯語句中的關鍵字記錄正確物件的innerHTML

物件可以有這些方法,同樣函式物件也可以有這些方法。事實上,JavaScript函式帶有四個內建方法,它們是:

  • Function.prototype.apply()
  • Function.prototype.bind() (Introduced in ECMAScript 5 (ES5))
  • Function.prototype.call()
  • Function.prototype.toString()

Function.prototype.toString()返回函式原始碼的字串表示形式。

到目前為止,我們已經討論過.call(),.apply()toString()。 與callapply不同,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一樣,在陣列中傳遞引數。

#總結

這些概念對JavaScript來說是激進的,如果您想要處理更高階的話題,這一點很重要。 我希望你能更好地理解JavaScript Scope及其周圍的事情。如果有什麼疑問,請隨時在下面的評論中詢問。

擴充套件您的程式碼,直到那時,快樂編碼!

Scope up your code and till then, Happy Coding!

相關文章