JavaScript核心

JeremyWei發表於2013-11-26

這篇文章是「深入ECMA-262-3」系列的一個概覽和摘要。面向讀者:經驗豐富的程式設計師,專家。我們以思考物件的概念做為開始,這是ECMAScript的基礎。

物件

ECMAScript做為一個高度抽象的面嚮物件語言,是通過物件來互動的。即使ECMAScript裡邊也有基本型別,但是,當需要的時候,它們也會被轉換成物件。

讓我們看一個關於物件的基本例子。一個物件的prototype是以內部的[[Prototype]]屬性來引用的。但是,在示意圖裡邊我們將會使用__<internal-property>__下劃線標記來替代兩個括號,對於prototype物件來說是:__proto__

對於以下程式碼:

我們擁有一個這樣的結構,兩個明顯的自身屬性和一個隱含的__proto__屬性,這個屬性是對foo原型物件的引用:

basic-object

這些prototype有什麼用?讓我們以原型鏈(prototype chain)的概念來回答這個問題。

原型鏈

原型物件也是簡單的物件並且可以擁有它們自己的原型。如果一個原型物件的原型是一個非null的引用,那麼以此類推,這就叫作原型鏈

考慮這麼一個情況,我們擁有兩個物件,它們之間只有一小部分不同,其他部分都相同。顯然,對於一個設計良好的系統,我們將會重用相似的功能/程式碼,而不是在每個單獨的物件中重複它。在基於類的系統中,這個程式碼重用風格叫作類繼承-你把相似的功能放入類A中,然後類 B和類 C繼承類 A,並且擁有它們自己的一些小的額外變動。

ECMAScript中沒有類的概念。但是,程式碼重用的風格並沒有太多不同(儘管從某些方面來說比基於類(class-based)的方式要更加靈活)並且通過原型鏈來實現。這種繼承方式叫作委託繼承(delegation based inheritance)(或者,更貼近ECMAScript一些,叫作原型繼承(prototype based inheritance))。

跟例子中的類ABC相似,在ECMAScript中你建立物件:abc。於是,物件a中儲存物件bc中通用的部分。然後bc只儲存它們自身的額外屬性或者方法。

足夠簡單,是不是?我們看到bc訪問到了在物件a中定義的calculate方法。這是通過原型鏈實現的。

規則很簡單:如果一個屬性或者一個方法在物件自身中無法找到(也就是物件自身沒有一個那樣的屬性),然後它會嘗試在原型鏈中尋找這個屬性/方法。如果這個屬性在原型中沒有查詢到,那麼將會查詢這個原型的原型,以此類推,遍歷整個原型鏈(當然這在類繼承中也是一樣的,當解析一個繼承的方法的時候-我們遍歷class鏈( class chain))。第一個被查詢到的同名屬性/方法會被使用。因此,一個被查詢到的屬性叫作繼承屬性。如果在遍歷了整個原型鏈之後還是沒有查詢到這個屬性的話,返回undefined值。

注意,繼承方法中所使用的this的值被設定為原始物件,而並不是在其中查詢到這個方法的(原型)物件。也就是,在上面的例子中this.y取的是bc中的值,而不是a中的值。但是,this.x是取的是a中的值,並且又一次通過原型鏈機制完成。

如果沒有明確為一個物件指定原型,那麼它將會使用__proto__的預設值-Object.prototypeObject.prototype物件自身也有一個__proto__屬性,這是原型鏈的終點並且值為null

下一張圖展示了物件abc之間的繼承層級:

prototype-chain

注意: ES5標準化了一個實現原型繼承的可選方法,即使用Object.create函式:

你可以在對應的章節獲取到更多關於ES5新API的資訊。 ES6標準化了 __proto__屬性,並且可以在物件初始化的時候使用它。

通常情況下需要物件擁有相同或者相似的狀態結構(也就是相同的屬性集合),賦以不同的狀態值。在這個情況下我們可能需要使用建構函式(constructor function),其以指定的模式來創造物件。

建構函式

除了以指定模式建立物件之外,建構函式也做了另一個有用的事情-它自動地為新建立的物件設定一個原型物件。這個原型物件儲存在ConstructorFunction.prototype屬性中。

換句話說,我們可以使用建構函式來重寫上一個擁有物件b和物件c的例子。因此,物件a(一個原型物件)的角色由Foo.prototype來扮演:

這個程式碼可以表示為如下關係:

constructor-proto-chain

這張圖又一次說明了每個物件都有一個原型。建構函式Foo也有自己的__proto__,值為Function.prototypeFunction.prototype也通過其__proto__屬性關聯到Object.prototype。因此,重申一下,Foo.prototype就是Foo的一個明確的屬性,指向物件b和物件c的原型。

正式來說,如果思考一下分類的概念(並且我們已經對Foo進行了分類),那麼建構函式和原型物件合在一起可以叫作「類」。實際上,舉個例子,Python的第一級(first-class)動態類(dynamic classes)顯然是以同樣的屬性/方法處理方案來實現的。從這個角度來說,Python中的類就是ECMAScript使用的委託繼承的一個語法糖。

注意: 在ES6中「類」的概念被標準化了,並且實際上以一種構建在建構函式上面的語法糖來實現,就像上面描述的一樣。從這個角度來看原型鏈成為了類繼承的一種具體實現方式:

有關這個主題的完整、詳細的解釋可以在ES3系列的第七章找到。分為兩個部分:7.1 物件導向.基本理論,在那裡你將會找到對各種物件導向範例、風格的描述以及它們和ECMAScript之間的對比,然後在7.2 物件導向.ECMAScript實現,是對ECMAScript中物件導向的介紹。

現在,在我們知道了物件的基礎之後,讓我們看看執行時程式的執行(runtime program execution)在ECMAScript中是如何實現的。這叫作執行上下文棧(execution context stack),其中的每個元素也可以抽象成為一個物件。是的,ECMAScript幾乎在任何地方都和物件的概念打交道;)

執行上下文堆疊

這裡有三種型別的ECMAScript程式碼:全域性程式碼、函式程式碼和eval程式碼。每個程式碼是在其執行上下文(execution context)中被求值的。這裡只有一個全域性上下文,可能有多個函式執行上下文以及eval執行上下文。對一個函式的每次呼叫,會進入到函式執行上下文中,並對函式程式碼型別進行求值。每次對eval函式進行呼叫,會進入eval執行上下文並對其程式碼進行求值。

注意,一個函式可能會建立無數的上下文,因為對函式的每次呼叫(即使這個函式遞迴的呼叫自己)都會生成一個具有新狀態的上下文:

一個執行上下文可能會觸發另一個上下文,比如,一個函式呼叫另一個函式(或者在全域性上下文中呼叫一個全域性函式),等等。從邏輯上來說,這是以棧的形式實現的,它叫作執行上下文棧

一個觸發其他上下文的上下文叫作caller。被觸發的上下文叫作callee。callee在同一時間可能是一些其他callee的caller(比如,一個在全域性上下文中被呼叫的函式,之後呼叫了一些內部函式)。

當一個caller觸發(呼叫)了一個callee,這個caller會暫緩自身的執行,然後把控制權傳遞給callee。這個callee被push到棧中,併成為一個執行中(活動的)執行上下文。在callee的上下文結束後,它會把控制權返回給caller,然後caller的上下文繼續執行(它可能觸發其他上下文)直到它結束,以此類推。callee可能簡單的返回或者由於異常而退出。一個丟擲的但是沒有被捕獲的異常可能退出(從棧中pop)一個或者多個上下文。

換句話說,所有ECMAScript程式的執行時可以用執行上下文(EC)棧來表示,棧頂是當前活躍(active)上下文:

ec-stack

當程式開始的時候它會進入全域性執行上下文,此上下文位於棧底並且是棧中的第一個元素。然後全域性程式碼進行一些初始化,建立需要的物件和函式。在全域性上下文的執行過程中,它的程式碼可能觸發其他(已經建立完成的)函式,這些函式將會進入它們自己的執行上下文,向棧中push新的元素,以此類推。當初始化完成之後,執行時系統(runtime system)就會等待一些事件(比如,使用者滑鼠點選),這些事件將會觸發一些函式,從而進入新的執行上下文中。

在下個圖中,擁有一些函式上下文EC1和全域性上下文Global EC,當EC1進入和退出全域性上下文的時候下面的棧將會發生變化:

ec-stack-changes1

這就是ECMAScript的執行時系統如何真正地管理程式碼執行的。

更多有關ECMAScript中執行上下文的資訊可以在對應的第一章 執行上下文中獲取。

像我們所說的,棧中的每個執行上下文都可以用一個物件來表示。讓我們來看看它的結構以及一個上下文到底需要什麼狀態(什麼屬性)來執行它的程式碼。

執行上下文

一個執行上下文可以抽象的表示為一個簡單的物件。每一個執行上下文擁有一些屬性(可以叫作上下文狀態)用來跟蹤和它相關的程式碼的執行過程。在下圖中展示了一個上下文的結構:

execution-context2

除了這三個必需的屬性(一個變數物件(variable objec),一個this值以及一個作用域鏈(scope chain))之外,執行上下文可以擁有任何附加的狀態,這取決於實現。

讓我們詳細看看上下文中的這些重要的屬性。

變數物件

注意,函式表示式(與函式宣告相對)不包含在變數物件之中。

變數物件是一個抽象概念。對於不同的上下文型別,在物理上,是使用不同的物件。比如,在全域性上下文中變數物件就是全域性物件本身(這就是為什麼我們可以通過全域性物件的屬性名來關聯全域性變數)。

讓我們在全域性執行上下文中考慮下面這個例子:

之後,全域性上下文的變數物件(variable objec,簡稱VO)將會擁有如下屬性:

variable-object3

再看一遍,函式baz是一個函式表示式,沒有被包含在變數物件之中。這就是為什麼當我們想要在函式自身之外訪問它的時候會出現ReferenceError

注意,與其他語言(比如C/C++)相比,在ECMAScript中只有函式可以建立一個新的作用域。在函式作用域中所定義的變數和內部函式在函式外邊是不能直接訪問到的,而且並不會汙染全域性變數物件。

使用eval我們也會進入一個新的(eval型別)執行上下文。無論如何,eval使用全域性的變數物件或者使用caller(比如eval被呼叫時所在的函式)的變數物件。

那麼函式和它的變數物件是怎麼樣的?在函式上下文中,變數物件是以活動物件(activation object)來表示的。

活動物件

當一個函式被caller所觸發(被呼叫),一個特殊的物件,叫作活動物件(activation object)將會被建立。這個物件中包含形參和那個特殊的arguments物件(是對形參的一個對映,但是值是通過索引來獲取)。活動物件之後會做為函式上下文的變數物件來使用。

換句話說,函式的變數物件也是一個同樣簡單的變數物件,但是除了變數和函式宣告之外,它還儲存了形參和arguments物件,並叫作活動物件

考慮如下例子:

我們看下函式foo的上下文中的活動物件(activation object,簡稱AO):

activation-object4

並且函式表示式baz還是沒有被包含在變數/活動物件中。

關於這個主題所有細節方面(像變數和函式宣告的提升問題(hoisting))的完整描述可以在同名的章節第二章 變數物件中找到。

注意,在ES5中變數物件活動物件被併入了詞法環境模型(lexical environments model),詳細的描述可以在對應的章節找到。

然後我們向下一個部分前進。眾所周知,在ECMAScript中我們可以使用內部函式,然後在這些內部函式我們可以引用函式的變數或者全域性上下文中的變數。當我們把變數物件命名為上下文的作用域物件,與上面討論的原型鏈相似,這裡有一個叫作作用域鏈的東西。

作用域鏈

這個規則還是與原型鏈同樣簡單以及相似:如果一個變數在函式自身的作用域(在自身的變數/活動物件)中沒有找到,那麼將會查詢它父函式(外層函式)的變數物件,以此類推。

就上下文而言,識別符號指的是:變數名稱,函式宣告,形參,等等。當一個函式在其程式碼中引用一個不是區域性變數(或者區域性函式或者一個形參)的識別符號,那麼這個識別符號就叫作自由變數搜尋這些自由變數(free variables)正好就要用到作用域鏈

在通常情況下,作用域鏈是一個包含所有父(函式)變數物件__加上(在作用域鏈頭部的)函式自身變數/活動物件的一個列表。但是,這個作用域鏈也可以包含任何其他物件,比如,在上下文執行過程中動態加入到作用域鏈中的物件-像with物件或者特殊的catch從句(catch-clauses)物件。

解析(查詢)一個識別符號的時候,會從作用域鏈中的活動物件開始查詢,然後(如果這個識別符號在函式自身的活動物件中沒有被查詢到)向作用域鏈的上一層查詢-重複這個過程,就和原型鏈一樣。

我們可以假設通過隱式的__parent__屬性來和作用域鏈物件進行關聯,這個屬性指向作用域鏈中的下一個物件。這個方案可能在真實的Rhino程式碼中經過了測試,並且這個技術很明確得被用於ES5的詞法環境中(在那裡被叫作outer連線)。作用域鏈的另一個表現方式可以是一個簡單的陣列。利用__parent__概念,我們可以用下面的圖來表現上面的例子(並且父變數物件儲存在函式的[[Scope]]屬性中):

scope-chain5

在程式碼執行過程中,作用域鏈可以通過使用with語句和catch從句物件來增強。並且由於這些物件是簡單的物件,它們可以擁有原型(和原型鏈)。這個事實導致作用域鏈查詢變為兩個維度:(1)首先是作用域鏈連線,然後(2)在每個作用域鏈連線上-深入作用域鏈連線的原型鏈(如果此連線擁有原型)。

對於這個例子:

我們可以給出如下的結構(確切的說,在我們查詢__parent__連線之前,首先查詢__proto__鏈):

scope-chain-with6

注意,不是在所有的實現中全域性物件都是繼承自Object.prototype。上圖中描述的行為(從全域性上下文中引用「未定義」的變數x)可以在諸如SpiderMonkey引擎中進行測試。

由於所有父變數物件都存在,所以在內部函式中獲取父函式中的資料沒有什麼特別-我們就是遍歷作用域鏈去解析(搜尋)需要的變數。就像我們上邊提及的,在一個上下文結束之後,它所有的狀態和它自身都會被銷燬。在同一時間父函式可能會返回一個內部函式。而且,這個返回的函式之後可能在另一個上下文中被呼叫。如果自由變數的上下文已經「消失」了,那麼這樣的呼叫將會發生什麼?通常來說,有一個概念可以幫助我們解決這個問題,叫作(詞法)閉包,其在ECMAScript中就是和作用域鏈的概念緊密相關的。

閉包

在ECMAScript中,函式是第一級(first-class)物件。這個術語意味著函式可以做為引數傳遞給其他函式(在那種情況下,這些引數叫作「函式型別引數」(funargs,是”functional arguments”的簡稱))。接收「函式型別引數」的函式叫作高階函式或者,貼近數學一些,叫作高階操作符。同樣函式也可以從其他函式中返回。返回其他函式的函式叫作以函式為值(function valued)的函式(或者叫作擁有函式類值的函式(functions with functional value))。

這有兩個在概念上與「函式型別引數(funargs)」和「函式型別值(functional values)」相關的問題。並且這兩個子問題在“Funarg problem”(或者叫作”functional argument”問題)中很普遍。為了解決整個”funarg problem”閉包(closure)的概念被創造了出來。我們詳細的描述一下這兩個子問題(我們將會看到這兩個問題在ECMAScript中都是使用圖中所提到的函式的[[Scope]]屬性來解決的)。

「funarg問題」的第一個子問題是「向上funarg問題」(upward funarg problem)。它會在當一個函式從另一個函式向上返回(到外層)並且使用上面所提到的自由變數的時候出現。為了在即使父函式上下文結束的情況下也能訪問其中的變數,內部函式在被建立的時候會在它的[[Scope]]屬性中儲存父函式的作用域鏈。所以當函式被呼叫的時候,它上下文的作用域鏈會被格式化成活動物件與[[Scope]]屬性的和(實際上就是我們剛剛在上圖中所看到的):

再次注意這個關鍵點-確切的說在建立時刻-函式會儲存父函式的作用域鏈,因為確切的說這個儲存下來的作用域鏈將會在未來的函式呼叫時用來查詢變數。

這個型別的作用域叫作靜態(或者詞法)作用域。我們看到變數x在返回的bar函式的[[Scope]]屬性中被找到。通常來說,也存在動態作用域,那麼上面例子中的變數x將會被解析成20,而不是10。但是,動態作用域在ECMAScript中沒有被使用。

「funarg問題」的第二個部分是「向下funarg問題」。這種情況下可能會存在一個父上下文,但是在解析識別符號的時候可能會模糊不清。問題是:識別符號該使用哪個作用域的值-以靜態的方式儲存在函式建立時刻的還是在執行過程中以動態方式生成的(比如caller的作用域)?為了避免這種模稜兩可的情況並形成閉包,靜態作用域被採用:

我們可以斷定靜態作用域是一門語言擁有閉包的必需條件。但是,一些語言可能會同時提供動態和靜態作用域,允許程式設計師做選擇-什麼應該包含(closure)在內和什麼不應包含在內。由於在ECMAScript中只使用了靜態作用域(比如我們對於funarg問題的兩個子問題都有解決方案),所以結論是:ECMAScript完全支援閉包,技術上是通過函式的[[Scope]]屬性實現的。現在我們可以給閉包下一個準確的定義:

注意,由於每個(標準的)函式都在建立的時候儲存了[[Scope]],所以理論上來講,ECMAScript中的所有函式都是閉包

另一個需要注意的重要事情是,多個函式可能擁有相同的父作用域(這是很常見的情況,比如當我們擁有兩個內部/全域性函式的時候)。在這種情況下,[[Scope]]屬性中儲存的變數是在擁有相同父作用域鏈的所有函式之間共享的。一個閉包對變數進行的修改會體現在另一個閉包對這些變數的讀取上:

以上程式碼可以通過下圖進行說明:

shared-scope7

確切來說這個特性在迴圈中建立多個函式的時候會使人非常困惑。在建立的函式中使用迴圈計數器的時候,一些程式設計師經常會得到非預期的結果,所有函式中的計數器都是同樣的值。現在是到了該揭開謎底的時候了-因為所有這些函式擁有同一個[[Scope]],這個屬性中的迴圈計數器的值是最後一次所賦的值。

這裡有幾種技術可以解決這個問題。其中一種是在作用域鏈中提供一個額外的物件-比如,使用額外函式:

對閉包理論和它們的實際應用感興趣的同學可以在第六章 閉包中找到額外的資訊。如果想獲取更多關於作用域鏈的資訊,可以看一下同名的第四章 作用域鏈。

然後我們移動到下個部分,考慮一下執行上下文的最後一個屬性。這就是關於this值的概念。

This

任何物件都可以做為上下文中的this的值。我想再一次澄清,在一些對ECMAScript執行上下文和部分this的描述中的所產生誤解。this經常被錯誤的描述成是變數物件的一個屬性。這類錯誤存在於比如像這本書中(即使如此,這本書的相關章節還是十分不錯的)。再重複一次:

這個特性非常重要,因為與變數相反this從不會參與到識別符號解析過程。換句話說,在程式碼中當訪問this的時候,它的值是直接從執行上下文中獲取的,並不需要任何作用域鏈查詢this的值只在進入上下文的時候進行一次確定。

順便說一下,與ECMAScript相反,比如,Python的方法都會擁有一個被當作簡單變數的self引數,這個變數的值在各個方法中是相同的的並且在執行過程中可以被更改成其他值。在ECMAScript中,給this賦一個新值是不可能的,因為,再重複一遍,它不是一個變數並且不存在於變數物件中。

在全域性上下文中,this就等於全域性物件本身(這意味著,這裡的this等於變數物件):

在函式上下文的情況下,對函式的每次呼叫,其中的this值可能是不同的。這個this值是通過函式呼叫表示式(也就是函式被呼叫的方式)的形式由caller所提供的。舉個例子,下面的函式foo是一個callee,在全域性上下文中被呼叫,此上下文為caller。讓我們通過例子看一下,對於一個程式碼相同的函式,this值是如何在不同的呼叫中(函式觸發的不同方式),由caller給出不同的結果的:

為了深入理解this為什麼(並且更本質一些-如何)在每個函式呼叫中可能會發生變化,你可以閱讀第三章 This。在那裡,上面所提到的情況都會有詳細的討論。

總結

通過本文我們完成了對概要的綜述。儘管,它看起來並不像是「概要」;)。對所有這些主題進行完全的解釋需要一本完整的書。我們只是沒有涉及到兩個大的主題:函式(和不同函式之間的區別,比如,函式宣告函式表示式)和ECMAScript中所使用的求值策略(evaluation strategy )。這兩個主題是可以ES3系列的在對應章節找到:第五章 函式和第八章 求值策略。

如果你有留言,問題或者補充,我將會很樂意地在評論中討論它們。

祝學習ECMAScript好運!

相關文章