AngularJS 1.5 最佳實務

文章目錄
  1. 1. 前言
  2. 2. 最佳實務
    1. 2.1. 不要使用 ng-controller
    2. 2.2. 不要在路由中指定 controller
    3. 2.3. 盡可能元件化 (使用 component 取代 ng-controller)
    4. 2.4. 使用 component() 定義 element 元件;使用 directive() 定義 attribute directive
    5. 2.5. 永遠使用 isolated scope
    6. 2.6. 永遠將屬性資料以物件加以包裹,或使用 "controllerAs" 語法
      1. 2.6.1. 指定 "controllerAs" 之後,controller 中的 this 與 $scope 有何不同?
      2. 2.6.2. 參考資料 / 延伸閱讀
    7. 2.7. 少用 $rootScope
    8. 2.8. 盡量讓狀態靠近使用的元件
    9. 2.9. 就使用 factory() 函數 (忘記 service() 和 provider() 吧)
      1. 2.9.1. 詳細說明
        1. 2.9.1.1. 建議主要以 factory() 函數來建立服務
        2. 2.9.1.2. Service vs Factory - Once and for all
      2. 2.9.2. 使用 factory() 函數來建立服務的常見用法
    10. 2.10. 不要使用 module.config
    11. 2.11. 謹慎使用 scope 事件
    12. 2.12. 善加使用 $exceptionHandler
    13. 2.13. 找到合適的方法上傳 log 到後端伺服器
    14. 2.14. 使用 angular-ui-router
    15. 2.15. 小心使用 Promise 並且注意 AngularJS 對 Promise 錯誤的特殊處理
    16. 2.16. 避免使用延遲載入
    17. 2.17. 了解 $digest 循環的判斷方式
  3. 3. 結論


前言

本文整理截至目前 AngularJS 1.5 為止, 個人以為的最佳實務做法。

為什麼現在?基於以下三點理由:

  • 雖然 AngularJS 2.0 發佈在即,但是既有的 AngularJS 1.x 程式碼仍需要維護。
  • 由於 AngularJS 1.x 受到廣泛的歡迎,前後做了不少的改進,因此網路上到處可見其實已經過時的作法。
  • 基於向後相容的原則,有些功能雖然被保留下來,但未必是最佳作法。

『你可以這麼做,不代表你必須這麼做』,基於『JavaScript: The Good Parts』的哲理,本文適合具有 AngularJS 1.x 實務開發經驗,然而卻隨著 AngularJS 的發展,逐漸對於網路上充斥各種主觀、矛盾的說法、用法感到困惑的開發者。

雖然遵循本文提出的原則,可以以趨近於元件化的風格開發 AngularJS 1.5 應用程式,但本文不涉及 AngularJS 1.x 移轉到 AngularJS 2.0 議題。

在閱讀本文之前,建議讀者可以先閱讀 Angular 1 Style Guide 這篇文章,本文的程式碼基本上遵循該文倡導的風格,除了少數與下一篇參考文章的建議牴觸之外。

本文主要內容則是基於 Sane, scalable Angular apps are tricky, but not impossible. Lessons learned from PayPal Checkout. 這篇文章,並補充個人觀點。

最佳實務

不要使用 ng-controller

出處:Don’t use ng-controller

典型的 ng-controller 用法如下:

1
2
3
<div ng-controller="myController">
<strong>{{foo}}</strong>
</div>

這有什麼問題呢?

  • ng-controller 違反元件封裝的原則:template 未能與 controller 程式碼封裝在一起,而獨自暴露在 html 中。
  • 無法重複使用:基於第一個理由,你的 controller 無法直接重複使用:必須複製 template。

不要在路由中指定 controller

出處:Don’t specify controllers in your routes

不論是 AngularJS 內建的 router 或是一般常用的 ui-router,典型的寫法,都是同時指定 template / templateUrlcontroller

1
2
3
4
5
$routeProvider.
when('/phones', {
templateUrl: 'partials/phone-list.html',
controller: 'PhoneListController'
});

這有什麼問題呢?

其實跟『不要使用 ng-controller』的狀況類似,都是與封裝有關:

  • 若使用 template 來指定,那麼當要重複使用 controller 時,就必須複製該 template,
  • 若使用 templateUrl 來指定,那麼就有可能不小心指定了錯誤的 template 檔案,造成與 controller 不一致的狀況。

盡可能元件化 (使用 component 取代 ng-controller)

出處:

基於上面兩點,應該將原本可能使用到 ng-controller 的部份,一律改用 component 來寫。

雖然還是可以使用 $compileProvider.directive() 來定義 directive,在 AngularJS 1.5 下,則提供了新的 component() 函數。這是為了讓 AngularJS 1.X 程式移植到 AngularJS 2.0 比較容易,而特地提供,讓開發者以比較接近 AngularJS 2.0 component 寫法的方式來定義元件:

myComponent.html

1
<strong>{{vm.foo}}</strong>

myComponent.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
var myTemplate = require('./myComponent.html');

var myComponent = {
bindings: {
foo: '=',
value: '@',
oneWay: '<',
twoWay: '=',
callback: '&'
},
template: myTemplate,
controller: MyComponentController,
controllerAs: 'vm'
};

myapp.component('myComponent', myComponent);

MyComponentController.$inject = ['$http'];
function MyComponentController($http) {
this.foo = 'bar';
}

注意,使用 component() 函數來建立元件時:

  • 必然以 restrict: 'E' 的形式建立,也就是只能建立 element 形式的元件,
  • 必然以 scope: {} 的形式建立,也就是建立 "isolated scope",不會繼承 parent scope。因此,若需要外部 scope 的資料,必須明確在 bindings 中定義屬性,並在使用 element tag 時傳入。
  • 承上,在 component() 函數中,已使用 bindings 取代 scope 及在 1.3 ~ 1.4 之間引入的 bindToController 屬性。所以在 component() 函數中,scopebindToController 屬性無效,也不應再使用。
  • 如果你沒有自行指定 controllerAs,則會自動幫你指定為 $ctrl,也就是說,強迫必須使用 controllerAs 語法 (參考後面『永遠使用 "controllerAs" 語法』)。

另外,

  • 由於 component() 函數只能用來建立 element 元件,如果元件必須同時或者只支援其他類型,譬如 attribute 形式,則必須使用 directive() 函數定義。
  • 上面使用 require() 引入 template 檔案的作法,是採用 Webpack 的作法。實務上可以直接使用 templateUrl 指定外部檔案, 或直接以內聯的形式定義 template 內容。
  • 推薦像上面這樣,將 myComponent 的設定獨立定義,甚至獨立為一個檔案,這樣將來要移轉為 AngularJS 2.0 元件會比較方便。
  • 將 controller 放在後面獨立定義,並以 constructor 的形式,開頭名稱大寫,是 John Papa 推薦的作法

最後,再提醒一下,定義元件時,使用 myComponent 名稱,這名稱就稱為該元件的 "directive name"。首字小寫,這是 AngularJS 定義元件的推薦慣例。(當作是定義 instance 變數,所以小寫開頭。)
而在 html 中使用時,則必須使用 my-component,這稱為該元件的 "tag name"。全部小寫,以 - 分隔單字,雖然 HTML 不區分大小寫,但是使用全小寫是推薦的慣例。
而 controller 的名稱為 MyComponent,因為它是一個 constructor。首字大寫,表示這是一個 class,這是 JavaScript 的慣例。

一旦定義好 component,就可以如下使用:

在 markup 中使用

1
2
3
<body ng-app="myApp">
<my-component foo="bar"></my-component>
</body>

在路由中使用

1
2
3
4
$routeProvider.
when('/phones', {
template: '<my-component foo="bar"></my-component>'
});

好處如下:

  • 完全封裝,不須再擔心 controller 與 template 不一致,
  • 需要的屬性可以由外部以偏好的方式,以 expression 的形式指定,傳入的是計算後的值,元件內部不再依賴於外部的特定屬性名稱,
  • 如果需要處理事件,也可以在 bindings 中以 eventName: '&' 的方式定義,同樣由外部以偏好的方式指定,元件不需要依賴於外部 scope 的特定方法名稱,
  • 因為是 "isolated scope",所以不必擔心 scope 之間的資料存取、覆蓋、同步問題。

Refactoring Angular Apps to Component Style 這篇文章詳細介紹各種將 controller 改用 component 的形式撰寫的步驟,相當值得一讀,強烈推薦。

使用 component() 定義 element 元件;使用 directive() 定義 attribute directive

建議只要是 element 形式的 directive,都使用 component() 函數來建立。其它情況則使用 directive() 函數。這裡一併列出建議的 element 元件 及 attribute directive 的參考寫法,做為對照。

  1. 定義 component / element directive:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    var myTemplate = '<div></div>';
    var myComponent = {
    bindings: {
    value: '@',
    oneWay: '<',
    twoWay: '=',
    callback: '&'
    },
    template: myTemplate,
    controller: MyComponentController,
    controllerAs: 'vm'
    };

    app.component('myComponent', myComponent);

    MyComponentController.$inject = ['$http'];
    function MyComponentController($http) {
    }
  2. 定義 attribute directive:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    var myDirective = {
    restrict: 'A',
    scope: {
    value: '@',
    oneWay: '<',
    twoWay: '=',
    callback: '&'
    },
    link: myDirectiveLinker
    };

    app.directive('myDirective', myDirective);

    function myDirectiveLinker(scope, element, attrs) {
    }

永遠使用 isolated scope

出處:Always use an isolated scope

首先,你只有在定義 directive 時,才能指定 scope 種類 (註)。而 scope 有三種:

  1. isolated scope: scope: {}

    建立一個獨立的 scope。要交換的資料必須明確定義。

  2. new scope: scope: true

    建立新的 child scope,並且繼承 parent scope。

  3. no scope: scope: false

    不建立 scope,直接使用 parent scope。

這有什麼問題呢?

  • 既然 directive 就是 element 和 attribute (還有 class 和 comment 但是官方不建議使用),讓它們可以直接存取父元件的資料不是很奇怪嗎?標準 html 的標籤不都是要自行指定屬性?

  • 繼承了 parent scope,也就意謂著依賴於 parent scope,因此元件的可重用性就大幅降低。

  • 因為 scope 是以類似 prototpye 的方式繼承,親子 scope 之間會發生屬性遮蔽問題 (請參考下一段說明)。

因此,強烈建議一律使用 "isolated scope",不要使用另外兩種。當需要 parent scope 資料時,請明確定義屬性,並由 parent scope 指定傳入。

註:即使在 ng-controller 中使用 "controllerAs" 語法,也只是幫你以指定的名稱建立一個包裹物件而已,scope 類型仍然是 "new scope",並無法指定 scope 類型。這也是一個不應該使用 ng-controller 的好理由。

永遠將屬性資料以物件加以包裹,或使用 "controllerAs" 語法

出處:

只要遵循上面『盡可能元件化 (使用 component 取代 ng-controller)』的原則,即自動 (強制) 獲得使用 "controllerAs" 及 "isolated scope" 的好處。

如果你不需要知道 AngularJS 過去最被詬病的 scope 黑暗歷史,或者,你已經了解什麼是 "controllerAs",可以放心跳過這一段。

像這樣的寫法:

1
<input type="text" ng-model="username" />

其中 username 將直接繫結到上層 scope 的 username 屬性。這裡的關鍵字是『上層 scope』。

這有什麼問題呢?

  • 由於某些 AngularJS 的 directive,譬如 ngIf,會建立新的 scope,也就是前面的提到的 "new scope",注意 "new scope" 會繼承到 parent scope。意思是說,你的 binding 將會根據你的 element 所在的位置,因而繼承了你沒有預料到的『上層 scope』,因此而可能有不同的行為。

  • 其次,由於屬性是以 prototype 的方式繼承,一旦 child scope 建立了同名的屬性,就同時遮蔽了 parent scope 的同名屬性。使得原本希望直接更新 parent scope 屬性的意圖落空。

注意,以下範例為了清楚起見,直接使用 ng-controller 建立新的 scope,實務上仍然建議『盡可能元件化 (使用 component 取代 ng-controller)』。

See the Pen AngularJS Scope Problem by amobiz (@amobiz) on CodePen.

注意上面 Form 2,當你輸入資料時,ParentController 的 username 並不會同步更新。

為了避開巢狀 scope 的 prototype properties 繼承問題,可以在 parent scope 中以物件的形式將值包裝起來。
這樣,在 child scope 中,只要永遠不直接對 scope 設定屬性,就不用擔心 scope 屬性遮蔽問題。而透過該物件存取其屬性,就能自由與 parent scope 交換存取資料。

See the Pen AngularJS Scope via Object by amobiz (@amobiz) on CodePen.

注意 vm 在這裡是可以任意自訂的名稱,取名 vm 是 "View Model" 的意思。

指定 "controllerAs" 之後,controller 中的 this 與 $scope 有何不同?

由於上面的作法,可以優雅地解決 scope 遮蔽問題,AngularJS 1.2 針對這個作法,新增了 "controllerAs" 語法。

首先,不推薦,但你可以使用 ng-controller="MyController as $ctrl" 的方式啟用這個功能;
再者,你可以在 directive()component() 函數,使用 controller: 'MyController as $ctrl' 的方式啟用,或者以 controllerAs: '$ctrl' 的方式另外指定。

"ControllerAs" 語法,只是上面介紹的作法的語法蜜糖,當你寫 controller as $ctrl 時,AngularJS 在背後所做的事,基本上就跟上面一樣。

細節是這樣的,首先,記住 controller 函數其實是一個 constructor,AngularJS 會以 new 操作子呼叫你的 controller 函數,以建立一個新的物件,然後,AngularJS 會自動幫你在 $scope 中,以 $ctrl 為屬性名稱,將該物件指定給 $scope。直接以程式展示的話,大概是像這樣:

1
2
3
4
5
6
7
function MyController() {
this.username = 'My Name';
}

function $$MyControllerWrapper$$($scope) {
$scope.$ctrl = new MyController();
}

所以,從另一個角度看,在上面的 controller 中的 this,只是一個普通的 JavaScript 物件,當然也就沒有 $scope 的功能。這就解釋了為什麼使用了 "controllerAs" 語法後,就無法在 controller 中使用 $scope 的功能。因此,如果在 controller 中需要 $scope 功能的話,就必須再另外透過 DI 注入 $scope 物件,就像這樣:

1
2
3
4
5
6
7
8
MyController.$inject = ['$scope'];
function MyController($scope) {
this.username = 'My Name';

$scope.$watch('registered', function (newValue, oldValue) {
// ...
});
}

參考資料 / 延伸閱讀

這一篇解釋得很詳細:

AngularJS: "Controller as" or "$scope"?

這一篇說明為何 1.3 需要引進 bindToController,以及後來在 1.4 的演進:

Exploring Angular 1.3: Binding to Directive Controllers

John Papa 是一直大力推廣 "controllerAs" 語法的人:

Do You Like Your Angular Controllers with or without Sugar?
AngularJS's Controller As and the vm Variable

然後,AngularJS 1.5 引進了上面介紹的 component() 函數,同時,似乎是為了有所區別,AngularJS team 決定 (沒有找到相關資料,請看下面的推論) 引入新的 bindings 屬性,用來完全取代 scopebindToController。我想這應該是為了避免使用者誤用,而造成模稜兩可的狀況:

  1. 退回到 1.4,強制定義 scope: {},而使用 bindToController 定義屬性

    因為在 1.4 時,bindToController 屬性就可以直接定義屬性,其意義與 bindings 完全相同,只不過並未強制 scope: {} 的定義。若這麼做的話,等於是期望使用者這樣定義:

    1
    2
    3
    4
    5
    6
    {
    scope: {},
    bindToController: {
    attribute: '='
    }
    }

    但是若使用者卻使用 1.3 的語法:

    1
    2
    3
    4
    5
    6
    {
    scope: {
    attribute: '='
    },
    bindToController: true
    }

    那麼,哪個屬性有效呢?記住,我們已經強制定義 scope: {},所以這裡的 scope 定義無效;而我們期望定義為 hash object 的 bindToController,在這裡當然也找不到正確的屬性定義。

  2. 退回到 1.3,仍然使用 scope 定義屬性,強制 bindToController: true 屬性。

    也就是說,期望使用者這樣定義:

    1
    2
    3
    4
    5
    6
    {
    scope: {
    attribute: '='
    },
    bindToController: true
    }

    但是若使用者卻使用 1.4 的功能:

    1
    2
    3
    4
    5
    6
    {
    scope: {},
    bindToController: {
    attribute: '='
    }
    }

    那麼,又是哪個屬性有效呢?同樣地,我們已經強制定義 bindToController: true,所以這裡的 bindToController 定義無效;而我們期望定義為 hash object 的 scope,在這裡當然也找不到正確的屬性定義。

所以,由此推論,AngularJS team 為了避免上述兩種模稜兩可的狀況,而決定乾脆同時忽略 scopebindToController 的使用者定義,分別強制其值為 {}true,而另外使用 bindings 來定義屬性。

雖然不確定理由是否如上述推論,但同時有三個屬性 (scope, bindToController, bindings),提供一樣的功能,顯然又是一個為了向後相容,而疊床架屋的例子,雖然解決了問題,但同時也造成了更多混淆。

少用 $rootScope

出處:Limit your use of $rootScope

應該不須贅言,$rootScope 基本上就等於 global。

盡量讓狀態靠近使用的元件

出處:Keep your state as close as possible to the components which need it.

大部分情況下,你的元件應該能自給自足,或者,頂多需要由父元件提供資料。有些時候,你可能需要在多個元件之間共用資料。除非你是在開發通用的元件,譬如 Tab / TabPanel,這時你可能要考慮使用 require 屬性。否則,開發一般應用程式時,應該盡量在最靠近它們的共同父元件上提供共用的資料。

index.html

1
2
3
<body ng-app="app">
<parent></parent>
</body>

parent.js

1
2
3
4
5
6
7
8
9
10
11
12
13
var template = [
'<child-one foo="vm.bar"></child-one>',
'<child-two baz="vm.bar"></child-two>'
].join('');
app.component('parent', {
template: template,
controller: ParentController,
controllerAs: 'vm'
});

function ParentController() {
this.bar = 'woo';
}

child-one.js

1
2
3
4
5
6
7
8
9
10
11
app.component('childOne', {
bindings: {
foo: '='
},
controller: ChildOneController,
controllerAs: 'vm'
});

function ChildOneController() {
console.log(this.foo);
}

child-two.js

1
2
3
4
5
6
7
8
9
10
11
app.component('childTwo', {
bindings: {
baz: '='
},
controller: ChildTwoController,
controllerAs: 'vm'
});

function ChildTwoController() {
console.log(this.baz);
}

就使用 factory() 函數 (忘記 service()provider() 吧)

出處:Forget about services and providers

直接先講結論:

在 AngularJS 中,能夠透過 DI 注入使用的,都是服務。

一般常在爭論的,所謂 factory, service 及 provider,其實根本都不是真正的主體,它們都只是用來建立服務的方法。

正確的說,我們透過 factory(), service(), provider(),甚至 constant(), value() 這些方法,建立服務。而不論是用哪一個方法建立的服務,它們本質上完全相同,唯一的不同就只有建立的形式不同。

爭論什麼是 factory, service 及 provider,只是讓問題失焦。甚至於,我主張,不應該說『一個 factory』,而應該這麼說:『一個使用 factory() 函數建立的服務』。

如果你了解它們背後的實作方式,全部都是基於同一個其界面具有 $get() 函數的物件,你就可以根據需要,自在地採用適合你的情境的『形式』來建立服務;如果還是不了解,就根據本項建議,全部採用 factory() 來建立服務。

詳細說明

許多人都建議只使用 factory,主要理由如下:

  1. 不管是 factory, service 或 provider,其本質上都相同,背後的實作都是 provider,
  2. 它們全都是 singleton,
  3. provider 的存在,只是為了提供更改預設值的初始化功能,但其實有許多方式可以做到,並不是非 provider 不可,
  4. factory 比 service 有彈性。

我認為以上的說法還不夠精準,最好改為:

以下透過模擬它們的實作方式,稍微說明一下它們的差異。簡單地說,它們的差別可以用下面這段虛擬碼表達,為了簡單起見,dependency injection 處理的部份完全不列入。

首先,我們建立的 module,具有 (自動注入) 下列 $provide 提供的 factory(), service()provider() 等函數 (其它略):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
var $provide = {
provider: provider,
factory: factory,
service: service
};

var $$providerRegistry = {};
var $$serviceRegistry = {};

function provider(serviceName, ProviderConstructor) {
var serviceProvider = new ProviderConstructor();
var serviceProviderName = serviceName + 'Provider';
$$providerRegistry[ serviceProviderName ] = serviceProvider;

var service = serviceProvider.$get();
$$serviceRegistry[ serviceName ] = service;
}

function factory(serviceName, factoryFunction) {
return provider(serviceName, function ProviderConstructor() {
this.$get = factoryFunction;
});
}

function service(serviceName, ServiceConstructor) {
return factory(serviceName, function factoryFunction() {
return new ServiceConstructor();
});
}

其中 provider() 函數,做了這些事:

  1. new 運算子呼叫我們提供的 ProviderConstructor() 建構子,建立一個物件,這個物件也就是所謂的 Provider 服務,
  2. 將此 Provider 服務物件以 serviceName + 'Provider' 的名稱,註冊為一個服務,這個服務將只能透過 config() 函數注入使用,
  3. 呼叫此物件的 $get() 方法,此方法回傳的物件才是真正的服務物件,
  4. 將服務物件以我們提供的 serviceName 為名稱,註冊為一個服務,這個服務將能夠在 run(), factory(), service(), provider(), controller(), directive() ... 等函數,以及任意建構子中注入使用。
  5. 對每個服務而言,上面的過程,在整個應用程式生命週期中,只會執行一次。

factory() 函數,則是:

  1. 建立一個 ProviderConstructor() 建構子,以該建構子做為參數呼叫上面的 provider() 函數,
  2. provider() 函數以 new 運算子呼叫該 ProviderConstructor() 建構子時,將建立一個具有 $get() 方法的物件,該 $get() 方法直接被設定為我們傳入的 factoryFunction() 函數,因此呼叫 $get() 方法,即等於呼叫 factoryFunction() 函數,
  3. 根據上面 provider() 函數的說明, ProviderConstructor() 建構子建立的物件,將被以 serviceName + 'Provider' 的名稱,註冊為一個所謂的 Provider 服務,
  4. 根據上面 provider() 函數的說明,$get() 方法被呼叫,於是 factoryFunction() 函數回傳的任意值,將以我們提供的 serviceName 為名稱,註冊為一個服務,
  5. 對每個服務而言,上面的過程,在整個應用程式生命週期中,只會執行一次。

service() 函數,則是:

  1. 建立一個 factoryFunction() 函數,以該函數做為參數呼叫上面的 factory() 函數,
  2. 在剛剛的 factoryFunction() 函數中,以 new 運算子呼叫傳入的 ServiceConstructor 建構子,建立服務物件,並將其返回,
  3. 根據上面 factory() 函數的說明,factory() 函數又會轉呼叫 provider() 函數,
  4. factoryFunction() 函數返回的物件,也就是 ServiceConstructor 建構子建立的物件,最終被以 serviceName 為名稱,註冊為服務,
  5. 同時,會有一個僅包含 $get() 方法的物件,被以 serviceName + 'Provider' 的名稱,註冊為所謂的 Provider 服務。
  6. 對每個服務而言,上面的過程,在整個應用程式生命週期中,只會執行一次。

解釋起來似乎很麻煩,但是直接看程式碼,其實真的沒有什麼驚奇的地方。

重點有兩個:

  1. 每個服務,實際上都會伴隨著一個 Provider 服務,但只有透過 provider() 函數來建立,該 Provider 服務才有機會附加其他額外方法,而 factory()service() 函數建立的 Provider 服務,都只會有 $get() 方法,
  2. 它們全部都只有在註冊的時候,被執行唯一的一次,其結果就被儲存下來成為 singleton,再也沒有機會改變。
  3. 不管在哪個模組註冊,註冊的服務都是全域共用的。

我過去一直以為 factory() 函數與 service() 函數的差異是,它每次被要求注入的時候都會執行一次,畢竟它的名稱叫做 "factory",而且又只是個普通函數,所以我一直以為它就是 "factory method",應該每次都回傳新的物件。但事實上它卻不是這樣被設計的,它跟 使用 service() 函數來建立服務完全一樣,我們傳入的 factory function 總是只被呼叫一次。

我從來沒有在任何一篇文章中看過有人強調這一點,最多只說它們全部都是 singleton。甚至大部分的文章在描述的時候,反而讓人誤以為每次都會呼叫 $get() 函數,譬如,在 Service vs Factory - Once and for all 這篇文章中這樣描述:

大概的意思就是說,每次我們向 injector 要求時,就會呼叫 provider 的 $get() 函數。

錯錯錯! $get() 函數永遠只會被呼叫一次!

正是因為只會被呼叫一次,並且其結果被儲存下來,所以不論是透過 factory(), service()provider() 函數,實際上建立的服務都是 singleton,建立之後的行為完全一樣,它們之間真正的差別就是建立的形式不同

AngularJS team 真的是把一件簡單的事情極度複雜化了,而且明明同樣都是服務,卻用了三種不同的方式來建立,又偏偏取了很不好的一組名稱,才會導致連他們自己人,都會在所謂的解惑文中,用不正確的方式描述其行為。

由下面的範例就可以看出來 (請打開 console 看 log):

  1. 雖然建立的是 personFactory,但是卻同時存在有 personFactoryProvider
  2. console.log 中,"personFactory begin" 與 "personFactory end" 各只出現一次,表示 factory 函數只被呼叫一次,
  3. personFactory.rtti 的值,在兩個 controller 中相同,表示 personFactory 的確是同一個物件,
  4. PersonController1 透過 personFactory.defaults() 函數設定的值,在 PersonController2 可以看得到。

See the Pen Angular: factory function only got called once by amobiz (@amobiz) on CodePen.

了解背後的運作原理之後,再來看看實際上要建立服務時,我們如何透過 factory(), service()provider() 函數來建立。

基本定義方式如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
angular.module('app', [])
.provider('myProvider', function ProviderConstructor() {
this.$get = function getter() {
return 'whatever';
};
this.config = function config(options) {
};
}
.service('myService', function ServiceConstructor() {
this.method = function () {
};
}
.factory('myFactory', function factoryFunction() {
return 'whatever';
};

雖然透過 provider() 函數建立服務的彈性最大,也能提供設定功能,但是如同前面的範例的 defaults() 函數,設定功能有很多方式可以達成,因此並沒有非使用 provider() 函數不可的理由。而 service() 函數由於強制必須提供 constructor,所以服務本身只能是物件類型,彈性上多少受到一些限制。基於這些理由,多數的人才會建議:一律使用 factory() 函數來建立服務。

使用 factory() 函數來建立服務的常見用法

回傳常數

1
2
3
4
5
6
7
8
.factory('pi', function () {
return 3.14159;
};

use.$inject = ['pi'];
function use(pi) {
var area = pi * this.radius * this.radius;
}

這個作法足以取代 constant(), .value()

回傳服務物件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
app.factory('personFactory', personFactory);

function Person(name, attributes) {
this.name = name;
this.attributes = attributes;
}

Person.prototype.eat = function (what) {
};

function personFactory() {
var defaults = {
gender: 'unspecified'
};

return {
setDefaults: function (options) {
Object.assign(defaults, options);
},
create: function (name) {
return new Person(name, Object.assign({}, defaults));
}
};
}

app.run(run)
.controller('PersonController', PersonController);

run.$inject = ['personFactory'];
function run(personFactory) {
personFactory.setDefault({
country: 'Taiwan'
});
}

PersonController.$inject = ['personFactory'];
function PersonController(personFactory) {
var person = personFactory.create('John');
}

可以看到:

  • 可以提供 factory method,如上面的 create() 函數。最重要的是,它能夠接受參數。
  • 可以提供 config 功能,如上面的 setDefaults() 函數,足以取代 provider。

回傳建構子

老實說,我比較不建議這個作法。使用 DI,就是不希望依賴於實作,不希望由 client 自行建構依賴物件。使用 DI 應該直接取得立即可用的物件或服務,應該依賴於其界面,而不是其實作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
.factory('Person', function () {
function Person() {
}

Person.prototype.eat = function (what) {
};

return Person;
}

use.$inject = ['Person'];
function use(Person) {
var person = new Person();
}

不要使用 module.config

出處:Forget about module.config

大多數時候,module.run() 函數即可勝任模組的初始化工作,沒有必要非在 module.config() 中進行初始化。許多時候,基於封裝原則,甚至於應該在元件中進行初始化。

唯一的例外,就是必須使用到 AngularJS 或第三方程式庫提供的 Provider 的時候,譬如 $routeProvider。這是因為 AngularJS 做了一個很不好的決定:強制 Provider 只能在 module.config() 中使用。你在開發自己的應用程式時,實在沒有必要跟著這麼做。

謹慎使用 scope 事件

出處:Be careful with event publishing and listening

透過 $rootScope$scope 發佈事件,應該是針對不特定受眾所為,通常是設計 API 時才需要考慮。多數時候,你只是在開發應用程式,所有的元件都在你的掌握之中,你可以透過元件之間的屬性,或 callback 函數來傳遞資料。如果是服務,則應該考慮使用 Promise。如果隨意濫用 $rootScope$scope 發佈事件,小心導致程式難以除錯的風險。

善加使用 $exceptionHandler

出處:Take advantage of $exceptionHandler

你的程式中未捕獲的 exception 都會丟給 $exceptionHandler 這個服務處理,這個服務預設的行為是輸出 log。你可以定義自己的 $exceptionHandler 服務,這將覆蓋原本的服務,讓你有機會自行處理錯誤:也許是上傳錯誤到後端伺服器進行記錄等等。

1
2
3
4
5
6
angular.module('exceptionOverride', []).factory('$exceptionHandler', function () {
return function(exception, cause) {
exception.message += ' (caused by "' + cause + '")';
throw exception;
};
});

找到合適的方法上傳 log 到後端伺服器

出處:Find a good way to publish logs to the server side

SPA 應用程式是在使用者的瀏覽器中執行,如果沒有適當地記錄 log 訊息,根本無從得知在使用者的瀏覽器中到底發生了什麼事。

最底限至少應該在應用程式發生錯誤時,輸出錯誤訊息,請求使用者幫忙回報。若經過使用者同意,也可以考慮記錄整個執行過程,排除敏感資料,上傳到後端伺服器加以記錄分析。

上傳的時候應該盡力減少發送次數及流量,並且務必做好加密的動作。

另外,可以考慮使用商業化的產品,譬如 LoggyPaper Trail 等。

使用 angular-ui-router

出處:Use angular-ui-router

如果你的服務 API 遵循 RESTful 原則,你可能會有許多 resources 具有 sub-resources,而 RESTful 要求每個 resource 應該要有唯一的 URL。然而 AngularJS 內建的 ngRoute 不支援巢狀路由,因此並不算 RESTful 友好。

稍具規模,支援 RESTful 的 AngularJS 1.X 應用程式,都應該考慮使用 ui-router,甚至可以考慮搭配 ui-router-extras,以提供更多進階功能。

為什麼不使用新的 AngularJS 2.0 的 ngComponentRouter

目前不推薦 ngComponentRouter 的主要原因,是因為:

  1. 尚未正式發佈

    ngComponentRouter 原本計畫要隨同在 AngularJS 1.4 發佈,但是現在已 1.5 卻還是沒有正式發佈,想必 AngularJS team 也還有其他考量。或許只是因為 AngularJS 2.0 尚未正式發佈,而針對 AngularJS 1.x 的規格也仍然還在變動。

  2. 目前的參考文件比較缺乏。

    最新的文件可以參考 Component Router。其它目前在網路上可以找到的針對 AngularJS 1.x 的文章幾乎全部都已經過時,都是還是使用 controller 的方式。建議可以參考 StackOverflow 上 Angular 1.5 and new Component Router 的回答。或直接參考 ngComponentRouter 的 examples。另外,我也寫了一個 sample 可以參考:

See the Pen Angular 1.5 component router playground by amobiz (@amobiz) on CodePen.

小心使用 Promise 並且注意 AngularJS 對 Promise 錯誤的特殊處理

出處:Be careful with promises and error handling

首先,不論是透過 AngularJS 自己的 $q 提供的 Promise,或是標準的 Promise/A+,如果你沒有使用 catch() 捕捉錯誤,任何錯誤,包括 throw 的錯誤以及沒有 fulfilled 的錯誤,都將無聲無息地被吃掉。

再來,除非你是透過 return $q.reject(err) 回傳錯誤,否則任何其它被 throw 出來的錯誤,即使你已經使用 catch() 捕捉了,它們都還是會被丟到前面提到的 $exceptionHandler 服務。這就面臨了兩難的困境。意謂著,如果你使用 catch() 捕捉錯誤,同時也使用 $exceptionHandler 服務處理未捕捉的錯誤,結果是同一個錯誤你將處理兩次。

為了避免這個問題,可以借用 Java 的 checked exception 的概念:屬於你的設計中,明確會丟出來的錯誤,把它們當作是 checked exception,一律使用 $q.reject(err) 來處理,這樣它們就不會進入到 $exceptionHandler 服務;至於哪些你沒有預料到,或者是程式設計錯誤造成的錯誤,則把它們當作是 unchecked exception,讓它們由 $exceptionHandler 服務來把捕捉。

避免使用延遲載入

出處:Try to avoid lazy loading

AngularJS 1.x 的模組,只能在啟動階段定義,使用任何的 hack,即使是透過路由來做延遲載入,都無可避免的有其缺陷,更不用說這會無謂地增加程式的複雜度。

了解 $digest 循環的判斷方式

出處:Be careful with the digest cycle

在 AngularJS 1.x 中,所謂的雙向資料繫結 (two way data binding),是靠著不斷地檢查 scope 中的變數是否有變動,而在變動時做出反應:更新畫面或根據使用者輸入更新 scope 資料。由於資料之間可能有關聯性,所以某些資料更新後,可能會導致其它資料也需要更新,也就是發生所謂的漣漪效應。因此,AngularJS 會持續地進行檢查、更新,直到資料不再發生變動為止。如果資料持續不斷發生變動,則 10 次 $digest loop 之後,AngularJS 就會丟出 exception。

AngularJS 如何檢查資料是否發生異動?它並不是靠傳說中的 Object.observe()(註),而是藉由儲存舊值,透過比對舊值與現值,來判斷是否發生異動。然而,這裡存在著一個陷阱:由於資料可以經由函數回傳,如果函數將資料包裹在物件或陣列中回傳,雖然就『值』本身來看,並沒有發生異動,實際的包裹物件已經不是同一個了。

1
2
3
$scope.foo = function () {
return { bar: 'baz' };
};

JavaScript 並不像 Java 一樣,有所謂 equals()hashCode() 方法,用來快速檢查兩個物件的『值』是否相等,也就是所謂的『等價』。因此,為了效率考量,AngularJS 並不會進行所謂 deepEqual() 檢查,而只是檢查物件是不是同一個 (位址相同)。由於每次要比對現值時,就會呼叫一次函數,而函數每次都會回傳一個新的包裹物件,因此每次都判定為資料發生異動,於是超過 10 次之後,就丟出錯誤了。

因此,上面的例子比較好的作法,應該像這樣:

1
2
3
4
5
var foo = { bar: 'baz' };

$scope.foo = function () {
return foo;
};

這樣每次被呼叫時,回傳的都是同一個物件,AngularJS 就不會誤判了。

註:Object.observe() 提案已經於 2015 年 11 月撤銷,因此未來也不可能使用 Object.observe() 來檢查資料異動。

結論

AngularJS 可以算是最早引入自訂 html 元件概念的 framework,只是早期的推廣,強調 ng-controller 的使用方便性,導致許許多多程式將業務邏輯與畫面呈現全部混在 controller 中。雖然 AngularJS 2.0 發佈在即,也更趨近於 Web Component 標準,舊有使用 AngularJS 1.x 的應用程式,只要嚴守元件化的原則,仍然可以寫出相當容易維護的程式,甚至於更有利於將來移植到 AngularJS 2.0。

以上整理,已盡力將參考資料完整列出,惟個人學識有限,如有謬誤或不足之處,歡迎補正,謝謝。