SAM模式:響應式前端架構
在不斷發展的JavaScript編程領域,響應式編程技術正變得愈加流行。這一系列文章試圖向大家介紹該方法目前的進展,介紹各種可用技術,以及該領域產生的變化。從Elm等新語言到Angular 2對RxJS的支持,無論從事什么工作的開發者均有相關新技術可供使用。
InfoQ的這篇文章已包含在“響應式JavaScript”系列文章中。你可以訂閱RSS并在內容更新后獲得通知。
主要結論
前端架構師正在快速向著函數響應式(Functional reactive)的模式躍進。
函數式HTML、單向數據流或單態樹(Single state tree)是該模式的重要元素。
RxJS和不可變性的作用被高估了。
SAM模式的不同之處在于,它主要專注于恰當地實現“應用程序狀態突變”。
我們可以使用自己慣用的Web框架實現SAM。
現代化用戶體驗要求所用架構不僅要能持續“響應”用戶輸入,而且要能對不同類型的環境(好友和玩家、物理傳感器、優步司機抵達乘客上車位置的方式…)做出響應。GUI通過進化已成為廣泛、動態的分布式系統中的一個節點,因此會受到從并發到組件失敗等各種系統復雜性的影響。
這種情況下,前端架構師正在以為函數響應式模式為目標經歷一次重大的轉型。各種框架、庫,甚至語言層出不窮,它們試圖通過積極主動的競爭領導此次轉型:
React/Redux
Elm
Cycle.js
Angular 2
Vue.js
Om
MobX
Inferno
DART
Jean-Jacques今年二月發布的一篇文章中描述了一種受到React.js和TLA+啟發的全新函數響應式模式:SAM模式。
SAM建議將圖形用戶界面底層的業務邏輯分為三個概念:操作(Action)、模型(Model)和狀態(State)。操作向模型供值,僅模型可以接受這樣的值。一旦接受,將通過狀態驗證所有訂閱方,尤其是視圖(視圖可看作“狀態的具體呈現”)已經獲得通知。每個事件可作為“步驟”進行處理,步驟可由提議/接受/學習流所組成。這種概念為事件排序和效果(如后端API調用)的處理提供了一個堅實的基礎。
SAM的使用不依賴具體框架,共同打造這一模式的很多社區成員1也陸續開發了一系列開發者工具,并通過不同框架編寫了大量范例代碼,所用框架涵蓋了從Vanilla JavaScript到AWS Lambda等諸多類型。
本文將介紹我們在實現SAM模式的過程中學到的經驗。本文的目標并不在于比較或評價各種框架或庫的優劣,這種類型的比較已經太多了(例如Matt Raible的《Comparing Hot JavaScript Frameworks》一書)。
本文將主要專注于在前端架構中,能對最終交付成果與現代化最終用戶應用程序的可維護性產生直接影響的,實踐層面的內容:
編程模型(基于組件的視圖、一致性、副作用…)
連接(RxJS)
架構(通用JavaScript)
我們打算解決哪些問題?
前端編程模型完全基于事件和回調,傳統上這些內容是通過觀察者模式(Observer pattern)聯系在一起的。
例如當我們需要通過鼠標拖拽手勢繪制一個矩形時,與鼠標事件有關的句柄應該是這樣的:
function onMouseDown(event) {
rectangle = { from: event.position, to: event.position }
isMouseDown = true
}
function onMouseMove(event) {
if (isMouseDown) {
rectangle.to = event.position;
draw(rectangle);
}
}
function onMouseUp(event) {
isMouseDown = false
}
然而在狀態管理方面依然留下了一個核心問題:
回調驅動的代碼會顯得遲鈍,因為事件句柄只能通過狀態與系統中的其他東西通信,[因此一般來說]會遇到并發錯誤,包括丟棄事件、數據爭用與缺乏。
...這會對代碼質量產生切實的影響。Sean Parent在2007年的一份報告中提到,Adobe:
桌面應用程序中有1/3的代碼被用于事件處理邏輯。
而產品生命周期內上報的所有Bug中,有1/2位于這些代碼中。
來自EPFL的Ingo Maier和Martin Odersky提供了一份更詳細的問題清單:
副作用
封裝
可組合性(Composability)
資源管理
關注點分離(Separation of Concern)
數據一致性
均勻性
抽象
語義距離(Semantic Distance)
他們得出的結論是:
用戶界面編程領域的很多范例[...]很難用觀察者的方式實現,例如一組項目的選擇,在一系列對話框之間的順序操作,文本的編輯和標記 – 基本上這涵蓋了用戶執行一系列步驟過程中的每一步操作。
函數響應式編程方法如何解決這一問題?
大體方向是為UI構造應用一種純粹的函數響應式方法,其中:
然而顯而易見的是:在函數響應式編程模式中,該如何繞過副作用(例如查詢和更新)?諸如Cycle.js、Elm,以及某種程度上的Redux等框架主要專注于將作用與業務邏輯隔離。Richard Feldman解釋了作用是如何以數據的方式呈現的,以及從功能測試的角度來看,這種方式可提供的收益。SAM雖然并不強制要求,但也可以支持這樣的分隔。然而單元測試(Unit test)的價值還遠未得到證實,決定使用單元測試的方法前需要權衡利弊并考慮可能造成的開銷。視圖與模型之間的函數關系(即函數式HTML)在這方面是如此的強大,可以肯定的是,MVC模式已經時日無多了。鑒于模板和數據綁定(Data binding)已經成為新的Flash,業內領先的框架將很快接受這種全新范式,自然而然地實現視圖與無狀態組件的解耦。
大家必須意識到,函數響應式框架依然在開發過程中,隨著努力發現更有效的業務邏輯因子,還可能進行較大規模的重構。僅考慮Redux社區本身,他們為了解決特定問題所創建的庫數量就已大幅增加:redux-sagas、redux-gen、redux-loop、redux-effects、redux-side-effects、redux-thunks、rx-redux、redux-rx... 而這甚至并未算上React本身、GraphQL或Relay。
我們從SAM中學到了什么經驗?
1. SAM可以通過你自己慣用的框架來實現
SAM這種模式本身可以使用大部分流行的前端框架來實現,例如Angular和React。
David Fall提供了一種卓越的雙客戶端React/Redux井字棋(Tic-Tac-Toe)實現;就職于Orange的Bruno Darrigues將SAM與Angular 1.5配合使用提供了一種TypeScript實現;Fred Daoud提供了一種Cycle.js實現;Troy Ingram提供了一種Knockout.js實現,還有Michael Solovyov的一種Vue.js實現。
更重要的是,我們可以在Vanilla JavaScript的基礎上實現SAM模式。當然,此時需要對跨站點腳本(XSS)付諸更多關注。然而就算React這樣默認Escape所有值的框架也可能顯得很脆弱。
2. 語義很重要
SAM在意圖(Intent)和實際的改變(Mutation)之間進行了十分清晰的區分。操作(Action)將值提供給模型(Model),但操作絕對無法控制模型是否產生改變以及如何改變,因為這要求操作必須對整個系統具備全面了解。
例如用戶點擊了一個按鈕。點擊操作僅僅代表了用戶希望做某事的意圖。是否允許執行該操作,以及執行后會發生什么事,這些因素是由模型負責考慮的。
因此SAM完全符合軟件架構的重要設計原則:
關注點分離(Separation of concern):將應用程序拆分為不同的功能,并確保不同部分在功能上的重疊范圍盡可能小。此時最重要的因素在于確保交互點(Interaction point)數量保持最小,以實現高內聚和弱耦合。
單一職責(Single Responsibility):每個組件或模塊只應承擔某一特定功能或特性,或內聚功能的聚合。
最少知識(Least Knowledge):一個組件或對象不應了解其他組件或對象的內部細節。
不要重復自己(Don’t repeat yourself,DRY):只需要在一個位置指定意圖。例如在應用程序設計過程中,一個具體的功能只要在一個組件中實現即可,該功能不應重復出現在任何其他組件中。
當然,就算不遵守這些規則也可以寫出Web應用,然而如果你追求的目標是可維護性、可擴展性,以及可復用性,此時SAM將會是一種非常有前景的備選方案。
3. 時間旅行2.0
Redux因其時間旅行和實時代碼編輯功能而知名。其實SAM也足夠靈活,可以實現類似的功能,并在此基礎上進一步提供更多功能。
隨著將模型恢復為早先的時點,其狀態也會做出響應。如果使用VirtualDOM庫(如React),視圖也會對新的狀態做出響應。nap()函數還提供了額外的層。輸入新的狀態即可觸發nap()函數并可能拋出自己的操作。
因此我們可以使用時間旅行功能測試模型、狀態和nap()。SAM DevTools還提供了一種實時概念證實的功能。雖然可以返回至某一時點,但無法維持counter == 10的狀態,因為nap()會立刻觸發啟動(hasLaunched = true)。
實時代碼編輯可通過webpack實現。如果使用React,也可以將Dan Abramov的react-hot-loader與SAM配合使用。該工具還提供了一個服務器端的版本,并已包含在SAM的SAFE中間件中。
4. 視圖可與模型全面解耦
這也許是使用SAM所能獲得的最大價值。SAM的一個獨特之處在于,不同于MVx模式,SAM模式中的視圖是與模型嚴格隔離的,而這種隔離通常可通過操作和狀態函數實現。
V = S(M)
在SAM中,狀態就是一種純粹的函數。
Thomas J. Buhr解釋說:
足夠好的前端架構應該能讓你用盡可能解耦的方式將模塊化的函數固定(Pin)至UI組件。借此即可按需更換為組件提供支撐的技術,而無須擔心影響所有業務邏輯(在已經遲到的下一代框架支配下)
SAM的模型通常被稱之為“應用程序狀態”,在Flux/Redux中則僅僅被稱之為“狀態”,模型通常由一組屬性值組成。狀態函數負責通過模型的屬性值構建狀態的具體呈現(State Representation)。應用程序的控制狀態通常也源自屬性值,但并不需要將與視圖有關的各類屬性也放入模型中。
David Fall在為自己的井字棋(Tic-Tac-Toe)范例實現“兩玩家”對戰的過程中解釋了這一問題:
可以取消對‘showJoinSessionForm’模型屬性的依賴,轉為從封裝了表單組件的容器組件中推導出可見性。例如可以用一個名為JoinSession的狀態,如果gameType === 'Join Game',并且session === undefined,則該狀態為True。組成狀態的這兩種條件已經足以確定所要顯示的表單組件。一旦模型接受了有效的‘session’鍵,JoinSession狀態將不再為True,因此不會渲染表單組件。
此外視圖也可分解至無狀態組件中,這些無狀態組件對于要在哪里渲染,以及相關事件如何連接至應用程序的操作全不知情。
SAM支持(但非強制要求)使用Virtual-dom庫。這個概念最初是由React發揚光大的,隨后人們據此開發了很多庫,例如virtual-dom、mithril以及snabbdom。Jose Pedro Dias[1]提供了一種使用Sabbdom的SAM實現。
5. 通用JavaScript亦可毫不費力地實現
SAM的實現從本質上來說是通用的。該模式的任何元素均可部署在客戶端或服務器端,并可按需遷移:
作用可通過操作和模型產生。“博客”范例展示了相同代碼(操作、模型、狀態)如何以Node.js形式部署到客戶端,甚至以無服務器架構形式部署到AWS Lambda。
對于諸如Elm、React/Redux以及Cycle.js(也受到了Elm所用方法的影響)等框架,人們在副作用方面進行了大量的研究。對于這些問題,redux-side-effect的作者Greg Weber解釋說:
需要注意的是,redux受到了Elm的啟發。在最新版Elm中,Reducer可返回新狀態以及作用。[redux-side-effect]庫會在Javascript和Redux的約束下盡可能模擬Elm中的作用處理方式。
redux-effects庫針對下列類型的作用提供了驅動:
setTimeout/setInterval/requestAnimationFrame
HTTP請求
Cookie get/set
位置(window.location)綁定和設置
生成隨機數
拋出操作,作為對window/document事件(如滾動/調整大小/彈出狀態等)的回應
localStorage作用驅動
如果URL與某一模式相匹配,自動使用狀態中存儲的憑據對Fetch請求進行補充。
另一方面,SAM并不強制要求進行如此清晰的分隔,而是專注于實現更可靠的應用程序狀態變化。SAM的語義(繼承自TLA+符合Paxos協議的要求:
操作提供的值由模型接受(或拒絕),狀態使得系統了解這些變化。
對于SAM來說,狀態的變化僅受到模型的控制,對操作和狀態本身是不可見的。這意味著需要由操作對允許發起HTTP請求的用戶意圖進行充實(Enrich)和驗證,并僅在返回HTTP請求的情況下將結果呈現給模型。同理,并沒有什么特別的理由需要我們對模型之外的持久層進行更新(例如通過專門的Elm任務),因為應用程序狀態通常取決于更新結果是否成功或失敗,而中間狀態的體現(“更新”)可能對用戶是無關的,用戶只關心最終結果。
當然,在此類代碼的可測試性方面還有一些爭議,但是,舉例來說,我們可以使用諸如MounteBank等API虛擬化工具(而非構建Stub)創建受控的可測試環境。
Redux社區目前已經取得了唯一的壓倒性優勢,建議使用(有狀態)“Sagas”以處理副作用。在某種程度上,這個選擇讓人有些吃驚,畢竟Sagas并未遵守Redux和現代化函數響應式前端架構的第一個基本原則,即并沒有基于單一狀態書樹。SAM的“next-action-predicate(下一步操作預測)”(nap()函數)提供了類似的能力,盡管它使用了一種函數式(例如無狀態)的方法。換句話說,nap()函數需要依賴應用程序的當前狀態(模型的屬性值)來決定是否需要觸發某個自動化操作。它并不像Sagas那樣會維持自己的狀態。當然,“下一步操作”可能需要運行較長時間,并有可能產生副作用,但最終依然能將數據提供給模型。
6. SAM獨一無二的“步驟”概念
Lamport博士曾解釋說:
編程語言未能給程序的步驟提供精確定義的概念。
由于以TLA+為基礎,SAM支持用于對狀態的變化進行封裝所用的“步驟(Step)”這一概念。SAM的步驟流始終包含三個階段:提議(操作)、接受(模型),以及學習(狀態/視圖)。作為對比,在Elm的任務/命令或Redux Sagas中,并不具備有關“步驟”的概念,甚至可在與特定狀態轉換(如某個操作)無關的情況下隨意觸發效果。
步驟這一概念使得SAM可以支持一般的操作授權和取消機制。這些概念是通過SAM的State Action Fabric Element(SAFE)實現的。
7. 將RxJs用作連接機制的做法被高估了
RxJS是一種流行的庫,可用于為JavaScript實現響應式擴展。人們現在/曾經廣泛認為RxJs和事件流是“連接”諸如Cycle.js等框架中不同元素的一種方法:
Cycle.js實際上就是一種構建響應式Web應用的架構:提供了一系列幫你使用RxJS確定應用構造的想法。
甚至谷歌的Angular Team也在使用“ng-rx”,Netflix的Ben Lesh最近還發布了一個redux-observable中間件。
RxJS的問題在于,連接是通過訂閱的形式進行的。當我們創建一個“可觀察”的變量后,程序中的部分內容需要進行訂閱,而有訂閱就必然需要退訂。更糟的是,如果對同一個可觀測變量訂閱兩次,實例化過程中通常將需要兩個執行線程
在最近一篇文章中,André Medeiros提到:
在Cycle.js中,我們只允許[負責處理作用的]驅動內部執行subscribe()。這意味著應用程序[邏輯]對訂閱完全不知情。當開發者假設來自驅動的每個可觀察目標只有一個執行時,應用程序將變得難以理解和調試。
MobX的創建者Michel Weststrate補充說:
在管理這些訂閱時肯定會出錯,可能訂閱過量(持續訂閱組件中不再使用的值或存儲)或訂閱不足(忘記偵聽更新導致產生不易察覺的老舊Bug)。
對于基于觀察者的編程模型,最常見的問題在于無法理所當然地產生用于接受所提議變化的臨界區段(Critical section)。它們太“響應式”了。這使得我們開始再次面對前端架構最初的問題:事件(現在已經封裝為可觀察目標或流)直接連接至需要通過某種方式對操作進行同步的事件處理方。
作為對比,以TLA+為基礎的SAM提供了一系列側重于決定特定時間“允許”執行哪些操作的語義。SAM的語義甚至可以在可發起的操作,和已經發起過并能用于提供數據(操作的取消)的操作之間進行明確的區分。這個判斷完全基于應用程序的當前狀態(始于最后一個步驟),而無須考慮這個狀態是通過什么路徑到達的。SAM語義可以幫助我們更容易地推理,因為其語義僅基于“當下”,而不像Rx或Saga語義那樣需要了解過去(曾訂閱的內容)。
結論
前端架構正在快速演化:似乎每周都會出現新的庫,以及現有庫的常量重構。對函數響應式基礎的廣泛關注似乎還會繼續持續下去,不過我們可能需要對這種方式的真正含義做出更精確的定義。函數式HTML、單向數據流,以及單一狀態樹等概念已經帶來了巨大的價值,而響應式擴展(連接)以及作用的處理似乎還需要進一步研究。在這一過渡過程中,模板和數據綁定似乎還沒有什么進展。
和目前所用的方法相比,SAM提供了三個關鍵的語義。首先,SAM要求在提議和接受模型的變化之間進行清晰的分隔(借此簡化副作用的管理)。其次,SAM鼓勵開發者將系統事件轉換為專門的操作(打造模塊化程度更高的代碼)。最后,SAM引入了狀態函數這一概念,可通過解釋模型的屬性值推導出狀態的呈現和下一步操作。總的來說,這些語義可以幫助我們控制模型變化的順序,對于包含在更廣泛的動態分布式系統中的GUI,這一點非常重要。
來自:http://www.uml.org.cn/AJAX/201612051.asp