Redux 進階 - react 全家桶學習筆記(二)

佯真愚發表於2018-08-12

注:這篇是17年1月的文章,搬運自本人 blog...

github.com/BuptStEve/b…

零、前言

在上一篇中介紹了 Redux 的各項基礎 api。接著一步一步地介紹如何與 React 進行結合,並從引入過程中遇到的各個痛點引出 react-redux 的作用和原理。

不過目前為止還都是紙上談兵,在日常的開發中最常見非同步操作(如通過 ajax、jsonp 等方法 獲取資料),在學習完上一篇後你可能依然沒有頭緒。因此本文將深入淺出地對於 redux 的進階用法進行介紹。

一、中介軟體(MiddleWare)

It provides a third-party extension point between dispatching an action, and the moment it reaches the reducer. ———— by Dan Abramov

這是 redux 作者對 middleware 的描述,middleware 提供了一個分類處理 action 的機會,在 middleware 中你可以檢閱每一個流過的 action,挑選出特定型別的 action 進行相應操作,給你一次改變 action 的機會。

說得好像很吊...不過有啥用咧...?

1. 日誌應用場景[2]

因為改變 store 的唯一方法就是 dispatch 一個 action,所以有時需要將每次 dispatch 操作都列印出來作為操作日誌,這樣一來就可以很容易地看出是哪一次 dispatch 導致了異常。

1.1. 第一次嘗試:強行懟...

const action = addTodo('Use Redux');

console.log('dispatching', action);
store.dispatch(action);
console.log('next state', store.getState());
複製程式碼

顯然這種在每一個 dispatch 操作的前後都手動加程式碼的方法,簡直讓人不忍直視...

1.2. 第二次嘗試:封裝 dispatch

聰明的你一定馬上想到了,不如將上述程式碼封裝成一個函式,然後直接呼叫該方法。

function dispatchAndLog(store, action) {
    console.log('dispatching', action);
    store.dispatch(action);
    console.log('next state', store.getState());
}

dispatchAndLog(store, addTodo('Use Redux'));
複製程式碼

矮油,看起來不錯喲。

不過每次使用都需要匯入這個額外的方法,一旦不想使用又要全部替換回去,好麻煩啊...

1.3. 第三次嘗試:猴子補丁(Monkey Patch)

在此暫不探究為啥叫猴子補丁而不是什麼其他補丁。

簡單來說猴子補丁指的就是:以替換原函式的方式為其新增新特性或修復 bug。

let next = store.dispatch; // 暫存原方法

store.dispatch = function dispatchAndLog(action) {
    console.log('dispatching', action);
    let result = next(action); // 應用原方法
    console.log('next state', store.getState());

    return result;
};
複製程式碼

這樣一來我們就“偷樑換柱”般的為原 dispatch 新增了輸出日誌的功能。

1.4. 第四次嘗試:隱藏猴子補丁

目前看起來很不錯,然鵝假設我們又要新增別的一箇中介軟體,那麼程式碼中將會有重複的 let next = store.dispatch; 程式碼。

對於這個問題我們可以通過引數傳遞,返回新的 dispatch 來解決。

function logger(store) {
    const next = store.dispatch;

    return function dispatchAndLog(action) {
        console.log('dispatching', action);
        const result = next(action); // 應用原方法
        console.log('next state', store.getState());

        return result;
    }
}

store.dispatch = logger(store);
store.dispatch = anotherMiddleWare(store);
複製程式碼

注意到最後應用中介軟體的程式碼其實就是一個鏈式的過程,所以還可以更進一步優化繫結中介軟體的過程。

function applyMiddlewareByMonkeypatching(store, middlewares) {
    // 因為傳入的是原物件引用的值,slice 方法會生成一份拷貝,
    // 所以之後呼叫的 reverse 方法不會改變原陣列
    middlewares = middlewares.slice();
    // 我們希望按照陣列原本的先後順序觸發各個中介軟體,
    // 所以最後的中介軟體應當最接近原本的 dispatch,
    // 就像洋蔥一樣一層一層地包裹原 dispatch
    middlewares.reverse();

    // 在每一個 middleware 中變換 store.dispatch 方法。
    middlewares.forEach((middleware) =>
        store.dispatch = middleware(store);
    );
}

// 先觸發 logger,再觸發 anotherMiddleWare 中介軟體(類似於 koa 的中介軟體機制)
applyMiddlewareByMonkeypatching(store, [ logger, anotherMiddleWare ]);
複製程式碼

so far so good~! 現在不僅隱藏了顯式地快取原 dispatch 的程式碼,而且呼叫起來也很優雅~,然鵝這樣就夠了麼?

1.5. 第五次嘗試:移除猴子補丁

注意到,以上寫法仍然是通過 store.dispatch = middleware(store); 改寫原方法,並在中介軟體內部通過 const next = store.dispatch; 讀取當前最新的方法。

本質上其實還是 monkey patch,只不過將其封裝在了內部,不過若是將 dispatch 方法通過引數傳遞進來,這樣在 applyMiddleware 函式中就可以暫存 store.dispatch(而不是一次又一次的改寫),豈不美哉?

豈不美哉

// 通過引數傳遞
function logger(store, next) {
    return function dispatchAndLog(action) {
        // ...
    }
}

function applyMiddleware(store, middlewares) {
    // ...

    // 暫存原方法
    let dispatch = store.dispatch;

    // middleware 中通過閉包獲取 dispatch,並且更新 dispatch
    middlewares.forEach((middleware) =>
        dispatch = middleware(store, dispatch);
    );
}
複製程式碼

接著應用函數語言程式設計的 curry 化(一種使用匿名單引數函式來實現多引數函式的方法。),還可以再進一步優化。(其實是為了使用 compose 將中介軟體函式先組合再繫結)

function logger(store) {
    return function(next) {
        return function(action) {
            console.log('dispatching', action);
            const result = next(action); // 應用原方法
            console.log('next state', store.getState());

            return result;
        }
    }
}

// -- 使用 es6 的箭頭函式可以讓程式碼更加優雅更函式式... --
const logger = (store) => (next) => (action) => {
    console.log('dispatching', action);
    const result = next(action); // 應用原方法
    console.log('next state', store.getState());

    return result;
};

function applyMiddleware(store, middlewares) {
    // ...

    let dispatch = store.dispatch;

    middlewares.forEach((middleware) =>
        dispatch = middleware(store)(dispatch); // 注意呼叫了兩次
    );

    // ...
}
複製程式碼

以上方法離 Redux 中最終的 applyMiddleware 實現已經很接近了,

1.6. 第六次嘗試:組合(compose,函式式方法)

在 Redux 的最終實現中,並沒有採用我們之前的 slice + reverse 的方法來倒著繫結中介軟體。而是採用了 map + compose + reduce 的方法。

先來說這個 compose 函式,在數學中以下等式十分的自然。

f(g(x)) = (f o g)(x) f(g(h(x))) = (f o g o h)(x)

用程式碼來表示這一過程就是這樣。

// 傳入引數為函式陣列
function compose(...funcs) {
    // 返回一個閉包,
    // 將右邊的函式作為內層函式執行,並將執行結果作為外層函式再次執行
    return funcs.reduce((a, b) => (...args) => a(b(...args)));
}
複製程式碼

不瞭解 reduce 函式的人可能對於以上程式碼會感到有些費解,舉個例子來說,有函式陣列 [f, g, h]傳入 compose 函式執行。

  • 首次 reduce 執行的結果是返回一個函式 (...args) => f(g(...args))
  • 接著該函式作為下一次 reduce 函式執行時的引數 a,而引數 b 是 h
  • 再次執行時 h(...args) 作為引數傳入 a,即最後返回的還是一個函式 (...args) => f(g(h(...args)))

因此最終版 applyMiddleware 實現中並非依次執行繫結,而是採用函式式的思維,將作用於 dispatch 的函式首先進行組合,再進行繫結。(所以要中介軟體要 curry 化)

// 傳入中介軟體函式的陣列
function applyMiddleware(...middlewares) {
  // 返回一個函式的原因在 createStore 部分再進行介紹
  return (createStore) => (reducer, preloadedState, enhancer) => {
    const store = createStore(reducer, preloadedState, enhancer)
    let dispatch = store.dispatch
    let chain = [] // 儲存繫結了 middlewareAPI 後的函式陣列

    const middlewareAPI = {
      getState: store.getState,
      dispatch: (action) => dispatch(action)
    }
    chain = middlewares.map(middleware => middleware(middlewareAPI))
    // 使用 compose 函式按照從右向左的順序繫結(執行順序是從左往右)
    dispatch = compose(...chain)(store.dispatch)

    return {
      ...store,
      dispatch
    }
  }
}

// store -> { getState } 從傳遞整個 store 改為傳遞部分 api
const logger = ({ getState }) => (next) => (action) => {
    console.log('dispatching', action);
    const result = next(action); // 應用原方法
    console.log('next state', getState());

    return result;
};
複製程式碼

綜上如下圖所示整個中介軟體的執行順序是類似於洋蔥一樣首先按照從外到內的順序執行 dispatch 之前的中介軟體程式碼,在 dispatch(洋蔥的心)執行後又反過來,按照從內到左外的順序執行 dispatch 之後的中介軟體程式碼。

中介軟體

橋都麻袋!

橋都麻袋

你真的都理解了麼?

  • 在之前的實現中直接傳遞 store,為啥在最終實現中傳遞的是 middlewareAPI?
  • middlewareAPI 裡的 dispatch 是為啥一個匿名函式而不直接傳遞 dispatch?
  • 如下列程式碼所示,如果在中介軟體裡不用 next 而是呼叫 store.dispatch 會怎樣呢?
const logger = (store) => (next) => (action) => {
    console.log('dispatching', action);
    // 呼叫原始 dispatch,而不是上一個中介軟體傳進來的
    const result = store.dispatch(action); // <- 這裡
    console.log('next state', store.getState());

    return result;
};
複製程式碼

1.7. middleware 中呼叫 store.dispatch[6]

中介軟體使用 store.dispatch

正常情況下,如圖左,當我們 dispatch 一個 action 時,middleware 通過 next(action) 一層一層處理和傳遞 action 直到 redux 原生的 dispatch。如果某個 middleware 使用 store.dispatch(action) 來分發 action,就發生了右圖的情況,相當於從外層重新來一遍,假如這個 middleware 一直簡單粗暴地呼叫 store.dispatch(action),就會形成無限迴圈了。(其實就相當於猴子補丁沒補上,不停地呼叫原來的函式)

因此最終版裡不是直接傳遞 store,而是傳遞 getState 和 dispatch,傳遞 getState 的原因是可以通過 getState 獲取當前狀態。並且還將 dispatch 用一個匿名函式包裹 dispatch: (action) => dispatch(action),這樣不但可以防止 dispatch 被中介軟體修改,而且只要 dispatch 更新了,middlewareAPI 中的 dispatch 也會隨之發生變化。

1.8. createStore 進階

在上一篇中我們使用 createStore 方法只用到了它前兩個引數,即 reducer 和 preloadedState,然鵝其實它還擁有第三個引數 enhancer。

enhancer 引數可以實現中介軟體、時間旅行、持久化等功能,Redux 僅提供了 applyMiddleware 用於應用中介軟體(就是 1.6. 中的那個)。

在日常使用中,要應用中介軟體可以這麼寫。

import {
    createStore,
    combineReducers,
    applyMiddleware,
} from 'redux';

// 組合 reducer
const rootReducer = combineReducers({
    todos: todosReducer,
    filter: filterReducer,
});

// 中介軟體陣列
const middlewares = [logger, anotherMiddleWare];

const store = createStore(
    rootReducer,
    initialState,
    applyMiddleware(...middlewares),
);

// 如果不需要 initialState 的話也可以忽略
const store = createStore(
    rootReducer,
    applyMiddleware(...middlewares),
);
複製程式碼

在上文 applyMiddleware 的實現中留了個懸念,就是為什麼返回的是一個函式,因為 enhancer 被定義為一個高階函式,接收 createStore 函式作為引數。

/**
 * 建立一個 redux store 用於儲存狀態樹,
 * 唯一改變 store 中資料的方法就是對其呼叫 dispatch
 *
 * 在你的應用中應該只有一個 store,想要針對不同的部分狀態響應 action,
 * 你應該使用 combineReducers 將多個 reducer 合併。
 *
 * @param  {函式}  reducer 不多解釋了
 * @param  {物件}  preloadedState 主要用於前後端同構時的資料同步
 * @param  {函式}  enhancer 很牛逼,可以實現中介軟體、時間旅行,持久化等
 * ※ Redux 僅提供 applyMiddleware 這個 Store Enhancer ※
 * @return {Store}
 */
export default function createStore(reducer, preloadedState, enhancer) {
  if (typeof preloadedState === 'function' && typeof enhancer === 'undefined') {
    enhancer = preloadedState
    preloadedState = undefined
  }

  if (typeof enhancer !== 'undefined') {
    if (typeof enhancer !== 'function') {
      throw new Error('Expected the enhancer to be a function.')
    }

    // enhancer 是一個高階函式,接收 createStore 函式作為引數
    return enhancer(createStore)(reducer, preloadedState)
  }

  // ...
  // 後續內容推薦看看參考資料部分的【Redux 莞式教程】
}
複製程式碼

總的來說 Redux 有五個 API,分別是:

  • createStore(reducer, [initialState], enhancer)
  • combineReducers(reducers)
  • applyMiddleware(...middlewares)
  • bindActionCreators(actionCreators, dispatch)
  • compose(...functions)

createStore 生成的 store 有四個 API,分別是:

  • getState()
  • dispatch(action)
  • subscribe(listener)
  • replaceReducer(nextReducer)

以上 API 我們還沒介紹的應該就剩 bindActionCreators 了。這個 API 其實就是個語法糖起了方便地給 action creator 繫結 dispatch 的作用。

// 一般寫法
function mapDispatchToProps(dispatch) {
    return {
        onPlusClick: () => dispatch(increment()),
        onMinusClick: () => dispatch(decrement()),
    };
}

// 使用 bindActionCreators
import { bindActionCreators } from 'redux';

function mapDispatchToProps(dispatch) {
    return bindActionCreators({
        onPlusClick: increment,
        onMinusClick: decrement,
        // 還可以繫結更多函式...
    }, dispatch);
}

// 甚至如果定義的函式輸入都相同的話還能更加簡潔
export default connect(
  mapStateToProps,
  // 直接傳一個物件,connect 自動幫你繫結 dispatch
  { onPlusClick: increment, onMinusClick: decrement },
)(App);
複製程式碼

二、非同步操作

下面讓我們告別乾淨的同步世界,進入“骯髒”的非同步世界~。

在函數語言程式設計中,非同步操作、修改全域性變數等與函式外部環境發生的互動叫做副作用(Side Effect) 通常認為這些操作是邪惡(evil)骯髒(dirty)的,並且也是導致 bug 的源頭。 因為與之相對的是純函式(pure function),即對於同樣的輸入總是返回同樣的輸出的函式,使用這樣的函式很容易做組合、測試等操作,很容易驗證和保證其正確性。(它們就像數學公式一般準確)

2.1. 通知應用場景[3]

現在有這麼一個顯示通知的應用場景,在通知顯示後5秒鐘隱藏該通知。

首先當然是編寫 action

  • 顯示:SHOW_NOTIFICATION
  • 隱藏:HIDE_NOTIFICATION

2.1.1. 最直觀的寫法

最直觀的寫法就是首先顯示通知,然後使用 setTimeout 在5秒後隱藏通知。

store.dispatch({ type: 'SHOW_NOTIFICATION', text: 'You logged in.' });
setTimeout(() => {
  store.dispatch({ type: 'HIDE_NOTIFICATION' });
}, 5000);
複製程式碼

然鵝,一般在元件中尤其是展示元件中沒法也沒必要獲取 store,因此一般將其包裝成 action creator。

// actions.js
export function showNotification(text) {
  return { type: 'SHOW_NOTIFICATION', text };
}
export function hideNotification() {
  return { type: 'HIDE_NOTIFICATION' };
}

// component.js
import { showNotification, hideNotification } from '../actions';

this.props.dispatch(showNotification('You just logged in.'));
setTimeout(() => {
  this.props.dispatch(hideNotification());
}, 5000);
複製程式碼

或者更進一步地先使用 connect 方法包裝。

this.props.showNotification('You just logged in.');
setTimeout(() => {
  this.props.hideNotification();
}, 5000);
複製程式碼

到目前為止,我們沒有用任何 middleware 或者別的概念。

2.1.2. 非同步 action creator

上一種直觀寫法有一些問題

  • 每當我們需要顯示一個通知就需要手動先顯示,然後再手動地讓其消失。其實我們更希望通知到時間後自動地消失。
  • 通知目前沒有自己的 id,所以有些場景下存在競爭條件(race condition),即假如在第一個通知結束前觸發第二個通知,當第一個通知結束時,第二個通知也會被提前關閉。

所以為了解決以上問題,我們可以為通知加上 id,並將顯示和消失的程式碼包起來。

// actions.js
const showNotification = (text, id) => ({
    type: 'SHOW_NOTIFICATION',
    id,
    text,
});
const hideNotification = (id) => ({
    type: 'HIDE_NOTIFICATION',
    id,
});

let nextNotificationId = 0;
export function showNotificationWithTimeout(dispatch, text) {
    const id = nextNotificationId++;
    dispatch(showNotification(id, text));

    setTimeout(() => {
        dispatch(hideNotification(id));
    }, 5000);
}

// component.js
showNotificationWithTimeout(this.props.dispatch, 'You just logged in.');

// otherComponent.js
showNotificationWithTimeout(this.props.dispatch, 'You just logged out.');
複製程式碼

為啥 showNotificationWithTimeout 函式要接收 dispatch 作為第一個引數呢? 雖然通常一個元件都擁有觸發 dispatch 的許可權,但是現在我們想讓一個外部函式(showNotificationWithTimeout)來觸發 dispatch,所以需要將 dispatch 作為引數傳入。

2.1.3. 單例 store

可能你會說如果有一個從其他模組中匯出的單例 store,那麼是不是同樣也可以不傳遞 dispatch 以上程式碼也可以這樣寫。

// store.js
export default createStore(reducer);

// actions.js
import store from './store';

// ...

let nextNotificationId = 0;
export function showNotificationWithTimeout(text) {
  const id = nextNotificationId++;
  store.dispatch(showNotification(id, text));

  setTimeout(() => {
    store.dispatch(hideNotification(id));
  }, 5000);
}

// component.js
showNotificationWithTimeout('You just logged in.');

// otherComponent.js
showNotificationWithTimeout('You just logged out.');
複製程式碼

這樣看起來似乎更簡單一些,不過牆裂不推薦這樣的寫法。主要的原因是這樣的寫法強制讓 store 成為一個單例。這樣一來要實現伺服器端渲染(Server Rendering)將十分困難。因為在服務端,為了讓不同的使用者得到不同的預先獲取的資料,你需要讓每一個請求都有自己的 store。

並且單例 store 也將讓測試變得困難。當測試 action creator 時你將無法自己模擬一個 store,因為它們都引用了從外部匯入的那個特定的 store,所以你甚至無法從外部重置狀態。

2.1.4. redux-thunk 中介軟體

首先宣告 redux-thunk 這種方案對於小型的應用來說足夠日常使用,然鵝對於大型應用來說,你可能會發現一些不方便的地方。(例如對於 action 需要組合、取消、競爭等複雜操作的場景)

首先來明確什麼是 thunk...

A thunk is a function that wraps an expression to delay its evaluation.

簡單來說 thunk 就是封裝了表示式的函式,目的是延遲執行該表示式。不過有啥應用場景呢?

目前為止,在上文中的 2.1.2. 非同步 action creator 部分,最後得出的方案有以下明顯的缺點

  • 我們必須將 dispatch 作為引數傳入。
  • 這樣一來任何使用了非同步操作的元件都必須用 props 傳遞 dispatch(不管有多深...)。我們也沒法像之前各種同步操作一樣使用 connect 函式來繫結回撥函式,因為 showNotificationWithTimeout 函式返回的不是一個 action。
  • 此外,在日常使用時,我們還需要區分哪些函式是同步的 action creator,那些是非同步的 action creator。(非同步的需要傳 dispatch...)
    • 同步的情況: store.dispatch(actionCreator(payload))
    • 非同步的情況: asyncActionCreator(store.dispatch, payload)

計將安出?

其實問題的本質在於 Redux “有眼不識 function”,目前為止 dispatch 函式接收的引數只能是 action creator 返回的普通的 action。所以如果我們讓 dispatch 對於 function 網開一面,走走後門潛規則一下不就行啦~

實現方式很簡單,想想第一節介紹的為 dispatch 新增日誌功能的過程。

// redux-thunk 原始碼
function createThunkMiddleware(extraArgument) {
  return ({ dispatch, getState }) => next => action => {
    if (typeof action === 'function') {
      return action(dispatch, getState, extraArgument);
    }

    return next(action);
  };
}

const thunk = createThunkMiddleware();
thunk.withExtraArgument = createThunkMiddleware;

export default thunk;
複製程式碼

以上就是 redux-thunk 的原始碼,就是這麼簡單,判斷下如果傳入的 action 是函式的話,就執行這個函式...(withExtraArgument 是為了新增額外的引數,詳情見 redux-thunk 的 README.md

  • 這樣一來如果我們 dispatch 了一個函式,redux-thunk 會傳給它一個 dispatch 引數,我們就利用 thunk 解決了元件中不方便獲取 dispatch 的問題。
  • 並且由於 redux-thunk 攔截了函式,也可以防止 reducer 接收到函式而出現異常。

新增了 redux-thunk 中介軟體後程式碼可以這麼寫。

// actions.js
// ...

let nextNotificationId = 0;
export function showNotificationWithTimeout(text) {
    // 返回一個函式
    return function(dispatch) {
        const id = nextNotificationId++;
        dispatch(showNotification(id, text));

        setTimeout(() => {
            dispatch(hideNotification(id));
        }, 5000);
    };
}

// component.js 像同步函式一樣的寫法
this.props.dispatch(showNotificationWithTimeout('You just logged in.'));

// 或者 connect 後直接呼叫
this.props.showNotificationWithTimeout('You just logged in.');
複製程式碼

2.2. 介面應用場景

目前我們對於簡單的延時非同步操作的處理已經瞭然於胸了,現在讓我們來考慮一下通過 ajax 或 jsonp 等介面來獲取資料的非同步場景。

很自然的,我們會發起一個請求,然後等待請求的響應(請求可能成功或是失敗)。

即有基本的三種狀態和與之對應的 action:

  • 請求開始的 action:isFetching 為真,UI 顯示載入介面 { type: 'FETCH_POSTS_REQUEST' }
  • 請求成功的 action:isFetching 為假,隱藏載入介面並顯示接收到的資料 { type: 'FETCH_POSTS_SUCCESS', response: { ... } }
  • 請求失敗的 action:isFetching 為假,隱藏載入介面,可能儲存失敗資訊並在 UI 中顯示出來 { type: 'FETCH_POSTS_FAILURE', error: 'Oops' }

按照這個思路,舉一個簡單的栗子。

// Constants
const FETCH_POSTS_REQUEST = 'FETCH_POSTS_REQUEST';
const FETCH_POSTS_SUCCESS = 'FETCH_POSTS_SUCCESS';
const FETCH_POSTS_FAILURE = 'FETCH_POSTS_FAILURE';

// Actions
const requestPosts = (id) => ({
    type: FETCH_POSTS_REQUEST,
    payload: id,
});

const receivePosts = (res) => ({
    type: FETCH_POSTS_SUCCESS,
    payload: res,
});

const catchPosts = (err) => ({
    type: FETCH_POSTS_FAILURE,
    payload: err,
});

const fetchPosts = (id) => (dispatch, getState) => {
    dispatch(requestPosts(id));

    return api.getData(id)
        .then(res => dispatch(receivePosts(res)))
        .catch(error => dispatch(catchPosts(error)));
};

// reducer
const reducer = (oldState, action) => {
    switch (action.type) {
        case FETCH_POSTS_REQUEST:
            return requestState;

        case FETCH_POSTS_SUCCESS:
            return successState;

        case FETCH_POSTS_FAILURE:
            return errorState;

        default:
            return oldState;
    }
};
複製程式碼

儘管這已經是最簡單的呼叫介面場景,我們甚至還沒寫一行業務邏輯程式碼,但講道理的話程式碼還是比較繁瑣的。

而且其實程式碼是有一定的“套路”的,比如其實整個程式碼都是針對請求、成功、失敗三部分來處理的,這讓我們自然聯想到 Promise,同樣也是分為 pending、fulfilled、rejected 三種狀態。

那麼這兩者可以結合起來讓模版程式碼精簡一下麼?

2.2.1. redux-promise 中介軟體[8]

首先開門見山地使用 redux-promise 中介軟體來改寫之前的程式碼看看效果。

// Constants
const FETCH_POSTS_REQUEST = 'FETCH_POSTS_REQUEST';

// Actions
const fetchPosts = (id) => ({
    type: FETCH_POSTS_REQUEST,
    payload: api.getData(id), // payload 為 Promise 物件
});

// reducer
const reducer = (oldState, action) => {
    switch (action.type) {
        case FETCH_POSTS_REQUEST:
            // requestState 被“吃掉”了
            // 而成功、失敗的狀態通過 status 來判斷
            if (action.status === 'success') {
                return successState;
            } else {
                return errorState;
            }

        default:
            return oldState;
    }
};
複製程式碼

可以看出 redux-promise 中介軟體比較激進、比較原教旨。

不但將發起請求的初始狀態被攔截了(原因見下文原始碼),而且使用 action.status 而不是 action.type 來區分兩個 action 這一做法也值得商榷(個人傾向使用 action.type 來判斷)。

// redux-promise 原始碼
import { isFSA } from 'flux-standard-action';

function isPromise(val) {
  return val && typeof val.then === 'function';
}

export default function promiseMiddleware({ dispatch }) {
  return next => action => {
    if (!isFSA(action)) {
      return isPromise(action)
        ? action.then(dispatch)
        : next(action);
    }

    return isPromise(action.payload)
      // 直接呼叫 Promise.then(所以發不出請求開始的 action)
      ? action.payload.then(
          // 自動 dispatch
          result => dispatch({ ...action, payload: result }),
          // 自動 dispatch
          error => {
            dispatch({ ...action, payload: error, error: true });
            return Promise.reject(error);
          }
        )
      : next(action);
  };
}
複製程式碼

以上是 redux-promise 的原始碼,十分簡單。主要邏輯是判斷如果是 Promise 就執行 then 方法。此外還根據是不是 FSA 決定呼叫的是 action 本身還是 action.payload 並且對於 FSA 會自動 dispatch 成功和失敗的 FSA。

2.2.2. redux-promise-middleware 中介軟體

儘管 redux-promise 中介軟體節省了大量程式碼,然鵝它的缺點除了攔截請求開始的 action,以及使用 action.status 來判斷成功失敗狀態以外,還有就是由此引申出的一個無法實現的場景————樂觀更新(Optimistic Update)。

樂觀更新比較直觀的栗子就是在微信、QQ等通訊軟體中,傳送的訊息立即在對話視窗中展示,如果傳送失敗了,在訊息旁邊展示提示即可。由於在這種互動方式中“樂觀”地相信操作會成功,因此稱作樂觀更新。

因為樂觀更新發生在使用者發起操作時,所以要實現它,意味著必須有表示使用者初始動作的 action。

因此為了解決這些問題,相對於比較原教旨的 redux-promise 來說,更加溫和派一點的 redux-promise-middleware 中介軟體應運而生。先看看程式碼怎麼說。

// Constants
const FETCH_POSTS = 'FETCH_POSTS'; // 字首

// Actions
const fetchPosts = (id) => ({
    type: FETCH_POSTS, // 傳遞的是字首,中介軟體會自動生成中間狀態
    payload: {
        promise: api.getData(id),
        data: id,
    },
});

// reducer
const reducer = (oldState, action) => {
    switch (action.type) {
        case `${FETCH_POSTS}_PENDING`:
            return requestState; // 可通過 action.payload.data 獲取 id

        case `${FETCH_POSTS}_FULFILLED`:
            return successState;

        case `${FETCH_POSTS}_REJECTED`:
            return errorState;

        default:
            return oldState;
    }
};
複製程式碼

如果不需要樂觀更新,fetchPosts 函式可以更加簡潔。

// 此時初始 actionGET_DATA_PENDING 仍然會觸發,但是 payload 為空。
const fetchPosts = (id) => ({
    type: FETCH_POSTS, // 傳遞的是字首
    payload: api.getData(id), // 等價於 payload: { promise: api.getData(id) },
});
複製程式碼

相對於 redux-promise 簡單粗暴地直接過濾初始 action,從 reducer 可以看出,redux-promise-middleware 會首先自動觸發一個 FETCH_POSTS_PENDING 的 action,以此保留樂觀更新的能力。

並且,在狀態的區分上,迴歸了通過 action.type 來判斷狀態的“正途”,其中 _PENDING_FULFILLED_REJECTED 字尾借用了 Promise 規範 (當然它們是可配置的) 。

字尾可以配置全域性或區域性生效,例如全域性配置可以這麼寫。

applyMiddleware(
  promiseMiddleware({
    promiseTypeSuffixes: ['LOADING', 'SUCCESS', 'ERROR']
  })
)
複製程式碼

原始碼地址點我,類似 redux-promise 也是在中介軟體中攔截了 payload 中有 Promise 的 action,並主動 dispatch 三種狀態的 action,註釋也很詳細在此就不贅述了。

注意:redux-promise、redux-promise-middleware 與 redux-thunk 之間並不是互相替代的關係,而更像一種補充優化。

2.3. redux-loop 中介軟體

簡單小結一下,Redux 的資料流如下所示:

UI => action => action creator => reducer => store => react => v-dom => UI

redux-thunk 的思路是保持 action 和 reducer 簡單純粹,然鵝副作用操作(在前端主要體現在非同步操作上)的複雜度是不可避免的,因此它將其放在了 action creator 步驟,通過 thunk 函式手動控制每一次的 dispatch。

redux-promise 和 redux-promise-middleware 只是在其基礎上做一些輔助性的增強,處理非同步的邏輯本質上是相同的,即將維護複雜非同步操作的責任推到了使用者的身上。

flux-diagram

這種實現方式固然很好理解,而且理論上可以應付所有非同步場景,但是由此帶來的問題就是模版程式碼太多,一旦流程複雜那麼非同步程式碼就會到處都是,很容易導致出現 bug。

redux-thunk-architecture

因此有一些其他的中介軟體,例如 redux-loop 就將非同步處理邏輯放在 reducer 中。(Redux 的思想借鑑了 Elm注意並不是“餓了麼”,而 Elm 就是將非同步處理放在 update(reducer) 層中)。

Synchronous state transitions caused by returning a new state from the reducer in response to an action are just one of all possible effects an action can have on application state. 這種通過響應一個 action,在 reducer 中返回一個新 state,從而引起同步狀態轉換的方式,只是在應用狀態中一個 action 能擁有的所有可能影響的一種。(可能沒翻好~歡迎勘誤~)

redux-loop 認為許多其他的處理非同步的中介軟體,尤其是通過 action creator 方式實現的中介軟體,錯誤地讓使用者認為非同步操作從根本上與同步操作並不相同。這樣一來無形中鼓勵了中介軟體以許多特殊的方式來處理非同步狀態。

與之相反,redux-loop 專注於讓 reducer 變得足夠強大以便處理同步和非同步操作。在具體實現上 reducer 不僅能夠根據特定的 action 決定當前的轉換狀態,而且還能決定接著發生的操作。

應用中所有行為都可以在一個地方(reducer)中被追蹤,並且這些行為可以輕易地分割和組合。(redux 作者 Dan 開了個至今依然 open 的 issue:Reducer Composition with Effects in JavaScript,討論關於對 reducer 進行分割組合的問題。)

redux-loop-architecture

redux-loop 模仿 Elm 的模式,引入了 Effect 的概念,在 reducer 中對於非同步等操作使用 Effect 來處理。如下官方示例所示:

import { Effects, loop } from 'redux-loop';

function fetchData(id) {
  return fetch(`endpoint/${id}`)
    .then((r) => r.json())
    .then((data) => ({ type: 'FETCH_SUCCESS', payload: data }))
    .catch((error) => ({ type: 'FETCH_FAILURE', payload: error.message }));
}

function reducer(state, action) {
  switch(action.type) {
    case 'FETCH_START':
      return loop( // <- 並沒有直接返回 state,實際上了返回陣列 [state, effect]
        { ...state, loading: true },
        Effects.promise(fetchData, action.payload.id)
      );

    case 'FETCH_SUCCESS':
      return { ...state, loading: false, data: action.payload };

    case 'FETCH_FAILURE':
      return { ...state, loading: false, errorMessage: action.payload };
  }
}
複製程式碼

雖然這個想法很 Elm 很函式式,不過由於修改了 reducer 的返回型別,這樣一來會導致許多已有的 Api 和第三方庫無法使用,甚至連 redux 庫中的 combineReducers 方法都需要使用 redux-loop 提供的定製版本。因此這也是 redux-loop 最終無法轉正的原因:

"If a solution doesn’t work with vanilla combineReducers(), it won’t get into Redux core."

三、複雜非同步操作

3.1. 更復雜的通知場景[9]

讓我們的思路重新回到通知的場景,之前的程式碼實現了:

  • 展示一個通知並在數秒後消失
  • 可以同時展示多個通知。

現在假設可親可愛的產品又提出了新需求:

  • 同時不展示多於3個的通知
  • 如果已有3個通知正在展示,此時的新通知請求將排隊延遲展示。

“這個實現不了...”(全文完)

這個當然可以實現,只不過如果只用之前的 redux-thunk 實現起來會很麻煩。例如可以在 store 中增加兩個陣列分別表示當前展示列表和等待佇列,然後在 reducer 中手動控制各個狀態時這倆陣列的變化。

3.2. redux-saga 中介軟體

首先來看看使用了 redux-saga 後程式碼會變成怎樣~(程式碼來自生產環境的某 app)

function* toastSaga() {
    const MaxToasts = 3;
    const ToastDisplayTime = 4000;

    let pendingToasts = []; // 等待佇列
    let activeToasts = [];  // 展示列表

    function* displayToast(toast) {
        if ( activeToasts >= MaxToasts ) {
            throw new Error("can't display more than " + MaxToasts + " at the same time");
        }

        activeToasts = [...activeToasts, toast];      // 新增通知到展示列表
        yield put(events.toastDisplayed(toast));      // 展示通知
        yield call(delay, ToastDisplayTime);          // 通知的展示時間
        yield put(events.toastHidden(toast));         // 隱藏通知
        activeToasts = _.without(activeToasts,toast); // 從展示列表中刪除
    }

    function* toastRequestsWatcher() {
        while (true) {
            const event = yield take(Names.TOAST_DISPLAY_REQUESTED); // 監聽通知展示請求
            const newToast = event.data.toastData;
            pendingToasts = [...pendingToasts, newToast]; // 將新通知放入等待佇列
        }
    }

    function* toastScheduler() {
        while (true) {
            if (activeToasts.length < MaxToasts && pendingToasts.length > 0) {
                const [firstToast,...remainingToasts] = pendingToasts;
                pendingToasts = remainingToasts;
                yield fork(displayToast, firstToast); // 取出隊頭的通知進行展示

                // 增加一點延遲,這樣一來兩個併發的通知請求不會同時展示
                yield call(delay, 300);
            }
            else {
                yield call(delay, 50);
            }
        }
    }

    yield [
        call(toastRequestsWatcher),
        call(toastScheduler)
    ]
}

// reducer
const reducer = (state = {toasts: []}, event) => {
    switch (event.name) {
        case Names.TOAST_DISPLAYED:
            return {
                ...state,
                toasts: [...state.toasts, event.data.toastData]
            };

        case Names.TOAST_HIDDEN:
            return {
                ...state,
                toasts: _.without(state.toasts, event.data.toastData)
            };

        default:
            return state;
    }
};
複製程式碼

先不要在意程式碼的細節,簡單分析一下上述程式碼的邏輯:

  • store 上只有一個 toasts 節點,且 reducer 十分乾淨
  • 排隊等具體的業務邏輯都放到了 toastSaga 函式中
    • displayToast 函式負責單個通知的展示和消失邏輯
    • toastRequestsWatcher 函式負責監聽請求,將其加入等待佇列
    • toastScheduler 函式負責將等待佇列中的元素加入展示列表

基於這樣邏輯分離的寫法,還可以繼續滿足更加複雜的需求:

  • 如果在等待佇列中有太多通知,動態減少通知的展示時間
  • 根據視窗大小的變化,改變最多展示的通知數量
  • ...

redux-saga V.S. redux-thunk[11] redux-saga 的優點:

  • 易於測試,因為 redux-saga 中所有操作都 yield 簡單物件,所以測試只要判斷返回的物件是否正確即可,而測試 thunk 通常需要你在測試中引入一個 mockStore
  • redux-saga 提供了一些方便的輔助方法。(takeLatest、cancel、race 等)
  • 在 saga 函式中處理業務邏輯和非同步操作,這樣一來通常程式碼更加清晰,更容易增加和更改功能
  • 使用 ES6 的 generator,以同步的方式寫非同步程式碼

redux-saga 的缺點:

  • generator 的語法("又是 * 又是 yield 的,很難理解誒~")
  • 學習曲線陡峭,有許多概念需要學習("fork、join 這不是程式的概念麼?這些 yield 是以什麼順序執行的?")
  • API 的穩定性,例如新增了 channel 特性,並且社群也不是很大。

通知場景各種中介軟體寫法的完整程式碼可以看這裡

3.3. 理解 Saga Pattern[14]

3.3.1. Saga 是什麼

Sagas 的概念來源於這篇論文,該論文從資料庫的角度談了 Saga Pattern。

Saga 就是能夠滿足特定條件的長事務(Long Lived Transaction)

暫且不提這個特定條件是什麼,首先一般學過資料庫的都知道事務(Transaction)是啥~

如果不知道的話可以用轉賬來理解,A 轉給 B 100 塊錢的操作需要保證完成 A 先減 100 塊錢然後 B 加 100 塊錢這兩個操作,這樣才能保證轉賬前後 A 和 B 的存款總額不變。 如果在給 B 加 100 塊錢的過程中發生了異常,那麼就要返回轉賬前的狀態,即給 A 再加上之前減的 100 塊錢(不然錢就不翼而飛了),這樣的一次轉賬(要麼轉成功,要麼失敗返回轉賬前的狀態)就是一個事務。

3.3.2. 長事務的問題

長事務顧名思義就是一個長時間的事務。

一般來說是通過給正在進行事務操作的物件加鎖,來保證事務併發時不會出錯。

例如 A 和 B 都給 C 轉 100 塊錢。

  • 如果不加鎖,極端情況下 A 先轉給 C 100 塊,而 B 讀取到了 C 轉賬前的數值,這時 B 的轉賬會覆蓋 A 的轉賬,C 只加了 100 塊錢,另 100 塊不翼而飛了。
  • 如果加了鎖,這時 B 的轉賬會等待 A 的轉賬完成後再進行。所以 C 能正確地收到 200 塊錢。

以押尾光太郎的指彈演奏會售票舉例,在一個售票的時間段後,最終舉辦方需要確定售票數量,這就是一個長事務。

然鵝,對於長事務來說總不能一直鎖住對應資料吧?

為了解決這個問題,假設一個長事務:T,

可以被拆分成許多相互獨立的子事務(subtransaction):t_1 ~ t_n。

以上述押尾桑的表演為例,每個 t 就是一筆售票記錄。

subtransaction

假如每次購票都一次成功,且沒有退票的話,整個流程就如下圖一般被正常地執行。

success-subtransaction

那假如有某次購票失敗了怎麼辦?

3.3.3. Saga 的特殊條件

A LLT is a saga if it can be written as a sequence of transactions that can be interleaved with other transactions. Saga 就是能夠被寫成事務的序列,並且能夠在執行過程中被其他事務插入執行的長事務。

Saga 通過引入補償事務(Compensating Transaction)的概念,解決事務失敗的問題。

即任何一個 saga 中的子事務 t_i,都有一個補償事務 c_i 負責將其撤銷(undo)。

注意是撤銷該子事務,而不是回到子事務發生前的時間點。

根據以上邏輯,可以推出很簡單的公式:

  • Saga 如果全部執行成功那麼子事務序列看起來像這樣:t_1, t_2, t_3, ..., t_n

success-subtransaction

  • Saga 如果執行全部失敗那麼子事務序列看起來像這樣:t_1, t_2, t_3, ..., t_n, c_n, ..., c_1

failure-subtransaction

注意到圖中的 c_4 其實並沒有必要,不過因為每次撤銷執行都應該是冪等(Idempotent)的,所以也不會出錯。

篇幅有限在此就不繼續深入介紹...

3.4. 響應式程式設計(Reactive Programming)[15]

redux-saga 中介軟體基於 Sagas 的理論,通過監聽 action,生成對應的各種子 saga(子事務)解決了複雜非同步問題。

redux-saga

而接下來要介紹的 redux-observable 中介軟體背後的理論是響應式程式設計(Reactive Programming)。

In computing, reactive programming is a programming paradigm oriented around data flows and the propagation of change.

簡單來說,響應式程式設計是針對非同步資料流的程式設計並且認為:萬物皆流(Everything is Stream)。

everything-is-stream

流(Stream)就是隨著時間的流逝而發生的一系列事件。

例如點選事件的示意圖就是這樣。

click-stream

用字元表示【上上下下左右左右BABA】可以像這樣。(注意順序是從左往右)

--上----上-下---下----左---右-B--A-B--A---X-|->

上, 下, 左, 右, B, A 是資料流發射的值
X 是資料流發射的錯誤
| 是完成訊號
---> 是時間線
複製程式碼

那麼我們要根據一個點選流來計算點選次數的話可以這樣。(一般響應式程式設計庫都會提供許多輔助方法如 map、filter、scan 等)

  clickStream: ---c----c--c----c------c-->
                    map(c becomes 1)
               ---1----1--1----1------1-->
                         scan(+)
counterStream: ---1----2--3----4------5-->
複製程式碼

如上所示,原始的 clickStream 經過 map 後產生了一個新的流(注意原始流不變),再對該流進行 scan(+) 的操作就生成了最終的 counterStream。

再來個栗子~,假設我們需要從點選流中得到關於雙擊的流(250ms 以內),並且對於大於兩次的點選也認為是雙擊。先想一想應該怎麼用傳統的命令式、狀態式的方式來寫,然後再想想用流的思考方式又會是怎麼樣的~。

multiple-clicks-stream

這裡我們用了以下輔助方法:

  • 節流:throttle(250ms),將原始流在 250ms 內的所有資料當作一次事件發射
  • 緩衝(不造翻譯成啥比較好):buffer,將 250ms 內收集的資料放入一個資料包裹中,然後發射這些包裹
  • 對映:map,這個不解釋
  • 過濾:filter,這個也不解釋

更多內容請繼續學習 RxJS

3.5. redux-observable 中介軟體[16]

redux-observable 就是一個使用 RxJS 監聽每個 action 並將其變成可觀測流(observable stream)的中介軟體。

其中最核心的概念叫做 epic,就是一個監聽流上 action 的函式,這個函式在接收 action 並進行一些操作後可以再返回新的 action。

At the highest level, epics are “actions in, actions out”

redux-observable 通過在後臺執行 .subscribe(store.dispatch) 實現監聽。

Epic 像 Saga 一樣也是 Long Lived,即在應用初始化時啟動,持續執行到應用關閉。雖然 redux-observable 是一箇中介軟體,但是類似於 redux-saga,可以想象它就像新開的進/執行緒,監聽著 action。

redux-observable-flow

在這個執行流程中,epic 不像 thunk 一樣攔截 action,或阻止、改變任何原本 redux 的生命週期的其他東西。這意味著每個 dispatch 的 action 總會經過 reducer 處理,實際上在 epic 監聽到 action 前,action 已經被 reducer 處理過了。

所以 epic 的功能就是監聽所有的 action,過濾出需要被監聽的部分,對其執行一些帶副作用的非同步操作,然後根據你的需要可以再發射一些新的 action。

舉個自動儲存的栗子,介面上有一個輸入框,每次使用者輸入了資料後,去抖動後進行自動儲存,並在向伺服器傳送請求的過程中顯示正在儲存的 UI,最後顯示成功或失敗的 UI。

autosave

使用 redux-observable 中介軟體編寫程式碼,可以僅用十幾行關鍵程式碼就實現上述功能。

import { Observable } from 'rxjs/Observable';
import 'rxjs/add/observable/dom/ajax';
import 'rxjs/add/observable/of';
import 'rxjs/add/operator/catch';
import 'rxjs/add/operator/debounceTime';
import 'rxjs/add/operator/map';
import 'rxjs/add/operator/mergeMap';
import 'rxjs/add/operator/startWith';

import {
    isSaving, savingSuccess, savingError,
} from '../actions/autosave-actions.js';

const saveField = (action$) => // 一般在變數後面加 $ 表示是個 stream
    action$
        .ofType('SAVE_FIELD')  // 使用 ofType 監聽 'SAVE_FIELD' action
        .debounceTime(500)     // 防抖動
        // 即 map + mergeAll 因為非同步導致 map 後有多個流需要 merge
        .mergeMap(({ payload }) =>
            Observable.ajax({  // 發起請求
                method: 'PATCH',
                url: payload.url,
                body: JSON.stringify(payload),
            })
            .map(res => savingSuccess(res))                 // 發出成功的 action
            .catch(err => Observable.of(savingError(err)))  // 捕捉錯誤併發出 action
            .startWith(isSaving())                          // 發出請求開始的 action
        );

export default saveField;
複製程式碼

篇幅有限在此就不繼續深入介紹...

四、總結

本文從為 Redux 應用新增日誌功能(記錄每一次的 dispatch)入手,引出 redux 的中介軟體(middleware)的概念和實現方法。

接著從最簡單的 setTimeout 的非同步操作開始,通過對比各種實現方法引出 redux 最基礎的非同步中介軟體 redux-thunk。

針對 redux-thunk 使用時模版程式碼過多的問題,有介紹了用於優化的 redux-promise 和 redux-promise-middleware 兩款中介軟體。

由於本質上以上中介軟體都是基於 thunk 的機制來解決非同步問題,所以不可避免地將維護非同步狀態的責任推給了開發者,並且也因為難以測試的原因。在複雜的非同步場景下使用起來難免力不從心,容易出現 bug。

所以還簡單介紹了一下將處理副作用的步驟放到 reducer 中並通過 Effect 進行解決的 redux-loop 中介軟體。然鵝因為其無法使用官方 combineReducers 的原因而無法被納入 redux 核心程式碼中。

此外社群根據 Saga 的概念,利用 ES6 的 generator 實現了 redux-saga 中介軟體。雖然通過 saga 函式將業務程式碼分離,並且可以用同步的方式流程清晰地編寫非同步程式碼,但是較多的新概念和 generator 的語法可能讓部分開發者望而卻步。

同樣是基於觀察者模式,通過監聽 action 來處理非同步操作的 redux-observable 中介軟體,背後的思想是響應式程式設計(Reactive Programming)。類似於 saga,該中介軟體提出了 epic 的概念來處理副作用。即監聽 action 流,一旦監聽到目標 action,就處理相關副作用,並且還可以在處理後再發射新的 action,繼續進行處理。儘管在處理非同步流程時同樣十分方便,但對於開發者的要求同樣很高,需要開發者學習關於函式式的相關理論。

五、參考資料

  1. Redux 英文原版文件
  2. Redux 中文文件
  3. Dan Abramov - how to dispatch a redux action with a timeout
  4. 阮一峰 - Redux 入門教程(二):中介軟體與非同步操作
  5. Redux 莞式教程
  6. redux middleware 詳解
  7. Thunk 函式的含義和用法
  8. Redux非同步方案選型
  9. Sebastien Lorber - how to dispatch a redux action with a timeout
  10. Sagas 論文
  11. Pros/cons of using redux-saga with ES6 generators vs redux-thunk with ES7 async/await
  12. Redux-saga 英文文件
  13. Redux-saga 中文文件
  14. Saga Pattern 在前端的應用
  15. The introduction to Reactive Programming you've been missing
  16. Epic Middleware in Redux

以上 to be continued...

相關文章