構建自己的AngularJS(1):Scope和Digest

發表於2013-11-18

Angular是一個成熟和強大的JavaScript框架。它也是一個比較龐大的框架,在熟練掌握之前,需要領會它提出的很多新概念。很多Web開發人員湧向Angular,有不少人面臨同樣的障礙。Digest到底是怎麼做的?定義一個指令(directive)有哪些不同的方法?Service和provider有什麼區別?

Angular的文件挺不錯的,第三方的資源也越來越豐富,想要學習一門新的技術,沒什麼方法比把它拆開研究其運作機制更好。

在這個系列的文章中,我將從無到有構建AngularJS的一個實現。隨著逐步深入的講解,讀者將能對Angular的運作機制有一個深入的認識。

在第一部分中,讀者將看到Angular的作用域是如何運作的,還有比如$eval, $digest, $apply這些東西怎麼實現。Angular的髒檢查邏輯看上去有些不可思議,但你將看到實際並非如此。

基礎知識

在Github上,可以看到這個專案的全部原始碼。相比只複製一份下來,我更建議讀者從無到有構建自己的實現,從不同角度探索程式碼的每個步驟。在本文中,我嵌入了JSBin的一些程式碼,可以直接在文章中進行一些互動。(譯者注:因為我在github上翻譯,沒法整合JSBin了,只能給連結……)

我們將使用Lo-Dash庫來處理一些在陣列和物件上的底層操作。Angular自身並未使用Lo-Dash,但是從我們的目的看,要儘量無視這些不太相關的比較底層的事情。當讀者在程式碼中看到下劃線(_)的時候,那就是在呼叫Lo-Dash的功能。

我們還將使用console.assert函式做一些特別的測試。這個函式應該適用於所有現代JavaScript環境。

下面是使用Lo-Dash和assert函式的示例:

http://jsbin.com/UGOVUk/4/embed?js,console

Scope物件

Angular的Scope物件是POJO(簡單的JavaScript物件),在它們上面,可以像對其他物件一樣新增屬性。Scope物件是用建構函式建立的,我們來寫個最簡單的版本:

現在我們就可以使用new操作符來建立一個Scope物件了。我們也可以在它上面附加一些屬性:

這些屬性沒什麼特別的。不需要呼叫特別的設定器(setter),賦值的時候也沒什麼限制。相反,在兩個特別的函式:$watch和$digest之中發生了一些奇妙的事情。

監控物件屬性:$watch和$digest

$watch和$digest是相輔相成的。兩者一起,構成了Angular作用域的核心:資料變化的響應。

使用$watch,可以在Scope上新增一個監聽器。當Scope上發生變更時,監聽器會收到提示。給$watch指定如下兩個函式,就可以建立一個監聽器:

  • 一個監控函式,用於指定所關注的那部分資料。
  • 一個監聽函式,用於在資料變更的時候接受提示。

作為一名Angular使用者,一般來說,是監控一個表示式,而不是使用監控函式。監控表示式是一個字串,比如說“user.firstName”,通常在資料繫結,指令的屬性,或者JavaScript程式碼中指定,它被Angular解析和編譯成一個監控函式。在這篇文章的後面部分我們會探討這是如何做的。在這篇文章中,我們將使用稍微低階的方法直接提供監控功能。

為了實現$watch,我們需要儲存註冊過的所有監聽器。我們在Scope建構函式上新增一個陣列:

在Angular框架中,雙美元符字首$ $表示這個變數被當作私有的來考慮,不應當在外部程式碼中呼叫。

現在我們可以定義$watch方法了。它接受兩個函式作引數,把它們儲存在$ $watchers陣列中。我們需要在每個Scope例項上儲存這些函式,所以要把它放在Scope的原型上:

另外一面就是$digest函式。它執行了所有在作用域上註冊過的監聽器。我們來實現一個它的簡化版,遍歷所有監聽器,呼叫它們的監聽函式:

現在我們可以新增監聽器,然後執行$digest了,這將會呼叫監聽函式:

http://jsbin.com/oMaQoxa/2/embed?js,console

這些本身沒什麼大用,我們要的是能檢測由監控函式指定的值是否確實變更了,然後呼叫監聽函式。

髒值檢測

如同上文所述,監聽器的監聽函式應當返回我們所關注的那部分資料的變化,通常,這部分資料就存在於作用域中。為了使得訪問作用域更便利,在呼叫監控函式的時候,使用當前作用域作為實參。一個關注作用域上fiestName屬性的監聽器像這個樣子:

這是監控函式的一般形式:從作用域獲取一些值,然後返回。

$digest函式的作用是呼叫這個監控函式,並且比較它返回的值和上一次返回值的差異。如果不相同,監聽器就是髒的,它的監聽函式就應當被呼叫。

想要這麼做,$digest需要記住每個監控函式上次返回的值。既然我們現在已經為每個監聽器建立過一個物件,只要把上一次的值存在這上面就行了。下面是檢測每個監控函式值變更的$digest新實現:

對每個監聽器,我們呼叫監控函式,把作用域自身當作實參傳遞進去,然後比較這個返回值和上次返回值,如果不同,就呼叫監聽函式。方便起見,我們把新舊值和作用域都當作引數傳遞給監聽函式。最終,我們把監聽器的last屬性設定成新返回的值,下一次可以用它來作比較。

有了這個實現之後,我們就可以看到在$digest呼叫的時候,監聽函式是怎麼執行的:

http://jsbin.com/OsITIZu/3/embed?js,console

我們已經實現了Angular作用域的本質:新增監聽器,在digest裡執行它們。

也已經可以看到幾個關於Angular作用域的重要效能特性:

  • 在作用域上新增資料本身並不會有效能折扣。如果沒有監聽器在監控某個屬性,它在不在作用域上都無所謂。Angular並不會遍歷作用域的屬性,它遍歷的是監聽器。
  • $digest裡會呼叫每個監控函式,因此,最好關注監聽器的數量,還有每個獨立的監控函式或者表示式的效能。

在Digest的時候獲得提示

如果你想在每次Angular的作用域被digest的時候得到通知,可以利用每次digest的時候挨個執行監聽器這個事情,只要註冊一個沒有監聽函式的監聽器就可以了。

想要支援這個用例,我們需要在$watch裡面檢測是否監控函式被省略了,如果是這樣,用個空函式來代替它:

如果用了這個模式,需要記住,即使沒有listenerFn,Angular也會尋找watchFn的返回值。如果返回了一個值,這個值會提交給髒檢查。想要採用這個用法又想避免多餘的事情,只要監控函式不返回任何值就行了。在這個例子裡,監聽器的值始終會是未定義的。

http://jsbin.com/OsITIZu/4/embed?js,console

這個實現的核心就這樣,但是離最終的還是差太遠了。比如說有個很典型的場景我們不能支援:監聽函式自身也修改作用域上的屬性。如果這個發生了,另外有個監聽器在監控被修改的屬性,有可能在同一個digest裡面檢測不到這個變動:

http://jsbin.com/eTIpUyE/2/embed?js,console

我們來修復這個問題。

當資料髒的時候持續Digest

我們需要改變一下digest,讓它持續遍歷所有監聽器,直到監控的值停止變更。

首先,我們把現在的$digest函式改名為$ $digestOnce,它把所有的監聽器執行一次,返回一個布林值,表示是否還有變更了:

然後,我們重新定義$digest,它作為一個“外層迴圈”來執行,當有變更發生的時候,呼叫$ $digestOnce:

$digest現在至少執行每個監聽器一次了。如果第一次執行完,有監控值發生變更了,標記為dirty,所有監聽器再執行第二次。這會一直執行,直到所有監控的值都不再變化,整個局面穩定下來了。

Angular作用域裡並不是真的有個函式叫做$ $digestOnce,相反,digest迴圈都是包含在$digest裡的。我們的目標更多是清晰度而不是效能,所以把內層迴圈封裝成了一個函式。

下面是新的實現:

http://jsbin.com/Imoyosa/3/embed?js,console

我們現在可以對Angular的監聽器有另外一個重要認識:它們可能在單次digest裡面被執行多次。這也就是為什麼人們經常說,監聽器應當是冪等的:一個監聽器應當沒有邊界效應,或者邊界效應只應當發生有限次。比如說,假設一個監控函式觸發了一個Ajax請求,無法確定你的應用程式發了多少個請求。

在我們現在的實現中,有一個明顯的遺漏:如果兩個監聽器互相監控了對方產生的變更,會怎樣?也就是說,如果狀態始終不會穩定?這種情況展示在下面的程式碼裡。在這個例子裡,$digest呼叫被註釋掉了,把註釋去掉看看發生什麼情況:

http://jsbin.com/eKEvOYa/3/embed?js,console

JSBin執行了一段時間之後就停止了(在我機器上大概跑了100,000次左右)。如果你在別的東西比如Node.js裡跑,它會一直執行下去。

放棄不穩定的digest

我們要做的事情是,把digest的執行控制在一個可接受的迭代數量內。如果這麼多次之後,作用域還在變更,就勇敢放手,宣佈它永遠不會穩定。在這個點上,我們會丟擲一個異常,因為不管作用域的狀態變成怎樣,它都不太可能是使用者想要的結果。

迭代的最大值稱為TTL(short for Time To Live)。這個值預設是10,可能有點小(我們剛執行了這個digest 100,000次!),但是記住這是一個效能敏感的地方,因為digest經常被執行,而且每個digest執行了所有的監聽器。使用者也不太可能建立10個以上鍊狀的監聽器。

事實上,Angular裡面的TTL是可以調整的。我們將在後續文章討論provider和依賴注入的時候再回顧這個話題。

我們繼續,給外層digest迴圈新增一個迴圈計數器。如果達到了TTL,就丟擲異常:

下面是更新過的版本,可以讓我們迴圈引用的監控例子丟擲異常:

http://jsbin.com/uNapUWe/2/embed?js,console

這些應當已經把digest的事情說清楚了。

現在,我們把注意力轉到如何檢測變更上吧。

基於值的髒檢查

我們曾經使用嚴格等於操作符(===)來比較新舊值,在絕大多數情況下,它是不錯的,比如所有的基本型別(數字,字串等等),也可以檢測一個物件或者陣列是否變成新的了,但Angular還有一種辦法來檢測變更,用於檢測當物件或者陣列內部產生變更的時候。那就是:可以監控值的變更,而不是引用。

這類髒檢查需要給$watch函式傳入第三個布林型別的可選引數當標誌來開啟。當這個標誌為真的時候,基於值的檢查開啟。我們來重新定義$watch,接受這個引數,並且把它存在監聽器裡:

我們所做的一切是把這個標誌加在監聽器上,通過兩次取反,強制轉換為布林型別。當使用者呼叫$watch,沒傳入第三個引數的時候,valueEq會是未定義的,在監聽器物件裡就變成了false。

基於值的髒檢查意味著如果新舊值是物件或者陣列,我們必須遍歷其中包含的所有內容。如果它們之間有任何差異,監聽器就髒了。如果該值包含巢狀的物件或者陣列,它也會遞迴地按值比較。

Angular內建了自己的相等檢測函式,但是我們會用Lo-Dash提供的那個。讓我們定義一個新函式,取兩個值和一個布林標誌,並比較相應的值:

為了提示值的變化,我們也需要改變之前在每個監聽器上儲存舊值的方式。只儲存當前值的引用是不夠的,因為在這個值內部發生的變更也會生效到它的引用上,$ $areEqual方法比較同一個值的兩個引用始終為真,監控不到變化,因此,我們需要建立當前值的深拷貝,並且把它們儲存起來。

就像相等檢測一樣,Angular也內建了自己的深拷貝函式,但我們還是用Lo-Dash提供的。我們修改一下$digestOnce,在內部使用新的$ $areEqual函式,如果需要的話,也複製最後一次的引用:

現在我們可以看到兩種髒檢測方式的差異:

http://jsbin.com/ARiWENO/3/embed?js,console

相比檢查引用,檢查值的方式顯然是一個更為複雜的操作。遍歷巢狀的資料結構很花時間,保持深拷貝的資料也佔用不少記憶體。這就是Angular預設不使用基於值的髒檢測的原因,使用者需要顯式設定這個標記去開啟它。

Angular也提供了第三種髒檢測的方法:集合監控。就像基於值的檢測,也能提示物件和陣列中的變更。但不同於基於值的檢測方式,它做的是一個比較淺的檢測,並不遞迴進入到深層去,所以它比基於值的檢測效率更高。集合檢測是通過“$watchCollection”函式來使用的,在這個系列的後續部分,我們會來看看它是如何實現的。

在我們完成值的比對之前,還有些JavaScript怪事要處理一下。

非數字(NaN)

在JavaScript裡,NaN(Not-a-Number)並不等於自身,這個聽起來有點怪,但確實就這樣。如果我們在髒檢測函式裡不顯式處理NaN,一個值為NaN的監聽器會一直是髒的。

對於基於值的髒檢測來說,這個事情已經被Lo-Dash的isEqual函式處理掉了。對於基於引用的髒檢測來說,我們需要自己處理。來修改一下$ $areEqual函式的程式碼:

現在有NaN的監聽器也正常了:

http://jsbin.com/ijINaRA/2/embed?js,console

基於值的檢測實現好了,現在我們該把注意力集中到應用程式程式碼如何跟作用域打交道上了。

$eval – 在作用域的上下文上執行程式碼

在Angular中,有幾種方式可以在作用域的上下文上執行程式碼,最簡單的一種就是$eval。它使用一個函式作引數,所做的事情是立即執行這個傳入的函式,並且把作用域自身當作引數傳遞給它,返回的是這個函式的返回值。$eval也可以有第二個引數,它所做的僅僅是把這個引數傳遞給這個函式。

$eval的實現很簡單:

$eval的使用一樣很簡單:

http://jsbin.com/UzaWUC/1/embed?js,console

那麼,為什麼要用這麼一種明顯很多餘的方式去執行一個函式呢?有人覺得,有些程式碼是專門與作用域的內容打交道的,$eval讓這一切更加明顯。$scope也是構建$apply的一個部分,後面我們就來講它。

然後,可能$eval最有意思的用法是當我們不傳入函式,而是表示式。就像$watch一樣,可以給$eval一個字串表示式,它會把這個表示式編譯,然後在作用域的上下文中執行。我們將在這個系列的後面部分實現這些。

$apply – 整合外部程式碼與digest迴圈

可能Scope上所有函式裡最有名的就是$apply了。它被譽為將外部庫整合到Angular的最標準的方式,這話有個不錯的理由。

$apply使用函式作引數,它用$eval執行這個函式,然後通過$digest觸發digest迴圈。下面是一個簡單的實現:

$digest的呼叫放置於finally塊中,以確保即使函式丟擲異常,也會執行digest。

關於$apply,大的想法是,我們可以執行一些與Angular無關的程式碼,這些程式碼也還是可以改變作用域上的東西,$apply可以保證作用域上的監聽器可以檢測這些變更。當人們談論使用$apply整合程式碼到“Angular生命週期”的時候,他們指的就是這個事情,也沒什麼比這更重要的了。

這裡是$apply的實踐:

http://jsbin.com/UzaWUC/2/embed?js,console

延遲執行 – $evalAsync

在JavaScript中,經常會有把一段程式碼“延遲”執行的情況 – 把它的執行延遲到當前的執行上下文結束之後的未來某個時間點。最常見的方式就是呼叫setTimeout()函式,傳遞一個0(或者非常小)作為延遲引數。

這種模式也適用於Angular程式,但更推薦的方式是使用$timeout服務,並且使用$apply把要延遲執行的函式整合到digest生命週期。

但在Angular中還有一種延遲程式碼的方式,那就是Scope上的$evalAsync函式。$evalAsync接受一個函式,把它列入計劃,在當前正持續的digest中或者下一次digest之前執行。舉例來說,你可以在一個監聽器的監聽函式中延遲執行一些程式碼,即使它已經被延遲了,仍然會在現有的digest遍歷中被執行。

我們首先需要的是儲存$evalAsync列入計劃的任務,可以在Scope建構函式中初始化一個陣列來做這事:

我們再來定義$evalAsync,它新增將在這個佇列上執行的函式:

我們顯式在放入佇列的物件上設定當前作用域,是為了使用作用域的繼承,在這個系列的下一篇文章中,我們會討論這個。

然後,我們在$digest中要做的第一件事就是從佇列中取出每個東西,然後使用$eval來觸發所有被延遲執行的函式:

這個實現保證了:如果當作用域還是髒的,就想把一個函式延遲執行,那這個函式會在稍後執行,但還處於同一個digest中。

下面是關於如何使用$evalAsync的一個示例:

http://jsbin.com/ilepOwI/1/embed?js,console

作用域階段

$evalAsync做的另外一件事情是:如果現在沒有其他的$digest在執行的話,把給定的$digest延遲執行。這意味著,無論什麼時候呼叫$evalAsync,可以確定要延遲執行的這個函式會“很快”被執行,而不是等到其他什麼東西來觸發一次digest。

需要有一種機制讓$evalAsync來檢測某個$digest是否已經在執行了,因為它不想影響到被列入計劃將要執行的那個。為此,Angular的作用域實現了一種叫做階段(phase)的東西,它就是作用域上一個簡單的字串屬性,儲存了現在正在做的資訊。

在Scope的建構函式裡,我們引入一個叫$ $phase的欄位,初始化為null:

然後,我們定義一些方法用於控制這個階段變數:一個用於設定,一個用於清除,也加個額外的檢測,以確保不會把已經啟用狀態的階段再設定一次:

在$digest方法裡,我們來從外層迴圈設定階段屬性為“$digest”:

我們把$apply也修改一下,在它裡面也設定個跟自己一樣的階段。在除錯的時候,這個會有些用:

最終,把對$digest的排程放進$evalAsync。它會檢測作用域上現有的階段變數,如果沒有(也沒有已列入計劃的非同步任務),就把這個digest列入計劃。

有了這個實現之後,不管何時、何地,呼叫$evalAsync,都可以確定有一個digest會在不遠的將來發生。

http://jsbin.com/iKeSaGi/1/embed?js,console

在digest之後執行程式碼 – $ $postDigest

還有一種方式可以把程式碼附加到digest迴圈中,那就是把一個$ $postDigest函式列入計劃。

在Angular中,函式名字前面有雙美元符號表示它是一個內部的東西,不是應用開發人員應該用的。但它確實存在,所以我們也要把它實現出來。

就像$evalAsync一樣,$ $postDigest也能把一個函式列入計劃,讓它“以後”執行。具體來說,這個函式將在下一次digest完成之後執行。將一個$ $postDigest函式列入計劃不會導致一個digest也被延後,所以這個函式的執行會被推遲到直到某些其他原因引起一次digest。顧名思義,$ $postDigest函式是在digest之後執行的,如果你在$ $digest裡面修改了作用域,需要手動呼叫$digest或者$apply,以確保這些變更生效。

首先,我們給Scope的建構函式加佇列,這個佇列給$ $postDigest函式用:

然後,我們把$ $postDigest也加上去,它所做的就是把給定的函式加到佇列裡:

最終,在$digest裡,當digest完成之後,就把佇列裡面的函式都執行掉。

下面是關於如何使用$ $postDigest函式的:

http://jsbin.com/IMEhowO/1/embed?js,console

異常處理

現有對Scope的實現已經逐漸接近在Angular中實際的樣子了,但還有些脆弱,因為我們迄今為止沒有花精力在異常處理上。

Angular的作用域在遇到錯誤的時候是非常健壯的:當產生異常的時候,不管在監控函式中,在$evalAsync函式中,還是在$ $postDigest函式中,都不會把digest終止掉。我們現在的實現裡,在以上任何地方產生異常都會把整個$digest弄掛。

我們可以很容易修復它,把上面三個呼叫包在try…catch中就好了。

Angular實際上是把這些異常拋給了它的$exceptionHandler服務。既然我們現在還沒有這東西,先扔到控制檯上吧。

$evalAsync和$ $postDigest的異常處理是在$digest函式裡,在這些場景裡,從已列入計劃的程式中丟擲的異常將被記錄成日誌,它後面的還是正常執行:

監聽器的異常處理放在$ $digestOnce裡。

現在我們的digest迴圈碰到異常的時候健壯多了。

http://jsbin.com/IMEhowO/2/embed?js,console

銷燬一個監聽器

當註冊一個監聽器的時候,一般都需要讓它一直存在於整個作用域的生命週期,所以很少會要顯式把它移除。也有些場景下,需要保持作用域的存在,但要把某個監聽器去掉。

Angular中的$watch函式是有返回值的,它是個函式,如果執行,就把剛註冊的這個監聽器銷燬。想在我們這個版本里實現這功能,只要返回一個函式在裡面把這個監控器從$ $watchers陣列去除就可以了:

現在我們就可以把$watch的這個返回值存起來,以後呼叫它來移除這個監聽器:

http://jsbin.com/IMEhowO/4/embed?js,console

展望未來

我們已經走了很長一段路了,已經有了一個完美可以執行的類似Angular這樣的髒檢測作用域系統的實現了,但是Angular的作用域上面還做了更多東西。

或許最重要的是,在Angular裡,作用域並不是孤立的物件,作用域可以繼承於其他作用域,監聽器也不僅僅是監聽本作用域上的東西,還可以監聽這個作用域的父級作用域。這種方法,概念上很簡單,但是對於初學者經常容易造成混淆。所以,本系列的下一篇文章主題就是作用域的繼承。

後面我們會討論Angular的事件系統,也是實現在Scope上的。

相關文章