理解JavaScript做用域

簡介

JavaScript 有個特性稱爲做用域。儘管對於不少開發新手來講,做用域的概念不容易理解,我會盡量地從最簡單的角度向你解釋它們。理解做用域能讓你編寫更優雅、錯誤更少的代碼,並能幫助你實現強大的設計模式。javascript

什麼是做用域?

做用域是你的代碼在運行時,各個變量、函數和對象的可訪問性。換句話說,做用域決定了你的代碼裏的變量和其餘資源在各個區域中的可見性。html

爲何須要做用域?最小訪問原則

那麼,限制變量的可見性,不容許你代碼中全部的東西在任意地方均可用的好處是什麼?其中一個優點,是做用域爲你的代碼提供了一個安全層級。計算機安全中,有個常規的原則是:用戶只能訪問他們當前須要的東西。java

想一想計算機管理員吧。他們在公司各個系統上擁有不少控制權,看起來甚至能夠給予他們擁有所有權限的帳號。假設你有一家公司,擁有三個管理員,他們都有系統的所有訪問權限,而且一切運轉正常。可是忽然發生了一點意外,你的一個系統遭到惡意病毒攻擊。如今你不知道這誰出的問題了吧?你這才意識到你應該只給他們基本用戶的帳號,而且只在須要時賦予他們徹底的訪問權。這能幫助你跟蹤變化並記錄每一個人的操做。這叫作最小訪問原則。眼熟嗎?這個原則也應用於編程語言設計,在大多數編程語言(包括 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'

局部做用域

定義在函數中的變量就在局部做用域中。而且函數在每次調用時都有一個不一樣的做用域。這意味着同名變量能夠用在不一樣的函數中。由於這些變量綁定在不一樣的函數中,擁有不一樣做用域,彼此之間不能訪問。app

// 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那樣用數組傳入參數。

結論

這些概念是 JavaScript 的基礎,若是你想鑽研更深的話,理解這些很重要。我但願你對 JavaScript 做用域及相關概念有了更好地理解。若是有東西不清楚,能夠在評論區提問。

做用域常伴你的代碼左右,享受編碼!

點擊查看編程系列視頻教程

點擊訪問個人博客

相關文章
相關標籤/搜索