寫在2017的前端數據層不完全指北
不知不覺間時間已經來到了 2017 年的末尾,在過去的一年中,關於前端數據層的討論依然在持續升溫。不論數據類型層面的 TypeScript,Flow,PropTypes,應用架構層面的 MVC,MVP,MVVM,還是應用狀態層面的 Redux,MobX,RxJS,都各自擁有一群忠實的擁躉,卻又誰都無法說服對方認同自己的觀點。
關於技術選型上的討論,筆者一直所持的態度都是求同存異。在討論上述方案差異的文章已汗牛充棟的今天,不如讓我們暫且放緩腳步,再回頭去看一下這些方案所要解決的共同問題,並試圖給出一些最簡單的解法。
接下來讓我們以通用的 MVVM 架構為例,逐層剖析前端數據層的共同痛點。
Model 層
作為應用數據鏈路的最下游,前端的 Model 層與後端的 Model 層其實有著很大的區別。其中最核心的就是,相較於後端 Model,前端 Model 並不能起到定義數據結構的目的,而更像是一個容器,用於存放後端介面返回的數據。
在這樣的前提下,在 RESTful 風格的介面已然成為業界標準的今天,如果後端的數據已經是按照數據資源的最小粒度返回給前端的話,我們是不是可以直接將每一個介面的標準返回,當做我們最底層的 Model 呢?換句話說,我們好像也別無選擇,因為介面的返回數據就是前端數據層的最上游,也是接下來一切數據流動的起點。
在明確了 Model 層的定義之後,我們再來看一下 Model 層存在的問題。
數據資源粒度過細
數據資源粒度過細通常會導致以下兩個問題,一是單個頁面需要訪問多個介面以獲取所有的顯示數據,二是各個數據資源之間存在獲取順序的問題,需要按順序依次非同步獲取。
對於第一個問題,常見的解法為搭建一個 Node.js 的數據中間層,來做介面整合,最終暴露給客戶端以頁面為粒度的介面,並與客戶端路由保持一致。
這種解法的優點和缺點都非常明顯,優點是每個頁面都只需要訪問一個介面,在生產環境下的載入速度可以得到有效的提升。另一方面,因為服務端已經準備好了所有的數據,做起服務端渲染來也是輕鬆隨意。但從開發效率的角度來講,不過是將業務複雜度後置的一種做法,並且只適用於頁面與頁面之間關聯較少,應用複雜度較低的項目,畢竟頁面級別的 ViewModel 粒度還是太粗了,而且因為是 API 級別的解決方案,可復用性幾乎為零。
對於第二個問題,筆者提供一個基於最簡單的 redux-thunk 的工具函數來鏈接兩個非同步請求。
import isArray from lodash/isArray;nnfunction createChainedAsyncAction(firstAction, handlers) {n if (!isArray(handlers)) {n throw new Error([createChainedAsyncAction] handlers should be an array);n }nn return dispatch => (n firstAction(dispatch)n .then((resultAction) => {n for (let i = 0; i < handlers.length; i += 1) {n const { status, callback } = handlers[i];n const expectedStatus = `_${status.toUpperCase()}`;nn if (resultAction.type.indexOf(expectedStatus) !== -1) {n return callback(resultAction.payload)(dispatch);n }n }nn return resultAction;n })n );n}n
基於此,我們再提供一個常見的業務場景來幫助大家理解。比如一個類似於知乎的網站,前端在先獲取登錄用戶信息後,才可以根據用戶 id 去獲取該用戶的回答。
// src/app/action.jsnfunction getUser() {n return createAsyncAction(APP_GET_USER, () => (n api.get(/api/me)n ));n}nnfunction getAnswers(user) {n return createAsyncAction(APP_GET_ANSWERS, () => (n api.get(`/api/answers/${user.id}`)n ));n}nnfunction getUserAnswers() {n const handlers = [{n status: success,n callback: getAnswers,n }, {n status: error,n callback: payload => (() => {n console.log(payload);n }),n }];nn return createChainedAsyncAction(getUser(), handlers);n}nnexport default {n getUser,n getAnswers,n getUserAnswers,n};n
在輸出時,我們可以將三個 actions 全部輸出,供不同的頁面根據情況按需取用。
數據不可復用
每一次的介面調用都意味著一次網路請求,在沒有全局數據中心的概念之前,許多前端在開發新需求時都不會在意所要用到的數據是否已經在其他地方被請求過了,而是再次粗暴地去完整地請求一遍自己所有需要用到的數據。
這也就是 Redux 中的 Store 所想要去解決的問題,有了全局的 store,不同頁面之間就可以方便地共享同一份數據,從而達到了介面層面也就是 Model 層面的可復用。這裡需要注意的一點是,因為 Redux Store 中的數據是存在內存中的,一旦用戶刷新頁面就會導致所有數據的丟失,所以在使用 Redux Store 的同時,我們也需要配合 Cookie 以及 LocalStorage 去做核心數據的持久化存儲,以保證在未來再次初始化 Store 時能夠正確地還原應用狀態。特別是在做同構時,一定要保證服務端可以將 Store 中的數據注入到 HTML 的某個位置,以供客戶端初始化 Store 時使用。
ViewModel 層
ViewModel 層作為客戶端開發中特有的一層,從 MVC 的 Controller 一步步發展而來,雖然 ViewModel 解決了 MVC 中 Model 的改變將直接反應在 View 上這一問題,卻仍然沒有能夠徹底擺脫 Controller 最為人所詬病的一大頑疾,即業務邏輯過於臃腫。另一方面,單單一個 ViewModel 的概念,也無法直接抹平客戶端開發所特有的,業務邏輯與顯示邏輯之間的巨大鴻溝。
業務邏輯與顯示邏輯之間對應關係複雜
舉例來說,常見的應用中都有使用社交網路賬號登錄這一功能,產品經理希望實現在用戶連接了社交賬戶之後,首先嘗試直接登錄應用,如果未註冊則為用戶自動註冊應用賬戶,特殊情況下如果社交網路返回的用戶信息不滿足直接註冊的條件(如缺少郵箱或手機號),則跳轉至補充信息頁面。
在這個場景下,登錄與註冊是業務邏輯,根據介面返回在頁面上給予用戶適當的反饋,進行相應的頁面跳轉則是顯示邏輯,如果從 Redux 的思想來看,這二者分別就是 action 與 reducer。使用上文中的鏈式非同步請求函數,我們可以將登錄與註冊這兩個 action 鏈接起來,定義二者之間的關係(登錄失敗後嘗試驗證用戶信息是否足夠直接註冊,足夠則繼續請求註冊介面,不足夠則跳轉至補充信息頁面)。代碼如下:
function redirectToPage(redirectUrl) {n return {n type: APP_REDIRECT_USER,n payload: redirectUrl,n }n}nnfunction loginWithFacebook(facebookId, facebookToken) {n return createAsyncAction(APP_LOGIN_WITH_FACEBOOK, () => (n api.post(/auth/facebook, {n facebook_id: facebookId,n facebook_token: facebookToken,n })n ));n}nnfunction signupWithFacebook(facebookId, facebookToken, facebookEmail) {n if (!facebookEmail) {n redirectToPage(/fill-in-details);n }nn return createAsyncAction(APP_SIGNUP_WITH_FACEBOOK, () => (n api.post(/accounts, {n authentication_type: facebook,n facebook_id: facebookId,n facebook_token: facebookToken,n email: facebookEmail,n })n ));n}nnfunction connectWithFacebook(facebookId, facebookToken, facebookEmail) {n const firstAction = loginWithFacebook(facebookId, facebookToken);n const callbackAction = signupWithFacebook(facebookId, facebookToken, facebookEmail);nn const handlers = [{n status: success,n callback: () => (() => {}), // 用戶登陸成功n }, {n status: error,n callback: callbackAction, // 使用 facebook 賬戶登陸失敗,嘗試幫用戶註冊新賬戶n }];nn return createChainedAsyncAction(firstAction, handlers);n}n
這裡,只要我們將可復用的 action 拆分到了合適的粒度,並在鏈式 action 中將他們按照業務邏輯組合起來之後,Redux 就會在不同的情況下 dispatch 不同的 action。可能的幾種情況如下:
// 直接登錄成功nAPP_LOGIN_WITH_FACEBOOK_REQUESTnAPP_LOGIN_WITH_FACEBOOK_SUCCESSnn// 直接登錄失敗,註冊信息充足nAPP_LOGIN_WITH_FACEBOOK_REQUESTnAPP_LOGIN_WITH_FACEBOOK_ERRORnAPP_SIGNUP_WITH_FACEBOOK_REQUESTnAPP_LOGIN_WITH_FACEBOOK_SUCCESSnn// 直接登錄失敗,註冊信息不足nAPP_LOGIN_WITH_FACEBOOK_REQUESTnAPP_LOGIN_WITH_FACEBOOK_ERRORnAPP_REDIRECT_USERn
於是,在 reducer 中,我們只要在相應的 action 被 dispatch 時,對 ViewModel 中的數據做相應的更改即可,也就做到了業務邏輯與顯示邏輯相分離。
這一解法與 MobX 及 RxJS 有相同又有不同。相同的是都定義好了數據的流動方式(action 的 dispatch 順序),在合適的時候通知 ViewModel 去改變數據,不同的是 Redux 不會在某個數據變動時自動觸發某條數據管道,而是需要使用者顯式地去調用某一條數據管道,如上述例子中,用戶點擊『連接社交網路』按鈕時。綜合起來和 redux-observable 的思路可能更為一致,即沒有完全拋棄 redux,又引入了數據管道的概念,只是限於工具函數的不足,無法處理更為複雜的場景。但從另一方面來說,如果業務中確實沒有非常複雜的場景,在理解了 redux 之後,使用最簡單的 redux-thunk 就可以完美地覆蓋到絕大部分需求。
業務邏輯臃腫
最後再讓我們來看一下如何解決業務邏輯臃腫的問題,應該說拆分並組合可復用的 action 解決了一部分的業務邏輯,但另一方面,Model 層的數據需要通過組合及格式化後才能成為 ViewModel 的一部分,也是困擾前端開發的一大難題。
這裡推薦使用抽象出通用的 Selector 和 Formatter 的概念來解決這一問題。
上面我們提到了,後端的 Model 會隨著介面直接進入到各個頁面的 reducer,這時我們就可以通過 Selector 來組合不同 reducer 中的數據,並通過 Formatter 將最終的數據格式化為可以直接顯示在 View 上的數據。
舉個例子,在用戶的個人中心頁面,我們需要顯示用戶在各個分類下喜歡過的回答,於是我們需要先獲取所有的分類,並在所有分類前加上一個後端並不存在的『熱門』分類。又因為分類是一個非常常用的數據,所以我們之前已經在首頁獲取過並存在了首頁的 reducer 中。代碼如下:
// src/views/account/formatter.jsnimport orderBy from lodash/orderBy;nnfunction categoriesFormatter(categories) {n const customCategories = orderBy(categories, priority);n const popular = {n id: 0,n name: 熱門,n shortname: popular,n };n customCategories.unshift(popular);nn return customCategories;n}nn// src/views/account/selector.jsnimport formatter from ./formatter.js;nimport homeSelector from ../home/selector.js;nnconst categoriesWithPopularSelector = state =>n formatter.categoriesFormatter(homeSelector.categoriesSelector(state));nnexport default {n categoriesWithPopularSelector,n};n
總的來說,在明確了 ViewModel 層需要解決的問題後,有針對性地去復用並組合 action,selector,formatter 就可以得到一個思路非常清晰的解決方案。在保證所有數據都只在相應的 reducer 中存儲一份的前提下,各個頁面數據不一致的問題也將迎刃而解。反過來說,數據不一致問題的根源就是代碼的可復用性太低,才導致了同一份數據以不同的方式流入了不同的數據管道並最終得到了不同的結果。
View 層
在理清楚前面兩層之後,作為前端最重要的 View 層反倒簡單了許多,通過 mapStateToProps, mapDispatchToProps,我們就可以將粒度極細的顯示數據與組合完畢的業務邏輯直接映射到 View 層的相應位置,從而得到一個純凈,易調試的 View 層。
可復用 View
但問題好像又並沒有這麼簡單,因為 View 層的可復用性也是困擾前端的一大問題,基於以上思路,我們又該怎樣處理呢?
受益於 React 等框架,前端組件化不再成為一個難題,我們也只需要遵守以下幾個原則,就可以較好地實現 View 層的復用。
- 所有的頁面都隸屬於一個文件夾,只有頁面級別的組件才會被 connect 到 redux store。每個頁面又都是一個獨立的文件夾,存放自己的 action,reducer,selector 及 formatter。
- components 文件夾中存放業務組件,業務組件不會被 connect 到 redux store,只能從 props 中獲取數據,從而保證其可維護性及可復用性。
- 另一個文件夾或 npm 包中存放 UI 組件,UI 組件與業務無關,只包含顯示邏輯,不包含業務邏輯。
總結
雖然說開發靈活易用的組件庫是一件非常難的事情,但在積累了足夠多的可復用的業務組件及 UI 組件之後,新的頁面在數據層面,又可以從其他頁面的 action,selector,formatter 中尋找可復用的業務邏輯時,新需求的開發速度應當是越來越快的,而不是越來越多的業務邏輯與顯示邏輯交織在一起,最終導致整個項目內部複雜度過高,無法維護只能推倒重來。
一點心得
在新技術層出不窮的今天,在我們執著於說服別人接受自己的技術觀點時,我們還是需要回到當前業務場景下,去看一看要解決的到底是一個什麼樣的問題。
拋去少部分極端複雜的前端應用來看,目前大部分的前端應用都還是以展示數據為主,在這樣的場景下,再前沿的技術與框架都無法直接解決上面提到的這些問題,反倒是一套清晰的數據處理思路及對核心概念的深入理解,再配合上嚴謹的團隊開發規範才有可能將深陷複雜數據泥潭的前端開發者們拯救出來。
作為工程學的一個分支,軟體工程的複雜度從來都不在於那些無法解決的難題,而是如何用簡單的規則讓不同的模塊各司其職。這也是為什麼在各種框架,庫,解決方案層出不窮的今天,大家還是在強調基礎,強調經驗,強調要看到問題的本質。
王陽明所說的知行合一,現代人往往是知道卻做不到。但在軟體工程方面,我們又常常會陷入照貓畫虎地做到了,卻並不理解其中道理的另一極端,這二者顯然都是不可取的。
推薦閱讀:
※Redux-Saga 初識和總結
※如何規模化React應用
※redux middleware 詳解
※React+AntD後台管理系統解決方案(補)
※【React/Redux/Router/Immutable】React最佳實踐的正確食用姿勢