一起學習造輪子(二):從零開始寫一個Redux

JAVASCRIPT發表於2018-06-21

本文是一起學習造輪子系列的第二篇,本篇我們將從零開始寫一個小巧完整的Redux,本系列文章將會選取一些前端比較經典的輪子進行原始碼分析,並且從零開始逐步實現,本系列將會學習Promises/A+,Redux,react-redux,vue,dom-diff,webpack,babel,kao,express,async/await,jquery,Lodash,requirejs,lib-flexible等前端經典輪子的實現方式,每一章原始碼都託管在github上,歡迎關注~
相關係列文章:
一起學習造輪子(一):從零開始寫一個符合Promises/A+規範的promise
一起學習造輪子(二):從零開始寫一個Redux
一起學習造輪子(三):從零開始寫一個React-Redux
本系列github倉庫:
一起學習造輪子系列github(歡迎star~)

前言

Redux是JavaScript狀態容器,提供可預測化的狀態管理。本文將會詳細介紹Redux五個核心方法 createStore,applyMiddleware,bindActionCreators,combineReducers,compose的實現原理,最後將自己封裝一個小巧完整的redux庫,隨後會介紹一下經常與Redux一起結合使用的Redux常用中介軟體redux-logger,redux-thunk,redux-promise等中介軟體的實現原理。

本文對於Redux是什麼及Redux幾個核心方法如何使用只會做簡單介紹,如果還沒用過Redux建議先學習基礎知識。

推薦文章:
Redux 入門教程(一):基本用法
Redux 入門教程(二):中介軟體與非同步操作
Redux 入門教程(三):React-Redux 的用法

本文所有程式碼在github建有程式碼倉庫,可以點此檢視本文程式碼,也歡迎大家star~

開始

createStore

首先,我們先來看一種使用Redux的基礎場景:

function reducer(state, action) {}

const store = createStore(reducer) //用reducer生成了store

store.subscribe(() => renderApp(store.getState())) //註冊state變化的回撥

renderApp(store.getState()) //初始化頁面

store.dispatch(xxxaction) //發出action
複製程式碼

上面程式碼是一個用到Redux的基礎場景,首先定義了一個reducer,然後用這個reducer生成了store,在store上註冊當state發生變化後要執行的回撥函式,然後使用初始state先渲染一下頁面,當頁面有操作時,store.dispatch發出一個action,action和舊的state經過reducer計算生成新的state,此時state變化,觸發回撥函式使用新的state重新渲染頁面,這個簡單的場景囊括了整個redux工作流, 如圖所示:

redux工作流

這個場景主要用到Redux裡面的createStore方法,這是Redux裡最核心的方法,下面我們簡單實現一下這個方法。

function createStore(reducer) {
    let state = null //用來儲存全域性狀態
    let listeners = [] //用來儲存狀態發生變化的回撥函式陣列

    const subscribe = (listener) => { //用來註冊回撥函式
        listeners.push(listener)
    }
    const getState = () => state //用來獲取最新的全域性狀態
    const dispatch = (action) => { //用來接收一個action,並利用reducer,根據舊的state和action計算出最新的state,然後遍歷回撥函式陣列,執行回撥.
        state = reducer(state, action) //生成新state
        listeners.forEach((listener) => listener()) //執行回撥
    }

    dispatch({}) //初始化全域性狀態
    return { getState, dispatch, subscribe } //返回store物件,物件上有三個方法供外部使用
}
複製程式碼

其實實現這個方法並不複雜

  1. 首先,定義2個變數,一個是state,一個是listeners,state用來存放全域性狀態,listeners用來儲存狀態發生變化的回撥函式陣列。
  2. 然後定義三個方法subscribe,getState,dispatch。subscribe用於註冊回撥函式,getState用來獲取最新的state狀態,dispatch用來接收一個action,並利用reducer,根據舊的state和action計算出最新的state,然後遍歷回撥函式陣列,執行回撥。
  3. 當呼叫createStore時,會先執行dispatch({})利用reducer生成一個初始state,然後返回一個store物件,物件上掛載著getState, dispatch, subscribe這三個方法供外部呼叫

經過以上三步,我們便實現了一個簡單的createStore方法。

combineReducers

我們在開發稍微大一些的專案時reducer一般有多個,我們會一般會建立一個reducers資料夾,裡面儲存專案中用到的所有reducer,然後使用一個combineReducers方法將所有reducer合併成一個傳給createStore方法。

import userInfoReducer from './userinfo.js'
import bannerDataReducer from './banner.js'
import recordReducer from './record.js'
import clientInfoReducer from './clicentInfo.js'

const rootReducer = combineReducers({
    userInfoReducer,
    bannerDataReducer,
    recordReducer,
    clientInfoReducer
})

const store = createStore(rootReducer)
複製程式碼

接下來,我們就一起來實現combineReducers這個方法:

const combineReducers = reducers => (state = {}, action) => {
    let currentState = {};
    for (let key in reducers) {
        currentState[key] = reducers[key](state[key], action);
    }
    return currentState;
};
複製程式碼
  1. 首先combineReducers這個函式接收一個reducer集合,返回一個合併後的reducer函式,所以返回的函式傳參仍然和平常的reducer一樣,接收state和action,返回新的state。
  2. 然後宣告一個currentState物件,用來儲存全域性狀態,接著遍歷reducers陣列,使用reducer函式生成對應的state物件掛載到currentState上。 比如說reducers裡傳入了2個reducer{userInfoReducer,bannerDataReducer},userInfoReducer裡state本來是這樣:{userId:1,name:"張三"},而bannerDataReducer裡的state本來是{pictureId:1,pictureUrl:"http://abc.com/1.jpg"} 合併以後的currentState變為
{
    userInfoReducer: {
        userId: 1,
        name: "張三"
    },
    bannerDataReducer: {
        pictureId: 1,
        pictureUrl: "http://abc.com/1.jpg"
    }
}
複製程式碼

到此我們實現了第二個方法combineReducers。

bindActionCreators

接下來介紹bindActionCreators這個方法,這是redux提供的一個輔助方法,能夠讓我們以方法的形式來呼叫action。同時,自動dispatch對應的action。它接收2個引數,第一個引數是接收一個action creator,第二個引數接收一個 dispatch 函式,由 Store 例項提供。

比如說我們有一個TodoActionCreators

export function addTodo(text) {
    return {
      type: 'ADD_TODO',
      text
    };
}
export function removeTodo(id) {
   return {
     type: 'REMOVE_TODO',
     id
   };
}
複製程式碼

我們之前需要這樣使用:

import * as TodoActionCreators from './TodoActionCreators';

let addReadAction = TodoActionCreators.addTodo('看書');
dispatch(addReadAction);

let addEatAction = TodoActionCreators.addTodo('吃飯');
dispatch(addEatAction);

let removeEatAction = TodoActionCreators.removeTodo('看書');
dispatch(removeEatAction);
複製程式碼

現在只需要這樣:

import * as TodoActionCreators from './TodoActionCreators';
let TodoAction = bindActionCreators(TodoActionCreators, dispatch);

TodoAction.addTodo('看書')
TodoAction.addTodo('吃飯')
TodoAction.removeTodo('看書')
複製程式碼

好了,說完了如何使用,我們來實現一下這個方法

function bindActionCreator(actions, dispatch) {
    let newActions = {};
    for (let key in actions) {
        newActions[key] = () => dispatch(actions[key].apply(null, arguments));
    }
    return newActions;
}
複製程式碼

方法實現也不難,就是遍歷ActionCreators裡面的所有action,每個都使用一個函式進行包裹dispatch行為並將這些函式掛載到一個物件上對外暴露,當我們在外部的呼叫這個函式的時候,就會自動的dispatch對應的action,這個方法的實現其實也是利用了閉包的特性。

這個方法在使用react-redux裡面經常見到,等講react-redux實現原理時會再說一下。

compose

最後,還剩兩個方法,一個是compose,一個是applyMiddleware,這兩個都是使用redux中介軟體時要用到的方法,先來說說compose這個方法,這是一個redux裡的輔助方法,其作用是把一系列的函式,組裝生成一個新的函式,並且從後到前依次執行,後面函式的執行結果作為前一個函式執行的引數。

比如說我們有這樣幾個函式:

function add1(str) {
    return str + 1
}

function add2(str) {
    return str + 2
}

function add3(str) {
    return str + 3
}
複製程式碼

我們想依次執行函式,並把執行結果傳到下一層就要像下面一樣一層套一層的去寫:
let newstr = add3(add2(add1("abc"))) //"abc123"
這只是3個,如果數量多了或者數量不固定處理起來就很麻煩,但是我們用compose寫起來就很優雅:

let newaddfun = compose(add3, add2, add1);
let newstr = newaddfun("abc") //"abc123"
複製程式碼

那compose內部是如何實現的呢?

function compose(...funcs) {
    return funcs.reduce((a, b) => (...args) => a(b(...args)));
}
複製程式碼

其實核心程式碼就一句,這句程式碼使用了reduce方法巧妙地將一系列函式轉為了add3(add2(add1(...args)))這種形式,我們使用上面的例子一步一步地拆分看一下,當呼叫compose(add3, add2, add1),funcs是add3, add2, add1,第一次進入時a是add3,b是add2,展開就是這樣子:(add3, add2)=>(...args)=>add3(add2(...args)),傳入了add3, add2,返回一個這樣的函式(...args)=>add3(add2(...args)),然後reduce繼續進行,第二次進入時a是上一步返回的函式(...args)=>add3(add2(...args)),b是add1,於是執行到a(b(...args)))時,b(...args)作為a函式的引數傳入,變成了這種形式:(...args)=>add3(add2(add1(...args))),是不是很巧妙。

applyMiddleware

最後我們來看最後一個方法applyMiddleware,我們在redux專案中,使用中介軟體時一般這樣寫:

import thunk from 'redux-thunk'
import logger from 'redux-logger'
const middleware = [thunk, logger]
const store = createStore(rootReducer, applyMiddleware(...middleware))
複製程式碼

上面我們用到了thunk和logger這兩個中介軟體,在createStore建立倉庫時傳入一個新的引數applyMiddleware(...middleware),在此告訴redux我們要使用的中介軟體,所以我們要先改造一下createStore方法,讓其支援中介軟體引數的傳入。

function createStore(reducer, enhancer) {
    //如果傳入了中介軟體函式,使用中介軟體增強createStore方法
    if (typeof enhancer === 'function') {
        return enhancer(createStore)(reducer)
    }
    let state = null
    const listeners = []
    const subscribe = (listener) => {
        listeners.push(listener)
    }
    const getState = () => state
    const dispatch = (action) => {
        state = reducer(state, action)
        listeners.forEach((listener) => listener())
    }
    dispatch({})
    return { getState, dispatch, subscribe }
}
複製程式碼

然後接下來以redux-logger中介軟體為例來分析一下redux中介軟體的實現方式。 首先我們可以先思考一下,如果我們不用logger中介軟體,想實現logger的功能該怎樣做呢?

let store = createStore(reducer);
let dispatch = store.dispatch;
store.dispatch = function (action) {
  console.log(store.getState());
  dispatch(action);
  console.log(store.getState())
};
複製程式碼

我們可以在原始dispatch方法外面包裝一層函式,讓發起真正的dispatch之前和之後都列印一下日誌,呼叫時呼叫包裝後的這個dispatch函式,其實redux中介軟體原理的思路就是這樣的:將store的dispatch進行替換,換成一個功能增強了但是仍然具有dispach功能的新函式。

那applyMiddleware方法裡是如何改造dispatch來增強功能的呢?首先我們來看個簡單版本,假如我們只有一箇中介軟體,如何實現applyMiddleware方法呢?

function applyMiddleware(middleware) {
    return function a1(createStore) {
        return function a2(reducer) {
            //取出原始dispatch方法
            const store = createStore(reducer)
            let dispatch = store.dispatch

            //包裝dispatch
            const middlewareAPI = {
                getState: store.getState,
                dispatch: (action) => dispatch(action)
            }
            let mid = middleware(middlewareAPI)
            dispatch = mid(store.dispatch)
            
            //使用包裝後的dispatch覆蓋store.dispatch返回新的store物件
            return {
                ...store,
                dispatch
            }
        }
    }
}
//中介軟體
let logger = function({ dispatch, getState }) {
        return function l1(next) {
            return function l2(action) {
                console.log(getState());
                next(action)
                console.log(getState())
            }
        }
    }
//reducer函式
function reducer(state, action) {
    if (!state) state = {
        count: 0
    }
    console.log(action)
    switch (action.type) {
        case 'add':
            let obj = {...state,
                count: ++state.count
            }
            return obj;
        case 'sub':
            return {...state,
                count: --state.count
            }
        default:
            return state
    }
}

const store = createStore(reducer, applyMiddleware(logger))
複製程式碼
  1. 首先我們定義了的applyMiddleware方法,它接收一箇中介軟體作為引數。然後定義了一個logger中介軟體函式,它接收dispatch和getState方法以供內部使用。這兩個函式Redux原始碼裡都是使用高階函式實現的,在這裡與原始碼保持一致也使用高階函式實現,但是為了方便理解,使用具名的function函式代替匿名箭頭函式可以看得更清晰。

  2. 當我們執行const store = createStore(reducer,applyMiddleware(logger))時,首先applyMiddleware(logger)執行,將logger存在閉包裡,然後返回了一個接收createStore方法的函式a1,將a1這個函式作為第二個引數傳入createStore方法,因為傳入了第二個引數,所以createstore裡面其實會執行這一段程式碼:

if (typeof enhancer === 'function') {
    return enhancer(createStore)(reducer)
}
複製程式碼

當執行return enhancer(createStore)(reducer),其實執行的是a1(createStore)(reducer),當執行a1(createStore)時返回a2,最後return的是a2(reducer)的執行結果。

  1. 然後,我們看看a2內部都做了些什麼,我給這個函式定義了三個階段,首先為取出原始dispatch階段,這一階段執行createStore(reducer)方法,並拿出原始的dispatch方法。

  2. 接著,我們到了第二個階段包裝原始dispatch,首先我們定義了middlewareAPI用來給中介軟體函式使用,這裡的getState直接使用了store.getState,而dispatch使用函式包了一層,(action)=>dispatch(action),為什麼呢,因為我們最終要給中介軟體使用的dispatch方法,一定是經過各種中介軟體包裝後的dispatch方法,而不是原方法,所以我們這裡將dispatch方法設定為一個變數。然後將middlewareAPI傳入middleware執行,返回一個函式mid(也就是logger裡面的l1),這個函式接收一個next方法作為引數,然後當我們執行dispatch = mid(store.dispatch)時,將store.dispatch作為next方法傳入,並把返回的函式l2作為新的dispatch,我們可以看到新的dispatch方法其實裡面做了和我們上面自己直接改造store.dispatch做了同樣的事情:

function l2(action) {
    console.log(getState());
    next(action)
    console.log(getState())
}
複製程式碼

都是接收一個action,先列印日誌,然後執行原始的dispatch方法去發一個action,然後再列印日誌。

  1. 最後到了第三個階段:使用包裝後的dispatch覆蓋store.dispatch方法後返回新的store物件。

  2. 到此,當我們在外面執行store.dispatch({type:add})時,實際上執行的是包裝後的dispatch方法,所以logger中介軟體就生效了,如圖所示真正發起dispatch的前後都列印出了最新狀態:

示例
現在我們在上一版applyMiddleware的基礎上再改造,使其支援多箇中介軟體:

import compose from './compose';

function applyMiddleware(...middlewares) {
    return function a1(createStore) {
        return function a2(reducer) {
            const store = createStore(reducer)
            let dispatch = store.dispatch
            let chain = []

            const middlewareAPI = {
                getState: store.getState,
                dispatch: (action) => dispatch(action)
            }
            chain = middlewares.map(middleware => middleware(middlewareAPI))
            dispatch = compose(...chain)(store.dispatch)

            return {
                ...store,
                dispatch
            }
        }
    }
}

let loggerone = function({ dispatch, getState }) {
    return function loggerOneOut(next) {
        return function loggerOneIn(action) {
            console.log("loggerone:", getState());
            next(action)
            console.log("loggerone:", getState())
        }

    }
}
let loggertwo = function({ dispatch, getState }) {
    return function loggerTwoOut(next) {
        return function loggerTwoIn(action) {
            console.log("loggertwo:", getState());
            next(action)
            console.log("loggertwo:", getState())
        }
    }
}
const store = createStore(reducer, applyMiddleware([loggertwo, loggerone]))
複製程式碼
  1. 首先當呼叫applyMiddleware方法時,由傳入一箇中介軟體變為傳入一箇中介軟體陣列。

  2. 然後我們在applyMiddleware方法中維護一個chain陣列,這個陣列用於儲存中介軟體鏈。

  3. 當執行到 chain = middlewares.map(middleware => middleware(middlewareAPI))時,chain裡面存放的是[loggerTwoOut,loggerOneOut]

  4. 然後下一步我們改造dispatch時用到了我們之前講過的compose方法,dispatch=compose(...chain)(store.dispatch)其實相當於是執行了dispatch =loggerTwoOut(loggerOneOut(store.dispatch)),然後這一句loggerTwoOut(loggerOneOut(store.dispatch))再次拆開看一下是如何執行的,當執行loggerOneOut(store.dispatch),返回loggerOneIn函式,並將store.dispatch方法作為loggerOneIn裡面的next方法。現在函式變成了這樣:loggerTwoOut(loggerOneIn),當執行這一句時,返回loggerTwoIn函式,並將loggerOneIn作為loggerTwoIn方法裡的next方法。最後給dispatch賦值:dispatch =loggerTwoIn

  5. 在外部我們呼叫store.dispatch({type:add})時,實際執行的是loggerTwoIn({type:add}),所以會先執行 console.log("loggertwo:", getState()),然後執行next(action)時執行的其實是loggerOneIn(action),進入到loggerOneIn內部,所以會執行console.log("loggerone:",getState());然後執行next(action),這裡的其實執行的是原始的store.dispatch方法,所以會真正的把action提交,提交完後繼續執行,執行console.log("loggerone:",getState()),然後loggerOneIn執行完畢,執行權交還到上一層loggerTwoIn,loggerTwoIn繼續執行,執行console.log("loggertwo:", getState()),結束。

一起學習造輪子(二):從零開始寫一個Redux
畫一張圖形象的表示下執行流程:

流程圖

到此,applymiddleware方法就講完了,我們來看下redux官方原始碼的實現:

function applyMiddleware(...middlewares) {
    return (createStore) => (reducer, preloadedState, enhancer) => {
        const store = createStore(reducer, preloadedState, enhancer)
        let dispatch = store.dispatch
        let chain = []

        const middlewareAPI = {
            getState: store.getState,
            dispatch: (action) => dispatch(action)
        }
        chain = middlewares.map(middleware => middleware(middlewareAPI))
        dispatch = compose(...chain)(store.dispatch)

        return {
            ...store,
            dispatch
        }
    }
}
複製程式碼

我們實現的applyMiddleware方法對比官方除了沒有對前後端同構時預取資料preloadedState做支援外,其餘功能都完整實現了。 到此我們把redux裡所有方法都實現了一遍,當然我們實現的只是每個方法最核心最常用的部分,並沒有將redux原始碼逐字逐句去翻譯。因為個人認為對於原始碼的學習應該抓住主線,學習原始碼中的核心程式碼及閃光點,如果對redux其他功能感興趣的,可以自行看官方原始碼學習。

常用中介軟體 redux-logger,redux-thunk,redux-promise

接下來,我們將redux常用的三個中介軟體來實現一下

redux-logger

let logger = function({ dispatch, getState }) {
        return function(next) {
            return function(action) {
                console.log(getState());
                next(action)
                console.log(getState())
            }
        }
    }
複製程式碼

這個我們上面講applyMiddleware時已經講過了,不再多說。

redux-thunk

redux-thunk在我們平常使用時主要用來處理非同步提交action情況,引入了redux-thunk後我們可以非同步提交action

const fetchPosts = postTitle => (dispatch, getState) => {
    dispatch(requestPosts(postTitle));
    return fetch(`/some/API/${postTitle}.json`)
        .then(response => response.json())
        .then(json => dispatch(receivePosts(postTitle, json)));
};
store.dispatch(fetchPosts('reactjs'))
複製程式碼

我們可以看到fetchPosts('reactjs')返回的是一個函式,而redux裡的dispatch方法不能接受一個函式,Redux官方原始碼中明確說了,action必須是一個純粹的物件,處理非同步action時需要使用中介軟體,

function dispatch(action) {
    if (!isPlainObject(action)) {
        throw new Error(
            'Actions must be plain objects. ' +
            'Use custom middleware for async actions.'
        )
    }
    ......
}
複製程式碼

那redux-thunk到底做了什麼使dispatch可以傳入函式呢?

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

thunk中介軟體在內部進行判斷,如果傳入了一個函式,就去執行它,不是函式就不管交給下一個中介軟體,以上面的fetchPosts為例,當執行store.dispatch(fetchPosts('reactjs'))時,給dispatch傳入了一個函式:

postTitle => (dispatch, getState) => {
    dispatch(requestPosts(postTitle));
    return fetch(`/some/API/${postTitle}.json`)
        .then(response => response.json())
        .then(json => dispatch(receivePosts(postTitle, json)));
};
複製程式碼

thunk中介軟體發現是個函式,於是執行它,先發出一個Action(requestPosts(postTitle)),然後進行非同步操作。拿到結果後,先將結果轉成 JSON 格式,然後再發出一個Action(receivePosts(postTitle,json))。這兩個Action都是普通物件,所以當dispatch時會走else {next(action);}這個分支,繼續執行.這樣就解決了dispatch不能接受函式的問題。

redux-promise

最後講一個redux-promise中介軟體.dispatch目前可以支援傳入函式了,利用redux-promise我們再讓它支援傳入promise物件,平時我們在用這個中介軟體時,一般有兩種用法: 寫法一,返回值是一個 Promise 物件。

const fetchPosts =
    (dispatch, postTitle) => new Promise(function(resolve, reject) {
        dispatch(requestPosts(postTitle));
        return fetch(`/some/API/${postTitle}.json`)
            .then(response => {
                type: 'FETCH_POSTS',
                payload: response.json()
            });
    });
複製程式碼

寫法二,Action 物件的payload屬性是一個Promise物件。這需要從redux裡引入createAction方法,並且寫法也要變成下面這樣。

import { createAction } from 'redux-actions';

class AsyncApp extends Component {
    componentDidMount() {
        const { dispatch, selectedPost } = this.props
            // 發出同步 Action
        dispatch(requestPosts(selectedPost));
        // 發出非同步 Action
        dispatch(createAction(
            'FETCH_POSTS',
            fetch(`/some/API/${postTitle}.json`)
            .then(response => response.json())
        ));
    }
}
複製程式碼

讓我們來實現一下redux-promise中介軟體:

let promise = function({ getState, dispatch }) {
        return function(next) {
            return function(action) {
                if (action.then) {
                    action.then(dispatch);
                } else if (action.payload && action.payload.then) {
                    action.payload.then(payload => dispatch({...action, payload }), payload => dispatch({...action, payload }));
                } else {
                    next(action);
                }
            }
        }
    }
複製程式碼

我們實現redux-thunk時是判斷如果傳入function就執行這個function,否則next(action)繼續執行;redux-promise同理,當action或action的payload上面有then方法時,我們認為它是promise物件,就讓dispatch到promise的then裡面再執行,直到dispatch提交的action沒有then方法,我們認為它不是promise了,可以執行next(action)交給下一個中介軟體執行了。

最後

本篇介紹了Redux五個方法createStore,applyMiddleware,bindActionCreators,combineReducers,compose的實現原理,並自己封裝了一個小巧完整的Redux庫,同時簡單介紹了Redux裡常用的3箇中介軟體redux-logger,redux-thunk,redux-promise的實現原理,本文所有程式碼在github建有程式碼倉庫,可以點選檢視本文原始碼

與Redux相關的比較經典的輪子還有React-Redux和redux-saga,因本文篇幅現在已經很長,所以這兩個輪子的實現將放到後續的一起學習造輪子系列中,敬請關注~

相關文章