理解 JavaScript 作用域

一杯雜湊不加鹽發表於2017-06-11

簡介

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

什麼是作用域?

作用域是你的程式碼在執行時,各個變數、函式和物件的可訪問性。換句話說,作用域決定了你的程式碼裡的變數和其他資源各個區域中的可見性。

為什麼需要作用域?最小訪問原則

那麼,限制變數的可見性,不允許你程式碼中所有的東西在任意地方都可用的好處是什麼?其中一個優勢,是作用域為你的程式碼提供了一個安全層級。電腦保安中,有個常規的原則是:使用者只能訪問他們當前需要的東西。

想想計算機管理員吧。他們在公司各個系統上擁有很多控制權,看起來甚至可以給予他們擁有全部許可權的賬號。假設你有一家公司,擁有三個管理員,他們都有系統的全部訪問許可權,並且一切運轉正常。但是突然發生了一點意外,你的一個系統遭到惡意病毒攻擊。現在你不知道這誰出的問題了吧?你這才意識到你應該只給他們基本使用者的賬號,並且只在需要時賦予他們完全的訪問權。這能幫助你跟蹤變化並記錄每個人的操作。這叫做最小訪問原則。眼熟嗎?這個原則也應用於程式語言設計,在大多數程式語言(包括 JavaScript)中稱為作用域,接下來我們就要學習它。

在你的程式設計旅途中,你會意識到作用域在你的程式碼中可以提升效能,跟蹤 bug 並減少 bug。作用域還解決不同範圍的同名變數命名問題。記住不要弄混作用域和上下文。它們是不同的特性。

JavaScript中的作用域

在 JavaScript 中有兩種作用域

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

當變數定義在一個函式中時,變數就在區域性作用域中,而定義在函式之外的變數則從屬於全域性作用域。每個函式在呼叫的時候會建立一個新的作用域。

全域性作用域

當你在文件中(document)編寫 JavaScript 時,你就已經在全域性作用域中了。JavaScript 文件中(document)只有一個全域性作用域。定義在函式之外的變數會被儲存在全域性作用域中。

全域性作用域裡的變數能夠在其他作用域中被訪問和修改。

區域性作用域

定義在函式中的變數就在區域性作用域中。並且函式在每次呼叫時都有一個不同的作用域。這意味著同名變數可以用在不同的函式中。因為這些變數繫結在不同的函式中,擁有不同作用域,彼此之間不能訪問。

塊語句

塊級宣告包括if和switch,以及for和while迴圈,和函式不同,它們不會建立新的作用域。在塊級宣告中定義的變數從屬於該塊所在的作用域。

ECMAScript 6 引入了let和const關鍵字。這些關鍵字可以代替var。

和var關鍵字不同,let和const關鍵字支援在塊級宣告中建立使用區域性作用域。

一個應用中全域性作用域的生存週期與該應用相同。區域性作用域只在該函式呼叫執行期間存在。

上下文

很多開發者經常弄混作用域和上下文,似乎兩者是一個概念。但並非如此。作用域是我們上面講到的那些,而上下文通常涉及到你程式碼某些特殊部分中的this值。作用域指的是變數的可見性,而上下文指的是在相同的作用域中的this的值。我們當然也可以使用函式方法改變上下文,這個之後我們再討論。在全域性作用域中,上下文總是 Window 物件。

如果作用域定義在一個物件的方法中,上下文就是這個方法所在的那個物件

(new User).logName()是建立物件關聯到變數並呼叫logName方法的一種簡便形式。通過這種方式你並不需要建立一個新的變數。

你可能注意到一點,就是如果你使用new關鍵字呼叫函式時上下文的值會有差異。上下文會設定為被呼叫的函式的例項。考慮一下上面的這個例子,用new關鍵字呼叫的函式。

當在嚴格模式(strict mode)中呼叫函式時,上下文預設是 undefined。

執行環境

為了解決掉我們從上面學習中會出現的各種困惑,“執行環境(context)”這個詞中的“環境(context)”指的是作用域而並非上下文。這是一個怪異的命名約定,但由於 JavaScript 的文件如此,我們只好也這樣約定。

JavaScript 是一種單執行緒語言,所以它同一時間只能執行單個任務。其他任務排列在執行環境中。當 JavaScript 解析器開始執行你的程式碼,環境(作用域)預設設為全域性。全域性環境新增到你的執行環境中,事實上這是執行環境裡的第一個環境。

之後,每個函式呼叫都會新增它的環境到執行環境中。無論是函式內部還是其他地方呼叫函式,都會是相同的過程。

每個函式都會建立它自己的執行環境。

當瀏覽器執行完環境中的程式碼,這個環境會從執行環境中彈出,執行環境中當前環境的狀態會轉移到父級環境。瀏覽器總是先執行在執行棧頂的執行環境(事實上就是你程式碼最裡層的作用域)。

全域性環境只能有一個,函式環境可以有任意多個。
執行環境有兩個階段:建立和執行。

建立階段

第一階段是建立階段,是函式剛被呼叫但程式碼並未執行的時候。建立階段主要發生了 3 件事。

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

變數物件

變數物件(Variable Object)也稱為活動物件(activation object),包含所有變數、函式和其他在執行環境中定義的宣告。當函式呼叫時,解析器掃描所有資源,包括函式引數、變數和其他宣告。當所有東西裝填進一個物件,這個物件就是變數物件。

作用域鏈

在執行環境建立階段,作用域鏈在變數物件之後建立。作用域鏈包含變數物件。作用域鏈用於解析變數。當解析一個變數時,JavaScript 開始從最內層沿著父級尋找所需的變數或其他資源。作用域鏈包含自己執行環境以及所有父級環境中包含的變數物件。

執行環境物件

執行環境可以用下面抽象物件表示:

程式碼執行階段

執行環境的第二個階段就是程式碼執行階段,進行其他賦值操作並且程式碼最終被執行。

詞法作用域

詞法作用域的意思是在函式巢狀中,內層函式可以訪問父級作用域的變數等資源。這意味著子函式詞法繫結到了父級執行環境。詞法作用域有時和靜態作用域有關。

你可能注意到了詞法作用域是向前的,意思是子執行環境可以訪問name。但不是由父級向後的,意味著父級不能訪問likes。這也告訴了我們,在不同執行環境中同名變數優先順序在執行棧由上到下增加。一個變數和另一個變數同名,內層函式(執行棧頂的環境)有更高的優先順序。

閉包

閉包的概念和我們剛學習的詞法作用域緊密相關。當內部函式試著訪問外部函式的作用域鏈(詞法作用域之外的變數)時產生閉包。閉包包括它們自己的作用域鏈、父級作用域鏈和全域性作用域。

閉包不僅能訪問外部函式的變數,也能訪問外部函式的引數。

即使函式已經return,閉包仍然能訪問外部函式的變數。這意味著return的函式允許持續訪問外部函式的所有資源。

當你的外部函式return一個內部函式,呼叫外部函式時return的函式並不會被呼叫。你必須先用一個單獨的變數儲存外部函式的呼叫,然後將這個變數當做函式來呼叫。看下面這個例子:

值得注意的是,即使在greet函式return後,greetLetter函式仍可以訪問greet函式的name變數。如果不使用變數賦值來呼叫greet函式return的函式,一種方法是使用()兩次()(),如下所示:

共有作用域和私有作用域

在許多其他程式語言中,你可以通過 public、private 和 protected 作用域來設定類中變數和方法的可見性。看下面這個 PHP 的例子

將函式從公有(全域性)作用域中封裝,使它們免受攻擊。但在 JavaScript 中,沒有 共有作用域和私有作用域。然而我們可以用閉包實現這一特性。為了使每個函式從全域性中分離出去,我們要將它們封裝進如下所示的函式中:

函式結尾的括號告訴解析器立即執行此函式。我們可以在其中加入變數和函式,外部無法訪問。但如果我們想在外部訪問它們,也就是說我們希望它們一部分是公開的,一部分是私有的。我們可以使用閉包的一種形式,稱為模組模式(Module Pattern),它允許我們用一個物件中的公有作用域和私有作用域來劃分函式。

模組模式

模組模式如下所示:

Module 的return語句包含了我們的公共函式。私有函式並沒有被return。函式沒有被return確保了它們在 Module 名稱空間無法訪問。但我們的共有函式可以訪問我們的私有函式,方便它們使用有用的函式、AJAX 呼叫或其他東西。

一種習慣是以下劃線作為開始命名私有函式,並返回包含共有函式的匿名物件。這使它們在很長的物件中很容易被管理。向下面這樣:

立即執行函式表示式(IIFE)

另一種形式的閉包是立即執行函式表示式(Immediately-Invoked Function Expression,IIFE)。這是一種在 window 上下文中自呼叫的匿名函式,也就是說this的值是window。它暴露了一個單一全域性介面用來互動。如下所示:

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

Call 和 Apply 函式來改變函式呼叫時的上下文。這帶給你神奇的程式設計能力(和終極統治世界的能力)。你只需要使用 call 和 apply 函式並把上下文當做第一個引數傳入,而不是使用括號來呼叫函式。函式自己的引數可以在上下文後面傳入。

.call()和.apply()的區別是 Call 中其他引數用逗號分隔傳入,而 Apply 允許你傳入一個引數陣列。

Call 比 Apply 的效率高一點。

下面這個例子列舉文件中所有專案,然後依次在控制檯列印出來。

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:

Bind 像call函式一樣用逗號分隔其他傳入引數,不像apply那樣用陣列傳入引數。

結論

這些概念是 JavaScript 的基礎,如果你想鑽研更深的話,理解這些很重要。我希望你對 JavaScript 作用域及相關概念有了更好地理解。如果有東西不清楚,可以在評論區提問。

作用域常伴你的程式碼左右,享受編碼!

打賞支援我翻譯更多好文章,謝謝!

打賞譯者

打賞支援我翻譯更多好文章,謝謝!

任選一種支付方式

理解 JavaScript 作用域 理解 JavaScript 作用域

相關文章