/ JavaScript

AngularJS directives內compile及link的函式本質

Original article: The nitty-gritty of compile and link functions inside AngularJS directives by Jurgen Van de Moere.
This is a translated version in Traditinoal Chinese.


AngularJS directives是令人驚豔的。它允許你創造高度語意且可重複利用的元件。在某種意義上你可以認為它是極致的web components先驅者。

有許多很棒的文章,甚至是書籍,在教導你如何撰寫自己的directives。相較之下,只有少許的資訊談到有關compilelink函式的差異,更不用說有關pre-linkpost-link函式差別。

大多數的導引只有簡單地提到compile函式主要由***AngularJS在內部使用***,並且建議你***只要用link函式***,應該能夠涵蓋大多數的使用案例。

這是十分不幸的,因為了解這些函式其中的差異能夠提昇你的能力,更加的了解AngularJS內部運作,並且訂製出更好的directives。

所以跟著我,文章最後你將會正確地了解這些函式是什麼以及什麼時候該使用它們。

本文假設你已經了解什麼是AngularJS directive。如果不了解,我強烈建議你先閱讀AngularJS開發者指南的directive章節。

AngularJS如何處理directives?

在我們開始之前,讓我打斷一下,先了解AngularJS是如何處理directives。

當瀏覽器渲染(render)頁面時,基本地讀取HTML標籤,建立一個DOM,當DOM準備好時,廣播(broadcast)出一個事件(event)。

當你使用<script></script>標籤引入你的AngularJS程式碼到頁面時,AngularJS會監聽(listen)該事件,一旦該事件發出,AngularJS便會開始走遍(traversing)DOM,尋找所有元素(element)中的屬性(attribute)是否具有ng-app

一旦找到具有該屬性的元素,AngularJS便以該元素作為起始點,進行DOM***處理***。如果在html元素的屬性內設定ng-app,那麼AngularJS將會從html元素開始***處理***DOM。

從起始點開始,AngularJS遞迴地調查所有子元素,從你的AngularJS應用程式中所定義的directives中去找尋相對應的樣式。

AngularJS如何***處理***元素,取決於實際定義directive的物件(譯註:directive definition object)。你可以預先定義compile函式或link函式,兩者可同時存在。或者選擇性的定義pre-linkpost-link這兩個函式來取代link函式,

所以,這些函式有什麼差異?為什麼及何時該使用這些函式?

堅持下去...

程式碼

為了解釋這些差異,我會用程式碼來做示範,希望能夠更容易的理解。

如果你有任何疑問或評論,歡迎直接在本文最下方留下你的意見。

考慮下列HTML標籤:

<level-one>  
    <level-two>
        <level-three>
            Hello {{name}}         
        </level-three>
    </level-two>
</level-one>  

以及下列JavaScript:

var app = angular.module('plunker', []);

function createDirective(name){  
  return function(){
    return {
      restrict: 'E',
      compile: function(tElem, tAttrs){
        console.log(name + ': compile');
        return {
          pre: function(scope, iElem, iAttrs){
            console.log(name + ': pre link');
          },
          post: function(scope, iElem, iAttrs){
            console.log(name + ': post link');
          }
        }
      }
    }
  }
}

app.directive('levelOne', createDirective('levelOne'));  
app.directive('levelTwo', createDirective('levelTwo'));  
app.directive('levelThree', createDirective('levelThree'));  

目標很簡單:讓AngularJS處理巢狀的三個directives,而每個directive都有自己的compilepre-linkpost-link函式,各函式輸出訊息至console,我們可以藉此作為識別。

這讓我們可以一睹AngularJS是如何在背後處理這些directives。

輸出結果

這是console輸出結果的截圖:

如果你要自己試試看,開啟這個plnkr連結,並在打開瀏覽器的Console。
(譯註:Chrome可以按下快捷鍵Ctrl+Shift+I(Mac為Cmd+Opt+I)來開啟DevTools,切換至Console頁即可)

開始分析

第一件要注意的是,函式呼叫的順序:

// COMPILE階段
// levelOne:    compile函式已呼叫
// levelTwo:    compile函式已呼叫
// levelThree:  compile函式已呼叫

// PRE-LINK階段
// levelOne:    pre link函式已呼叫
// levelTwo:    pre link函式已呼叫
// levelThree:  pre link函式已呼叫

// POST-LINK階段 (注意到反向順序)
// levelThree:  post link函式已呼叫
// levelTwo:    post link函式已呼叫
// levelOne:    post link函式已呼叫

這個清處的展示AngularJS一開始compile所有directives,compile階段尚未連結scope,link階段也分成pre-linkpost-link階段。

注意到呼叫compilepre-link的順序是一致的,但是呼叫post-link的順序則是相反的。

所以在這裡我們可以已經清處的辨別這幾個不同的階段,但是compilepre-link又有什麼不同呢?它們也有同樣的順序,為什麼要將它們分開?

文件物件模型(DOM)

稍微深入一些,進一步修改JavaScript,呼叫時一併輸出元素的DOM:

var app = angular.module('plunker', []);

function createDirective(name){  
  return function(){
    return {
      restrict: 'E',
      compile: function(tElem, tAttrs){
        console.log(name + ': compile => ' + tElem.html());
        return {
          pre: function(scope, iElem, iAttrs){
            console.log(name + ': pre link => ' + iElem.html());
          },
          post: function(scope, iElem, iAttrs){
            console.log(name + ': post link => ' + iElem.html());
          }
        }
      }
    }
  }
}

app.directive('levelOne', createDirective('levelOne'));  
app.directive('levelTwo', createDirective('levelTwo'));  
app.directive('levelThree', createDirective('levelThree'));  

注意到console.log額外的輸出訊息。沒有任何更動,仍然是最原始的標籤。

這應該能讓我們更詳細的了解函式的來龍去脈。

讓我們再次執行程式碼。

輸出結果

這是修改程式碼後console輸出結果的截圖:

同樣地,如果你想要自己試試看,開啟plnkr連結,從Console就可以看到結果。

觀察

輸出DOM結果透漏某些有趣的東西:compilepre-link階段的DOM不一樣。

所以,發生什麼事?

Compile

我們已經學習到當AngularJS偵測DOM準備好時,會進行DOM處理。

所以,當AngularJS開始走遍DOM,它遇見<level-one>元素,並從它的directive定義(directive definition)中得知需要執行某些行為。

因為在levelOne的directive定義中,定義了compile函式,所以會呼叫此函式並帶入元素DOM作為函式的參數。

如果你靠近一點你會看到,在這個時機點,元素的DOM仍然是最初剛開始的DOM,係由瀏覽器根據原始HTML標籤所創造出來的DOM。

在AngularJS裡,經常用樣板元素(template element)來提到原始的DOM,因此基於這個原因我個人用tElem來作為compile函式內的參數名稱,用來表示樣板元素。

levelOnecompile執行之後,AngularJS更深入且遞迴地走入DOM,對<level-two><level-three>重複相同的編譯步驟。

在我們深入pre-link函式前,讓我們先看一下post-link函式。

如果你產生的directive只有link函式,AngularJS會將它當作是post-link函式。因為這個原因我們要在先討論它。

一旦AngularJS走到DOM的最後(底)並執行完所有compile函式,它會往回(上)走並且執行所有關聯的post-link函式。

現在DOM是用反方向在走遍,因此呼叫post-link函式是相反的順序。所以前幾分鐘看到相反順序覺得很奇怪,現在開始覺得合理了。

這相反順序保證所有的子元素post-link會先被執行,接著才是父元素的post-link

所以,當<level-one>post-link函式被呼叫,我們可以保證<level-two><level-three>post-link已經被呼叫過。

這就是為什麼它被認為是用來加入你的directive邏輯最安全以及預設的地方。

那元素的DOM呢?為什麼在這裡它們是不同的?

當AngularJS呼叫了directive的compile函式之後,它會產生一個樣板元素(template element)的***實例***元素(instance element)(通常稱之為消滅實體),並且提供一個scope給這個實體。這個scope可以是全新的scope、繼承的子scope或孤立的scope,取決於相對應directive定義物件內scope屬性設定。

所以,到連結階段的時候,實例元素及scope已經可以開始使用,並且AngularJS會將它作為函式參數傳遞到post-link函式。

我個人一直使用iElem作為link函式的參數名稱,用來作為元素實例的參考。

當撰寫post-link函式時,你可以保證所有子元素的post-link函式已經執行過。

在大多數的案例中,這個非常合理,因此它也是最常用來撰寫directive程式碼的地方。

然而,AngularJS提供了一個附加的鉤子***,稱之為pre-link函式,程式碼會先被執行*,搶先在所有子元素的post-link被執行之前。

再次強調:

pre-link函式保證所有子元素的post-link被執行前,先執行pre-link函式,並且是在實體元素中執行。

所以當相反順序的呼叫post-link十分合理,那原始順序的呼叫pre-link也是十分合理。

回顧

如果我們回顧之前原始輸出,我們可以清晰的辨認出發生什麼事:

// 這裡的元素仍然是最原始的樣板標籤

// COMPILE 階段
// levelOne:    於原始DOM中呼叫compile函式
// levelTwo:    於原始DOM中呼叫compile函式
// levelThree:  於原始DOM中呼叫compile函式

// 從這裡開始,元素已經實例化且綁定了SCOPE
// (例:NG-REPEAT 已有多重實例)

// PRE-LINK 階段
// levelOne:    於元素實例中呼叫pre link函式
// levelTwo:    於元素實例中呼叫pre link函式
// levelThree:  於元素實例中呼叫pre link函式

// POST-LINK 階段 (注意到順序相反)
// levelThree:  於元素實例中呼叫post link函式
// levelTwo:    於元素實例中呼叫post link函式
// levelOne:    於元素實例中呼叫post link函式

摘要

回顧中我們可以描述不同的函式及使用案例如下:

Compile函式

在AngularJS產生實例及scope之前,使用compile函式來更動原始DOM(樣板元素)。

它可以有多個元素實例,但只會有一個樣板元素。ng-repeat就是這個案例的一個完美範例。它讓compile成為最佳的地方來進行更動DOM,之後才會套用所有實例,因為只會執行一次,所以當你要消滅很多實例時,可以獲得很多效率上的提昇。

樣板的元素及屬性都會作為參數傳遞到compile函式,但不會有scope傳入,因為還沒準備好:

/**
* Compile函式
* 
* @param tElem - 樣板元素
* @param tAttrs - 樣板元素的屬性
*/
function(tElem, tAttrs){

    // ...

};

當AngularJS已經compile子元素,在任何子元素的post-link執行之前,使用pre-link函式來實作邏輯。

Scope、實例元素及實例屬性都會作為參數傳遞到pre-link函式:

/**
* Pre-link函式
* 
* @param scope - 關連於此實例的scope
* @param iElem - 實例元素
* @param iAttrs - 實例元素的屬性
*/
function(scope, iElem, iAttrs){

    // ...

};

使用post-link來執行邏輯,該邏輯知道所有子元素已經編譯,並且所有子元素的pre-linkpost-link都已經被執行。

基於這個理由,post-link認為是***最安全***及***預設***的地方來撰寫你的程式碼。

Scope、實例元素及實例屬性都會作為參數傳遞到post-link函式:

/**
* Post-link函式
* 
* @param scope - 關連於此實例的scope
* @param iElem - 實例元素
* @param iAttrs - 實例元素的屬性
*/
function(scope, iElem, iAttrs){

    // ...

};

結論

到目前為止,但願你有清楚的理解關於compilepre-linkpost-link之間的差異。

如果沒有且你很認真的在做AngularJS開發,我強烈建議你再讀一次文章,直到你有穩固的抓住其運作原理。

了解這個重要的概念將會讓你更容易理解原生的AngularJS directive是如何運作,並且如何最佳化你訂製的directives。

如果你仍然感到疑惑且有額外的問題,歡迎在下方留言。

祝你愉快!

更新 (2014-09-03):

幾個朋友問我:

  • 如果directives使用transculsion,會有什麼作用?
  • 這個跟directive的controller函式有什麼關連?
  • 使用template或templateUrl之後,會有什麼影響?

為了避免文章篇幅過大,我會分別針對每個主題另外撰寫文章。

如果當接下來的文章發表出來後,你想要收到通知,請訂閱本站電子信(請放心,我不會寄送垃圾信)。

(譯註:意者請至此文章原文訂閱電子信。譯者也會持續翻譯接下來的文章)

同樣地,如果你覺得少了哪些重要的資訊,請不吝留下評論,我高度重視您的意見。

Thanks!