Redux專題:中介軟體

馬蹄疾發表於2018-09-18

本文是『horseshoe·Redux專題』系列文章之一,後續會有更多專題推出

來我的 GitHub repo 閱讀完整的專題文章

來我的 個人部落格 獲得無與倫比的閱讀體驗

Redux暴露非常少的API,優雅的將單向資料流落地。但有這些,Redux的作者Dan Abramov仍然覺得遠遠不夠。一個工具的強大之處體現在它的擴充套件能力上。Redux的中介軟體機制讓這種擴充套件能力同樣變的異常優雅。

中介軟體在前端的意思是插入某兩個流程之間的一段邏輯。具體到Redux,就是在dispatch一個動作前後插入第三方的處理函式。

使用

還記得嗎?Store構造器createStore有三個引數,第三個引數叫做enhancer,翻譯過來就是增強器。我們先將enhancer按下不表,並且告訴你其實Redux的另一個APIapplyMiddleware就是一個enhancer。

import { createStore, combineReducers, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';
import logger from 'redux-logger';
import { userReducer } from './user/reducer';
import { todoReducer } from './todo/reducer';

const reducers = combineReducers({
    userStore: userReducer,
    todoStore: todoReducer,
});

const enhancer = applyMiddleware(thunk, logger);
const store = createStore(reducers, null, enhancer);

export default store;
複製程式碼

只需要把所有中介軟體依次傳入applyMiddleware,就生成了一個增強器,它們就可以發揮作用了。

如果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.');
    }
    return enhancer(createStore)(reducer, preloadedState);
}

if (typeof reducer !== 'function') {
    throw new Error('Expected the reducer to be a function.');
}
複製程式碼

伺服器請求

一個元件免不了向伺服器請求資料,然而開發者不希望元件內部有過多的邏輯,請求應該封裝成函式給元件呼叫,同時元件需要實時獲取請求的狀態以便展示不同的介面。最好的辦法就是將請求也納入Redux的管理中。

import api from './api';

export const fetchMovieAction = () => {
    dispatch({ type: 'FETCH_MOVIE_START' });
    api.fetchMovie().then(res => {
        dispatch({ type: 'FETCH_MOVIE_END', payload: { movies: res.data } });
    }).catch(err => {
        dispatch({ type: 'FETCH_MOVIE_ERROR', error: true, payload: { msg: err } });
    });
};
複製程式碼
import React, { Component } from 'react';
import { connect } from 'react-redux';

import { fetchMovieAction } from './actions';
import Card from './Card';

class App extends Component {
    render() {
        const { movies } = this.props;
        return (
            <div className="movie">
                {movies.map(movie => <Card key={movie.id} {...movies} />)}
            </div>
        );
    }

    componentDidMount() {
        this.props.fetchMovie();
    }
}

const mapState = (state) => {
    return {
        movies: state.payload.movies,
    };
};

const mapDispatch = (dispatch) => {
    return {
        fetchMovie: () => dispatch(fetchMovieAction()),
    };
};

export default connect(mapState, mapDispatch)(App);

複製程式碼

大功告成了。

只需要將請求封裝成一個函式,然後偽裝成Action被髮射出去,請求呼叫前後,真正的Action會被髮射,在Store中儲存請求的狀態,並且能夠被元件訂閱到。

非同步Action

你是不是發現了什麼?對咯,這裡的Action不是一個純物件。

因為請求一定是一個函式,為了讓請求入會,只能反過頭來修改大會章程。但是大會章程豈能隨便推翻,這時意見領袖出來說話了:

當初規定Action必須是一個純物件不是為了搞個人崇拜,而是出於實際需要。因為reducer必須是一個純函式,這決定了dispatch的引數Action必須是一個帶type欄位的純物件。現如今我們要拉非同步請求入會,而中介軟體又可以中途攔截做一些處理,那Action為什麼不能是一個函式呢?Action必須是一個純物件這種說法是完完全全的教條主義!

大家還動腦筋想出了一個非同步Action的名頭,這下函式型別的Action終於名正言順了。

閉包

你是不是還發現了什麼?對咯,請求函式中的dispatch哪去了。

不知道,可能會報錯吧(無辜臉)。

其實我們還有一件事沒幹:把dispatch方法偷渡到請求函式中。

export const fetchMovieAction = () => {
    return (dispatch) => {
        dispatch({ type: 'FETCH_MOVIE_START' });
        api.fetchMovie().then(res => {
            dispatch({ type: 'FETCH_MOVIE_END', payload: { movies: res.data } });
        }).catch(err => {
            dispatch({ type: 'FETCH_MOVIE_ERROR', error: true, payload: { msg: err } });
        });
    };
};
複製程式碼

很簡單哪,加一個閉包,dispatch從返回函式的引數中偷渡進來。

腦洞

我們要的不就是一個dispatch方法麼,我能不能這樣:

export const fetchMovie = (dispatch) => {
    dispatch({ type: 'FETCH_MOVIE_START' });
    api.fetchMovie().then(res => {
        dispatch({ type: 'FETCH_MOVIE_END', payload: { movies: res.data } });
    }).catch(err => {
        dispatch({ type: 'FETCH_MOVIE_ERROR', error: true, payload: { msg: err } });
    });
};
複製程式碼
const mapDispatch = (dispatch) => {
    return {
        fetchMovie: () => fetchMovie(dispatch),
    };
};
複製程式碼

貌似是能行得通的,只不過這時候請求函式已經不能叫Action了。考慮到之前請求函式偽裝成Action渾水摸魚,還要插入中介軟體來幫助特殊處理,我們這樣做也不過分是吧。

好處就是不再需要能夠處理非同步Action的中介軟體了。

壞處就是這不符合規範,是我的腦洞,闖了禍不要打我(蔑視)。

redux-thunk

前面多次提到處理非同步Action的中介軟體,到底是何方神聖?

市面上流行的方案有很多種,我們挑最簡單的一種來說一說(都不點贊怪我咯)。

redux-thunk算是Redux官方出品的非同步請求中介軟體,但是它沒有整合到Redux中,原因還是為了擴充套件性,社群可以提出各種方案,開發者各取所需。

讓我們來探討一下redux-thunk的思路:原來Action只有一種,就是純物件,現在Action有兩種,純物件和非同步請求函式。只不過多了一種情況,不算棘手嘛。如果Action是一個物件,不為難它直接放走;如果Action是一個函式,就地執行,呼叫非同步請求前後,真正的Action自然會釋放出來,又回到第一步,放它走。

這是redux-thunk簡化後的程式碼,其實原始碼也跟這差不多。是不是很恐慌?

const thunk = ({ dispatch, getState }) => next => action => {
    if (typeof action === 'function') {
        return action(dispatch, getState);
    }
    return next(action);
};
複製程式碼

上面的函式就部署在我的個人部落格中用來處理非同步請求,完全沒有問題。既然它這麼簡單,而且可以預計它萬年不會變,那我為什麼要憑空多一個依賴包。就將它放在我的眼皮底下不是挺好的嘛。

不過,它是一個研究中介軟體很好的範本。

我們先將thunk先生降級成普通函式的寫法:

const thunk = function({ dispatch, getState }) {
    return function(next) {
        return function(action) {
            if (typeof action === 'function') {
                return action(dispatch, getState);
            }
            return next(action);
        }
    }
};
複製程式碼

compose

我知道compose是Redux的五大護法之一,可為什麼挑在這個時候講它呢?

先不告訴你。

compose在函數語言程式設計中的含義是組合。假如你有一堆函式要依次執行,而且上一個函式的返回結果是下一個函式的引數,我們怎樣寫看起來最裝逼?

const result = a(b(c(d(e('redux')))));
複製程式碼

這種寫法讓人一眼就看穿了呼叫細節,裝逼明顯是不夠的。

我們來看Redux是怎麼實現compose的:

export default function compose(...funcs) {
    if (funcs.length === 0) {
        return arg => arg;
    }
    if (funcs.length === 1) {
        return funcs[0];
    }
    return funcs.reduce((a, b) => (...args) => a(b(...args)));
}
複製程式碼

誒,我看見reduce了,然後...就沒有然後了。

假設我們現在有三個函式:

const funcA = arg => console.log('funcA', arg);
const funcB = arg => console.log('funcB', arg);
const funcC = arg => console.log('funcC', arg);
複製程式碼

執行reduce的第一步返回的accumulator(accumulator是reduce中的概念),結果顯而易見:

(...args) => funcA(funcB(...args));
複製程式碼

執行reduce的第二步返回的accumulator,注意到,這時reduce已經執行完了,返回的是一個函式。

(...args) => funcA(funcB(funcC(...args)));
複製程式碼

特別提醒:執行compose最終返回的是一個函式。也就是說開發者得這麼幹compose(a, b, c)()才能讓傳入的函式依次執行。

另外需要注意的是:傳入的函式是從右到左依次執行的。

applyMiddleware

廢話少說,先上原始碼:

export default function applyMiddleware(...middlewares) {
    return createStore => (...args) => {
        const store = createStore(...args);
        let dispatch = () => {
            throw new Error(
                `Dispatching while constructing your middleware is not allowed. ` +
                `Other middleware would not be applied to this dispatch.`
            );
        };
        const middlewareAPI = {
            getState: store.getState,
            dispatch: (...args) => dispatch(...args),
        };
        const chain = middlewares.map(middleware => middleware(middlewareAPI));
        dispatch = compose(...chain)(store.dispatch);
        return { ...store, dispatch };
    }
}
複製程式碼

還記得中介軟體閉包好幾層的寫法嗎?現在我們就來一層一層的剝開它。

middlewareAPI是一個物件,正好是傳給第一層中介軟體函式的引數。執行它,返回的chain是由第二層函式組成的中介軟體陣列。貼一下redux-thunk第二層轉正後的樣子:

function(next) {
    return function(action) {
        if (typeof action === 'function') {
            return action(dispatch, getState);
        }
        return next(action);
    }
}
複製程式碼

中介軟體第二層函式接收一個next引數,那這個next具體指什麼呢?我先透露一下,next是整個Redux中介軟體機制的題眼,理解了next就可以對Redux中介軟體的理解達到大徹大悟的化境。

之前我們已經拆解了compose的內部機制,從右到左執行,最右邊的中介軟體的引數就是store.dispatch,它返回的值就是倒數第二個中介軟體的next。它返回什麼呢?我們再剝一層:

function(action) {
    if (typeof action === 'function') {
        return action(dispatch, getState);
    }
    return next(action);
}
複製程式碼

別看redux-thunk麻雀雖小,大家發現沒有,第三層函式才是它的邏輯,前面兩層都是配合redux的演出。也就是說呀同學們,除了最後一箇中介軟體的next是原始的dispatch之外,倒數往前的中介軟體傳入的next都是上一個中介軟體的邏輯函式。

Redux中介軟體本質上是將dispatch套上一層自己的邏輯。

最終applyMiddleware裡得到的這個dispatch是經過無數中介軟體精心包裝,植入了自己的邏輯的dispatch。然後用這個臃腫的dispatch覆蓋原有的dispatch,將Store的API返回。

每一個Action就是這樣穿過重重的邏輯程式碼才能最後被髮射成功。只不過處理非同步請求的中介軟體不再往下走,直到非同步請求發生,真正的Action被髮射出來,才會走到下一個中介軟體的邏輯。

構建dispatch過程中禁止執行dispatch

middlewareAPI中的dispatch為什麼是一個丟擲錯誤的函式?

我們現在已經知道,applyMiddleware的目的只有一個:用所有中介軟體組裝成一個超級dispatch,並將它覆蓋原生的dispatch。但是如果超級dispatch還沒組裝完成,就被中介軟體呼叫了原生的dispatch,那這遊戲別玩了。

所以Redux來了一手掉包。

middlewareAPI初始傳入的dispatch是一個炸彈,中介軟體的開發者膽敢在頭兩層閉包函式的外層作用域呼叫dispatch,炸彈就會引爆。而一旦超級dispatch構建完成,這個超級dispatch就會替換掉炸彈。

怎麼替換呢?

函式也是引用型別對吧,炸彈dispatch之所以用let定義,就是為了將來修改它的引用地址:

let dispatch = () => {
    throw new Error(
        `Dispatching while constructing your middleware is not allowed. ` +
        `Other middleware would not be applied to this dispatch.`
    );
};
// ...
dispatch = compose(...chain)(store.dispatch);
複製程式碼

當然,這是對中介軟體開發者的約束,如果你只是一箇中介軟體的使用者,這無關緊要。

applyMiddleware的花式呼叫

我們注意到,執行applyMiddleware返回的是一個函式,這個函式有唯一的引數createStore。

WTF?

applyMiddleware不是createStore的引數之一麼:

const store = createStore(reducer, applyMiddleware(middleware1, middleware2, middleware3));
複製程式碼

怎麼createStore也成了applyMiddleware的引數了?

貴圈真亂。

首先我們明確一點,applyMiddleware是一個增強器,增強器是需要改造Store的API的,這樣才能達到增強Store的目的。所以applyMiddleware必須傳入createStore以生成初始的Store。

所以生成一個最終的Store其實可以這樣寫:

const enhancedCreateStore = applyMiddleware(middleware1, middleware2, middleware3)(createStore);
const store = enhancedCreateStore(reducer);
複製程式碼

那通常的那種寫法,Redux內部是怎麼處理的呢?

if (typeof enhancer !== 'undefined') {
    if (typeof enhancer !== 'function') {
        throw new Error('Expected the enhancer to be a function.')
    }
    return enhancer(createStore)(reducer, preloadedState)
}
複製程式碼

上面是createStore原始碼中的一段。

如果enhancer存在並且是一個函式,那麼直接傳入createStore執行,再傳入reducer和preloadedState執行(這時候再傳入enhancer就沒完沒了了),然後直接返回。

喵,後面還有好多程式碼呢,怎麼就返回了?

不,就這麼任性。

這麼看下來,以下寫法才是正宗的Redux:

const store = applyMiddleware(middleware1, middleware2, middleware3)(createStore)(reducer);
複製程式碼

以下寫法只是Redux為開發者準備的語法糖:

const store = createStore(reducer, applyMiddleware(middleware1, middleware2, middleware3));
複製程式碼

洋蔥圈模型

想必大家都聽說過中介軟體的洋蔥圈模型,這個比喻非常形象,乍聽上去,啊,好像明白了。但是大家真的對洋蔥圈模型有一個具象化的理解嗎?

假設現在有三個中介軟體:

const middleware1 = ({ dispatch, getState }) => next => action => {
    console.log('middleware1 start');
    next(action);
    console.log('middleware1 end');
}

const middleware2 = ({ dispatch, getState }) => next => action => {
    console.log('middleware2 start');
    next(action);
    console.log('middleware2 end');
}

const middleware3 = ({ dispatch, getState }) => next => action => {
    console.log('middleware3 start');
    next(action);
    console.log('middleware3 end');
}
複製程式碼

現在將它傳入applyMiddleware:

function reducer(state = {}, action) {
    console.log('reducer return state');
    return state;
}

const middlewares = [middleware1, middleware2, middleware3];
const store = createStore(reducer, applyMiddleware(...middlewares));
複製程式碼

我們看一下列印的結果:

middleware1 start
middleware2 start
middleware3 start
reducer return state
middleware3 end
middleware2 end
middleware1 end
複製程式碼

對結果感到驚訝嗎?其實理解函式呼叫棧的同學就能明白為什麼是這樣的結果。reducer執行之前也就是dispatch真正執行之前的日誌好理解,dispatch被一層一層包裝,一層一層的深入呼叫。但是dispatch執行完以後呢?這時候的執行權在呼叫棧最深的那一層邏輯那裡,也就是最接近原始dispatch的邏輯函式那裡,所以之後的執行順序是從最深處往上呼叫。

總的看下來,一個Action的更新Store之旅就像穿過一個洋蔥圈的旅行。一堆中介軟體簇擁著Action鑽到洋蔥的中心,Action執行自己的使命更新Store後就地圓寂,然後中介軟體帶著它的遺志再從洋蔥的中心鑽出來。

回看compose

其實我解釋上面的列印日誌,還有一個關節沒有打通。

記得applyMiddleware的原始碼嗎?內部呼叫了compose來執行chain。

我們強調過,compose的函式型別引數的執行順序是從右到左的,我相信大家在不少的地方都見到過這樣的表述。但是大家想過沒有,為什麼要從右到左執行?原生JavaScript除了實現reduce之外還有一個reduceRight,從左到右執行並沒有什麼技術障礙,那麼為什麼要讓執行順序這麼彆扭呢?

答案就在上面的列印日誌裡。

列印日誌很好哇,根據傳入的順序執行。對,執行compose是從右到左,但是compose返回的終極dispatch是一層一層從外面包裹的呀,最後一箇中介軟體也就是最左邊的中介軟體的邏輯,包裹在最外面一層,自然它的日誌最先被列印出來。

所以compose被設計成引數從右到左執行,不是有技術障礙,也不是Redux特立獨行,而是其中本來就要經歷一次反轉,compose只有再反轉一次才能將它扭轉過來。

Redux專題一覽

考古

實用

中介軟體

時間旅行

相關文章