NG筆記16-John Papa Angular Style Guide
4 |
微軟的老牌技術傳教士 John Papa 前些時候寫了一份 Angular 開發風格指南,近來打算在專案正式使用 AngularJS,便花了點時間詳讀,特筆記備忘兼分享。
先聲明一點:「開發風格」並無對錯可言,不同做法各有優劣,開發團隊可自行評估利害,取得共識維持一致即可。故文件所提並不是唯一正確的做法,只能說是蠻多人認同的一種選擇。很棒的一點是 John 花了不少篇幅解說「為什麼選擇這種做法?」,方便大家評估是否採納。筆記未能詳述之處,建議大家可以看原文,收獲會更多。
- 單一責任原則(Single Responsibility Principle)
- 每個元件一個檔案,每個 Module、Controller、Service 也都要獨立成檔
(註:實務上得配合打包壓縮機制,千萬別在 <script src="…"> 一個一個檔案載入) - 所有宣告都用 Immediately Invoked Function Expression (IIFE) 包起來
(function() {
angular.module('app').factory('logger', logger);
function logger() { … };
})();
目的:將自用函式、變數名稱全部區域化,避免與其他 JavaScript 併用及打包壓縮時出現撞名。
(註:若使用 TypeScript,可善用 module 特性,不需要手工寫。 )
- 每個元件一個檔案,每個 Module、Controller、Service 也都要獨立成檔
- Module
- 直接寫 angular.module('app', ['ngAnimate','app.shared']).controller('booCtrl', booCtrl);
不要宣告 app 變數,如:var app = angular.module('app'… 可減少變數撞名及Memory Leak風險 - 以具名函式取代匿名函式,以提高可讀性,有利偵錯。
避免
angular.module('app').controller('dashboard', function() { … });
建議
angular.module('app').controller('dashboard', dashboardFunction);
function dashboardFuncion() { … };
- 直接寫 angular.module('app', ['ngAnimate','app.shared']).controller('booCtrl', booCtrl);
- Controller
-
以 controllerAs(稱為 Controller As)取代 $scope(稱為 Classic Controller),直接將 Controller 執行個體視為繫結對象,像這樣:
function theController() {
var self = this;
self.propA = '…';
self.propB = '…';
}
(註:John Papa 慣用 var vm = this,我選擇沿用 var self = this;)
但如果要動用 $scope.$watch(),建構時仍需傳入 $scope。而 controllerAs 屬語法甜頭(Syntax Sugar),背後仍靠 $scope 運作。 -
為 controller 取別名,繫結時寫成 controllerName.propName。如下所示:
<div ng-controller="Customer as customer">
{{ customer.name }}
</div>
如此,當出現繼承關係時,父、子物件可透過別名存取,不需再搬出 $parent。參考 -
將可被繫結的屬性及方法寫在 Controller 程式的前段方便閱讀(姑且叫它「托高集中」原則吧!XD ),較複雜的匿名函式改用具名函式,讓宣告區整齊劃一,增加可讀性。
例如:self.method1 = function() { … }; 改為 self.method1 = method1,再將 function method1 { … } 寫在宣告區之後。
function Sessions() {
var self = this;
self.gotoSession = gotoSession;
self.refresh = refresh;
self.search = search;
self.sessions = [];
self.title = 'Sessions';
////////////
function gotoSession() {
/* */
}
function refresh() {
/* */
}
function search() {
/* */
}
-
用 function method() { … } 取代 var method = function() { … } 可以避免宣告順序調整造成的錯誤。延伸閱讀
-
將延遲執行的作業邏輯由 Controller 搬至 Service(例如:透過 AJAX 讀取資料)。
優點:Service 的邏輯可以被多個 Controller 共用、方便單元測試、減少 Controller 對實作細節的依賴性(例如:在 Controller 寫 $http.get() 會綁死 XHR) -
避免在 View 裡寫死 Controller,例如:<div ng-controller="Avengers as vm">…</div>
建議透過 Route 設定
$routeProvider.when("/avengers", {
templateUrl: 'avengers.html',
controller: 'Avengers',
controllerAs: 'vm'
});
如此 View 寫 <div>…</div> 就好。
-
- Service(服務)
- 可以寫函式讓 Angular 透過 new 建立執行個體(在函式中用 this.method = function() {…}宣告公開方法及屬性),也可以透過 Factory 模式建立,擇一並統一為宜。
- 所有的 Service 都是 Singleton,只有一個執行個體。
- Factory
- 單一職責:目標不同就拆成另一個 Factory。
- Singleton:所有 Factory 都是 Singleton,負責傳回包含服務方法或屬性的物件。
- 托高集中:將服務的公開方法、屬性宣告移到前段,實作內容放在後段,比照在 Controller 的做法。
- 透過 service.$injector = ['a','b','c'] 配合 function service(a,b,c) {…} 解決相依要求。
- Data Service
- 分離資料呼叫:將 XHR 呼叫、localStorage、記憶體暫存等資料操作邏輯移至 Factory。
考量:Controller 只負責資料呈現及蒐集,不要涉及資料取得及傳輸以求單純、聚焦。(如同 MVC的 SoC 原理)如此將有利測試以及抽換實作(如:由 XHR 改 localStorage) - 利用 Deferrer 物件處理非同步呼叫的銜接順序。
- 分離資料呼叫:將 XHR 呼叫、localStorage、記憶體暫存等資料操作邏輯移至 Factory。
- Directive
- 將每個 Directive 寫成獨立檔案 (註:這點我持保留看法,可能造成檔案數驟增,我的想法是將 Directive 實作寫成獨立函式,集中在單一 TypeScript,透過強型別關聯應不難追蹤管理)
- 避免在 Directive 中直接增刪變更 DOM,考慮以 CSS、動畫服務、樣版(Templating)、ngShow/ngHide 取代之。減少對 DOM 的依賴,將有助於測試。
- Directive 採用自訂元素或 Attribute 宣告就好,透過 class="…" 宣告可讀性不佳。
- 處理 Controller 的非同步作業
- 將非同步作業(例如:XHR 取值)包進 function activate() {…} 再呼叫,不要直接寫成建構式裡的程式片段,這樣比較一致好找。
- 透過 $routeProvider.when("/avengers", { …, resolve: { 必備資料: function() { … }); 確保 Controller 在建構時,資料已備妥。延伸閱讀
- 依賴注入(Dependency Injection,DI)
- 為避免 JavaScript 打包壓縮時變數更名破壞 DI,過去常見以下寫法
.controller('Dashboard', ['$location','$routeParams','common',
'dataService',Dashboard]);
建議改成
Dashboard.$inject=['$location','$routeParams','common','dataService'];
可讀性較佳。使用於 Directive,Dashboard.$inject = […] 記得寫在 return { controller: Dashbaord }; 之前。 - 在寫 $routeProvider.when() resolve 時,也請改用 .$inject 處理相依變數傳遞
- 為避免 JavaScript 打包壓縮時變數更名破壞 DI,過去常見以下寫法
- Minification 及 Annontation
- 利用 ng-annotate 讓 Gulp 或 Grunt 偵測程式自動加入 $inject 宣告
- 自動偵測失效時,使用 /* @ngInjnect */ 註解提示
- 例外處理
- 為 Module 加入一致的例外處理邏輯
/* recommended */
angular
.module('blocks.exception')
.config(exceptionConfig);
exceptionConfig.$inject = ['$provide'];
function exceptionConfig($provide) {
$provide.decorator('$exceptionHandler', extendExceptionHandler);
}
extendExceptionHandler.$inject = ['$delegate', 'toastr'];
function extendExceptionHandler($delegate, toastr) {
return function (exception, cause) {
$delegate(exception, cause);
var errorData = {
exception: exception,
cause: cause
};
/**
* 例外處理邏輯,例如:將錯誤呈報給$rootScope,傳送錯誤訊息至伺服器備查…
*/
toastr.error(exception.msg, errorData);
};
}
- 用 Factory 提供統一的例外處理機制
- 透過 $rootScope.$on('$routeChangeError', …) 處理路由錯誤
- 為 Module 加入一致的例外處理邏輯
- 命名原則
- 建議:feature.type.js
例:avengers.controller.js、logger.service.js、constants.js、avengers.module.js、avengers.routes.js、avenger-profile.directive.js
測試檔 avengers.routes.spec.js、logger.service.spec.js - Controller 名稱:Pascal
- Factory 名稱:Camel
- Directive 名稱:加上統一的前置詞,Camel。ex: dkUserProfile,<dk-user-profile>
- Module 檔名:主 Module 為 app.module.js,其餘自取,如 admin.module.js
- Configuration 檔名:app.config.js、admin.config.js
- Route 檔名:app.route.js、admin.route.js
- 建議:feature.type.js
- LIFT 原則
- LIFT
Locate our code is easy
檔案、目錄結構分明
Identify code at a glance
每個元件一個檔案,並與命名相符,讓團隊成員能快速找到程式
Flat structure as long as we can
目錄不要超過兩層,儘可能扁平化
目錄超過7-10個檔案,考慮建立子資料夾
Try to stay DRY or T-DRY
DRY!DRY!DRY!很重要,所以說三次而且不解釋。
- LIFT
- 應用程式架構
- 看實際範例最清楚
- 網頁配置框架、選單、導覽列等相關 View、Controller 集中在 Layout 目錄
- 以功能來區分資料夾!
但也有另一種選擇是用型別來區分:Views、Controllers、Directives、Services… 但很容易一個資料夾出現數十個檔案,違背 LIFT 原則。
- 看實際範例最清楚
- Modularity 切割粒度
- 切割成多個功能聚焦的小型 Module:SRP(單一職責原則),方便組裝運用
- 用一個 App Module 把模組功能組合起來,構成應用程式。
- App 要輕薄,只負責組裝,實作邏輯留在各 Module 中。(如 MVC 中的 Controller 角色)
- 將功能目標相近的邏輯切割成獨立 Module,例如:Layout、共用服務、系統服務項目(客戶模組、管理模組…)
- 將可重複使用的區塊切成模組,例如:例外處理、Log、偵錯、安控、本機資料管理
- Module 相依性:將跨 App 參照的相依模組(Cross App Modules)放入 app.core、App 主模組再照 app.core,以及其他功能性的 Module:
(圖片來源:https://github.com/johnpapa/angularjs-styleguide)
- $Wrapper
- 使用 $document、$window、$timeout、$interval 取代 document、window、setTimeout、setInterval,測試時可改用假物件模擬,擺脫對 DOM 的依賴。
- 單元測試
- 用故事描述的方式撰寫測試案例,先寫好案例說明,內容留白,再一一補上程式。(類似 TDD 的精神)
- 用大家都在用的 Jasmine 或 Mocha 寫單元測試
- 用大家都在用的 Karma 跑測試(可與 Grunt、Gulp、Visual Studio 整合)
- 用 Sinon 做 Stubbing 及 Spying
- 用 PhatomJS 跑網頁測試
- 用 JSHint 分析程式碼(用 /*global sinon, describe, it, afterEach, beforeEach, expect, inject */ 排除測試程式,不做檢查)
- 動畫
- 適度使用過場動畫提高使用者體驗
- 動畫播放長度可抓300ms
- 善用 animate.css [延伸閱讀]
- 註解
- 使用 jsDoc 格式
/**
* @name logError
* @desc Logs errors
* @param {String} msg Message to log
* @returns {String}
*/
function logError(msg) {
- 使用 jsDoc 格式
- JSHint
- 使用 JSHint 檢核 JavaScript 程式碼規範
- 團隊協調使用一致的 JSHint 要求準則
- 常數
- 將全域變數集中在 app.core:angular.module('app.core').constant('var1','value1');
Comments
# by REX
對這句話有疑問 「 不要宣告 app 變數,如:var app = angular.module('app'… 可減少變數撞名及Memory Leak風險 」 一個app可能會擁有不同的module,而module內會有不同元件角色來分工 如果便於管理,雖然 controller,directive,service 立屬同一個module,但應該還是會拆開來寫成不同檔案,那這樣就違背了上述的精神,變數命名個人覺得好解決,但Memory Leak 說得有些嚴重,請問有好的建議嗎?
# by Jeffrey
to REX, 依我的解讀,這裡的用意只差在要不要多設一個變數app指向app這個Module。(開發指南用app當成主Module的名稱) 例如:原本是 var app = angular.module('app'...) app.controller("MyCtrl", ...) 改為不設app變數,在其他需要存取該module的地方(可能是其他檔案),要改寫成angular.module('app').controller('MyCtrl',...) 取代 app.controller('MyCtrl',...)。其餘 controller, directive, service 的規劃完全不受影響,只差在少了一個var app全域變數而已。 不知道這樣說明有沒有比較清楚。
# by REX
OK,感謝你的解釋,但其實還是很困惑 會這樣問是因為開發上有遇到不同元件掛同一個module,例如 angular.module('test',['anotherModule']) .controller('testController',testController) .directive('testDirective',testDirective) .provider('testProvider',testProvider); 因為 module 可以掛載其他 module 所以假使拆檔案就必須要先判斷避免重複定義 angular.module('test',['anotherModule']) || angular.module('test') 但每一個檔案都要這樣判斷實在很累 所以才會在最父層先 new 好 module,存在某個window變數內 map['moduleName'] = newModuleObject; 再由每個子層的檔案自己去變數取回來用 map['moduleName'].controller('testController',testController); map['moduleName'].directive('testDirective',testDirective); map['moduleName'].provider('testProvider',testProvider); 莫非是我對 module 的使用觀念錯誤?
# by REX
我看懂了.....感謝XD