在Web前端技術飛速發展的今天,Angular 1.x可以說是一個比較舊的東西,而ES6是新生事物。我們想要把這兩個東西結合起來,感覺就好像“十八新娘八十郎,蒼蒼白髮對紅妝。”但這件事的難度也並不大,因為我們最終是要把ES6構建成ES5程式碼,而ES5程式碼是可以很容易和Angular 1.x協作的。
不過,為什麼我們要幹這件事呢?
在這篇文章中,我提到過:
儘管在整個前端開發圈中,大家並不是很歡迎Angular,而且很多人認為它的1.x版本已經衰落,但我跟 @小豬有個觀點是一致的,那就是:“在企業開發領域,ng1的應用才方興未艾”,也就是說,它在這個領域其實還是上升階段。
所以,在不少場合下,它還是要承載一些開發工作,部分老系統的逐步平滑遷移也是比較重要的。
做這件事的另外一個意圖是:雖然未來的框架選型會有不少爭議,但有一點毋庸置疑,那就是業務JS程式碼的全面ES6或者TS化,這一點我們現在就可以著手去做,並且可以儘量把資料和業務邏輯層實現成框架無關的形式。
在這篇裡大致講了點對這方面的考慮。
模組機制
Angular 1.x的module機制是比較彆扭的,也是一種框架私有的模組機制,所以,我們需要淡化這層東西,具體的措施是:
- 把各功能模組的具體實現程式碼獨立出來
- module機制作為一個殼子,對功能模組進行包裝
- 每個功能分組,使用一個總的殼子來包裝,減少上級模組的引用成本
- 每個殼子檔案把module的name屬性export出去
舉例來說,我們有一個moduleA,裡面有serviceA,serviceB,那麼,就有這樣一些檔案:
serviceA的實現,service/a.js
1 |
export default class ServiceA {} |
serviceB的實現,service/b.js
1 |
export default class ServiceB {} |
moduleA的殼子定義,moduleA.js
1 2 3 4 5 6 7 |
import ServiceA from "./services/a"; import ServiceB from "./services/b"; export default angular.module("moduleA", []) .service("ServiceA", ServiceA) .service("ServiceB", ServiceB) .name; |
存在一個moduleB要使用moduleA:
1 2 3 |
import moduleA from "./moduleA"; export default angular.module("moduleB", [moduleA]).name; |
注意,這裡為什麼我們要export module的name呢?這是為了這個module的引用者方便,如果某個module改名了,所有依賴它的module可以不修改程式碼。
在這裡我們可以看到,a.js,b.js,moduleA.js這三個檔案,只有moduleA是作為一次性的配置項,而a和b可以儘量實現成框架無關的程式碼,這樣將來的遷移代價會比較小。
service,factory,controller,filter
在Angular 1.x裡面,有factory和service兩個概念,其實這兩者可以替換,service傳入的是建構函式,通過new建立出例項,而factory傳入的是工廠函式,通過對這個工廠函式的呼叫而建立例項。
所以,如果要使用ES6程式碼來編寫這個部分,也就很自然了:
serviceA的實現,service/a.js
1 |
export default class ServiceA {} |
serviceA的模組包裝器moduleA的實現
1 2 3 4 5 |
import ServiceA from "./service/a"; export angular.module("moduleA", []) .service("ServiceA", ServiceA) .name; |
factoryA的實現,factory/a.js
1 2 3 4 5 |
import EntityA from "./model/a"; export default function FactoryA { return new EntityA(); } |
factoryA的模組包裝器moduleA的實現
1 2 3 4 5 |
import FactoryA from "./factory/a"; export angular.module("moduleA", []) .factory("FactoryA", FactoryA) .name; |
注意看這個例子中,FactoryA函式的返回結果是new EntityA,在實際專案中,這裡不一定是通過某個實體類建立的,也可能是直接一個物件字面量:
1 2 3 4 5 |
export default function FactoryA { return { a: 1 }; } |
在ES6下,factory的定義其實可以有一些優化,比如說,我們可以不需要factory/a.js這個檔案,也不需要這層factory封裝,而是在module定義的地方,這樣寫:
1 2 3 4 5 |
import EntityA from "./model/a"; export angular.module("moduleA", []) .factory("FactoryA", () => new EntityA()) .name; |
使用ES6定義controller的方式大致與service相同,
如何處理依賴注入
有一點值得注意,剛才我們提到的模組定義方式裡,並沒有考慮依賴注入,但實際業務中一般都要注入點東西,那怎麼辦呢?
有兩種辦法:
controllers/a.js
1 2 3 4 5 6 7 |
export default class ControllerA { constructor(ServiceA) { this.serviceA = ServiceA; } } ControllerA.$inject = ["ServiceA"]; |
1 2 3 4 |
import ControllerA from "./controllers/a"; export angular.module("moduleA", []) .controller("ControllerA", ControllerA); |
或者:
controllers/a.js
1 2 3 4 5 |
export default class ControllerA { constructor(ServiceA) { this.serviceA = ServiceA; } } |
1 2 3 4 |
import ControllerA from "./controllers/a"; export angular.module("moduleA", []) .controller("ControllerA", ["ServiceA", ControllerA]); |
個人推薦前一種,理由是,一個模組的依賴項宣告,最好跟其實現放在一起,這樣對可維護性更有利。
在考慮依賴注入的時候,還存在另外一個問題,我們現在這樣做,實質上已經弱化了Angular自身的DI,但這時候,為什麼我們還需要DI?如果我們在一個Controller裡面依賴某個Service,大可以直接import它啊,為什麼還非要去從DI走一圈?
這裡面有個麻煩,如果你所依賴的東西沒有對Angular DI依賴,那還好,不然的話,沒法例項化,比如說:
1 2 3 4 5 |
export default class ServiceA { constructor($http) {} } ServiceA.$inject = ["$http"]; |
如果我要在一個別的東西里例項化這個ServiceA,就沒法給它傳入$http,這些東西要從ng裡獲取,考慮是不是搞個專門的例項化函式,類似provider,專門去做這個例項化,這樣可以消除DI,直接import。
directive
這個是終極糾結點了,因為一個directive,可能包含有compile,link等多個成員函式,各種配置項,一個可選controller之類,這裡面我們要考慮這麼一些東西:
- directive自身怎麼定義為ES6程式碼
- 裡面的各項成員如何處理
- controller如何定義
我們看一下directive主要包含些什麼東西,它其實是一個ddo(Directive Definition Object),所以本質上這是一個物件,我們可以給它構建一個類。
1 2 |
export default class DirectiveA { } |
DDO上面的東西大致可以分兩類,屬性和方法,所以就在建構函式裡這樣定義:
1 2 3 4 |
constructor () { this.template = template; this.restrict = "E"; } |
像這些都是基礎的配置字串,沒什麼特別的。剩下的就是controller和link,compile等函式了,這些東西其實也簡單,比如controller,可以先實現一個普通controller類,然後賦值到controller屬性上來:
1 |
this.controller = ControllerA; |
注意現在寫directive,儘量使用controllerAs這樣的語法,這樣controller可以清晰些,不必注入$scope,而且還可以使用bindToController屬性,把在attr上定義的屬性或者方法直接傳遞到controller例項上來。
比如我們要做一個日期控制元件,最後合起來就是這樣:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
import template from "../templates/calendar.html"; import CalendarCtrl from "../controllers/calendar"; import "../css/calendar.css"; export default class CalendarDirective { constructor() { this.template = template; this.restrict = "E"; this.controller = CalendarCtrl; this.controllerAs = "calendarCtrl"; this.bindToController = true; this.scope = { minDate: "=", maxDate: "=", selectedDate: "=", dateClick: "&" }; } link (scope) { // 這段程式碼太彆扭了,但問題是如果搬到controller裡面去寫成setter,會在constructor之前執行,真頭疼,先這樣吧 scope.$watch("calendarCtrl.selectedDate", newDate => { if (newDate) { scope.calendarCtrl.calendar.year = newDate.getFullYear(); scope.calendarCtrl.calendar.month = newDate.getMonth(); scope.calendarCtrl.calendar.date = newDate.getDate(); } }); } } |
然後,在module定義的地方:
1 2 3 4 5 |
import CalendarDirective from "./directives/calendar"; export default angular.module("components.form.calendar", []) .directive("snCalendar", () => new CalendarDirective()) .name; |
上面這個例子裡,還有些比較頭疼的地方。本來我們剝離了清晰的controller,就是為了裡面不要有$scope這些奇奇怪怪的東西,但我們需要$watch這個selectedDate的賦值,就折騰了,$watch是定義在$scope上面的,而如果在controller上給selectedDate定義一個setter,可能由於babel跟angular共同的作用,時序有點問題……後面再想辦法優化吧。
一個directive除了有這些,還可以有template的定義,所以在這個例子裡我們也是用import把一個html加進來了,Webpack的html loader會自動把它變成一個字串。
還有,元件化的思想指導下,單個元件也應當管理自己的樣式,所以我們在這裡也import了一個css,這個後面會被Webpack的css loader處理。
消除顯式的$scope
我們前面提到,做這套方案有一個很重要的意圖,那就是在資料和業務邏輯層儘量清除Angular的影子,使得除了最上層的部分,其他都可以被其他框架方案使用,比如React和Vue,這裡面有一些關鍵。
在Angular 1.x中,一個核心的東西是$scope,它是一切東西執行的基石,然而,把這些東西暴露給一線開發者,其實並不優雅,所以,Angular 1.2之後,逐步提供了一些選項,用於減少開發過程中對$scope的顯式依賴。
那麼,我們可能會在什麼場景下用到$scope,主要用到它的什麼能力呢?
- controller中注入,給介面模板中的繫結變數或者方法用
- 依賴屬性的計算,比如說我們可以手動$watch一個變數、物件、陣列,然後在變更回撥中更改另外的東西
- 事件的冒泡和廣播,根作用域
- directive中的controller,link等函式使用
我們一個一個來看,這些東西怎麼消除。
controller注入
以前我們一般要在controller中注入$scope,但是從1.2版本之後,有了controllerAs語法,所以這個就不再必要了,之前是這樣:
1 2 3 |
<div ng-controller="TestCtrl"> <input ng-model="aaa"> </div> |
1 2 3 |
xxx.controller("TestCtrl", ["$scope", function($scope) { $scope.aaa = 1; }]); |
現在成了:
1 2 3 |
<div ng-controller="TestCtrl as testCtrl"> <input ng-model="testCtrl.aaa"> </div> |
1 2 3 |
xxx.controller("TestCtrl", [function() { this.aaa = 1; }]); |
這裡的關鍵點就在於,controller變成了一個純淨的檢視模型,實際上框架會做一件事:
1 |
$scope.testCtrl = new TestCtrl(); |
所以,對於這一塊,其實我們是不必擔憂的,把那個function換成一個普通的ES6 Class就好了。
依賴屬性的計算
我們知道,在$scope上,除了有$watch,$watchGroup,$watchCollection,還有$eval(作用域上的表示式求值)這類東西,我們必須想到對它們的替代辦法。
先來看看$watch,一個典型的例子是:
1 2 3 |
$scope.$watch("a", function(val) { $scope.b = val + 1; }); |
這個我們的辦法很簡單,在ES5+,物件上是有setter和getter的,那我們只要在ES6程式碼裡這麼定義就行了:
1 2 3 4 5 |
class A { set a(val) { this.b = val + 1; } } |
如果有多個變數的觀測,比如:
1 2 3 |
$scope.$watchGroup(["firstName", "lastName"], function(val) { $scope.fullName = val.join(","); }); |
我們可以寫多個setter來做,也可以寫一個getter:
1 2 3 4 5 |
class A { get fullName() { return this.firstName + "," + this.lastName; } } |
下一個,$watchCollection,這個有些複雜,因為它可以觀測陣列內部元素的變化,但其實JavaScript語法層面是缺少一些東西的,對比其他語言,早在十多年前,C# 1.0中就支援了indexer,也就是可以自定義下標操作。
不過這個也難不倒我們,在Adobe Flex裡面,有一個ArrayCollection,實際上是封裝了對於陣列的操作,所以,我們需要的只是把陣列的變更操作封裝起來,不直接在原始陣列上進行操作就好了。
所以我們的結構就類似如下:
1 2 3 4 5 6 7 8 9 10 11 |
class A { constructor() { this.arr = []; } add(item) { this.arr.push[item]; //這裡乾點別的 } } |
對於這個封裝好的東西,我們的原則是:讀取操作可以直接取引用,但是寫入操作必須通過封裝的這些方法去呼叫。
這裡還有技巧,我們其實是可以把這類陣列操作全部封裝,也搞成類似ArrayCollection那樣,但很多時候,ArrayCollection太通用了,我們其實要的是強化的領域模型,而不是通用模型。所以,針對每個業務模型單獨封裝,有其自身的優勢。
注意,我們這裡僅僅是封裝了陣列元素的操作,並未對元素自身屬性的變更,或者高維陣列,這些需要多層封裝。
事件的冒泡和廣播
在$scope上,另外一套常用的東西是$emit,$broadcast,$on,這些API其實是有爭議的,因為如果說做元件的事件傳遞,應當以元件為單位進行通訊,而不是在另外一套體系中。所以我們也可以不用它,比較直接的東西通過directive的attr來傳遞,更普遍的東西用全域性的類似Flux的派發機制去通訊。
根作用域的問題也是一樣,儘量不要去使用它,對於一個應用中全域性存在的東西,我們有各種策略去處理,不必糾結於$rootScope。
directive等地方中的$scope
哎,其實理論上是可以把業務程式碼中每個地方都搞得完全沒有$scope的,而且也能比較優雅通用,但是。。。總有一些例外。
先看看正常的吧。
我們知道,在定義directive的時候,ddo中有個屬性是scope,這個裡面定義了要在directive內外進行傳遞的屬性或者方法,並且有不同的傳遞型別。我們又知道,directive有個controllerAs選項,可以類似前面提到的,controller中不注入$scope:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
class TestCtrl { constructor() { this.a = 1; } } export default class CalendarDirective { constructor() { //... this.controller = TestCtrl; this.controllerAs = "testCtrl"; this.scope = { a: "=" }; } } |
這時候就有個問題了,我們知道,最終結構會變成:
1 |
$scope.testCtrl.a == 1; |
但這句:
1 2 3 |
this.scope = { a: "=" }; |
又會導致$scope.a == 1,而且,在testCtrl這個例項中,如果你不顯式傳入$scope,還訪問不到外面那個a,這跟我們的預期是不相符的。所以,這時候我們要配合用bindToController,可以寫個屬性true,也可以把scope物件搬上去(1.4以上版本支援)。
所以程式碼就成了這樣:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
class TestCtrl { constructor() { this.a = 1; } } export default class CalendarDirective { constructor() { //... this.controller = TestCtrl; this.controllerAs = "testCtrl"; this.bindToController = true; this.scope = { a: "=" }; } } |
這樣都對了嗎,並不會……
我們再綜合一下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
class TestCtrl { constructor() { this.a = 1; } set a(val) { this.b = val + 1; } } export default class CalendarDirective { constructor() { //... this.controller = TestCtrl; this.controllerAs = "testCtrl"; this.bindToController = true; this.scope = { a: "=" }; } } |
這裡,只是在TestCtrl中給a加了一個setter,然而這個程式碼是不執行的,貌似繫結過程有問題,所以我才會在上面那個地方加了個很彆扭的$watch,也就是:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
class TestCtrl { constructor() { this.a = 1; } } export default class CalendarDirective { constructor() { //... this.controller = TestCtrl; this.controllerAs = "testCtrl"; this.bindToController = true; this.scope = { a: "=" }; } link(scope) { scope.$watch("testCtrl.a", val => scope.testCtrl.b = val + 1); } } |
而且,這裡再$watch的話,需要把controller例項的別名也作為路徑放進去,testCtrl.a,而不是a。總之還是有些彆扭,但我覺得這裡應該還有辦法解決。
// 上面這段等我有空詳細再想想
有的時候,直接把setter或者getter繫結到介面,會不太適合,雖然Angular的ng-model中支援getterSetter這種輔助,但畢竟還有所不同,所以很多時候我們很多時候可能需要把帶getter和setter的業務物件下沉一級,外面再包裝一層給angular繫結用。
小結
在任何一個嚴謹的專案中,應當有比較確定的業務模型,即使脫離介面本身,這些模型也應當是可以運作的,而ES6之類語法的便利性,使得我們可以更好地組織下層業務程式碼。即使目的不是為了使用Angular 1.x,這一層的精心構造也是有價值的。當做完這層之後,上層遷移到各種框架都基本只剩體力活了。