背景
相信大家在專案開發中,在頁面較複雜的情況下,往往會遇到一個問題,就是在頁面元件之間通訊會非常困難。
比如說一個商品列表和一個已新增商品列表:
假如這兩個列表是獨立的兩個元件,它們會共享一個資料 “被選中的商品”,在商品列表
選中一個商品,會影響已新增商品列表
,在已新增列表
中刪除一個商品,同樣會影響商品列表
的選中狀態。
它們兩個是兄弟元件,在沒有資料流框架的幫助下,在元件內資料有變化的時候,只能通過父元件傳輸資料,往往會有 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)
複製程式碼
所以真實的場景會出現如下或更復雜的情況:
問題就出在,更新資料比較麻煩,混亂,每次要更新資料,都要一層層傳遞,在頁面互動複雜的情況下,無法對資料進行管控。
有沒有一種方式,有個集中的地方去管理資料,集中處理資料的接收,修改和分發?答案顯然是有的,資料流框架就是做這個事情,熟悉 Redux
的話,就知道其實上面講的就是 Redux
的核心理念,它和 React
的資料驅動原理是相匹配的。
資料流框架
Redux
資料流框架目前佔主要地位的還是 Redux,它提供一個全域性 Store
處理應用資料的接收,修改和分發。
它的原理比較簡單,View
裡面有任何互動行為需要改變資料,首先要發一個 action
,這個 action
被 Store
接收並交給對應的 reducer
處理,處理完後把更新後的資料傳遞給 View
。Redux
不依賴於任何框架,它只是定義一種方式控制資料的流轉,可以應用於任何場景。
雖然定義了一套資料流轉的方式,但真正使用上會有不少問題,我個人總結主要是兩個問題:
- 定義過於繁瑣,檔案多,容易造成思維跳躍。
- 非同步流的處理沒有優雅的方案。
我們來看看寫一個資料請求的例子,這是非常典型的案例:
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
的概念,很好地把 action
、reducers
、state
結合到一個 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
mirrorx 和 dva
差不多,只是它使用了單例的方式,所有的 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
但是我最終沒有選擇使用 mirrorx
或 dva
,因為用它們就捆綁一堆東西,我覺得不應該做成這樣子,為啥好好的解決 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
有個共同點,就是隻是賦值,沒有任何操作,那我內建一個 setState
的 reducer
,專門去處理這種只是賦值的 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 應用的新選擇》 有簡單提過,歡迎大家體驗。