Angular 1.x和ES6的結合

發表於2015-12-24

在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

serviceB的實現,service/b.js

moduleA的殼子定義,moduleA.js

存在一個moduleB要使用moduleA:

注意,這裡為什麼我們要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

serviceA的模組包裝器moduleA的實現

factoryA的實現,factory/a.js

factoryA的模組包裝器moduleA的實現

注意看這個例子中,FactoryA函式的返回結果是new EntityA,在實際專案中,這裡不一定是通過某個實體類建立的,也可能是直接一個物件字面量:

在ES6下,factory的定義其實可以有一些優化,比如說,我們可以不需要factory/a.js這個檔案,也不需要這層factory封裝,而是在module定義的地方,這樣寫:

使用ES6定義controller的方式大致與service相同,

如何處理依賴注入

有一點值得注意,剛才我們提到的模組定義方式裡,並沒有考慮依賴注入,但實際業務中一般都要注入點東西,那怎麼辦呢?

有兩種辦法:

controllers/a.js

或者:
controllers/a.js

個人推薦前一種,理由是,一個模組的依賴項宣告,最好跟其實現放在一起,這樣對可維護性更有利。

在考慮依賴注入的時候,還存在另外一個問題,我們現在這樣做,實質上已經弱化了Angular自身的DI,但這時候,為什麼我們還需要DI?如果我們在一個Controller裡面依賴某個Service,大可以直接import它啊,為什麼還非要去從DI走一圈?

這裡面有個麻煩,如果你所依賴的東西沒有對Angular DI依賴,那還好,不然的話,沒法例項化,比如說:

如果我要在一個別的東西里例項化這個ServiceA,就沒法給它傳入$http,這些東西要從ng裡獲取,考慮是不是搞個專門的例項化函式,類似provider,專門去做這個例項化,這樣可以消除DI,直接import。

directive

這個是終極糾結點了,因為一個directive,可能包含有compile,link等多個成員函式,各種配置項,一個可選controller之類,這裡面我們要考慮這麼一些東西:

  • directive自身怎麼定義為ES6程式碼
  • 裡面的各項成員如何處理
  • controller如何定義

我們看一下directive主要包含些什麼東西,它其實是一個ddo(Directive Definition Object),所以本質上這是一個物件,我們可以給它構建一個類。

DDO上面的東西大致可以分兩類,屬性和方法,所以就在建構函式裡這樣定義:

像這些都是基礎的配置字串,沒什麼特別的。剩下的就是controller和link,compile等函式了,這些東西其實也簡單,比如controller,可以先實現一個普通controller類,然後賦值到controller屬性上來:

注意現在寫directive,儘量使用controllerAs這樣的語法,這樣controller可以清晰些,不必注入$scope,而且還可以使用bindToController屬性,把在attr上定義的屬性或者方法直接傳遞到controller例項上來。

比如我們要做一個日期控制元件,最後合起來就是這樣:

然後,在module定義的地方:

上面這個例子裡,還有些比較頭疼的地方。本來我們剝離了清晰的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語法,所以這個就不再必要了,之前是這樣:

現在成了:

這裡的關鍵點就在於,controller變成了一個純淨的檢視模型,實際上框架會做一件事:

所以,對於這一塊,其實我們是不必擔憂的,把那個function換成一個普通的ES6 Class就好了。

依賴屬性的計算

我們知道,在$scope上,除了有$watch,$watchGroup,$watchCollection,還有$eval(作用域上的表示式求值)這類東西,我們必須想到對它們的替代辦法。

先來看看$watch,一個典型的例子是:

這個我們的辦法很簡單,在ES5+,物件上是有setter和getter的,那我們只要在ES6程式碼裡這麼定義就行了:

如果有多個變數的觀測,比如:

我們可以寫多個setter來做,也可以寫一個getter:

下一個,$watchCollection,這個有些複雜,因為它可以觀測陣列內部元素的變化,但其實JavaScript語法層面是缺少一些東西的,對比其他語言,早在十多年前,C# 1.0中就支援了indexer,也就是可以自定義下標操作。

不過這個也難不倒我們,在Adobe Flex裡面,有一個ArrayCollection,實際上是封裝了對於陣列的操作,所以,我們需要的只是把陣列的變更操作封裝起來,不直接在原始陣列上進行操作就好了。

所以我們的結構就類似如下:

對於這個封裝好的東西,我們的原則是:讀取操作可以直接取引用,但是寫入操作必須通過封裝的這些方法去呼叫。

這裡還有技巧,我們其實是可以把這類陣列操作全部封裝,也搞成類似ArrayCollection那樣,但很多時候,ArrayCollection太通用了,我們其實要的是強化的領域模型,而不是通用模型。所以,針對每個業務模型單獨封裝,有其自身的優勢。

注意,我們這裡僅僅是封裝了陣列元素的操作,並未對元素自身屬性的變更,或者高維陣列,這些需要多層封裝。

事件的冒泡和廣播

在$scope上,另外一套常用的東西是$emit,$broadcast,$on,這些API其實是有爭議的,因為如果說做元件的事件傳遞,應當以元件為單位進行通訊,而不是在另外一套體系中。所以我們也可以不用它,比較直接的東西通過directive的attr來傳遞,更普遍的東西用全域性的類似Flux的派發機制去通訊。

根作用域的問題也是一樣,儘量不要去使用它,對於一個應用中全域性存在的東西,我們有各種策略去處理,不必糾結於$rootScope。

directive等地方中的$scope

哎,其實理論上是可以把業務程式碼中每個地方都搞得完全沒有$scope的,而且也能比較優雅通用,但是。。。總有一些例外。

先看看正常的吧。

我們知道,在定義directive的時候,ddo中有個屬性是scope,這個裡面定義了要在directive內外進行傳遞的屬性或者方法,並且有不同的傳遞型別。我們又知道,directive有個controllerAs選項,可以類似前面提到的,controller中不注入$scope:

這時候就有個問題了,我們知道,最終結構會變成:

但這句:

又會導致$scope.a == 1,而且,在testCtrl這個例項中,如果你不顯式傳入$scope,還訪問不到外面那個a,這跟我們的預期是不相符的。所以,這時候我們要配合用bindToController,可以寫個屬性true,也可以把scope物件搬上去(1.4以上版本支援)。

所以程式碼就成了這樣:

這樣都對了嗎,並不會……

我們再綜合一下:

這裡,只是在TestCtrl中給a加了一個setter,然而這個程式碼是不執行的,貌似繫結過程有問題,所以我才會在上面那個地方加了個很彆扭的$watch,也就是:

而且,這裡再$watch的話,需要把controller例項的別名也作為路徑放進去,testCtrl.a,而不是a。總之還是有些彆扭,但我覺得這裡應該還有辦法解決。

// 上面這段等我有空詳細再想想

有的時候,直接把setter或者getter繫結到介面,會不太適合,雖然Angular的ng-model中支援getterSetter這種輔助,但畢竟還有所不同,所以很多時候我們很多時候可能需要把帶getter和setter的業務物件下沉一級,外面再包裝一層給angular繫結用。

小結

在任何一個嚴謹的專案中,應當有比較確定的業務模型,即使脫離介面本身,這些模型也應當是可以運作的,而ES6之類語法的便利性,使得我們可以更好地組織下層業務程式碼。即使目的不是為了使用Angular 1.x,這一層的精心構造也是有價值的。當做完這層之後,上層遷移到各種框架都基本只剩體力活了。

相關文章