簡介
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 作用域及相關概念有了更好地了解。如果有東西不清楚,可以在評論區提問。
作用域常伴你的代碼左右,享受編碼!