- 1. 前言
- 2. 最佳實務
- 2.1. 不要使用 ng-controller
- 2.2. 不要在路由中指定 controller
- 2.3. 盡可能元件化 (使用 component 取代 ng-controller)
- 2.4. 使用 component() 定義 element 元件;使用 directive() 定義 attribute directive
- 2.5. 永遠使用 isolated scope
- 2.6. 永遠將屬性資料以物件加以包裹,或使用 "controllerAs" 語法
- 2.7. 少用 $rootScope
- 2.8. 盡量讓狀態靠近使用的元件
- 2.9. 就使用 factory() 函數 (忘記 service() 和 provider() 吧)
- 2.10. 不要使用 module.config
- 2.11. 謹慎使用 scope 事件
- 2.12. 善加使用 $exceptionHandler
- 2.13. 找到合適的方法上傳 log 到後端伺服器
- 2.14. 使用 angular-ui-router
- 2.15. 小心使用 Promise 並且注意 AngularJS 對 Promise 錯誤的特殊處理
- 2.16. 避免使用延遲載入
- 2.17. 了解 $digest 循環的判斷方式
- 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
典型的 ng-controller
用法如下:
1 | <div ng-controller="myController"> |
這有什麼問題呢?
ng-controller
違反元件封裝的原則:template 未能與 controller 程式碼封裝在一起,而獨自暴露在 html 中。- 無法重複使用:基於第一個理由,你的 controller 無法直接重複使用:必須複製 template。
不要在路由中指定 controller
出處:Don’t specify controllers in your routes
不論是 AngularJS 內建的 router 或是一般常用的 ui-router,典型的寫法,都是同時指定 template
/ templateUrl
及 controller
:
1 | $routeProvider. |
這有什麼問題呢?
其實跟『不要使用 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 | var myTemplate = require('./myComponent.html'); |
注意,使用 component()
函數來建立元件時:
- 必然以
restrict: 'E'
的形式建立,也就是只能建立 element 形式的元件, - 必然以
scope: {}
的形式建立,也就是建立 "isolated scope",不會繼承 parent scope。因此,若需要外部 scope 的資料,必須明確在bindings
中定義屬性,並在使用 element tag 時傳入。 - 承上,在
component()
函數中,已使用bindings
取代scope
及在 1.3 ~ 1.4 之間引入的bindToController
屬性。所以在component()
函數中,scope
及bindToController
屬性無效,也不應再使用。 - 如果你沒有自行指定
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 | <body ng-app="myApp"> |
在路由中使用
1 | $routeProvider. |
好處如下:
- 完全封裝,不須再擔心 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 的參考寫法,做為對照。
定義 component / element directive:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18var 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) {
}定義 attribute directive:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15var 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 有三種:
isolated scope:
scope: {}
建立一個獨立的 scope。要交換的資料必須明確定義。
new scope:
scope: true
建立新的 child scope,並且繼承 parent scope。
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" 語法
出處:
- Only ever bind to sub-properties of an object
- Exploring Angular 1.3: Binding to Directive Controllers
只要遵循上面『盡可能元件化 (使用 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.
1 | <div ng-app="app"> |
1 | angular.module('app', []) |
注意上面 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.
1 | <div ng-app="app"> |
1 | angular.module('app', []) |
注意 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 | function MyController() { |
所以,從另一個角度看,在上面的 controller 中的 this
,只是一個普通的 JavaScript 物件,當然也就沒有 $scope 的功能。這就解釋了為什麼使用了 "controllerAs" 語法後,就無法在 controller 中使用 $scope 的功能。因此,如果在 controller 中需要 $scope 功能的話,就必須再另外透過 DI 注入 $scope 物件,就像這樣:
1 | MyController.$inject = ['$scope']; |
參考資料 / 延伸閱讀
這一篇解釋得很詳細:
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
屬性,用來完全取代 scope
及 bindToController
。我想這應該是為了避免使用者誤用,而造成模稜兩可的狀況:
退回到 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
,在這裡當然也找不到正確的屬性定義。退回到 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 為了避免上述兩種模稜兩可的狀況,而決定乾脆同時忽略 scope
和 bindToController
的使用者定義,分別強制其值為 {}
及 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 | <body ng-app="app"> |
parent.js
1 | var template = [ |
child-one.js
1 | app.component('childOne', { |
child-two.js
1 | app.component('childTwo', { |
就使用 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,主要理由如下:
- 不管是 factory, service 或 provider,其本質上都相同,背後的實作都是 provider,
- 它們全都是 singleton,
- provider 的存在,只是為了提供更改預設值的初始化功能,但其實有許多方式可以做到,並不是非 provider 不可,
- factory 比 service 有彈性。
我認為以上的說法還不夠精準,最好改為:
以下透過模擬它們的實作方式,稍微說明一下它們的差異。簡單地說,它們的差別可以用下面這段虛擬碼表達,為了簡單起見,dependency injection 處理的部份完全不列入。
首先,我們建立的 module,具有 (自動注入) 下列 $provide
提供的 factory()
, service()
及 provider()
等函數 (其它略):
1 | var $provide = { |
其中 provider()
函數,做了這些事:
- 以
new
運算子呼叫我們提供的ProviderConstructor()
建構子,建立一個物件,這個物件也就是所謂的 Provider 服務, - 將此 Provider 服務物件以
serviceName + 'Provider'
的名稱,註冊為一個服務,這個服務將只能透過config()
函數注入使用, - 呼叫此物件的
$get()
方法,此方法回傳的物件才是真正的服務物件, - 將服務物件以我們提供的
serviceName
為名稱,註冊為一個服務,這個服務將能夠在run()
,factory()
,service()
,provider()
,controller()
,directive()
... 等函數,以及任意建構子中注入使用。 - 對每個服務而言,上面的過程,在整個應用程式生命週期中,只會執行一次。
而 factory()
函數,則是:
- 建立一個
ProviderConstructor()
建構子,以該建構子做為參數呼叫上面的provider()
函數, provider()
函數以new
運算子呼叫該ProviderConstructor()
建構子時,將建立一個具有$get()
方法的物件,該$get()
方法直接被設定為我們傳入的factoryFunction()
函數,因此呼叫$get()
方法,即等於呼叫factoryFunction()
函數,- 根據上面
provider()
函數的說明,ProviderConstructor()
建構子建立的物件,將被以serviceName + 'Provider'
的名稱,註冊為一個所謂的 Provider 服務, - 根據上面
provider()
函數的說明,$get()
方法被呼叫,於是factoryFunction()
函數回傳的任意值,將以我們提供的serviceName
為名稱,註冊為一個服務, - 對每個服務而言,上面的過程,在整個應用程式生命週期中,只會執行一次。
而 service()
函數,則是:
- 建立一個
factoryFunction()
函數,以該函數做為參數呼叫上面的factory()
函數, - 在剛剛的
factoryFunction()
函數中,以new
運算子呼叫傳入的ServiceConstructor
建構子,建立服務物件,並將其返回, - 根據上面
factory()
函數的說明,factory()
函數又會轉呼叫provider()
函數, factoryFunction()
函數返回的物件,也就是ServiceConstructor
建構子建立的物件,最終被以serviceName
為名稱,註冊為服務,- 同時,會有一個僅包含
$get()
方法的物件,被以serviceName + 'Provider'
的名稱,註冊為所謂的 Provider 服務。 - 對每個服務而言,上面的過程,在整個應用程式生命週期中,只會執行一次。
解釋起來似乎很麻煩,但是直接看程式碼,其實真的沒有什麼驚奇的地方。
重點有兩個:
- 每個服務,實際上都會伴隨著一個 Provider 服務,但只有透過
provider()
函數來建立,該 Provider 服務才有機會附加其他額外方法,而factory()
和service()
函數建立的 Provider 服務,都只會有$get()
方法, - 它們全部都只有在註冊的時候,被執行唯一的一次,其結果就被儲存下來成為 singleton,再也沒有機會改變。
- 不管在哪個模組註冊,註冊的服務都是全域共用的。
我過去一直以為 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):
- 雖然建立的是
personFactory
,但是卻同時存在有personFactoryProvider
, - console.log 中,"personFactory begin" 與 "personFactory end" 各只出現一次,表示 factory 函數只被呼叫一次,
personFactory.rtti
的值,在兩個 controller 中相同,表示personFactory
的確是同一個物件,- 由
PersonController1
透過personFactory.defaults()
函數設定的值,在PersonController2
可以看得到。
See the Pen Angular: factory function only got called once by amobiz (@amobiz) on CodePen.
了解背後的運作原理之後,再來看看實際上要建立服務時,我們如何透過 factory()
, service()
或 provider()
函數來建立。
基本定義方式如下:
1 | angular.module('app', []) |
雖然透過 provider()
函數建立服務的彈性最大,也能提供設定功能,但是如同前面的範例的 defaults()
函數,設定功能有很多方式可以達成,因此並沒有非使用 provider()
函數不可的理由。而 service()
函數由於強制必須提供 constructor,所以服務本身只能是物件類型,彈性上多少受到一些限制。基於這些理由,多數的人才會建議:一律使用 factory()
函數來建立服務。
使用 factory()
函數來建立服務的常見用法
回傳常數
1 | .factory('pi', function () { |
這個作法足以取代 constant()
, .value()
。
回傳服務物件
1 | app.factory('personFactory', personFactory); |
可以看到:
- 可以提供 factory method,如上面的
create()
函數。最重要的是,它能夠接受參數。 - 可以提供 config 功能,如上面的
setDefaults()
函數,足以取代 provider。
回傳建構子
老實說,我比較不建議這個作法。使用 DI,就是不希望依賴於實作,不希望由 client 自行建構依賴物件。使用 DI 應該直接取得立即可用的物件或服務,應該依賴於其界面,而不是其實作。
1 | .factory('Person', function () { |
不要使用 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 | angular.module('exceptionOverride', []).factory('$exceptionHandler', function () { |
找到合適的方法上傳 log 到後端伺服器
出處:Find a good way to publish logs to the server side
SPA 應用程式是在使用者的瀏覽器中執行,如果沒有適當地記錄 log 訊息,根本無從得知在使用者的瀏覽器中到底發生了什麼事。
最底限至少應該在應用程式發生錯誤時,輸出錯誤訊息,請求使用者幫忙回報。若經過使用者同意,也可以考慮記錄整個執行過程,排除敏感資料,上傳到後端伺服器加以記錄分析。
上傳的時候應該盡力減少發送次數及流量,並且務必做好加密的動作。
另外,可以考慮使用商業化的產品,譬如 Loggy、Paper Trail 等。
使用 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 的主要原因,是因為:
尚未正式發佈
ngComponentRouter 原本計畫要隨同在 AngularJS 1.4 發佈,但是現在已 1.5 卻還是沒有正式發佈,想必 AngularJS team 也還有其他考量。或許只是因為 AngularJS 2.0 尚未正式發佈,而針對 AngularJS 1.x 的規格也仍然還在變動。
目前的參考文件比較缺乏。
最新的文件可以參考 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
服務來把捕捉。
避免使用延遲載入
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 | $scope.foo = function () { |
JavaScript 並不像 Java 一樣,有所謂 equals()
和 hashCode()
方法,用來快速檢查兩個物件的『值』是否相等,也就是所謂的『等價』。因此,為了效率考量,AngularJS 並不會進行所謂 deepEqual()
檢查,而只是檢查物件是不是同一個 (位址相同)。由於每次要比對現值時,就會呼叫一次函數,而函數每次都會回傳一個新的包裹物件,因此每次都判定為資料發生異動,於是超過 10 次之後,就丟出錯誤了。
因此,上面的例子比較好的作法,應該像這樣:
1 | var foo = { bar: 'baz' }; |
這樣每次被呼叫時,回傳的都是同一個物件,AngularJS 就不會誤判了。
註:Object.observe()
提案已經於 2015 年 11 月撤銷,因此未來也不可能使用 Object.observe()
來檢查資料異動。
結論
AngularJS 可以算是最早引入自訂 html 元件概念的 framework,只是早期的推廣,強調 ng-controller
的使用方便性,導致許許多多程式將業務邏輯與畫面呈現全部混在 controller 中。雖然 AngularJS 2.0 發佈在即,也更趨近於 Web Component 標準,舊有使用 AngularJS 1.x 的應用程式,只要嚴守元件化的原則,仍然可以寫出相當容易維護的程式,甚至於更有利於將來移植到 AngularJS 2.0。
以上整理,已盡力將參考資料完整列出,惟個人學識有限,如有謬誤或不足之處,歡迎補正,謝謝。