[無瑕的前端-2] 中卷

Angular、TypeScript實作前端MVC架構

前言

在上卷中,我們已經安裝好必要的套件,並且將資料夾目錄都已建立完畢。本卷的重點在如何實作職責分離,以及將此架構結合入原本的ASP.NET MVC框架。

職責分離

從TypeScripts的資料夾內起始,前端已經是自成一格的架構,接下來就是來實作各層的程式碼。

Root

這裡會放一個叫做app.ts的檔案,裡面主要是建立有用到的angular module以及設定相依性。

下列程式碼代表的意思是,宣告了一個叫做Sample的命名空間,裡面有五個module,分別是Hank.Chen、Hank.Chen.Configs、Hank.Chen.Components、Hank.Chen.Controllers、Hank.Chen.Services。

而這裡特別說明一下[ ]代表的意思,如果是空的話,代表這個module沒跟其他module相依;反之,以Hank.Chen.Controllers為例子,他後面的[]寫了Hank.Chen.Services,這就代表假如要使用Hank.Chen.Controllers,就一定要定義Hank.Chen.Services,如果沒定義的話,因為兩者有相依,就會出現出現錯誤。

module Sample {
    angular.module('Hank.Chen', [
        'ui.router',
        'Hank.Chen.Services',
        'Hank.Chen.Controllers',
        'Hank.Chen.Configs'
    ]);

    angular.module('Hank.Chen.Configs', []);
    angular.module('Hank.Chen.Components', []);
    angular.module('Hank.Chen.Controllers', ['Hank.Chen.Services']);
    angular.module('Hank.Chen.Services', []);
}

Configs

config相對比較好懂一點,可以把他想成類似web.config,可以設定一些基本設定檔,比如說api的root位置等等。

下列程式碼代表的意思是,宣告一個ConfigProvider的類別,實作angular的provider,所以被要求要實作$get方法,$get方法裡會把property config回傳。因為是強型別,所以需告知config的型別,這個例子是IConfig,所以要再宣告一個IConfig的類別,裡面只有一個property,SampleApiRoot。

最後一段是最重要的,如果漏了等於前面都白宣告了,必須把我們宣告好的類別註冊到angular的module裡。

module Hank.Chen.Configs {
    export class IConfig {
        SampleApiRoot :string
    }
}

module Hank.Chen.Configs {
    export class ConfigProvider implements ng.IServiceProvider {
        config:IConfig = {
            SampleApiRoot: 'http://localhost/sampleapi/api',
        }

        $get() {
            return this.config;
        }
    }

  angular.module('Hank.Chen.Configs')
        .provider('Config', ConfigProvider)
}

Services

這裡主要是定義與後端api交互的相關方法,這類型的功能會把它放到Services的module裡。

下列程式碼代表的意思是,宣告一個MemberService的類別,並且用相依性注入的方式注入angular的$http以及自定義的Config類別,裡面提供了一個GetMember()的方法,負責用get的方式呼叫後端api取得資料。

補充說明,`${this.config.SampleApiRoot}/Member/`的字串中 ` 這個符號可以想像成string.format的效果,它會幫忙把${this.config.SampleApiRoot}取代成對應的字串,而不需要用字串相加的方式,這樣看起來會更直覺。

最後,仍然別忘了要把定義好的Services註冊到module中。

module Hank.Chen.Services {
    export class MemberService {
        static $inject = ['$http', 'Config']

        constructor(
            private $http: ng.IHttpService,
            private config: Configs.IConfig

        ) {
        }

        GetMember(): ng.IHttpPromise<ViewModel.MemberViewModel> {
            var url = `${this.config.SampleApiRoot}/Member/`;
            return this.$http.get<ViewModel.MemberViewModel>(url);
        }
    }

    angular.module('Hank.Chen.Services')
        .service('MemberService', MemberService);
}

Controllers

這裡的想法跟原本MVC網站中的Controller資料夾定義有點類似,就是依據程式別來做拆分。

本例子是假設要撰寫一個MemberLogin相關的程式,因此會在controllers的資料夾下新增一個MemberLogin資料夾,裡面放置相關的controller和view的內容。

稍微描述一下此層的運作機制,在此層會註冊route、controller以及放置相關的html檔案。利用angular.ui-Route套件所提供的IStateProvider來監聽狀態,當監聽到的狀態與route所定義的內容match時,便會傳回對應的controller和html頁面。

以下方的例子而言, $stateProvider在state是member時會去找TypeScripts/Controllers/MemberLogin/member.base.html的頁面,而負責此頁面的controller則是MemberController,簡寫為MemberCtrl,因此,在member.base.html頁面上就可以呼叫MemberController定義好的功能,以及存取裡面的資料。

 function MemberControllerRoute($stateProvider: angular.ui.IStateProvider) {
        $stateProvider
            .state('member',
            {
                url: '/member',
                templateUrl: 'TypeScripts/Controllers/MemberLogin/member.html',
                controller: 'MemberController',
                controllerAs: 'MemberCtrl',
                resolve: {
                  
                }
            })
    }

    MemberControllerRoute.$inject = ['$stateProvider'];

    angular.module('Hank.Chen.Controllers')
        .config(MemberControllerRoute);

到這邊為止,只有完成原本MVC架構中的V 和 C的定義,嚴格說來,並沒有M,因為並沒有資料來源。

假設資料來源是後端API,下方例子是宣告一個GetMemberData的方法,MemberService是上方我們自定義的,就不多說。這裡比較需要說明的是IQService,它主要是幫助我們非同步取得後端api資料,在呼叫MemberService的GetMember方法後,只有先回傳一個deferred.promise(我跟你承諾我會回傳你物件),但實際上還沒有,真正回傳的時間是呼叫完GetMember success後的deferred.resolve(data),才會真正取得資料。

  function GetMemberData($q: ng.IQService,
        MemberService: Services.MemberService) 
        {
            var deferred = $q.defer();

            MemberService.GetMember()
                .success((data) => {
                deferred.resolve(data);
            });

        return deferred.promise;
    }

    GetMemberData.$inject = ['$q','MemberService'];

接下來,再把這個方法掛給$stateProvider的resolve屬性,請參考下方程式碼片段。

   $stateProvider
            .state('member',
            {
                url: '/member',
                templateUrl: 'TypeScripts/Controllers/MemberLogin/member.base.html',
                controller: 'MemberController',
                controllerAs: 'MemberCtrl',
                resolve: {
                 Member: GetMemberData 
                }
            })

完成route的定義之後,再來才是定義controller,controller只有一個重點要提醒,就是前面route在resolve區塊內取得的資料要注入給controller這段需要自己來實作,實作的方式也很簡單,就用$inject注入就可以了,直接參考下方程式碼片段,名稱大小寫要一模一樣。

module Hank.Chen.Controllers {
    export class MemberController {
        static $inject = ['Member'];
        constructor(
           member: Hank.Chen.ViewModel.MemberViewModel
        ) {
        }
    }

  angular.module('Hank.Chen.Controllers')
        .controller('MemberController', MemberController);
}

最後,就是實作html,為了減少複雜性,直接最簡單秀出Member的Id即可。

<div>
    {{MemberCtrl.member.Id}}
</div>

這裡有一個觀念要注意,如果只是這樣寫的話,html頁面會空白的,因為在controller裡面,你只有對它注入member物件,並沒有對它開放成屬性,因此,頁面上是讀不到的,要將它開放成屬性也不難,加個public即可,請參考下方程式碼片段。

   constructor(
         public  member: Hank.Chen.ViewModel.MemberViewModel
        ) {
        }

到這裡為止,前端的程式碼基本上已經做到了職責分離,在維護起來也會比較方便了。

 

啟動程式碼

到這裡為止程式碼就能work了嗎?..當然不行,還有幾個額外的動作要作。

其中最基本的當然是...要引用我們剛剛寫好的所有js檔,TypeScript其實它是在背後幫我們產生對應的js檔,而這些東西,我們必須手動把它加入到我們的網站中,一般都會直接在加在BundleConfig.cs中,可參考下列程式碼片段。

            bundles.Add(new ScriptBundle("~/bundles/app").Include(
                  //// Root
                  "~/TypeScripts/app.js",
                  //// Config
                  "~/TypeScripts/Configs/config.js",
                  "~/TypeScripts/Configs/config.route.js",
                  //// Components
     
                  //// Service
                  "~/TypeScripts/Services/member.service.js",
                  //// Controller/Member
                  "~/TypeScripts/Controllers/memberLogin/member.controller.js",
                  "~/TypeScripts/Base/base.controller.js",
                  "~/TypeScripts/Controllers/memberLogin/member.route.js"
                  ));

再來需要在TypeScripts的config資料夾內新增config.route.ts,基本上這裡會設定routing的規則,下面是偷懶的作法,單純作範例效果, $urlRouteProvider.otherwise("member")的意思是,沒有match到任何規則就幫我導到member。

module Hank.Chen.Configs {
    angular.module('Hank.Chen.Configs')
        .config([
            '$stateProvider',
            '$urlRouterProvider',
            '$locationProvider',
            (
                $stateProvider: angular.ui.IStateProvider,
                $urlRouteProvider: angular.ui.IUrlRouterProvider,
                $locationProvider: ng.ILocaleService) => {
                $urlRouteProvider.otherwise("member");
            }
        ])
}

如果沒有更改MVC本身的route設定的話,它進來預設會自動導到Home/Index,因此我們還要修改Index.cshtml,記得要引用我們設定好的bundle,以及掛上ng-app屬性。


@{
    Layout=null;
}

<div ng-app="Hank.Chen">

    <div ui-view></div>

</div>

@Scripts.Render("~/bundles/angular")
@Scripts.Render("~/bundles/app")

整體架構約如下圖所示, HomeIndex裡的<div ui-view></div>內的內容會經由config的route導到member頁面,接著由member的route接手,處理相關的html和controller,然後把<div ui-view></div>給取代掉。

 

小結

我們目前做到的有將前端的程式碼作了職責分離,並且利用狀態監控機制來抽換我們的頁面,在TypeScript的威能下,前端程式碼不但享受到有智慧提示的好處,也會在編譯時期就能檢查我們的程式碼,大幅提升了前端程式的可維護性。

在下一篇文章將會描述如何打造基本建設,利用component的方式來構築網站,當基礎設施建設到一定程度時,未來有任何新的需求時,只需專注在需求的商業邏輯上,而不用不斷地重複造輪子和挖坑..待續