可以用Directive製作自訂網頁元素是當初Angular吸引我的亮點之一(註:Knockout從3.2起也開始支援), 專案裡總不乏為特定規格量身打造的特製UI元素,像是分類、代碼或關鍵字多重查詢的商品輸入欄位,被重複應用在多個網頁輸入介面。用複製貼上是最下策(萬一邏輯要改,準備改到吐吧!),在ASP.NET WebForm裡可用UserControl或WebControl將這類要重覆利用的UI元素包成控制項,用<my:SuperProductPicker>快速安插到各網頁。在HTML5時代,自訂HTML元素也能實現類似的簡潔便利,配合NG/KO等Framework,將UI模版及邏輯包成Directive或Component, 便可比照<input>、<textarea>,用<my-super-product-picker prop1="…">在網頁擺放我們的自訂元素,這種組裝積木的做法,不正是寫應用程式的最高境界?

先前已寫過自訂Directive範例,但該範例在一般HTML元素加註anim-hide=,只透過jQuery加上動畫效果。在我後來的專案經驗,自訂Directive更常被拿來包裝自訂網頁元素,決定藉這篇文章整理自訂網頁元素的典型做法,自己筆記也分享供大家參考。

Directive的宣告格式有很多種,例如:可以只傳link函式,也可以傳一個包含link函式屬性物件再加上其他設定。scope方面也可選擇共享呼叫端的scope,或自建獨立的scope(Isolated Scope)。自訂元素若包含較複雜邏輯或會重覆出現,使用獨立scope是較好的選擇,我自己的應用中,Directive採獨立scope佔大多數。另外,<my-element>實際執行時需轉換成<div>、<input>… 等標準HTML元素,用jQuery操作DOM效率不佳且無法套用ng-bind、ng-show等語法,直接提供HTML範本給NG解析才是上策。要提供範本可透過template指定字串,或寫成獨立HTML檔案再以templateUrl載入。若範本簡單只有三五行,建議用template,否則寫成獨立HTML檔案較好,才不會"<div>line1…" + "line2…"+… 串字串串到抓狂。我做了一個範例,列舉我所知的常用的自訂元素Directive技巧。(如需要詳細的Directive說明可參考官方文件

懶得重想情境,直接拿Template範例來改,將原本的:

<span ng-repeat="member in model.members">
      <span ng-include src="'/contactTmpl.html'" ng-show="member" class='cont-block'>      </span>

改成用自訂元素<role-card>呈現,並開放電話號碼可修改:

<span class='cont-block' ng-repeat="member in model.members">
      <role-card name="member.name" phone-no="member.phone" />
</span>

另外,leader項目還多示範如何指定CSS及從Directive呼叫View Model端函式:

<role-card name="model.leader.name" phone-no="model.leader.phone" name-css="black" phone-css="red" on-change="model.log(newValue)" />

執行結果如下:


Directive宣告部分長這樣:

    .directive("roleCard", function() {
      return {
        restrict: "AE",
        scope: {
          name: "=", 
          phone: "=phoneNo", 
          nameCss: "@", 
          onChange: "&"
        },
        link: function(scope, element, attrs) {
          scope.phoneCss = attrs.phoneCss;
          scope.$watch("phone", function(v) {
            if (angular.isFunction(scope.onChange)) {
              scope.onChange({newValue: v});
            }
          });
        },
        template: 
        "<dl>" +
        "<dt>Name</dt><dd class=\"{{nameCss}}\">{{ name }}</dd>" +
        "<dt>Phone</dt><dd>" + 
        "<input type=\"text\" ng-model=\"phone\" class=\"{{phoneCss}}\"/>" +
        "</dd>" +
        "</dl>"
      }
    });

restrict: "AE",表示這個Directive支援<div role-card>("A"ttribute)及<role-card>("E"lement)兩種標示方法,採用後者就等於自訂元素。

template用來指定範本字串,在其中可放膽使用ng-model、{{propName}},跟一般NG寫完全相同,但如你所見,串接HTML字串時單引號、雙引號、換行字元都要特別處理,行數一多頗為煩人,將HTML搬去獨立檔案再改用templateUrl請NG動態載入是較好的策略。

link函式用來放置對DOM元素操作、事件處理及資料連動等程式邏輯,是Directive的程式核心。

獨立scope比較多眉角,故留到最後才講。使用自訂元素時,一般會由外部傳入參數,提供資料或控制元素外觀,而唯一的溝通管道只有HTML Attribute,例如:<my-element prop1="somePropToBind" pop2="elementStyle" pop3="someCallback()">,而這些外界傳入內容需放入獨立scope,才能在template或templateUrl的HTML中被正確存取,因此需在獨立scope宣告屬性對應這些Attribute。程式碼中出現三個特殊符號:"="、"@"、"&",各有不同意義,用以下實例解釋:

  • name: "="
    在獨立scope宣告name屬性並雙向繫結到<contact-card name="...">所指的ViewModel屬性,該屬性異動會反應到scope.name,修改scope.name也會反應回去。
  • phone: "=phoneNo"
    若scope使用的變數名稱與HTML Attriube命名不同,需另外註明。phone: "=phoneNo"指繫結對象由<contact-card phone-no="propName">指定。需注意:NG內部一律採Camel大小寫命名規則,故HTML Attribute得寫phone-no才會轉成phoneNo,寫成<contact-card phoneNo="…">永遠對不上。
  • nameCss: "@"
    @符號在scope中代表nameCss屬性將單向繫結到name-css Attribute,其傳入型別永遠是字串。Attribute值改變會反應到scope.nameCss,但修改scope.nameCss不會反應回外層View Model。 指定靜態值直接寫<contact-card name-css="black",若要跟View Model連動,則可以寫成name-css="{{model.nameCss}}"。
    =及@應用上有個小陷阱,若宣告成nameCss: "=",傳入靜態字串參數需寫成name-css="'black'"(要加單引號寫成JavaScript字串);而nameCss: "@"寫name-css="black"就可以。在傳字串常數時要認清是單向繫結"@"或雙向繫結"=",以免混淆。
  • onChange: "&"
    若要從Directive觸發事件或呼叫外層View Model函式,可使用&符號。以onChange: "&"為例,<contact on-change="vm.SomeFunction()">代表在Directive呼叫scope.onChange()會執行vm.SomeFunction()。
    如果呼叫函式帶有參數,記得要一併寫在on-change Atrribute,例如:<contact on-change="vm.SomeFunction(text)">,但Directive端呼叫時的做法較特殊,不能直接寫scope.onChange("The Parameter"),得將參數轉成物件寫成:scope.onChange({ text: "The Parameter" }) 才會順利。

以上就差不多就是寫Directive會用過的技巧,只要搞懂應不難上手。

大家一起來寫自訂元素,擺脫醜陋的複製貼上,讓網頁擠身上流社會吧~ :P

附上完整種式碼及Live Demo

<!DOCTYPE html>
<html ng-app="sampleApp">
<head>
  <meta charset="utf-8">
  <title>NG Directive 範例</title>
  <style>
    .cont-block 
    {
        font-family: Segoe UI; font-size: 10pt;
        border: 1px solid gray; padding: 5px; width: 130px;
        border-radius: 4px; box-shadow: 5px 5px 10px #444;
    }
    .memb-list .cont-block { float: left; margin-right: 15px; }
    dt { font-weight: bold; color: purple; }
    dd { color: brown; }
    dd input { width: 80px; }
    br { clear: both; }
    dd.black { color: black; }
    input.red { color: red; }
 
  </style>
</head>
<body ng-controller="defaultCtrl as model">
  <h3>Leader</h3>
  <div class='memb-list'>
    <span class='cont-block'>
        <role-card name="model.leader.name" phone-no="model.leader.phone" 
          name-css="black" phone-css="red" on-change="model.log(newValue)" />
    </span>
  </div> 
  <br />
  <h3>Members</h3>
  <div class='memb-list'>
    <span class='cont-block' ng-repeat="member in model.members">
          <role-card name="member.name" phone-no="member.phone" />
    </span>
  </div>
  <script src="http://ajax.googleapis.com/ajax/libs/angularjs/1.3.8/angular.js"></script>
  <script>
      
    function Contact(name, phone) {
      this.name = name;
      this.phone = phone;
    }
 
    function myViewModel($scope) {
      var self = this;
      self.leader = new Contact("美國隊長", "0800092000");
      self.members = [];
      self.members.push(new Contact("鋼鐵人", "28825252"));
      self.members.push(new Contact("索爾", "23939889"));
      self.members.push(new Contact("浩克", "0800956956"));        
      self.log = function(msg) {
        console.log(msg);
      }
    }
    
    angular.module("sampleApp", [])
    .controller("defaultCtrl", myViewModel)
    .directive("roleCard", function() {
      return {
        restrict: "AE",
        scope: {
          name: "=", 
          phone: "=phoneNo", 
          nameCss: "@", 
          onChange: "&"
        },
        link: function(scope, element, attrs) {
          scope.phoneCss = attrs.phoneCss;
          scope.$watch("phone", function(v) {
            if (angular.isFunction(scope.onChange)) {
              scope.onChange({newValue: v});
            }
          });
          
        },
        template: 
        "<dl>" +
        "<dt>Name</dt><dd class=\"{{nameCss}}\">{{ name }}</dd>" +
        "<dt>Phone</dt><dd>" + 
        "<input type=\"text\" ng-model=\"phone\" class=\"{{phoneCss}}\"/>" +
        "</dd>" +
        "</dl>"
      }
    });
    
  </script>
</body>
</html>

[NG系列]

http://www.darkthread.net/kolab/labs/default.aspx?m=post&t=angularjs

Comments

Be the first to post a comment

Post a comment