在 React 中處理資料流問題的一些思考

止戰之殤發表於2019-02-27

背景

相信大家在專案開發中,在頁面較複雜的情況下,往往會遇到一個問題,就是在頁面元件之間通訊會非常困難。

比如說一個商品列表和一個已新增商品列表:

在 React 中處理資料流問題的一些思考

假如這兩個列表是獨立的兩個元件,它們會共享一個資料 “被選中的商品”,在商品列表選中一個商品,會影響已新增商品列表,在已新增列表中刪除一個商品,同樣會影響商品列表的選中狀態。

它們兩個是兄弟元件,在沒有資料流框架的幫助下,在元件內資料有變化的時候,只能通過父元件傳輸資料,往往會有 onSelectedDataChange 這種函式出現,在這種情況下,還尚且能忍受,如果元件巢狀較深的話,那痛苦可以想象一下,所以才有解決資料流的各種框架的出現。

本質分析

我們知道 React 是 MVC 裡的 V,並且是資料驅動檢視的,簡單來說,就是資料 => 檢視,檢視是基於資料的渲染結果:

V = f(M)
複製程式碼

資料有更新的時候,在進入渲染之前,會先生成 Virtual DOM,前後進行對比,有變化才進行真正的渲染。

V + ΔV = f(M + ΔM)
複製程式碼

資料驅動檢視變化有兩種方式,一種是 setState,改變頁面的 state,一種是觸發 props 的變化。

我們知道資料是不會自己改變,那麼肯定是有“外力”去推動,往往是遠端請求資料回來或者是 UI 上的互動行為,我們統稱這些行為叫 action

ΔM = perform(action) 
複製程式碼

每一個 action 都會去改變資料,那麼檢視得到的資料(state)就是所有 action 疊加起來的變更,

state = actions.reduce(reducer, initState)
複製程式碼

所以真實的場景會出現如下或更復雜的情況:

在 React 中處理資料流問題的一些思考

問題就出在,更新資料比較麻煩,混亂,每次要更新資料,都要一層層傳遞,在頁面互動複雜的情況下,無法對資料進行管控。

有沒有一種方式,有個集中的地方去管理資料,集中處理資料的接收修改分發?答案顯然是有的,資料流框架就是做這個事情,熟悉 Redux 的話,就知道其實上面講的就是 Redux 的核心理念,它和 React 的資料驅動原理是相匹配的。

資料流框架

Redux

資料流框架目前佔主要地位的還是 Redux,它提供一個全域性 Store 處理應用資料的接收修改分發

在 React 中處理資料流問題的一些思考

它的原理比較簡單,View 裡面有任何互動行為需要改變資料,首先要發一個 action,這個 actionStore 接收並交給對應的 reducer 處理,處理完後把更新後的資料傳遞給 ViewRedux 不依賴於任何框架,它只是定義一種方式控制資料的流轉,可以應用於任何場景。

雖然定義了一套資料流轉的方式,但真正使用上會有不少問題,我個人總結主要是兩個問題:

  1. 定義過於繁瑣,檔案多,容易造成思維跳躍。
  2. 非同步流的處理沒有優雅的方案。

我們來看看寫一個資料請求的例子,這是非常典型的案例:

actions.js

export const FETCH_DATA_START = 'FETCH_DATA_START';
export const FETCH_DATA_SUCCESS = 'FETCH_DATA_SUCCESS';
export const FETCH_DATA_ERROR = 'FETCH_DATA_ERROR';

export function fetchData() {
  return dispatch => {
    dispatch(fetchDataStart());
    axios.get('xxx').then((data) => {
      dispatch(fetchDataSuccess(data));
    }).catch((error) => {
      dispatch(fetchDataError(error));
    });
  };
}

export function fetchDataStart() {
  return {
    type: FETCH_DATA_START,
  }
}

...FETCH_DATA_SUCCESS
...FETCH_DATA_ERROR

複製程式碼

reducer.js

import { FETCH_DATA_START, FETCH_DATA_SUCCESS, FETCH_DATA_ERROR  } from 'actions.js';
export default (state = { data: null }, action) => {
  switch (action.type) {
    case FETCH_DATA_START:
      ...
    case FETCH_DATA_SUCCESS:
      ...
    case FETCH_DATA_ERROR:
      ...
    default: 
      return state
  }
}
複製程式碼

view.js

import { createStore, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';
import reducer from 'reducer.js';
import { fetchData } from 'actions.js';

const store = createStore(reducer, applyMiddleware(thunk));
store.dispatch(fetchData());
複製程式碼

第一個問題,發一個請求,因為需要託管請求的所有狀態,所以需要定義很多的 action,這時很容易會繞暈,就算有人嘗試把這些狀態再封裝抽象,也會充斥著一堆模板程式碼。有人會挑戰說,雖然一開始是比較麻煩,繁瑣,但對專案可維護性,擴充套件性都比較友好,我不太認同這樣的說法,目前還算簡單,真正業務邏輯複雜的情況下,會顯得更噁心,效率低且閱讀體驗差,相信大家也寫過或看過這樣的程式碼,後面自己看回來,需要在 actions 檔案搜尋一下 action 的名稱,reducer 檔案查詢一下,繞一圈才慢慢看懂。

第二個問題,按照官方推薦使用 redux-thunk 實現非同步 action 的方法,只要在 action 裡返回一個函式即可,這對有強迫症的人來說,簡直受不了,actions 檔案顯得它很不純,本來它只是來定義 action,卻竟然要夾雜著資料請求,甚至 UI 上的互動!

我覺得 Redux 設計上沒有問題,思路非常簡潔,是我非常喜歡的一個庫,它提供的資料的流動方式,目前也是得到社群的廣泛認可。然而在使用上有它的缺陷,雖然是可以克服,但是它本身難道沒有可以優化的地方?

dva

dva 的出來就是為了解決 redux 的開發體驗問題,它首次提出了 model 的概念,很好地把 actionreducersstate 結合到一個 model 裡面。

model.js

export default {
  namespace: 'products',
  state: [],
  reducers: {
    'delete'(state, { payload: id }) {
      return state.filter(item => item.id !== id);
    },
  },
};
複製程式碼

它的核心思想就是一個 action 對應一個 reducer,通過約定,省略了對 action 的定義,預設 reducers 裡面的函式名稱即為 action 的名稱。

在非同步 action 的處理上,定義了 effects(副作用) 的概念,與同步 action 區分起來,內部藉助了 redux-saga 來實現。

model.js

export default {
  namespace: 'counter',
  state: [],
  reducers: {
  },
  effects: {
    *add(action, { call, put }) {
      yield call(delay, 1000);
      yield put({ type: 'minus' });
    },
  },
};
複製程式碼

通過這樣子的封裝,基本保持 Redux 的用法,我們可以沉浸式地在 model 編寫我們的資料邏輯,我覺得已經很好地解決問題了。

不過我個人喜好問題,不太喜歡使用 redux-saga 這個庫來解決非同步流,雖然它的設計很巧妙,利用了 generator 的特性,不侵入 action,而是通過中介軟體的方式進行攔截,很好地將非同步處理隔離出獨立的一層,並且以此聲稱對實現單元測試是最友好的。是的,我覺得設計上真的非常棒,那時候還特意閱讀了它的原始碼,讚歎作者真的牛,這樣的方案都能想出來,但是後來我看到還有更好的解決方案(後面會介紹),就放棄使用它了。

mirrorx

mirrorxdva 差不多,只是它使用了單例的方式,所有的 action 都儲存了 actions 物件中,訪問 action 有了另一種方式。還有就是處理非同步 action 的時候可以使用 async/await 的方式。

import mirror, { actions } from 'mirrorx'

mirror.model({
  name: 'app',
  initialState: 0,
  reducers: {
    increment(state) { return state + 1 },
    decrement(state) { return state - 1 }
  },
  effects: {
    async incrementAsync() {
      await new Promise((resolve, reject) => {
        setTimeout(() => {
          resolve()
        }, 1000)
      })
      actions.app.increment()
    }
  }
});
複製程式碼

它內部處理非同步流的問題,類似 redux-thunk 的處理方式,通過注入一箇中介軟體,這個中介軟體裡判斷 當前 action 是不是非同步 action(只要判斷是不是 effects 裡定義的 action 即可),如果是的話,就直接中斷了中介軟體的鏈式呼叫,可以看看這段程式碼

這樣的話,我們 effects 裡的函式就可以使用 async/await 的方式呼叫非同步請求了,其實不是一定要使用 async/await,函式裡的實現沒有限制,因為中介軟體只是呼叫函式執行而已。

我是比較喜歡使用 async/await 這種方式處理非同步流,這是我不用 redux-saga 的原因。

xredux

但是我最終沒有選擇使用 mirrorxdva,因為用它們就捆綁一堆東西,我覺得不應該做成這樣子,為啥好好的解決 Redux 問題,最後變成都做一個腳手架出來?這不是強制消費嗎?讓人用起來就會有限制。瞭解它們的原理後,我自己參照寫了個 xredux 出來,只是單純解決 Reudx 的問題,不依賴於任何框架,可以看作只是 Redux 的升級版。

使用上和 mirrorx 差不多,但它和 Redux 是一樣的,不繫結任何框架,可以獨立使用。

import xredux from "xredux";

const store = xredux.createStore();
const actions = xredux.actions;

// This is a model, a pure object with namespace, initialState, reducers, effects.
xredux.model({
  namespace: "counter",
  initialState: 0,
  reducers: {
    add(state, action) { return state + 1; },
    plus(state, action) { return state - 1; },
  },
  effects: {
    async addAsync(action, dispatch, getState) {
      await new Promise(resolve => {
        setTimeout(() => {
          resolve();
        }, 1000);
      });
      actions.counter.add();
    }
  }
});

// Dispatch action with xredux.actions
actions.counter.add();
複製程式碼

在非同步處理上,其實也存在問題,可能大家也遇到過,就是資料請求有三種狀態的問題,我們來看看,寫一個資料請求的 effects

import xredux from 'xredux';
import { fetchUserInfo } from 'services/api';

const { actions } = xredux;

xredux.model({
  namespace: 'user',
  initialState: {
    getUserInfoStart: false,
    getUserInfoError: null,
    userInfo: null,
  },
  reducers: {
    // fetch start
    getUserInfoStart (state, action) {
      return {
        ...state,
        getUserInfoStart: true,
      };
    },
    // fetch error
    getUserInfoError (state, action) {
      return {
        ...state,
        getUserInfoStart: false,
        getUserInfoError: action.payload,
      };
    },
    // fetch success
    setUserInfo (state, action) {
      return {
        ...state,
        userInfo: action.payload,
        getUserInfoStart: false,
      };
    }
  },
  effects: {
    async getUserInfo (action, dispatch, getState) {
      let userInfo = null;
      actions.user.getUserInfoStart();
      try {
        userInfo = await fetchUserInfo();
        actions.user.setUserInfo(userInfo);
      } catch (e) {
        actions.user.setUserInfoError(e);
      }
    }
  },
});
複製程式碼

可以看到,還是存在很多感覺沒用的程式碼,一個請求需要3個 reducer 和1個 effect,當時想著怎麼優化,但沒有很好的辦法,後來我想到這3個 reducer 有個共同點,就是隻是賦值,沒有任何操作,那我內建一個 setStatereducer,專門去處理這種只是賦值的 action 就好了。

最後變成這樣:

import xredux from 'xredux';
import { fetchUserInfo } from 'services/api';

const { actions } = xredux;

xredux.model({
  namespace: 'user',
  initialState: {
    getUserInfoStart: false,
    getUserInfoError: null,
    userInfo: null,
  },
  reducers: {
  },
  effects: {
    async getUserInfo (action, dispatch, getState) {
      let userInfo = null;
      // fetch start
      actions.user.setState({
        getUserInfoStart: true,
      });
      try {
        userInfo = await fetchUserInfo();
        // fetch success
        actions.user.setState({
          getUserInfoStart: false,
          userInfo,
        });
      } catch (e) {
        // fetch error
        actions.user.setState({
          getUserInfoError: e,
        });
      }
    }
  },
});
複製程式碼

這個目前是自己比較滿意的方案,在專案中也有實踐過,寫起來確實比較簡潔易懂,不知大家有沒有更好的辦法。

貧血元件/充血元件

使用了 Redux,按道理應用中的狀態資料應該都放到 Store 中,那元件是否能有自己的狀態呢?目前就會有兩種看法:

  • 所有狀態都應該在 Store 中託管,所有元件都是純展示元件。
  • 元件可擁有自己的部分狀態,另外一些由 Store 託管。

這兩種就是分別對應貧血元件和充血元件,區別就是元件是否有自己的邏輯,還是說只是純展示。我覺得這個問題不用去爭論,沒有對錯。

理論上當然是說貧血元件好,因為這樣保證資料是在一個地方管理的,但是付出的代價可能是沉重的,使用了這種方式,往往到後面會有想死的感覺,一種想回頭又不想放棄的感覺,其實沒必要這麼執著。

相信大家幾乎都是充血元件,有一些狀態只與元件相關的,由元件去託管,有些狀態需要共享的,交給 Store 去託管,甚至有人所有狀態都有元件託管,也是存在的,因為頁面太簡單,根本就不需要用到資料流框架。

總結

React 開發中不可避免會遇到資料流的問題,如何優雅地處理目前也沒有最完美的方案,社群也存在各種各樣的方法,可以多思考為什麼是這樣做,瞭解底層原理比盲目使用別人的方案更重要。

如果想詳細瞭解 xredux 如何在 React 中運用,可以使用 RIS 初始化一個 Standard 應用看看,之前的文章《RIS,建立 React 應用的新選擇》 有簡單提過,歡迎大家體驗。

參考資料

相關文章