在一個複雜的項目中,我們可以將Redux store看成爲客戶端的緩存數據庫。
衆所周知數據庫的設計大有講究,需要考慮到數據查找的性能,和可拓展性。作爲前端“數據庫”的Redux store也是同樣的,存放的方式、位置不好會增加bug出現的機率、讀寫數據的複雜度、維護的成本。
那你說我們在數據層已經將數據處理了一遍,前端再加一層處理不是多此一舉嗎?的確,隨着對用戶體驗的要求越來越高,而支持着這種極致體驗的網絡速度還無法跟上的時候,特殊手段的出現是必然的。
在這裏,我想討論幾種我用到或者看到的實現方式,每種可能適應不同的場景。如果你有更好的想法,歡迎留下評論。
全局唯一的數據
比如當前登陸用戶的一些必要信息。每次讀取API都直接覆蓋之前存的數據,特別簡單直觀。
store結構例子:
type MyStore = {
id: number,
name: string,
// ...
};
詳情數據
這裏的詳情指通常是作爲一個頁面的主要數據存在的,沒有這個數據頁面就沒法顯示的這種。比如:博客裏的博客內容,電商裏的產品信息,商店信息等。
這類的數據通常都會有一個全局唯一的ID,我們就拿這個ID作爲Object key來使用,如下:
type MyStoreById = {
[key: number]: {
id: number,
content: string,
// ...
}
列表數據
列表數據應該是最常見的了,比如:待辦事項列表,博客文章列表,搜索結果列表等。
這類的數據,處理的複雜度就在於,你可能需要根據具體的業務邏輯和UX來設計存儲的方式,而且通常要考慮分頁的問題。
分頁是指一個列表太長的時候,需要分成多頁顯示。目前市面上常見的逃不開到底/頂懶加載,和點擊頁碼切換這兩種UX(像瀑布流這種就是兩者結合)。
最常見的做法是通過offset(直譯爲偏移,列表開始的位置),limit(列表的長度)向API請求一定數量的數據。API返回數據中帶上一些必要的幫助前端判斷有沒有下一頁或者一共多少頁的信息,最常用的是列表總長。也就是說你在store裏存的一般不會是很簡單的一個Object數組,而可能是這樣的:
type MyListStore = {
[key: string | number]: {
list: ListItem[],
total: number
}
};
那這個key用什麼呢?答案也是不唯一的。我們要考慮的不僅僅是讀寫數據時的時間、空間複雜度,還有重用性,擴展性等等。
拿博客來說,通常博客的列表會根據欄目來分。這裏其實有2種列表:欄目列表,和該欄目的博客列表。
拿每個欄目裏的博客列表來說,有這個列表的前提是有這個欄目的ID對吧。所以就可以用欄目ID作爲key。
但是別忘了上面講的分頁問題。下拉懶加載的可以就粗暴地使用單個數組,每次懶加載都往數組最後追加數據, 如下:
type Action = {
type: string,
key: number,
data: BlogList
};
type Blog = {
id: number,
title: string,
content: string
};
type BlogList = {
list: Blog[],
total: number
};
type ListReducerState = {
[key: number]: BlogList
};
export default function listReducer(
state: ListReducerState = {},
action: Action
) {
switch (action.type) {
case ‘DATA_LOADED’: { // API返回數據後執行
const { key, data } = action;
if (!Array.isArray(data.list) || !data.list.length) { return; }
const existingList = state[key] || {};
const updatedList = {
// 往數組最後追加最新拉下來的數據
list: [...(existingList.list || []), ...data.list],
total: data.total
};
return {
...state,
[key]: updatedList
};
}
}
return state;
}
如上這種做法適用於下拉懶加載是由於懶加載的請求一定是按照從上到下順序來的,請求不允許跳躍。對於這樣的列表讀取數據特別地簡單。
但可以根據點擊頁碼切換的話,放在一個列表裏的結構就坑大了。
首先需要解決的問題是新加載的一頁數據存儲在什麼位置呢?如上一般直接放在同一個列表最後面不是明智的行爲。因爲用戶可以隨便選擇跳哪頁,導致請求的數據可能是不連續的。並且redux的設計理念跟Promise不同,它將請求數據的行爲,和獲取數據的過程隔離開了。在redux中想獲取對應頁面的數據,就要約定好數據存放的規則。
我曾經試圖用offset和limit來覆蓋列表指定位置的數據。如下:
function MyListReducer(state, action) {
switch (action.type) {
case ‘LIST_LOADED’: {
const { key, data, offset, limit } = action;
const items = [...state[key].items];
for (let i= 0; i < limit; i++) {
items[offset + i] = data.items[i];
}
return {
...state,
[key]: {
...state[key],
...data,
items,
}
};
}
// ...
}
return state;
}
然後如下選出該頁的item:
function getItems(state, key, offset, limit) {
return state && state[key] && state[key].items.slice(offset, limit);
}
這似乎是可以通用於兩種分頁設計的完美方案。但很快問題就暴露了出來。這個選取列表部分數據的方法會每次新建一個數組,在react中如果用props傳入這組數據,可能會大大增加不必要的渲染,導致性能下降。
還可能遇到非常奇葩的問題。比如線上我們不靠譜的API因爲緩存數據可能已經不再有效,在傳回數據前額外做了一次數據過濾,導致返回的列表數據可能不足limit的值。也就是說,這個列表裏可能會有空白位置,渲染時要注意處理,防止JS報錯。再如,腦洞大開的QA嘗試同一頁面加了兩個同類型的列表模塊,只是limit不同。2個異步請求有非常偶然的機會遇到同一位置的數據不同,而這2個請求最後都會改變同一個列表裏的數據,這就有了一個競爭的問題,而且有一定機率碰到中間有一兩個數據發生重複。那麼,顯示時還要做一次去重嗎?怎麼看都有些得不償失。
不如每頁都分開存吧。這樣只有在更新這一頁數據的時候,纔會改變它的引用,不會因爲其他部分更新導致重新渲染。也不用處理很奇葩的問題,選取數據的時候也簡單許多。
有朋友曾經糾結過這降低了數據的重用性。這就多慮了,有多大的機率用戶可能看到同一組不需要更新的數據呢?
假如你堅定地跟我說有,那好,我這還有個idea:
type MyListStore = {
list: {
[key: string]: {
items: ItemId[],
total: number
}
},
itemPool: {
[itemId: number]: Item[]
}
};
對,就是在列表中只存一組id,所有列表項的數據存在另外一個Object中,作爲一個item池。不過這很有可能意味着你要把API同學幸苦拼好的數據重新組裝。
列表和詳情
有列表,通常就有詳情頁。而API通常爲了節省帶寬,在列表中會考慮只返回必要的屬性值。比如博客列表就只需要返回博客標題和一個摘要,不會包括整篇博客內容。
爲什麼提到這個問題呢?因爲從用戶的行爲來看,瀏覽列表之後,非常可能點擊查看詳情。也就是說,列表裏的某一組數據非常有可能被重新利用。那何不在進入詳情時,先用這部分數據顯示一部分內容,等拿到詳情數據後替換呢?
這樣做的話,首先列表項最好用Map類型,或者用上述item池的方式存儲,否則詳情頁讀取指定數據的時候查找的複雜度高。然後,詳情頁就要充分做好數據缺失的準備,不要因爲讀取一個undefined對象的屬性值而報錯導致整個頁面掛掉。
API狀態
在異步處理的時候,顯示一些狀態和進度對用戶體驗來說是非常重要的。最常見的就如,拉取數據的時候,需要顯示一個加載的動圖;提交表單之後,就要禁用提交按鈕,防止用戶多次提交,並且告訴用戶系統正在處理之類。
這就涉及到,如何在像react這樣的框架中,和redux配合顯示API的狀態。
當然你可以簡單粗暴地用Promise。但在react中需要注意component是否已經unmount。比如:
class MyComponent extends React.Component {
static state = {
loading: false
};
componentDidMount() {
this.loadData();
this._unmounted = false;
}
componentWillUnmount() {
this._unmounted = true;
}
async loadData() {
this.setState({
loading: true
});
await this.props.loadData();
if (!this._unmounted) {
this.setState({
loading: false
});
}
}
render() {
return this.state.loading? (
<div>Loading...</div>
) : (
<Item data={this.props.data} />
)
}
}
這裏看起來非常多此一舉的this._unmounted
,是因爲用戶非常有可能在API返回結果前,就離開了當前頁面導致component unmount。如果在unmount之後還繼續做setState之類的操作,React會抱怨。
其實React從本質上看是“反映當前的狀態”,它期望根據一組狀態值來渲染對應的UI,而不期望像Promise一樣,需要等待。所以有人就提出了將API的狀態在redux store中保存,而redux store只要有更新就會通知React更新,這樣React就能通過讀取redux中存儲的當前狀態就可以顯示相應的內容。
舉例:
export function dataReducer(state = {}, action) {
switch(action.type) {
case ‘fetch_data_succeed’:
return {
...state,
[action.key]: action.data
};
}
return state;
}
export const API_STATE = {
init: 0,
loading: 1,
loaded: 2,
error: 3
};
export function apiStateReducer(state = {}, action) {
switch(action.type) {
case ‘fetch_data_requested’:
return {
...state,
[action.key]: {
state: API_STATE.loading
}
};
case ‘fetch_data_succeed’:
return {
...state,
[action.key]: {
state: API_STATE.loaded
}
};
case ‘fetch_data_failed’:
return {
...state,
[action.key]: {
state: API_STATE.error,
error: action.error
}
};
}
return state;
}
export default combineReducer({
data: dataReducer,
apiState: apiStateReducer
});
上例中我建了2個reducer,但其實只處理了3個行爲(action):
- fetch_data_requested 表示已請求API
- fetch_data_succeed 表示API請求成功,拿到結果
- fetch_data_failed 表示API請求失敗,服務器報錯
第一個dataReducer只管請求成功後將數據保存。第二個apiStateReducer則只保存這個API請求的狀態。兩者分開,可以保證互不影響。
然後在React component中,如果要顯示一個加載中的狀態,就只要在這個apiState中查找對應的狀態就行:
import React from ‘react’;
import { connect } from ‘react-redux’;
function MyComponent({ loading, data }) {
return loading ? (
<div>Loading...</div>
) : (
<DataComponent data={data} />
);
}
export default connect((state, ownProps) => {
const { key } = ownProps;
return {
loading: state.apiState[key].state === API_STATE.loading,
data: state.data[key]
};
})(MyComponent);
擴展:實現緩存
對這個apiState稍加修改,還可以實現類似“緩存”的功能,可以用於節流。
首先,在我們的apiState里加一個時間戳,假設用發出請求的時間作爲基準:
type ApiState = {
state: API_STATE,
error: string | number | void,
requestTime: number
};
Reducer 相應地修改爲:
export function apiStateReducer(state = {}, action) {
+ const requestTime = state[action.key] && state[action.key].requestTime;
switch(action.type) {
case ‘fetch_data_requested’:
return {
...state,
[action.key]: {
+ requestTime: Date.now(),
state: API_STATE.loading
}
};
case ‘fetch_data_succeed’:
return {
...state,
[action.key]: {
+ requestTime,
state: API_STATE.loaded
}
};
case ‘fetch_data_failed’:
return {
...state,
[action.key]: {
+ requestTime,
state: API_STATE.error,
error: action.error
}
};
}
return state;
}
在action中就可以判斷上次請求時間是否已經超過比如1分鐘,超過1分鐘才重新請求(例子用thunk中間件):
export function fetchAction(params) {
return async (dispatch, getState) => {
const key = makeKey(params);
const { requestTime } = getState().myState.apiState[key] || {};
if (Date.now() - requestTime < 60000) {
return;
}
dispatch({ type: ‘fetch_data_requested’ });
const response = await makeAPICall(API_ENDPOINT, params);
if (hasError(response)) {
dispatch({ type: ‘fetch_data_failed’, error: getError(response) });
} else {
dispatch({ type: ‘fetch_data_succeed’, data: response });
}
return response;
};
}