前端資料層不完全指北

誠身發表於2017-10-19

不知不覺間時間已經來到了 2017 年末尾。

在過去一年中,關於前端資料層的討論依然在持續升溫。不論是資料型別層面的 TypeScript,Flow,PropTypes,應用架構層面的 MVC,MVP,MVVM,還是應用狀態層面的 Redux,MobX,RxJS,都各自擁有一批忠實的擁躉,卻又誰都無法說服別人認同自己的觀點。

關於技術選型上的討論,筆者一直所持的態度都是求同存異。在討論上述方案差異的文章已汗牛充棟的今天,不如讓我們暫且放緩腳步,回頭去看一下這些方案所要解決的共同的問題,並試圖給出一些最簡單的解法。

接下來讓我們以通用的 MVVM 架構為例,逐層剖析前端資料層的共同痛點。

Model 層

作為應用資料鏈路的最下游,前端的 Model 層與後端的 Model 層其實有著很大的區別。相較於後端 Model,前端 Model 並不能起到定義資料結構的作用,而更像是一個容器,用於存放後端介面返回的資料。

在這樣的前提下,在 RESTful 風格的介面已然成為業界標準的今天,如果後端資料是按照資料資源的最小粒度返回給前端的話,我們是不是可以直接將每個介面的標準返回,當做我們最底層的資料 Model 呢?換句話說,我們好像也別無選擇,因為介面返回的資料就是前端資料層的最上游,也是接下來一切資料流動的起點。

在明確了 Model 層的定義之後,讓我們來看一下 Model 層存在的問題。

資料資源粒度過細

資料資源粒度過細通常會導致以下兩個問題,一是單個頁面需要訪問多個介面以獲取所有的顯示資料,二是各個資料資源之間存在獲取順序的問題,需要按順序依次非同步獲取。

對於第一個問題,常見的解法為搭建一個 Node.js 的資料中間層,來做介面整合,最終暴露給客戶端以頁面為粒度的介面,並與客戶端路由保持一致。

這種解法的優點和缺點都非常明顯,優點是每個頁面都只需要訪問一個介面,在生產環境下的頁面載入速度可以得到有效的提升。另一方面,因為服務端已經準備好了所有的資料,做起服務端渲染來也很輕鬆。但從開發效率的角度來講,不過是將業務複雜度後置的一種做法,並且只適用於頁面與頁面之間關聯較少,應用複雜度較低的專案,畢竟頁面級別的 ViewModel 粒度還是太粗了,而且因為是介面級別的解決方案,可複用性幾乎為零。

對於第二個問題,筆者提供一個基於最簡單的 redux-thunk 的工具函式來連結兩個非同步請求。

import isArray from 'lodash/isArray';

function createChainedAsyncAction(firstAction, handlers) {
  if (!isArray(handlers)) {
    throw new Error('[createChainedAsyncAction] handlers should be an array');
  }

  return dispatch => (
    firstAction(dispatch)
      .then((resultAction) => {
        for (let i = 0; i < handlers.length; i += 1) {
          const { status, callback } = handlers[i];
          const expectedStatus = `_${status.toUpperCase()}`;

          if (resultAction.type.indexOf(expectedStatus) !== -1) {
            return callback(resultAction.payload)(dispatch);
          }
        }

        return resultAction;
      })
  );
}複製程式碼

基於此,我們再提供一個常見的業務場景來幫助大家理解。比如一個類似於知乎的網站,前端在先獲取登入使用者資訊後,才可以根據使用者 id 去獲取該使用者的回答。

// src/app/action.js
function getUser() {
  return createAsyncAction('APP_GET_USER', () => (
    api.get('/api/me')
  ));
}

function getAnswers(user) {
  return createAsyncAction('APP_GET_ANSWERS', () => (
    api.get(`/api/answers/${user.id}`)
  ));
}

function getUserAnswers() {
  const handlers = [{
    status: 'success',
    callback: getAnswers,
  }, {
    status: 'error',
    callback: payload => (() => {
      console.log(payload);
    }),
  }];

  return createChainedAsyncAction(getUser(), handlers);
}

export default {
  getUser,
  getAnswers,
  getUserAnswers,
};複製程式碼

在輸出時,我們可以將三個 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) {
  return {
    type: 'APP_REDIRECT_USER',
    payload: redirectUrl,
  }
}

function loginWithFacebook(facebookId, facebookToken) {
  return createAsyncAction('APP_LOGIN_WITH_FACEBOOK', () => (
    api.post('/auth/facebook', {
      facebook_id: facebookId,
      facebook_token: facebookToken,
    })
  ));
}

function signupWithFacebook(facebookId, facebookToken, facebookEmail) {
  if (!facebookEmail) {
    redirectToPage('/fill-in-details');
  }

  return createAsyncAction('APP_SIGNUP_WITH_FACEBOOK', () => (
    api.post('/accounts', {
      authentication_type: 'facebook',
      facebook_id: facebookId,
      facebook_token: facebookToken,
      email: facebookEmail,
    })
  ));
}

function connectWithFacebook(facebookId, facebookToken, facebookEmail) {
  const firstAction = loginWithFacebook(facebookId, facebookToken);
  const callbackAction = signupWithFacebook(facebookId, facebookToken, facebookEmail);

  const handlers = [{
    status: 'success',
    callback: () => (() => {}), // 使用者登陸成功
  }, {
    status: 'error',
    callback: callbackAction, // 使用 facebook 賬戶登陸失敗,嘗試幫使用者註冊新賬戶
  }];

  return createChainedAsyncAction(firstAction, handlers);
}複製程式碼

這裡,只要我們將可複用的 action 拆分到了合適的粒度,並在鏈式 action 中將他們按照業務邏輯組合起來之後,Redux 就會在不同的情況下 dispatch 不同的 action。可能的幾種情況如下:

// 直接登入成功
APP_LOGIN_WITH_FACEBOOK_REQUEST
APP_LOGIN_WITH_FACEBOOK_SUCCESS

// 直接登入失敗,註冊資訊充足
APP_LOGIN_WITH_FACEBOOK_REQUEST
APP_LOGIN_WITH_FACEBOOK_ERROR
APP_SIGNUP_WITH_FACEBOOK_REQUEST
APP_LOGIN_WITH_FACEBOOK_SUCCESS

// 直接登入失敗,註冊資訊不足
APP_LOGIN_WITH_FACEBOOK_REQUEST
APP_LOGIN_WITH_FACEBOOK_ERROR
APP_REDIRECT_USER
複製程式碼

於是,在 reducer 中,我們只要在相應的 action 被 dispatch 時,對 ViewModel 中的資料做相應的更改即可,也就做到了業務邏輯與顯示邏輯相分離。

這一解法與 MobX 及 RxJS 有相同又有不同。相同的是都定義好了資料的流動方式(action 的 dispatch 順序),在合適的時候通知 ViewModel 去更新資料,不同的是 Redux 不會在某個資料變動時自動觸發某條資料管道,而是需要使用者顯式地去呼叫某一條資料管道,如上述例子中,在使用者點選『連線社交網路』按鈕時。綜合起來和 redux-observable 的思路可能更為一致,即沒有完全拋棄 redux,又引入了資料管道的概念,只是限於工具函式的不足,無法處理更復雜的場景。但從另一方面來說,如果業務中確實沒有非常複雜的場景,在理解了 redux 之後,使用最簡單的 redux-thunk 就可以完美地覆蓋到絕大部分需求。

業務邏輯臃腫

拆分並組合可複用的 action 解決了一部分的業務邏輯,但另一方面,Model 層的資料需要通過組合及格式化後才能成為 ViewModel 的一部分,也是困擾前端開發的一大難題。

這裡推薦使用抽象出通用的 SelectorFormatter 的概念來解決這一問題。

上面我們提到了,後端的 Model 會隨著介面直接進入到各個頁面的 reducer,這時我們就可以通過 Selector 來組合不同 reducer 中的資料,並通過 Formatter 將最終的資料格式化為可以直接顯示在 View 上的資料。

舉個例子,在使用者的個人中心頁面,我們需要顯示使用者在各個分類下喜歡過的回答,於是我們需要先獲取所有的分類,並在所有分類前加上一個後端並不存在的『熱門』分類。又因為分類是一個非常常用的資料,所以我們之前已經在首頁獲取過並存在了首頁的 reducer 中。程式碼如下:

// src/views/account/formatter.js
import orderBy from 'lodash/orderBy';

function categoriesFormatter(categories) {
  const customCategories = orderBy(categories, 'priority');
  const popular = {
    id: 0,
    name: '熱門',
    shortname: 'popular',
  };
  customCategories.unshift(popular);

  return customCategories;
}

// src/views/account/selector.js
import formatter from './formatter.js';
import homeSelector from '../home/selector.js';

const categoriesWithPopularSelector = state =>
    formatter.categoriesFormatter(homeSelector.categoriesSelector(state));

export default {
  categoriesWithPopularSelector,
};複製程式碼

在明確了 ViewModel 層需要解決的問題後,有針對性地去複用並組合 action,selector,formatter 就可以得到一個思路非常清晰的解決方案。在保證所有資料都只在相應的 reducer 中儲存一份的前提下,各個頁面資料不一致的問題也迎刃而解。反過來說,資料不一致問題的根源就是程式碼的可複用性太低,才導致了同一份資料以不同的方式流入了不同的資料管道並最終得到了不同的結果。

View 層

在理清楚前面兩層之後,作為前端最重要的 View 層反而簡單了許多,通過 mapStateToPropsmapDispatchToProps,我們就可以將粒度極細的顯示資料與組合完畢的業務邏輯直接對映到 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 中尋找可複用的業務邏輯時,新需求的開發速度應當是越來越快的。而不是越來越多的業務邏輯與顯示邏輯交織在一起,最終導致整個專案內部複雜度過高無法維護後只能推倒重來。

一點心得

在新技術層出不窮的今天,在我們執著於說服別人接受自己的技術觀點時,我們還是需要回到當前業務場景下,去看一看要解決的到底是一個什麼樣的問題。

拋去少部分極端複雜的前端應用來看,目前大部分的前端應用都還是以展示資料為主,在這樣的場景下,再前沿的技術與框架都無法直接解決上面提到的這些問題,反倒是一套清晰的資料處理思路及對核心概念的深入理解,再配合上嚴謹的團隊開發規範才有可能將深陷複雜資料泥潭的前端開發者們拯救出來。

作為工程學的一個分支,軟體工程的複雜度從來都不在於那些無法解決的難題,而是如何制定簡單的規則讓不同的模組各司其職。這也是為什麼在各種框架,庫,解決方案層出不窮的今天,我們還是在強調基礎,強調經驗,強調要看到問題的本質。

王陽明所說的知行合一,現代人往往是知道卻做不到。但在軟體工程方面,我們又常常會陷入照貓畫虎地做到了,卻並不理解其中原理的另一極端,而這二者顯然都是不可取的。


相關文章