后Angular時代二三事
來自: https://github.com/xufei/blog/issues/21
后Angular時代二三事
JavaScript框架/庫一直就是百花齊放,最近幾年更是層出不窮。回顧這幾年,有兩個最引人注目的東西,一個是Angular,一個是React。其中,Angular最火的時間是2013年中到2014年末,React從2014年中開始升溫,然后又由于ReactNative等周邊項目,導致關注度很高。
2014年末,Angular官方宣布了一個大新聞,要完全重寫Angular 2.0。這個事情讓很多想要使用Angular的人止步不前,也給很多人帶來了困惑。
隨后,Angular 2.0的開發者之一創建了新的框架Aurelia,整體思路上與Angular相似,有一些細節的差異。那么,我們應當如何看待這些框架呢?
為什么Angular 2重寫
如果不是有重大原因,沒有哪個開發者會做出徹底重寫,產生很多不兼容變更的決定。對于Angular來說,它面臨這么一些原因:
- Web標準的升級,主要是Web Components相關標準和ECMAScript的后續版本
- 自身存在的一些問題:性能,模塊,過于復雜的指令等等
使用轉譯語言
也正是Angular 2.0那篇大新聞,使大家知道了AtScript這樣的語言,它在TypeScript的基礎上添加了注解等功能。
有很多語言可以轉譯成JavaScript,比如CoffeeScript,Dart,TypeScript等,從最近的一些事件來看,TypeScript可以算是JavaScript轉譯領域的最大贏家。
很多人可能會有這樣的疑問:為什么我們要用這些東西,而不是直接編寫原生的JavaScript?開發語言的選擇,很大程度上反映了我們對JavaScript組件化方案抽象度的需求。
比如說,Angular中,可以使用TypeScript來寫業務代碼,React中,通過JSX來使用組件,這都是具有較高抽象度的方案,能夠讓業務代碼變得更直觀。
ES6
先不看這些轉譯語言,來看看ES6,它給我們帶來了很多編程的便利,每一次這種語言細節的升級,都引入了一些好用的東西,所以我們當然是期望盡早使用它。但問題是,瀏覽器的支持程度總是落后的,如果用它寫了,在很多瀏覽器上不支持,比如箭頭函數:
this.removeTodo = function(todo) { this.todos = this.todos.filter(item => todo!=item); };
所幸,我們有Babel這樣的轉換器,可以把這樣的代碼翻譯成ES5代碼,它的生成結果就是
this.removeTodo = function(todo) { this.todos = this.todos.filter(function(item) { return todo != item; }); };
這個例子并不明顯,如果你使用class之類的東西,就能體會到更大的改變。雖然說class這些只是語法糖,但用起來還是很爽的,可以復用一些傳統的設計模式之類。
對于那些只需支持ES5+的項目而言,現在開始選用ES6語法編寫代碼是非常合適的,因為我們有Babel這樣的東西,我們可以享受ES6新語法帶來的愉悅編程體驗,而無需承擔兼容風險。
ES6新語法有很多,想要在生產過程中更好地使用,可以參見百度ecomfe的這篇 使用ES6進行開發的思考
TypeScript
Angular和Aurelia都支持TypeScript,可以直接使用TypeScript編寫業務代碼。如果選用這樣的框架,個人建議直接使用TypeScript。
為什么在類似Angular這樣的體系里,我要建議使用TypeScript呢,因為這么幾個原因:
長期的兼容性更好
很可能在現在這個階段,你的項目還需要面對一些不支持ES6的瀏覽器,所以不能直接寫ES6代碼,但有可能有一天,瀏覽器支持了,但你的代碼還是老的,它基本上還在使用ES5編寫,想要遷移到ES6比較麻煩,以后每次遷移都是痛苦的過程。TypeScript就是以生成JavaScript為目標的,所以如果你用它寫,只需選擇生成參數,比如生成es5,es6就可以了,就算以后es繼續升級,也只要改個參數就完事。
編寫體驗更好
TypeScript為代碼提示作了很多特殊優化,比如:
ele.on("click", function(e) { // 這里我們是不知道e上面有什么,在編寫的時候得不到提示 });
但是如果使用TypeScript編寫,因為這個e的類型確定,所以就能有提示。
使用這樣的語言也能夠更快讓非前端方向的人參與項目。
工作流程與管控
Angular的整體方案,由于分層很清晰,在JavaScript代碼中基本就是純邏輯,這樣的代碼如果使用TypeScript編寫,會更加精煉,更加清晰。
這幾年,大家逐漸接受了一個現實,那就是:前端也是需要構建的,所以我們有grunt,gulp這樣的構建工具。之前我們不愿意寫轉譯語言,是因為其他環節不需要構建,為了一些語法糖而引入整個構建環節代價太大。現在,既然發布之前的構建環節不可缺少,使用轉譯語言也不過就是加一段配置而已,這個使用代價已經小很多了。
Angular這樣的解決方案,所面向的多數都是重量級產品,這些產品本身就會有構建環節,也基本上會使用IDE,所以,使用TypeScript的代價不大。
當項目變大的時候,我們會面臨很大的管理成本,比如對代碼的分析,結構調整,模塊依賴關系梳理等,在TypeScript上面做,會比在JavaScript上面做更有優勢。
最近幾年前端領域“工程化”這個詞被說得太多,但其實絕大部分說的都只是“工具化”。早在Visual Studio 2005中,就存在很多Factory插件,舉例來說,一個普通項目的工作流程可能是這樣:
- 使用ER圖設計模型結構
- 一鍵生成數據庫表結構和存取過程
- 一鍵生成數據庫訪問層和實體定義代碼
- 一鍵生成Web Service接口
- 根據WSDL,一鍵生成客戶端的調用接口
- 剩下的就是做界面,調用這些接口了
比如說我們做到一半,需要變更模型,也只是需要在ER圖那邊修改,然后依次一鍵變更過來。很多時候我們也會有代碼的目錄調整,批量更名,如果使用約束較強的語言,這部分可靠性會更高。
組件化與路由
如果用過angular 1.x,會對它的路由機制印象深刻。有復雜業務需求的人一般都不會使用內置的ng-route,而是會使用第三方的ui-router,這兩者的核心差別是子路由的定義。
比如:
A界面有兩個選項卡,分別B,C,如果我們想要:
app.html#a/b app.html#a/c
這樣的多級路由,在ng-route中想要定義,就比較麻煩,而在ui-router中,允許使用嵌套的ui-view指令,可以比較方便地支持這一功能。
在這兩種方式下,路由都是全局配置的,但我們考慮在全組件化的場景下,組件的嵌套會受到這種路由配置的制約。比如,本來我們只是期望把某個組件嵌入到另外一個組件中,就能完成功能,但為了路由,不得不額外在全局路由配置的地方,加一個配置,而且每當組件層級發生變更的時候,這個配置都需要改,這就大幅拖累了我們組件體系的靈活度。
為此,我們可能會期望把路由配置放在每個組件中,比如說,組件A定義自己的路由為a,組件B的路由為b,組件C的路由為c,無需額外的配置,當B和C放在A中作為選項卡的時候,上面那兩條路由會自動生效。
在Angular的新路由機制中,就是這樣處理的,這也是Angular 2.0和Aurelia的共同路由機制。在這種機制下,如果有一天我們在另外一個更高層的組件D中,引入了組件A,那路由就會自己變成類似:
app.html#d/a/b app.html#d/a/c
這個是非常靈活的,這對于我們構建一個全組件化的系統很有利,另外,這實際上實現了路由的動態配置。
當然,對這個問題,也是有爭議的,因為路由不再集中配置,很難有一個地方能查看所有的路由狀況了。
此外,由于在Angular 2和Aurelia中都凸顯了組件的概念,組件的生命周期被引入了,比如說,組件的四個狀態:
- 創建前
- 創建
- 銷毀前
- 銷毀
這些跟路由進行配合,可以把我們的加載過程,前置、后置條件過程都整理得很清楚。
指令與Web Components
最近,越來越多的人開始關注Web相關標準的推進,在HTML這個方面,最重要的標準就是Web Components,它主要是提供擴展HTML元素的能力(Custom Elements)。
HTML is great for declaring static documents, but it falters when we try to use it for declaring dynamic views in web-applications. AngularJS lets you extend HTML vocabulary for your application. The resulting environment is extraordinarily expressive, readable, and quick to develop.
這一段來自Angular的官方介紹。擴展HTML的詞匯,是Angular的一種愿景,在這個里面,除了包含對元素的擴展,還有屬性(Attribute)。
很多時候,僅僅有元素的擴展,是不足以滿足需求的。舉例說,讓某個按鈕閃爍,我們有兩種方式實現:
- 創建一種可以閃爍的按鈕
- 創建一種可以閃爍的行為
其中,前者是特定的解決方案,創建一個自定義元素<blink-button></blink-button>可以達到目的,但閃爍這個動作可以是一種通用行為,我們可能需要讓圖片閃爍,讓鏈接閃爍,讓各種元素都能閃爍,把這種行為擴展到不同的元素上。
如果用jQuery,我們可能會寫:
$.fn.blink = function(options) { // 這里對DOM進行處理,添加閃爍功能 };
然后在使用的時候:
$('.some-element').blink();
如果說有自定義屬性,可能我們就只要寫:
<span blink>aaa</span> <a blink>aaa</a> <button blink>aaa</button>
借助數據綁定,還可以把blink綁定到一個變量上,由這個變量動態控制是否閃爍。
<div blink="hasNewMessage">aaa</div>
在Angular 1.x中,使用指令(directive)來實現自定義元素和自定義屬性,這個東西設計得很復雜,所以不太容易上手,在2.0中,這一塊改了。
在Angular 2和Aurelia中,使用很簡單的標記來表明某個東西是自定義元素還是屬性。
@customAttribute('blink') @inject(Element) export class Blink { element:any;constructor(element) { this.element = element; } }</pre>
@customElement('my-calendar') export class Calendar { }自定義屬性的理念,在早期IE中實現的HTML Components中有很好的體現,它允許使用JavaScript編寫DOM元素相關的代碼,然后在css中作為行為附加到選擇器上。
組件化與MVVM
對于大型Web應用來說,組件化是必須的,但是如何實現組件化,每個人都有自己的看法,所以組件化這個詞就像民主,法制一樣,容易談,難做。
我們所期望的組件化往往是這樣:
![]()
但實際上,很可能是這樣:
![]()
實際在用組件,尤其UI組件的時候,會出現很多尷尬的地方,比如說同一個組件在不同場景下形態不一致,所以我們需要多個層次的組件復用級別。
在Angular 1.x中,組件化并不是一個很明確的概念,它的整體思路還是:邏輯層+模板層這樣的概念,此外,有一些指令(directive),用于表達對HTML標簽、屬性的增強。
在2.0版本中,組件成為了一個很清晰的東西。一個常見的組件,包含界面模板片段和邏輯類兩個部分。
如果我們經歷過Angular 1.2之前的版本,可能會感受到controller的一些變化。比如說,之前我們寫一個controller,可能是:
function TestCtrl($scope) { $scope.counter = 0;$scope.inc = function() { $scope.counter++; };
}</pre>
然后這樣用:
<div ng-controller="TestCtrl"> {{counter}} <button ng-click="inc()">+1</button> </div>在1.2之后,我們會這樣寫:
function TestCtrl() { this.counter = 0;this.inc = function() { this.counter++; };
}</pre>
然后這樣用:
<div ng-controller="TestCtrl as test"> {{test.counter}} <button ng-click="test.inc()">+1</button> </div>注意TestCtrl的實現,里面沒有$scope了,這意味著什么呢?意味著這個“controller”已經不再是controller了,而是view model,這個部分的代碼變得更加純凈,每有一個對應的界面片,就實例化一個出來與之對應綁定。
在Angular 2和Aurelia里面,HTML模板與視圖模型被視為一體,當做一個組件,而Aurelia的靈活度更高,因為它盡可能地把額外的配置放在HTML模板中,所以視圖模型變得更單純,也存在復用價值了。
Aurelia跟Angular 2有不少細節差異,寫法上大致的對比可以從這里看出: Porting an Angular 2.0 App to Aurelia
Angular支持使用pojo作為數據模型,這可以算是它的優點之一,這樣,它對模型層的定義就比BackBone和Knockout簡潔很多。
但是在2.0時代,我個人是傾向于預定義模型類型的,因為在MVVM這三層中,不宜過于淡化VM和M的分界,分清哪些東西是從屬于模型的,哪些東西是從屬于視圖模型,在很多情況下都會很重要。這會影響我們另外一些工程策略,比如測試環節的處理方式。
在大型應用中,model應當與store視為一體,在比如數據的共享,緩存,防沖突,防臟等方面綜合考慮,而view model可以不要考慮得這么復雜。
基于MVVM,我們可以在不同層級復用組件,可以把模板和視圖模型當做一個整體復用,也可以只復用視圖模型,使用不同的模板。在這一點上,Angular 2顯然比Aurelia欠考慮。
代碼的遷移
Angular的這次升級,最令人不滿的是它的不兼容變更。這些變更很多方面來說,是無奈之舉,因為前后的差距確實有那么大,想要短期平滑,就得在未來背負更重的歷史負擔。
但事實上,我們在很多場景下,比如企業應用領域,并沒有比它更好的解決方案,所以這時候需要來看看如果想要作一個版本遷移,需要做哪些事情。
如果我們要做從Angular 1.x到2.0的代碼遷移,相對最容易,也最值得做遷移的部分是數據模型,但這個問題說難也難,說簡單也簡單。
很多對分層理解不深的人,很可能把這個代碼遷移想得過于復雜。但其實,一個規劃良好的Angular 1.x工程,它的代碼結構應該是非常有序的,什么東西放在模板里,什么東西放在controller,service,都是非常清楚的,而且,絕大多數controller和service中,是不應有DOM相關的代碼的。
比如,service中是什么?主要是數據模型的存取,與服務端的交互,本地緩存,公共方法等,這些東西要遷移到2.0中,是很容易的,只是寫法會稍有差別。
接下來往上看看,看這個所謂的controller。在2.0中,不再有controller,service這些東西的區分,一切都是普通的ES類,但是理念還是有的。比如一個含有視圖的組件,它的邏輯部分就會是一個ES類,這個也就是視圖模型,基本上也就對等于1.x中的controller。
比如最簡單的todo:
function TodosCtrl() { this.todos = []; this.newTodo = {};this.addTodo = function() { this.todos.push(this.newTodo); this.newTodo = {}; }; this.removeTodo = function(todo) { this.todos = this.todos.filter(function(item) { return item != todo; }); }; this.remainingCount = function() { return this.todos.filter(function(item) { return item.finished; }).length; };
}</pre>
這代碼很簡單,就是給一個列表添加移除東西,假設我們要把這個代碼移植到2.0,可以說基本沒有代價,因為在2.0里你要實現這樣的功能,也得這么寫。
(注意,下面這段是Aurelia代碼,并且不是使用ES6,而是使用TypeScript編寫)
export class Todos { public todos: Array<Object> = []; public newTodo: Object = {};addTodo(): void { this.todos.push(this.newTodo); this.newTodo = { content: "" }; } removeTodo(todo): void { this.todos = this.todos.filter(item => todo != item); } get remainingCount() { return this.todos.filter(item => item["finished"]).length; }
}</pre>
這么一看,好像也很容易遷移過去,多數情況下是這樣,但這里面有坑。坑在什么地方呢?主要是手動添加變更檢測的部分。變更檢測是個復雜的話題,在本文中先不講,后面專門寫一篇來講。
現在我們把邏輯層擺平了,來看界面層,這里主要有三個東西,一個是原先的指令,一個是普通的模板,還有一個是過濾器。
指令的問題好辦,我們剛才提到的自定義元素,自定義屬性,其實對使用者是沒什么差別的,也就是實現的人要把代碼遷移一下。
我個人并不贊同在一個業務型的項目中封裝太多自定義元素,僅僅那種被稱為“控件”的東西才有這個必要,其他東西可以直接采用模板加視圖模型的方式,具體理由在前一篇的組件化之路中提到過。如果是按照這種理念去實現的業務項目,指令這塊遷移成本也不算高。
過濾器也很好辦,2.0 有同樣類似的機制實現。
普通模板這邊,絕大部分都是固定的工作量,比如ng-repeat,ng-click換個寫法而已,里面有一些影響,但基本上是可以用批量轉換去搞定的。
所以我們發現,遷移的成本并沒有想象的那么大,為了更好地擁抱Web標準和更好的性能,這樣的事情是比較值得去做的。
Angular與React
這兩種東西代表著現代Web前端的兩種方法論,前者是以分層和綁定為核心的大一統框架,后者提供了渲染模型多樣化,帶生命周期的多層組件機制。由于實現理念的不同,用它們分別開發同樣的Web應用也會有很大差異。好比我們造一個仿生機器人,用Angular是先造完骨架,把基本運動功能調試完,然后加裝肌肉等部件,最后貼皮膚,眉毛,頭發,指甲;用React是先造出各種器官,肢體,然后再拼裝。
方法論的事情那個很難說對錯,只有看場景。比如亞洲農民跟美洲農民種地,理念肯定是不同的,因為他們面臨的場景不同,比如亞洲種地普遍很精細化,美國種地很粗放。這也有些像React和Angular的差別。
我個人不贊同在框架的問題上有太多爭論,因為天下武功,到底什么厲害,完全是看人的,一陽指在段正淳手里,只能算二流,到了南帝段智興手里,可與降龍十八掌齊名。聚賢莊一戰,喬幫主用最普通的太祖長拳,打得天下英雄落花流水。如果深刻理解了一個技術的優點和缺點所在,揚長避短,則無往而不利。
近年來,各框架是在互相學習的過程,但是每個東西到底有什么不同,最好還是列出需求,分別用代碼體現。現在已經有todomvc這么一個庫,用各種框架實現todo,但在我看來,這個需求還太小,不足以表達各自的優勢。
我倡議,每個框架的熟練使用者能夠選出一些典型場景,然后寫一些demo,供更多的人學習對比之用。
Angular與未來
到目前為止,我們在瀏覽器中看到系統從規模來說都是中小型的,與傳統桌面的大型軟件們相比,還很幼小。比如Office的開發團隊,千人以上的規模,無論是代碼的架構,還是人員的分工協作,都可以算是偉大的工程。
在大型系統中,組件化可以說是立足的基礎,但怎樣去實踐組件化的思想,是一個見仁見智的話題。
還是以Office為例,它除了提供圖形化的操作界面,還提供了一套API,可以被VBA這樣的嵌入語言調用。
比如說,我們可以在界面上選中一個工作表,然后在某行某列填入數據,也可以在VBA中使用這樣的語句去達到同樣的目的
這就意味著,對于同一種操作,存在多樣化的外圍接口。繼續分析下去,我們會發現,存在一種叫做Office Object Model的東西,這也就是一個核心數據模型,我們所有的操作其實都是體現在這個模型上的,GUI和VBA分別是這個模型的兩個外圍表現。
所以可以想象,如果Office的測試團隊想要測試功能是否正確,他是有兩條路要走:
- 通過VBA這么一個相對簡單直接的方式,去調用OOM上的方法和屬性,然后再次通過VBA去驗證結果
- 通過GUI上類似錄屏的操作,去模擬人的一些操作,然后,通過VBA或者是界面選取的方式驗證結果
從這里可以大致感受到,當系統越復雜的時候,獨立的模型層越重要,因為必須保持這一層的絕對清晰,才能確保整個系統是正確而穩定的。層層疊加,單向依賴,這使得軟件正確性的驗證過程變得更加可控。
在業務系統中,又存在另外一些問題。以我曾經從事過的電信行業軟件系統為例,整個運營與業務支撐系統由若干個子系統構成,比如:
- 資源管理,管理卡、號、線等資源
- 營業系統,負責對外營業
- 計費與結算
- 運維與調度,負責人員權限考核調度等
- 相關的內部管理系統
這些系統基本都已經Web化,如果我們要探討它們的組件化方式,必須作相當深遠的考慮,因為,還可能出現終極殺手——比如呼叫中心系統。
大家打客服電話的時候,有沒有注意到,客服人員可以操作的東西,是超過了前臺營業員的,這也就說明他實際上能夠操作以上某幾個系統。可是我們也沒有發現他在切換多種功能的時候,花太多時間,說明其實他有一個高度集成的界面入口。
這就來了問題了,如果這里的多數功能是集成其他系統的組件所致,那都該是一些什么樣的組件啊?
小結
篇幅所限,不在本文中討論這些問題。拋出這樣的問題來,是為了讓大家察覺,在很多不為人知的地方,存在很值得思考的東西。一些新的Web標準是為了解決Web系統的大型化,應用化,但僅僅以這些標準本身而言,還是存在一定的不足,需要更深刻的改變。
我們期望Angular2和Aurelia為代表的新型框架能夠給這些領域帶來一些靈感,互相碰撞,解放更多人的生產力。
總而言之:
“I think we agree, the past is over.” – George W. Bush