微軟的老牌技術傳教士 John Papa 前些時候寫了一份 Angular 開發風格指南,近來打算在專案正式使用 AngularJS,便花了點時間詳讀,特筆記備忘兼分享。

先聲明一點:「開發風格」並無對錯可言,不同做法各有優劣,開發團隊可自行評估利害,取得共識維持一致即可。故文件所提並不是唯一正確的做法,只能說是蠻多人認同的一種選擇。很棒的一點是 John 花了不少篇幅解說「為什麼選擇這種做法?」,方便大家評估是否採納。筆記未能詳述之處,建議大家可以看原文,收獲會更多。

  1. 單一責任原則(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 特性,不需要手工寫。 )
  2. 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() { … };
  3. 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> 就好。

  4. Service(服務)
    • 可以寫函式讓 Angular 透過 new 建立執行個體(在函式中用 this.method = function() {…}宣告公開方法及屬性),也可以透過 Factory 模式建立,擇一並統一為宜。
    • 所有的 Service 都是 Singleton,只有一個執行個體。
  5. Factory
    • 單一職責:目標不同就拆成另一個 Factory。
    • Singleton:所有 Factory 都是 Singleton,負責傳回包含服務方法或屬性的物件。
    • 托高集中:將服務的公開方法、屬性宣告移到前段,實作內容放在後段,比照在 Controller 的做法。
    • 透過 service.$injector = ['a','b','c'] 配合 function service(a,b,c) {…} 解決相依要求。
  6. Data Service
    • 分離資料呼叫:將 XHR 呼叫、localStorage、記憶體暫存等資料操作邏輯移至 Factory。
      考量:Controller 只負責資料呈現及蒐集,不要涉及資料取得及傳輸以求單純、聚焦。(如同 MVC的 SoC 原理)如此將有利測試以及抽換實作(如:由 XHR 改 localStorage)
    • 利用 Deferrer 物件處理非同步呼叫的銜接順序。
  7. Directive
    • 將每個 Directive 寫成獨立檔案 (註:這點我持保留看法,可能造成檔案數驟增,我的想法是將 Directive 實作寫成獨立函式,集中在單一 TypeScript,透過強型別關聯應不難追蹤管理)
    • 避免在 Directive 中直接增刪變更 DOM,考慮以 CSS、動畫服務、樣版(Templating)、ngShow/ngHide 取代之。減少對 DOM 的依賴,將有助於測試。
    • Directive 採用自訂元素或 Attribute 宣告就好,透過 class="…" 宣告可讀性不佳。
  8. 處理 Controller 的非同步作業
    • 將非同步作業(例如:XHR 取值)包進 function activate() {…} 再呼叫,不要直接寫成建構式裡的程式片段,這樣比較一致好找。
    • 透過 $routeProvider.when("/avengers", { …, resolve: { 必備資料: function() { … }); 確保 Controller 在建構時,資料已備妥。延伸閱讀
  9. 依賴注入(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 處理相依變數傳遞
  10. Minification 及 Annontation
    • 利用 ng-annotate 讓 Gulp 或 Grunt 偵測程式自動加入 $inject 宣告
    • 自動偵測失效時,使用 /* @ngInjnect */ 註解提示
  11. 例外處理
    • 為 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', …) 處理路由錯誤
  12. 命名原則
    • 建議: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
  13. 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!很重要,所以說三次而且不解釋。
  14. 應用程式架構
    • 看實際範例最清楚
    • 網頁配置框架、選單、導覽列等相關 View、Controller 集中在 Layout 目錄
    • 以功能來區分資料夾!
      但也有另一種選擇是用型別來區分:Views、Controllers、Directives、Services… 但很容易一個資料夾出現數十個檔案,違背 LIFT 原則。
  15. 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
  16. $Wrapper
    • 使用 $document、$window、$timeout、$interval 取代 document、window、setTimeout、setInterval,測試時可改用假物件模擬,擺脫對 DOM 的依賴。
  17. 單元測試
    • 用故事描述的方式撰寫測試案例,先寫好案例說明,內容留白,再一一補上程式。(類似 TDD 的精神)
    • 用大家都在用的 Jasmine 或 Mocha 寫單元測試
    • 用大家都在用的 Karma 跑測試(可與 Grunt、Gulp、Visual Studio 整合)
    • 用 Sinon 做 Stubbing 及 Spying
    • 用 PhatomJS 跑網頁測試
    • 用 JSHint 分析程式碼(用 /*global sinon, describe, it, afterEach, beforeEach, expect, inject */ 排除測試程式,不做檢查)
  18. 動畫
  19. 註解
    • 使用 jsDoc 格式
      /**
      * @name logError
      * @desc Logs errors
      * @param {String} msg Message to log
      * @returns {String}
      */
      function logError(msg) {
  20. JSHint
    • 使用 JSHint 檢核 JavaScript 程式碼規範
    • 團隊協調使用一致的 JSHint 要求準則
  21. 常數
    • 將全域變數集中在 app.core:angular.module('app.core').constant('var1','value1');
[NG系列]
http://www.darkthread.net/kolab/labs/default.aspx?m=post&t=angularjs

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

Post a comment