可擴展的web單頁應用程序架構
本文轉載自:衆成翻譯
譯者:楊小福
鏈接:http://www.zcfy.cc/article/1319
原文:http://blog.mgechev.com/2016/04/10/scalable-javascript-single-page-app-angular2-application-architecture/
可擴展的web單頁應用架構
爲確保你能夠理解本篇文章的內容,你需要掌握面向對象編程和函數式編程。我也極力的推薦你先去了解和學習redux的設計思想。
幾個月之前我開始用單頁應用(spa)的方式的方式編寫一個動態業務需求的項目。和大多數的單頁應用一樣,隨着業務邏輯和狀態增多使得我們的應用日益龐大、臃腫。
需求說明
這是我一個創業項目的核心產品,因爲還處於早起發展階段以及商業競爭等因素,這個產品的業務變化是相當大的。
可擴展的通信層
我們具有相對穩定的業務領域,然而還是會有其他的因素影響着產品的狀態,我們具有如下的通信需求:
- 用戶
- RESTful API
在此基礎上可能會有(或沒有)如下的:;
- 與現有用戶建立 P2P 鏈接的相關成員
- 與服務器進行實時的通訊
爲支持不同的通信協議(HTTP,WebSocket,UDP[webRTC])我們需要不同格式的數據:
- HTTP/WebSocket採用JSON的通信格式
- JSON-RPC格式的WebSocket通信
- BERT or BERT-RTC格式的WebRTC或WebSocket通信
BERT通信協議非常適合P2P通信方式,尤其對於二進制數據的傳輸,比如圖片以及不適合文本表示的數據。
爲實現所有服務之間的通信,RxJS看起來是一個很不錯的選擇,通過它可以方便的管理各種類型的異步事件。
Given all the services we need to communicate with, RxJS seems like a perfect fit for organization of all the asynchronous events that the application needs to handle. We can multiplex several data streams over the same communication channel using hot-observers and declaratively filter, transform, process them, etc.
可預測的狀態管理
在上面所列舉的通信因素中很多都是會變化的。變化最多的就是用戶、實時推送服務以及通過WebRTC通信的其他成員。當我們需要存儲不同版本的store以及數據的時候,可預測的狀態管理就顯得非常重要。
有許多的架構模式可以幫助我們實現可預測的狀態管理。當前最爲流行的當屬redux了。爲了更好的類型安全以及工具,我們決定使用 TypeScript。
也許有人會爭論說相比於TypeScript這樣的語言,使用純函數式的語言能夠幫助我們降低副作用的影響。我對他們的觀點表示贊同,同時我自己也是Elm和ClojureScript這樣的函數式語言的粉絲。然而從程序的健壯性考慮,我們團隊選擇了 TypeScript。
要滿足所有的開發者的需要是很難找到一種合適的技術方案的。我們需要犧牲部分人員的訴求而滿足大部分人的要求,如同我提出了嘗試使用Elm和ClojureScript的需求一樣。
對我們來說,在無副作用和健壯性上來考慮最好的解決方案就是redux + TypeScript。Redux可以幫助我們實現可預測的數據狀態管理,TypeScript則可幫助我們實現類型的檢查以及更容易重構。
模塊設計
正如之前提到的,團隊會逐漸變得龐大。團隊成員在經驗方面也會有所不同。這也意味着不同層級經驗的開發者需要合作開發同一個項目。最完美的實現就是能讓初級的開發者最大化的發揮作用。爲了實現這個目的我們將代碼實現了比較高程度的抽象而看起來就如簡單的MVC模式一般。
下面的圖表顯示了我們當前階段核心模塊的架構情況:
最上層是視圖組件層,即用戶直接交互的層,比如對話框、表單等。
facade層是視圖層和底下各種服務的通信中間層,主要的目的是用來觸發action操作並調用reducers以及異步服務的action調用。圖表中的reducers和state即等同於redux中的reducers、state。
爲了方便,我們在這裏把facades稱爲models.例如,如果我們要開發一個遊戲,那麼我們的遊戲視圖組件GameComponent
就會通過GameModel
數據層來與store以及異步服務接口的通信。
facades的另外一個核心職能就是將異步服務接口調用轉變成相應的actions,並和相應的reducers連接。這樣我們就可以通過觸發相應的action來調用異步接口並管理返回的數據。我們可以將異步接口用來擴展服務的遠程代理服務 。它們將相應的action操作和遠程命令對應起來。那爲什麼不直接調用遠程服務而要通過異步調用的方式呢?那是因爲通過異步接口的方式可以實現對WebRTC,WebSocket以及IndexDB的統一調用。
如果我們的異步服務對應於一個遠程的RESTful API,那麼會通過對應的HTTP網關來連接。一旦異步服務接收到一個action調用,他就會將這個action轉換成對應的RESTful命令並通過網關傳輸過去。
需要注意的是,數據模型層(facade)不應該與具體的通信協議耦合,即使是異步服務。這意味着facade應該更具具體的使用場景來決定如何調用異步服務。
上下文依賴的實現
facades的上下文是由其視圖部分決定的。例如,假設我們要開發一款可以多人和單人使用的遊戲。對於單人的情況,我們需要實現玩家和遊戲服務器之間的數據通信,但對於多人玩家的情況除此之外還需要實現玩家與玩家之間的數據通信。
這也就意味着SinglePlayerComponent
需要通過GameModel
來連接GameServer
服務,而MultiPlayerComponent
則需要GameModel
同時與GameServer
和GameP2PService
通信。
爲了實現這樣的依賴方式,依賴注入模式成爲我們的首選實現方式,而且解決得很完美。
懶加載
應用會變得越來越大,我們的javascript代碼可能會操作5萬行,這也讓js的按需加載變得尤其重要。
以我們上面提到的遊戲爲例,我們會按如下的結構來組織代碼:
.
└── src
├── multi-player
│ ├── commands
│ ├── components
│ └── gateways
├── single-player
│ └── components
├── home
│ └── components
└── shared
當用戶打開首頁的時候,我們希望加載home
和share
目錄中的代碼.如果玩家進一步的選擇了單人模式,那麼我們就會去加載single-player
目錄中的代碼,以此類推來實現按需加載。
按照上面的目錄結構,我們也可以輕易的將整個應用拆分到多個開發者身上,給每個開發者一定的上下文限制。
其它需求
從架構層面考慮,我們還需要考慮如下的需求:
For the architecture we also have the standard set of requirements including:
Testability.
Maintability.
技術棧
在我們整理好需求和開發思路後我們決定在幾種技術棧中選擇其一。我們首先想到的是React 和 Angular2。我們有過react 和 redux模式的成功經驗。
但懶加載和依賴注入的問題依然讓我們難以在這兩者之間選擇,react-router很好的支持了懶加載,但依賴注入依然是個問題。而Angular2的一個優勢是 WebWorkers 的支持。
最終我們選擇瞭如下的技術方案:
* Angular 2.
在我進一步的說明之前我想聲明的是如上的架構並不侷限於Angular2,React或任何其他的框架,也可以使用不同的語法以及無需依賴注入功能。
示例程序
這裏有一個我們實現瞭如上架構的示例代碼,該示例使用Angular2 和 rxjs,但正如之前提到的,你也可以使用react來替代。
爲了更簡單的解釋相關概念,我將基於上面所提到的遊戲來講解。簡單的說,這是一個幫助你提高打字速度的遊戲,它有兩個數據模塊:
- 單人模式-可以練習打字的速度。該模塊會給你一段文本並計算你能以多快的速度敲出來。
- 多人模式-與其他玩家比拼打字速度。所有的玩家通過WebRTC連接到同一個聊天室,一旦連接建立起來,玩家之間就需要相互交換信息,優先完成信息交換的玩家就會成爲贏家。
現在我們根據我們上面圖表給出的架構模式來實現這個遊戲,我們首先從視圖開始:
視圖組件
視圖組件的實現依賴於具體使用的UI框架(這裏我們使用的是angular2)。組件可以保存某些狀態,但我們必須清楚組件狀態與store之間的對應關係以及組件內部的狀態。
所有的組件通過組合的形式形成一顆組件樹並通過控制器來將他們聯繫起來。
下面是GameComponent
的簡單實現:
@Component({
// Some component-specific declarations
providers: [GameModel]
})
export class GameComponent implements AfterViewInit {
// declarations...
@Input() text: string;
@Output() end: EventEmitter<number> = new EventEmitter<number>();
@Output() change: EventEmitter<string> = new EventEmitter<string>();
constructor(private _model: GameModel, private _renderer: Renderer) {}
ngAfterViewInit() {
// other UI related logic
this._model.startGame();
}
changeHandler(data: string) {
if (this.text === data) {
this.end.emit(this.timer.time);
this._model.completeGame(this.timer.time, this.text);
this.timer.reset();
} else {
this._model.onProgress(data);
// other UI related logic
}
}
reset() {
this.timer.reset();
this.text = '';
}
invalid() {
return this._model.game$
.scan((accum: boolean, current: any) => {
return (current && current.get('invalid')) || accum;
}, false);
}
}
該組件具有如下的幾個特點:
- 輸入輸出API
- 封裝組件內部自己的狀態,比如當前用戶的輸入文本就無需存儲在Store中
- 使用
GameModel
作爲該示例的Facade層
GameModel
給組件提供了訪問應用狀態的途徑。例如,GameComponent
對當前遊戲狀態比較感興趣,所以GameModel
就給其提供了訪問遊戲狀態的方法。
使用像GameModel
這樣的高級抽象能讓新團隊成員快速的投入開發,他們可以在Model層上直接開發UI組件,然後讓Model層去維護應用狀態的變化。團隊成員只需要會使用angular2和RxJS數據流就可以投入開發,他們不用關心任何的通信協議、包數據格式以及redux等。
Model定義
如下爲GameModel
的定義:
@Injectable()
export class GameModel extends Model {
games$: Observable<string>;
game$: Observable<string>;
constructor(protected _store: Store<any>,
@Optional() @Inject(AsyncService) _services: AsyncService[]) {
super(_services || []);
this.games$ = this._store.select('games');
this.game$ = this._store.select('game');
}
startGame() {
this._store.dispatch(GameActions.startGame());
}
onProgress(text: string) {
this.performAsyncAction(GameActions.gameProgress(text, new Date()))
.subscribe(() => {
// Do nothing, we're all good
}, (data: any) => {
if (data.invalidGame)
this._store.dispatch(GameActions.invalidateGame());
});
}
completeGame(time: number, text: string) {
const action = GameActions.completeGame(time, text);
this._store.dispatch(action);
this.performAsyncAction(action)
.subscribe(() => console.log('Done!'));
}
}
這個類將微服務形式的 ngrx store
抽象實例依賴進來並存儲在_Store
中。
model可以通過分發actions來改變Store.我們可以將actionis當做命令或是對我們應用有意義的指令。他們包含一個 action 類型和一個payload,payload中存儲相應的數據並提供給reducers
來更改Store.
GameModel
可以通過觸發startGame
action來開始遊戲,如下所示:
`this._store.dispatch(GameActions.startGame());`
觸發Store對應的action會調用所有相關的reducers來更新store,接收action傳過來的新的參數並創建一個新的store.最後store的變化會反饋到視圖上。