Angular 1.x + ES6 開發風格指南

原文:https://github.com/kuitos/kuitos.github.io/issues/34css

閱讀本文以前,請確保本身已經讀過民工叔的這篇 blog 《Angular 1.x和ES6的結合》html

大概年初開始在個人忽悠下我廠啓動了Angular1.x + ES6的切換準備工做,第一個試點項目是公司內部的組件庫。目前已經實施了三個多月,期間也包括一些其它新開產品的試點。中間也經歷的一些痛苦及反覆(組件庫代碼經歷過幾回調整,如今還在重構ing),總結了一些經驗分享給你們。(實際上民工叔的文章中提到了大部分實踐指南,我這裏嘗試做必定整理及補充,包括一些本身的思考及解決方案)前端

開始以前務必再次明確一件事情,就是咱們使用ES6來開發Angular1.x的目的。總結一下大概三點:vue

  1. 框架的選型在這幾年是很頭痛的事情,你沒法確定某個框架會是終極解決方案。可是有一點毫無疑問,就是使用ES6來寫業務代碼是勢在必行的。react

  2. 咱們能夠藉助ES6的一些新的語法特性,更清晰的劃分咱們應用的層級及結構。典型的就是module跟class語法。git

  3. 一樣的,在ES6語法的幫助下,咱們能較容易的將數據層跟業務模型層實現成框架無關的,這能有效的提高整個應用的可移植性及演化能力。從另外一方面講,數據層跟業務模型能脫離view獨立測試,是一個純數據驅動的web應用應該具有的基本素質。angularjs

其中第1點是技術投資須要,第二、3點是架構須要。 es6

咱們先來看看要達到這些要求,具體要如何一步步實現。github

Module

在ES6 module的幫助下,ng的模塊機制就變成了純粹的迎合框架的語法了。
實踐準則就是:web

  1. 各業務層及數據層代碼理想狀態下應該看不出框架痕跡。

  2. ng module最後做爲一個殼子將全部的業務邏輯包裝進框架內。

  3. 每一個ng module export出module name以便module之間相互引用。

example:

// moduleA.js 
import angular from 'angular';
import Controller from './Controller';

export default angular.module('moduleA', [])
    .controller('AppController', Controller)
    .name;
    
// moduleB.js 須要依賴module A
import angular from 'angular';
import moduleA from './moduleA';

angular.module('moduleB', [moduleA]);

經過這種方式,不管被依賴的模塊的模塊名怎麼改變都不會對其餘模塊形成影響。

Best Practice
index.js做爲框架語法包裝器生成angular module外殼,同時將module.name export出去。對於整個系統而言,理想狀態下只有index.js中能夠出現框架語法,其餘地方應該是看不到框架痕跡的。

Controller

ng1.2版本開始提供了一個controllerAs語法,自此Controller終於能變成一個純淨的ViewModel(視圖模型)了,而不是像以前同樣混入過多的$scope痕跡(供angular框架使用)。

example:

<div ng-controller="AppCtrl as app">
    <div ng-bind="app.name"></div>
    <button ng-click="app.getName">get app name</button>
</div>
// Controller AppCtrl.js
export default class AppCtrl {
    constructor() { 
        this.name = 'angular&es6';
    }
    
    getName() {
        return this.name;
    }
}
// module
import AppCtrl from './AppCtrl';

export default angular.module('app', [])
    .controller('AppCtrl', AppCtrl)
    .name;

這種方式寫controller等同於ES5中這樣去寫:

function AppCtrl() {
    this.name = 'angular&es6';
}

AppCtrl.prototype.getName = function() {
    return this.name;
};

....
.controller('AppCtrl', AppCtrl)

不過ES6的class語法糖會讓整個過程更天然,再加上ES6 Module提供的模塊化機制,業務邏輯會變得更清晰獨立。

Best Practice
在全部地方使用controllerAs語法,保證ViewModel(Controller)的純淨。

Component (Directive)

以datepicker組件爲例

// 目錄結構
+ date-picker
    - _date-picker.scss
    - date-picker.tpl.html
    - DatePickerCtrl.js
    - index.js
// DatePickerCtrl.js
export default class DatePickerCtrl {
    
    $onInit() {
        this.date = `${this.year}-${this.month}`;
    }
    
    getMonth() {
        ...
    }
    
    getYear() {
        ...
    }
}

注意,這裏咱們先寫的controller而不是指令的link/compile方法,緣由在於一個數據驅動的組件體系下,咱們應該儘可能減小DOM操做,所以理想狀態下,組件是不須要link或compile方法的,並且controller在語義上更貼合mvvm架構。

// index.js
import template from './date-picker.tpl.html';
import controller from './DatePickerCtrl';

const ddo = {
    restrict: 'E',
    template,
    controller,
    controllerAs: '$ctrl',
    bindToContrller: {
        year: '=',
        month: '='
    }
};

export default angular.module('components.datePicker', [])
    .directive('datePicker', ddo)
    .name;

注意,這裏跟民工叔的作法有點不同。叔叔的作法是把指令作成class而後在index.js中import並初始化,like this:

// Directive.js
export default class Directive {
    constructor() {
    }
    
    getXXX() {
    }
}

// index.js
import Directive from './Directive';

export default angular.module('xxx', [])
    .directive('directive', () => new Directive())
    .name;

可是個人意見是,整個系統設計中index.js做爲angular的包裝器使得代碼變成框架可識別的,換句話說就是隻有index.js中是能夠出現框架的影子的,其餘地方都應該是框架無關的使用原生代碼編寫的業務模型。

1.5以後提供了一個新的語法moduleInstance.component,它是moduleInstance.directive的高級封裝版,提供了更語義更簡潔的語法,同時也是爲了順應基於組件的應用架構的趨勢(以前也能作只是語法稍囉嗦且官方沒有給出best practice導向)。好比上面的例子用component語法重寫的話:

// index.js
import template from './date-picker.tpl.html';
import controller from './DatePickerCtrl';

const ddo = {
    template,
    controller,
    bindings: {
        year: '=',
        month: '='
    }
};

export default angular.module('components.datePicker', [])
    .component('datePicker', ddo)
    .name;

component語義更簡潔明瞭,好比 bindToController -> bindings的變化,並且默認controllerAs = '$ctrl'。還有一個重要的差別點就是,component語法只能定義自定義標籤,不能定義加強屬性,並且component定義的組件都是isolated scope。

另外angular1.5版本有一個大招就是,它給組件定義了相對完整的生命週期鉤子(雖然以前咱們能用其餘的一些手段來模擬init到destroy的鉤子,可是實現的方式框架痕跡過重,後面會詳細講到)!並且提供了單向數據流實現方式!
example

// DirectiveController.js
export class DirectiveController {
    
    $onInit() {
    }
    
    $onChanges(changesObj) {
    }
    
    $onDestroy() {
    }
    
    $postLink() {
    }
}

// index.js
import template from './date-picker.tpl.html';
import controller from './DatePickerCtrl';

const ddo = {
    template,
    controller,
    bindings: {
        year: '<',
        month: '<'
    }
};

export default angular.module('components.datePicker', [])
    .component('datePicker', ddo)
    .name;

component相關詳細看這裏:angular component guide

從angular的這些api變化來看,ng的開發團隊正在愈來愈多的吸收了一些其餘社區的思路,這也從側面上印證了前端框架正在趨於同質化的事實(至少在同類型問題領域,方案趨於同質)。順帶幫vue打個廣告,不管是進化速度仍是方案落地速度,vue都已經趕超angular了。推薦你們都去關注下vue。

Best Practice
在場景符合(只要你的指令是能夠做爲自定義標籤存在就算符合)的狀況下都應該用component語法,在$onInit回調中作初始化處理(而不是constructor,緣由見下文),$onDestroy中做組件銷燬回調。沒有link方法,只有組件Controller(ViewModel).這樣能幫助你從component-base的應用架構方向去思考問題。

Deprecation warning: although bindings for non-ES6 class controllers are currently bound to this before the controller constructor is called, this use is now deprecated. Please place initialization code that relies upon bindings inside a $onInit method on the controller, instead.

Service、Filter

自定義服務 provider、service、factory、constant、value

angular1.x中有五種不一樣類型的服務定義方式,可是若是咱們以功能歸類,大概能夠歸出兩種類型:

  1. 工具類/工具方法

  2. 一些應用級別的常量或存儲單元

angular本來設計service的目的是提供一個應用級別的共享單元,單例且私有,也就是隻能在框架內部使用(經過依賴注入)。在ES5的無模塊化系統下,這是一個很好的設計,可是它的問題也一樣明顯:

  1. 隨着系統代碼量的增加,出現服務重名的概率會愈來愈大。

  2. 查找一個服務的定義代碼比較困難,尤爲是一個多人開發的集成系統(固然你也能夠把緣由歸咎於 編輯器/IDE 不夠強大)。

很顯然,ES6 Module並不會出現這些問題。舉例說明,咱們以前使用一個服務是這樣的:

index.js

import angular from 'angular';
import Service from './Service';
import Controller from './Controller';

export default angular.module('services', [])
    .service('service', Service)
    .controller('controller', Controller)
    .name;

Service.js

export default class Service {
    getName() {
        return 'kuitos';
    }
}

Controller.js 這裏使用了工具庫angular-es-utils來簡化ES6中使用依賴注入的方式。

import {Inject} from 'angular-es-utils/decorators';

@Inject('service')
export default class Controller {
    
    getUserName() {
        return this._service.getName();
    }
}

假如哪天在調用controller.getUserName()時報錯了,並且錯誤出在service.getName方法,那麼查錯的方式是?我是隻能全局搜了不知道大家有沒有更好的辦法。。。

若是咱們使用依賴注入,直接基於ES6 Module來作,改造一下會變成這樣:

Service.js

export default {
    
    getName() {
        return 'kuitos';
    }
}

Controller.js

import Service from './Service';

export default class Controller {

    getUserName() {
        return Service.getName();
    }
}

這樣定位問題是否是容易不少!!

從這個案例上來看,咱們能完美模擬基礎的 Service、Factory 了,那麼還有Provider、Constant、Value呢?
Provider跟Service、Factory差別在於Provider在ng啓動階段可配置,脫離ng使用ES6 Module的方式,服務之間其實沒什麼區別。。。

Provider.js

let apiPrefix = '';

export function setPrefix(prefix) {
    apiPrefix = prefix;
}

export function genResource(url) {
    return resource(apiPrefix + url);
}

應用入口時配置:

app.js

import {setPrefix} from './Provider';

setPrefix('/rest/1.0');

Contant跟Value呢?其實若是咱們忘掉angular,它們倆徹底沒區別:

Constant.js

export const VERSION = '1.0.0';

使用ng內置服務

上面咱們提到咱們全部的服務其實均可以脫離angular來寫以消除依賴注入,可是有一種情況比較難搞,就是假如咱們自定義的工具方法中須要使用到angular的built-in服務怎麼辦?要獲取ng內置服務咱們就繞不開依賴注入。可是好在angular有一個核心服務$injector,經過它咱們能夠獲取任何應用內的服務(Service、Factory、Value、Constant)。可是$injector也是ng內置的服務啊,咱們如何避開依賴注入獲取它?我封裝了個小工具能夠作這個事:

import injector from 'angular-es-utils/injector';

export default {
    
    getUserName() {
        return injector.get('$http').get('/users/kuitos');
    }
};

這樣作確實能夠但總以爲不夠優雅,不過好在大部分場景下咱們須要用到built-in service的場景比較少,並且對於$http這類基礎服務,調用者不該該直接去用,而是提供一個更高級的封裝出去,對調用着而言內部使用的技術是透明,能夠是$http也能夠是fetch或者whatever。

import injector from 'angular-es-utils/injector';
import {FetchHttp} from 'es6-http-utils';

export const HttpClient {
    
    get(url) {
        return injector.get('$http').get(url);
    }
    
    save(url, payload) {
        return FetchHttp.post(url, payload);
    }
}

// Controller.js
import {HttpClient} from './HttpClient';
class Controller {
    saveUser(user) {
        HttpClient.save('/users', user);
    }
}

經過這些手段,對於業務代碼而言基本上是看不到依賴注入的影子的。

Filter

angular中filter作的事情有兩類:過濾和格式化。歸結起來它作的就是一種數據變換的工做。filter的問題不只僅在於DI的弊端,還有更多其餘的問題。vue2中甚至取消了filter的設計,參見[Suggestion]Vue 2.0 - Bring back filters please。其中有一點我特別承認:過分使用filter會讓你的代碼在不自知的狀況下走向混亂的狀態。咱們能夠本身去寫一系列的transformer(或者使用underscore之類的工具)來作數據處理,並在vm中顯式的調用它。

import {dateFormatter} from './transformers';

export default class Controller {

    constructor() {
        
        this.data = [1,2,3,4];
        
        this.currency = this.data
            .filter(v => v < 4)
            .map(v => '$' + v);
            
        this.date = Date.now();
        this.today = dateFormatter(this.date);
    }
}

Best Practice
理想狀態下,Service & Filter的語法在一個不須要跟其餘系統共享代碼單元的業務系統裏是徹底能夠抹除掉的,咱們徹底經過ES6 Module來代替依賴注入。同時,對於一些基礎服務,如$http$q之類的,咱們最好能提供更上層的封裝,確保業務代碼不會直接接觸到built-in service。

一步步淡化框架概念

若是想將業務模型完全從框架中抽離出來,下面這幾件事情是必須解決的。

依賴注入

前面提到過,經過一系列手段咱們能夠最大程度消除依賴注入。可是總有那些edge case,好比咱們要用$stateParams或者服務來自路由配置中注入的local service。我寫了一個工具能夠幫助咱們更舒服的應對這類邊緣案例 Link to Controller

依賴屬性計算

對於須要監控屬性變化的場景,以前咱們都是用$scope.$watch,可是這又跟框架耦合了。民工叔的文章裏提供了一個基於accessor的寫法:

class Controller {
    
    get fullName() {
        return `${this.firstName} ${this.lastName}`;
    }
}

template

<input type="text" ng-model="$ctrl.firstName">
<input type="text" ng-model="$ctrl.lastName">

<span ng-bind="$ctrl.fullName"></span>

這樣當firstName/lastName發生變化時,fullName也會相應的改變。基於的原理是Object.defineProperty。可是民工叔也指出了一個因爲某種不知名的緣由致使綁定失效,不得不用$watch的場景。這個時候$onChanges就派上用場了。可是$onChanges回調有個限制就是,它的變動檢測時基於reference的而不是值的內容的,也就是說綁定primitive沒問題,可是綁定引用類型(Object/Array等)那麼內容的變化並不會被捕獲到,例如:

class Controller {
    $onChanges(objs) {
        this.userCount = objs.users.length;
    }
}

const ddo = {
    controller: Controller,
    template: '<span ng-bind="$ctrl.listTitle"></span><span ng-bind="$ctrl.userCount"></span>'
    bindings: {
        title: '<',
        users: '<'
    }
};

angular.module('component', [])
    .component('userList', ddo);

template

<div ng-controller="ctrl as app">
    <user-list title="app.title" users="app.users" ng-click="app.change()"></user-list>
</div>
class Controller {
    contructor() {
        this.title = 'hhhh';
        this.users = [];
    }
    
    change() {
        this.users.push('s');
    }
}

angular.module('app', [])
    .controller('ctrl', Controller);

點擊user-list組件時,userCount值並不會變化,由於$onChanges並無被觸發。對於這種狀況呢,你可能須要引入immutable方案了。。。怎麼感受事情愈來愈複雜了。。。

組件生命週期

組件新增的四個生命週期對於我而言能夠說是最重大的變化了。雖然以前咱們也能經過一些手段來模擬生命週期:好比用compile模擬init,postLink模擬didMounted,$scope.$on('$destroy')模擬unmounted。

可是它們最大的問題就是身上攜帶了太多框架的氣息,並不能服務文明剝離框架的初衷。具體作法不贅述了,看上面組件部分的介紹Link To Component)。

事件通知

之前咱們在ng中使用事件模型有 $broadcast$emit$on這幾個api用,如今沒了它們咱們要怎麼玩?

個人建議是,咱們只在必要的場景使用事件機制,由於事件濫用和不及時的卸載很容易形成事件爆炸的狀況發生。必要的場景就是,當咱們須要在兄弟節點、或依賴關係不大的組件間觸發式通訊時,咱們可使用自制的 事件總線/中介者 來幫咱們完成(可使用個人這個工具庫angular-es-utils/EventBus)。在非必要的場景下,咱們應該儘可能使用inline-event的方式來達成通訊目標:

const ddo = {
    template: '<button type="button" ng-click="$ctrl.change('kuitos')">click me</button>',
    controller: class {
        click(userName) {
            this.onClick({userName});
        }    
    },    
    bindings: {
        onClick: '&'
    }
};

angular.module('app', [])
    .component('user', ddp);

useage

<user on-click="logUserName(userName)"></user>

總結

理想狀態下,對於一個業務系統而言,會用到angular語法只有 angular.controllerangular.component angular.directiveangular.config這幾種。其餘地方咱們均可以實現成框架無關的。

對於web app架構而言,angular/vue/react 等組件框架/庫 提供的只是 模板語法&膠水語法(其中膠水語法指的是框架/庫 定義組件/控制器 的語法),剝離這兩個外殼,咱們的業務模型及數據模型應該是能夠脫離框架運做的。古者有云,衡量一個完美的MV*架構的標準就是,在V隨意變化的狀況下,你的M*是能夠不改一行代碼的狀況下就完成遷移的。

在MV*架構中,V層是最薄且最易變的,可是M*理應是 穩定且純淨的。雖然要作到一行代碼不改實現框架的遷移是不可能的(視圖層&膠水語法的修改不可避免),可是咱們能夠儘可能將最重的 M* 作成框架無關,這樣作上層的遷移時剩下的就是一些語法替換的工做了,並且對V層的改變也是代價最小的。

事實上我認爲一個真正可伸縮的系統架構都應該是這樣一個思路:勿論是 MV* 仍是 Flux/Redux or whatever,確保下層 業務模型/數據模型 的純淨都是有必要的,這樣才能提供上層隨意變化的可能,任何模式下的應用開發,都應該具有這樣的一個能力。

相關文章
相關標籤/搜索