前端數據層不徹底指北

不知不覺間時間已經來到了 2017 年底尾。前端

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

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

接下來讓咱們以通用的 MVVM 架構爲例,逐層剖析前端數據層的共同痛點。後端

Model 層

做爲應用數據鏈路的最下游,前端的 Model 層與後端的 Model 層其實有着很大的區別。相較於後端 Model,前端 Model 並不能起到定義數據結構的做用,而更像是一個容器,用於存放後端接口返回的數據。api

在這樣的前提下,在 RESTful 風格的接口已然成爲業界標準的今天,若是後端數據是按照數據資源的最小粒度返回給前端的話,咱們是否是能夠直接將每一個接口的標準返回,當作咱們最底層的數據 Model 呢?換句話說,咱們好像也別無選擇,由於接口返回的數據就是前端數據層的最上游,也是接下來一切數據流動的起點。bash

在明確了 Model 層的定義以後,讓咱們來看一下 Model 層存在的問題。網絡

數據資源粒度過細

數據資源粒度過細一般會致使如下兩個問題,一是單個頁面須要訪問多個接口以獲取全部的顯示數據,二是各個數據資源之間存在獲取順序的問題,須要按順序依次異步獲取。數據結構

對於第一個問題,常見的解法爲搭建一個 Node.js 的數據中間層,來作接口整合,最終暴露給客戶端以頁面爲粒度的接口,並與客戶端路由保持一致。架構

這種解法的優勢和缺點都很是明顯,優勢是每一個頁面都只須要訪問一個接口,在生產環境下的頁面加載速度能夠獲得有效的提高。另外一方面,由於服務端已經準備好了全部的數據,作起服務端渲染來也很輕鬆。但從開發效率的角度來說,不過是將業務複雜度後置的一種作法,而且只適用於頁面與頁面之間關聯較少,應用複雜度較低的項目,畢竟頁面級別的 ViewModel 粒度仍是太粗了,並且由於是接口級別的解決方案,可複用性幾乎爲零。app

對於第二個問題,筆者提供一個基於最簡單的 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 中尋找可複用的業務邏輯時,新需求的開發速度應當是愈來愈快的。而不是愈來愈多的業務邏輯與顯示邏輯交織在一塊兒,最終致使整個項目內部複雜度太高沒法維護後只能推倒重來。

一點心得

在新技術層出不窮的今天,在咱們執着於說服別人接受本身的技術觀點時,咱們仍是須要回到當前業務場景下,去看一看要解決的究竟是一個什麼樣的問題。

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

做爲工程學的一個分支,軟件工程的複雜度歷來都不在於那些沒法解決的難題,而是如何制定簡單的規則讓不一樣的模塊各司其職。這也是爲何在各類框架,庫,解決方案層出不窮的今天,咱們仍是在強調基礎,強調經驗,強調要看到問題的本質。

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

相關文章
相關標籤/搜索