A case study of using jQuery plugin with AngularJS: One Page Nav

坊間的 jQuery plugin + AngularJS 的做法都蠻基本又簡單的(e.g. An approach to use jQuery Plugins with AngularJS)。

實際上真正想要做一個對 jQuery plugin 的完善包裝是相當費工的,最近正好因緣際會之下有個使用 jQuery plugin 的需求,我們就來將這一次包裝成 directive 的過程走一遍吧!

jQuery One Page Nav

這次要包裝的 jQuery plugin 是 jQuery One Page Nav,效果基本上就是提供一個「知道你捲動到哪一個區塊了」的導覽列,同時也提供「捲動到特定區塊」的功能。

效果可以參考它的官方網頁: http://davist11.github.io/jQuery-One-Page-Nav/

Directive: jq-one-page-nav

基本的 directive 包裝就可以讓這個 jQuery plugin 動起來(在基本的情境下),經過一番深思熟慮之後,我決定將這個 directive 叫做 jq-one-page-nav

/* file: jq-one-page-nav.js */

angular.module('pymaster.jq-one-page-nav', [])

.directive('jqOnePageNav', function ($timeout, $rootScope) {
  return {
    restrict: 'EA',
    scope: {
      items: '=',
      options: '='
    },
    replace: true,
    template: '\
      <div class="jq-one-page-nav">\
        <ul>\
          <li ng-repeat="item in items">\
            <a ng-href="#{{ item.id }}" ng-bind="item.content" title="{{ item.content }}"></a>\
          </li>\
        </ul>\
      </div>\
    ',
    link: function (scope, iElm, iAttrs) {
      $timeout(function () {
        iElm.find('ul').onePageNav(scope.options || {});
      });
    }
  };
});

這邊提供了兩個 attribute 可以傳入,分別是 itemsoptions

參數 功能
items 想要擺在導覽列的連結陣列,單一 item 的內容為 {id: '{HTML ID}', name: '{nav button name}'}
options 對應 jQuery One Page Nav 原本的 options

Code 說明

  • template 寫死 ul>li>a ,因為這是 jQuery One Page Nav 官方期待的 markup 結構
  • 裡面使用 $timeout 的原因是為了確保在 items 傳入後已經正確 compile 出 HTML 才做 jQuery plugin 的 initialization
  • 另外一個使用 $timeout 的原因是 One Page Nav 在 .onePageNav(...) 的當下會去取得各個導覽項目對應的 HTML ID 元素的捲動高度,若是你的 jq-one-page-nav 在 HTML 結構裡擺的 位置/順序 不對,很可能就無法取得正確的捲動高度

改進 1:支援重覆使用

基本款在一些 AngularJS 中 SPA 的情境下會發生問題:

試想現在有用 route 的功能(無論是 ngRouteui-router),你只在某一個 route(或 state) 下使用 jq-one-page-nav

此情境下, jQuery plugin 所應用的 HTML 元素會被移除,後來又重覆拜訪同一個 route 時才會又建立新的 HTML 元素,並且重新套用 jQuery plugin (這都發生在基本包裝的 directive 裡)。考慮這個 jQuery plugin 能達到的效果,因為它可以知道你目前捲動到頁面的哪個位置而做出反應,表示它應該有做以下的其中一項設定:

  • 在 window 上 listen scroll 事件,來得知當下的捲動位置
  • 利用 setInterval 來檢查當下的捲動位置

然而上述兩項都是必須主動進行清除的,於是問題就浮現了 - 在 One Page Nav 所套用的 HTML 元素遭到移除之後,沒有人去清除這些需要主動清除的東西,會造成 memory leak 並且吃掉額外的 CPU 資源

雖然常常說不要自己造輪子,但當想要改車或是車子壞了的時候,可能還是需要足夠的關於輪子的知識。

為了改善基本包裝的問題,此時我們還是得回去稍微翻一下 jQuery One Page Nav 的 source code

整理觀察到的內容:

  • 每次套用 .onePageNav(...) 都會建立一個新的 private instance (plugin 外面拿不到)
  • 利用 setInterval 來檢查當下的捲動位置
  • 有在 windows 上 listen resize 事件,在視窗改變大小時重新計算各區塊的位置
  • 沒有提供 private instance 的回收機制 (unlisten 事件們、移除 DOM reference 之類的)

為什麼會出現 private instance 並且沒有回收機制呢?

jQuery plugin 大都只要換頁就沒事了,壓根就沒有打算要讓你回收的!

修改 jquery.nav.js

為了賦予 AngularJS 方面對 plugin 的內容有足夠的掌控能力,我們還是得把自己的手弄髒(get your hands dirty),來改造一下輪子。

首先,先讓我們能夠透過 jQuery 的 .data() 來取得 private instance (API),便於之後進行有彈性的操作。

/* file: jquery.nav.js */

// 新增預設的 config: `apiDataName`
defaults: {
  ...
  apiDataName: 'onePageNav'
},

init: function () {
  ...
	// 在 init() 中加入這段
  this.$elem.data(this.config.apiDataName, this);
  
  return this;
}

取得 API 之後還必須使它具備回收的功能,為此我們幫 OnePageNav 增加一個新的 method destroy

/* file: jquery.nav.js */

destroy: function () {
  this.unbindInterval();

  this.$nav.off('click.onePageNav');
  this.$win.off('resize.onePageNav');

  this.$elem.removeData(this.config.apiDataName);

  /**
   * 這裡以下的部分正常來說可以不用清這麼乾淨,
   * 這邊硬是把它們清空的好處在於:
   *
   * - 若有其它未清空的 event handler / interval 還在 run ,會觸發錯誤 (因為需要的元素都空了)
   * - 真的不幸 memory leak 的時候可以把災害降到最低 (不含 scalar value) 
   */
  this.elem = null;
  this.$elem = null;
  this.options = null;
  this.metadata = null;
  this.$win = null;
  this.sections = null;
  this.didScroll = null;
  this.$doc = null;
  this.docHeight = null;
  this.t = null;
}

修改 jq-one-page-nav directive

最後修改 directive 的 link function 來搭配新的 API,在元素被移除的時候主動回收 instance 內的東西。

/* file: jq-one-page-nav.js */

link: function (scope, iElm, iAttrs) {
  var api, elmDestroyed;

  $timeout(function () {
    // 確保沒有被 $destroy 才繼續 initialize
    if (!elmDestroyed) {
      api = iElm.find('ul').onePageNav(scope.options || {});

      // Delay 了所以要主動告知更新 scroll 位置
      api.scrollChange();
    }
  });

  iElm.bind('$destroy', function () {
    elmDestroyed = true;
    if (api) {
      api.destroy();
    }
  });
}

改進 2:動態 新增/移除 導覽項目

AngularJS directive 與 jQuery plugin 有一點很大的不同 - AngularJS directive 內時常需要對動態變動的 binded data 做出正確的反應,而 jQuery plugin 則是常常 initialize 之後就不管它了。

想當然爾,One Page Nav 也是同樣的 case;身為 AngularJS 高手的我們絕對無法接受一個不能夠動態改變其生成結果的 directive,我們希望在 items 傳入的內容有變動的時候,可以正確對變動的內容套用 One Page Nav 所提供的功能。

修改 jquery.nav.js

改進 1 中我們已經將 jquery.nav.js 改得盡善盡美了,這個階段的功能不須要動它。

修改 jq-one-page-nav directive

我們已經 expose 出來的 One Page Nav 的 API 原本就不提供 新增/移除 導覽項目的功能,另外再去修改 jquery.nav.js 又顯得有點殺雞用牛刀(這個 case 下啦);這時候就要採用面對 jQuery plugin 常用的一個招式 - 砍掉重練。

所謂砍掉重練當然不是叫你重寫一個 jQuery plugin,而是每每發生資料變動的時候就把已經 initialize 的 jQuery plugin 回收,在資料變動套用到 HTML markup 之後再重新 initialize。

為了達到這個效果,撰寫 directive 的思維需要有所改變,我們來看一下修改後的 link function。

/* file: jq-one-page-nav.js */

link: function (scope, iElm, iAttrs) {
  var api, elmDestroyed;

  scope.$watchCollection('items', function () {
    if (api) {
      api.destroy();
    }

    $timeout(function () {
      var options = scope.options || {},
          apiDataName = options.apiDataName || 'onePageNav';
      if (!elmDestroyed) {
        api = iElm.find('ul').onePageNav(options).data(apiDataName);
        api.scrollChange();
      }
    });
  });

  iElm.bind('$destroy', function () {
    elmDestroyed = true;
    if (api) {
      api.destroy();
    }
  });
}

Code 說明

  • jQuery plugin initialization 的起點不再是這個 post link function 本身,而是實際的 items 有變動之後
  • 利用 scope.$watchCollection 來監看 items 第一層的變動 -> 即 新增/移除 項目
  • 每每有變動且已經 initialize 過(api 存在),就先 api.destroy() 再重新 initialize

繼續改進?

精益求精

老實說因為 jQuery plugin 的思維跟 AngularJS 實在是差蠻多的,想要讓一個 jQuery plugin 可以與 AngularJS directive 完美契合的過程十分漫長(directive 的使用情境太多,彈性太大,因此要考慮/處理的事情實在太多)。

當然你可以選擇繼續讓它們融合的更無縫,但我的話就會在瞭解該 jQuery plugin 的運作原理之後,重新再用 pure Angular 的方式來實作。可能會多花一點時間,但成果的 directive 既可以沒有 jQuery dependency,又可以不用多 include 一個 jQuery plugin,更何況 pure Angular 寫法的契合度又是最高的。

最近我發布的新 module - angular-scroll-watch 就是一個很好的例子

點到為止

取中間值而且CP值最高的做法就是達到效果之後就一概不理

這次 One Page Nav 的 case,改進 2 對我的使用情況來說是多餘的,所以根本就不用做。當然,我也不需要去擔心各種使用情境的問題,下次有碰到再說就好了。說不定我此生就只會套這一次 jQuery One Page Nav。

所以

上述兩種沒有說誰好誰不好,這有時候是與你的工作性質有關的。

比如說你常常有各種比較展示性(比較炫但功能較少)的 project,那我會建議針對確定會常用到的 jQuery plugin 走前者的路線;如果你只是偶爾需要展示一下,大部分時候都在寫比較功能性的 app,就選擇後者。

到頭來還是一句,你自己決定吧!

Demo

這個 demo 同時展示了套用上面兩款改進之後的成果,directive 也有再做一些額外的修改(雖然 demo 裡沒用到),透過設定可以讓 <li> 觸發 <a> 的 click (特殊導覽列設計時使用)。

  • 可以切換不同的頁面
  • 可以自行增加某頁面的導覽物件
  • 增加物件後會自動捲動自新增的物件

其實我

個人在開始用 AngularJS 之後的這些日子以來,鮮少做出用 directive 去包裝 jQuery plugin 之類的行為。

主因是我認為 jQuery plugin 原本的使用情境與 AngularJS 中 SPA 角度的使用情境差距非常大,這個差距讓「用 directive 來包裝 jQuery plugin」這回事在很多時候變得有點麻煩,尤其是當你越想要 generalize 它在 AngularJS 裡的可用度(搭配各種使用情境),這個包裝的過程就越麻煩。

當然除了主要原因之外也有一些次要原因:

  • jQuery plugin 已經是 plugin 了還要再包成 directive 多麻煩
  • 我本人就是 jQuery 大師,大部分 jQuery plugin 我直接用 directive 做 pure Angular 自幹就好

偏偏我這個人就是有點屬於沒什麼特別必要卻又喜歡精益求精的那種,常常搞得要重新寫一個 pure Angular 的 directive 出來 XD

jQuery One page Nav 的 bug

好像有幾個月沒有維護了,這次修改的過程中也有發現 bug,就順手也修了一下,目前 fork 了一版出來,歡迎有需要的人自行取用。

https://github.com/pc035860/jQuery-One-Page-Nav

發現的 bug 恰好就是屬於在 jQuery 的使用情境下不容易發現的,讓我們繼續看下去。

jQuery One Page Nav 在處理點擊導航項目的時候為了避免捲動的過程「觸發路上的導航項目顯示為 “current”」,會有以下的流程:

  1. 找到目標並將其導航項目顯示為 “current”
  2. 取消檢查捲動位置的 interval
  3. 捲動到目標區塊
  4. 重新設定檢查捲動位置的 interval

這個流程本身是沒什麼太大問題,但它在 (3) 的地方使用了這段寫法

$('html, body').animate({
  scrollTop: offset
}, this.config.scrollSpeed, this.config.easing, callback);

這個常見的 jQuery 捲動動畫寫法會觸發兩次 callback,造成 (4) 執行兩次。變成每按一次導覽項目,都會多加一個額外的 interval 上去;當然,這在 jQuery 的使用情境下很難查覺,因為這個 plugin 的寫法不會造成任何錯誤。

後記

這次的 blog 文還真的是寫蠻長的,花在 改善 directive / 製作 demo 的時間也不少,希望各位能夠有所收穫。