實現一個Redux(完善版)

JackySummer發表於2020-05-14

上次我們已經寫了 實現一個迷你Redux(基礎版) ,這次我們來繼續完善Redux,繼續上篇的例子續寫。

中介軟體

Redux 有個 API 是 applyMiddleware, 專門用來使用中介軟體的,首先我們得知道,它用來幹嘛的。

為什麼會需要中介軟體?

假設我們現在需要記錄每次的 dispatch 前後 state 的記錄, 那要怎麼做呢?於是,簡單粗暴的在第一個 dispatch 方法前後加程式碼

console.log('prev state', store.getState())
console.log(action)
store.dispatch({ type: 'INCREMENT' })
console.log('next state', store.getState())

這部分執行結果:

prev state {value: 10}
{type: "INCREMENT"}
當前數字為:11
next state {value: 11}

但加完發現情況不對,頁面有多個 dispatch 的話,要這樣寫很多次,會產生大量重複程式碼。突然,又要加需求了,需要記錄每次出錯的原因,單獨的功能要求如下:

try{
    store.dispatch(action)   
}catch(err){
    console.error('錯誤資訊: ', err)  
}

然後兩個需求都要,那就湊合兩個,但疊一起看更亂了。

中介軟體的概念

顯然,我們不能通過這種方式來做。比較理想的方案是Redux本身提供一個功能入口,讓我們可以在外面新增功能進去,這樣程式碼就不會複雜。

但如果給我們現有實現的Redux新增功能,在哪個環節新增比較合適呢?

  • Reducer: 純函式,只承擔計算 State 的功能,不合適承擔其他功能,也承擔不了,因為理論上,純函式不能進行讀寫操作。
  • View:與 State 一一對應,可以看作 State 的視覺層,也不合適承擔其他功能。
  • Action:存放資料的物件,即訊息的載體,只能被別人操作,自己不能進行任何操作。

我們發現,以上需求都是和 dispatch 相關,只有傳送 action 的這個步驟,即 store.dispatch() 方法,可以新增功能。比如新增日誌功能,我們只要把日誌放進 dispatch 函式裡,不就好了嗎,我們只需要改造 dispatch 函式,把 dispatch 進行一層封裝。

const store = createStore(counter)
const next = store.dispatch
store.dispatch = (action) => {
    try{
        console.log('prev state', store.getState())
        console.log(action)
        next(action)   
        console.log('next state', store.getState())
    }catch(err){
        console.error('錯誤資訊: ', err)  
    }
}

上面程式碼,對 store.dispatch 進行了重新定義,這就是中介軟體的雛形。

所以說Redux的中介軟體就是一個函式,是對 dispatch 方法的擴充套件,增強 dispatch 的功能。

實現中介軟體

對於上述 dispatch 的封裝,實際上是缺陷很大的。萬一又來 n 多個需求怎麼辦? 那 dispatch 函式就混亂到無法維護了,故需要擴充套件性強的多中介軟體合作模式。

  1. 我們把 loggerMiddleware 提取出來
const store = createStore(counter)
const next = store.dispatch

const loggerMiddleware = (action) => {
  console.log('prev state', store.getState())
  console.log(action)
  next(action)
  console.log('next state', store.getState())
}

store.dispatch = (action) => {
  try {
    loggerMiddleware(action)
  } catch (err) {
    console.error('錯誤資訊: ', err)
  }
}
  1. 把 exceptionMiddleware 提取出來
const exceptionMiddleware = (action) => {
  try {
    loggerMiddleware(action)
  } catch (err) {
    console.error('錯誤資訊: ', err)
  }
}

store.dispatch = exceptionMiddleware
  1. 現在程式碼有個問題,就是 exceptionMiddleware 中介軟體寫死 loggerMiddleware,但以後又萬一不要記錄功能呢,所以我們需要讓 next(action) 變成動態的,即換哪個中介軟體都可以
const exceptionMiddleware = (next) => (action) => {
  try {
    // loggerMiddleware(action)
    next(action)
  } catch (err) {
    console.error('錯誤資訊: ', err)
  }
}

這個寫法可能剛開始看不太適應,實際就是函式裡面,返回一個函式,即等效於

const exceptionMiddleware = function (next) {
  return function (action) {
    try {
      // loggerMiddleware(action)
      next(action)
    } catch (err) {
      console.error('錯誤資訊: ', err)
    }
  }
}

傳引數的時候即是exceptionMiddleware(next)(action)

  1. 同理,我們讓 loggerMiddleware 裡面無法擴充套件別的中介軟體了!我們也把 next 寫成動態的
const loggerMiddleware = (next) => (action) => {
  console.log('prev state', store.getState())
  console.log(action)
  next(action)
  console.log('next state', store.getState())
}

目前為止,整個中介軟體設計改造如下:

const store = createStore(counter)
const next = store.dispatch

const loggerMiddleware = (next) => (action) => {
  console.log('prev state', store.getState())
  console.log(action)
  next(action)
  console.log('next state', store.getState())
}

const exceptionMiddleware = (next) => (action) => {
  try {
    next(action)
  } catch (err) {
    console.error('錯誤資訊: ', err)
  }
}

store.dispatch = exceptionMiddleware(loggerMiddleware(next))
  1. 現在又有一個新問題,想想平時使用中介軟體是從外部引入的,那外部中介軟體裡面怎麼會有 store.getState() 這個方法,於是我們把 store 也給獨立出去。
const store = createStore(counter)
const next = store.dispatch

const loggerMiddleware = (store) => (next) => (action) => {
  console.log('prev state', store.getState())
  console.log(action)
  next(action)
  console.log('next state', store.getState())
}

const exceptionMiddleware = (store) => (next) => (action) => {
  try {
    next(action)
  } catch (err) {
    console.error('錯誤資訊: ', err)
  }
}

const logger = loggerMiddleware(store)
const exception = exceptionMiddleware(store)
store.dispatch = exception(logger(next))
  1. 如果又有一個新需求,需要在列印日誌前輸出當前時間戳,我們又需要構造一箇中介軟體
const timeMiddleware = (store) => (next) => (action) => {
  console.log('time', new Date().getTime())
  next(action)
}

const logger = loggerMiddleware(store)
const exception = exceptionMiddleware(store)
const time = timeMiddleware(store)
store.dispatch = exception(time(logger(next)))

中介軟體使用方式優化

上面的寫法可知,中介軟體的使用方式有點繁瑣,故我們需要把細節封裝起來,通過擴充套件createStore來實現。
先來看看期望的用法:

/* 接收舊的 createStore,返回新的 createStore */
const newCreateStore = applyMiddleware(exceptionMiddleware, timeMiddleware, loggerMiddleware)(createStore);

/* 返回了一個 dispatch 被重寫過的 store */
const store = newCreateStore(reducer);

實現 applyMiddleware

export const applyMiddleware = function (...middlewares) {
  /* 返回一個重寫createStore的方法 */
  return function rewriteCreateStoreFunc(oldCreateStore) {
    /* 返回重寫後新的 createStore */
    return function newCreateStore(reducer, preloadedState) {
      // 生成 store
      const store = oldCreateStore(reducer, preloadedState)
      let dispatch = store.dispatch

      // 只暴露 store 部分給中介軟體用的API,而不傳入整個store
      const middlewareAPI = {
        getState: store.getState,
        dispatch: (action) => store.dispatch(action),
      }
      // 給每個中介軟體傳入API
      // 相當於 const logger = loggerMiddleware(store),即 const logger = loggerMiddleware({ getState, dispatch })
      // const chain = [exception, time, logger]
      const chain = middlewares.map((middleware) => middleware(middlewareAPI))
      // 實現 exception(time((logger(dispatch))))
      chain.reverse().map((middleware) => {
        dispatch = middleware(dispatch)
      })
      // 重寫dispatch
      store.dispatch = dispatch
      return store
    }
  }
}

我們來看這一處程式碼:

chain.reverse().map((middleware) => {
    dispatch = middleware(dispatch)
})

要注意一點,中介軟體是順序執行,但是 dispatch 卻是反序生成的。所以在這步會把陣列順序給反序(比如 applyMiddleware(A, B, C),因為 A 在呼叫時需要知道 B 的 dispatch,B 在執行時需要知道 C 的 dispatch,那麼需要先知道 C 的 dispatch。)

官方Redux原始碼,採用了 compose 函式,我們也試試這種方式來寫:

export const applyMiddleware = (...middlewares) => {
  return (createStore) => (...args) => {
    // ...
    dispatch = compose(...chain)(store.dispatch)
    // ... 
  }
}

// compose(fn1, fn2, fn3)
// fn1(fn2(fn3))
// 從右到左來組合多個函式: 從右到左把接收到的函式合成後的最終函式
export const compose = (...funcs) => {
  if (funcs.length === 0) {
    return (arg) => arg
  }
  if (funcs.length === 1) {
    return funcs[0]
  }
  return funcs.reduce((ret, item) => (...args) => ret(item(...args)))
}

我們再對程式碼精簡:

export const applyMiddleware = (...middlewares) => {
  return (createStore) => (...args) => {
    const store = createStore(...args)
    let dispatch = store.dispatch

    const middlewareAPI = {
      getState: store.getState,
      dispatch: (action) => dispatch(action),
    }

    const chain = middlewares.map((middleware) => middleware(middlewareAPI))
    dispatch = compose(...chain)(store.dispatch)

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

export const compose = (...funcs) => {
  if (funcs.length === 0) {
    return (arg) => arg
  }
  if (funcs.length === 1) {
    return funcs[0]
  }
  return funcs.reduce((ret, item) => (...args) => ret(item(...args)))
}

createStore的處理

現在的問題是,有兩個 createStore 了,這怎麼區分,上篇我們其實已經先告知了對中介軟體程式碼處理,但具體怎麼推出的,我們繼續看。

// 沒有中介軟體的 createStore
const store = createStore(counter)

// 有中介軟體的 createStore
const rewriteCreateStoreFunc = applyMiddleware(exceptionMiddleware, timeMiddleware, loggerMiddleware);
const newCreateStore = rewriteCreateStoreFunc(createStore);
const store = newCreateStore(counter, preloadedState);

為了讓使用者用起來統一一些,我們可以很簡單的使他們的使用方式一致,我們修改下 createStore 方法

const createStore = (reducer, preloadedState, rewriteCreateStoreFunc) => {
    // 如果有 rewriteCreateStoreFunc,那就採用新的 createStore 
    if(rewriteCreateStoreFunc){
       const newCreateStore =  rewriteCreateStoreFunc(createStore);
       return newCreateStore(reducer, preloadedState);
    }
    // ...
}

不過Redux原始碼 rewriteCreateStoreFunc 換了個名字,還加了判斷,也就是我們上篇的程式碼:

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

所以中介軟體的用法為

const store = createStore(counter, /* preloadedState可選 */ applyMiddleware(logger))

combineReducers

如果我們做的專案很大,有大量 state,那麼維護起來很麻煩。Redux 提供了 combineReducers 這個方法,作用是把多個 reducer 合併成一個 reducer, 每個 reducer 負責獨立的模組。

我們用一個新例子來舉例:

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

const initCounterState = {
  value: 10,
}
const initInfoState = {
  name: 'jacky',
}

const reducer = combineReducers({
  counter: counterReducer,
  info: infoReducer,
})

// counter reducer處理函式
function counterReducer(state = initCounterState, action) {
  switch (action.type) {
    case 'INCREMENT':
      return {
        ...state,
        value: state.value + 1,
      }
    case 'DECREMENT':
      return {
        ...state,
        value: state.value - 1,
      }
    default:
      return state
  }
}

function infoReducer(state = initInfoState, action) {
  switch (action.type) {
    case 'FULL_NAME':
      return {
        ...state,
        name: state.name + ' lin',
      }
    default:
      return state
  }
}

const store = createStore(reducer)

const init = store.getState()
// 一開始counter為:10,info為 jacky
console.log(`一開始counter為:${init.counter.value},info為 ${init.info.name}`)
function listener() {
  store.getState()
}
store.subscribe(listener) // 監聽state的改變

// counterReducer
store.dispatch({ type: 'INCREMENT' })
store.dispatch({ type: 'INCREMENT' })
store.dispatch({ type: 'DECREMENT' })

// infoReducer
store.dispatch({ type: 'FULL_NAME' })

// 執行完counter為:11,info為jacky lin
console.log(`執行完counter為:${store.getState().counter.value},info為${store.getState().info.name}`)
export default store

我們來嘗試下如何實現這個 API,

首先要把一個函式裡的所有 reducers 迴圈執行一遍,並且這個函式要遵循(state, action) => newState 格式。還需要把每個 reducer 的 initState 合併成一個 rootState。
實現如下:

export function combineReducers(reducers) {
  // reducerKeys = ['counter', 'info']
  const reducerKeys = Object.keys(reducers)
  // 返回合併後的新的reducer函式
  return function combination(state = {}, action) {
    // 生成的新的state
    const nextState = {}

    // 遍歷執行所有的reducers,整合成為一個新的state
    for (let i = 0; i < reducerKeys.length; i++) {
      const key = reducerKeys[i]
      const reducer = reducers[key]
      // 之前的 key 的 state
      const previousStateForKey = state[key]
      // 執行 分 reducer,獲得新的state
      const nextStateForKey = reducer(previousStateForKey, action)

      nextState[key] = nextStateForKey
    }
    return nextState
  }
}

replaceReducer

在大型 Web 應用程式中,通常需要將應用程式程式碼拆分為多個可以按需載入的 JS 包。 這種稱為“程式碼分割”的策略通過減小初次載入時的 JS 的包的大小,來提高應用程式的效能。

reducer 拆分後,和元件是一一對應的。我們就希望在做按需載入的時候,reducer 也可以跟著元件在必要的時候再載入,然後用新的 reducer 替換老的 reducer。但實際上只有一個 root reducer 函式, 如果要實現的話就可以用 replaceReducer 這個函式,實現如下:

const createStore = function (reducer, initState) {
  // ...
  const replaceReducer = (nextReducer) => {
    if (typeof nextReducer !== 'function') {
      throw new Error('Expected the nextReducer to be a function.')
    }
    reducer = nextReducer
    // 重新整理一遍 state 的值,新來的 reducer 把自己的預設狀態放到 state 樹上去
    dispatch({ type: Symbol() })
  }
  // ...
  return {
    // ...
    replaceReducer
  }
}

使用如下:

const reducer = combineReducers({
  counter: counterReducer
});
const store = createStore(reducer);

/*生成新的reducer*/
const nextReducer = combineReducers({
  counter: counterReducer,
  info: infoReducer
});
/*replaceReducer*/
store.replaceReducer(nextReducer);

bindActionCreators

bindActionCreators 一般比較少用到,在react-redux的 connect 函式實現會用到

會使用到 bindActionCreators 的場景是當你需要把 action creator 往下傳到一個元件上,卻不想讓這個元件覺察到 Redux 的存在,而且不希望把 dispatch 或 Redux store 傳給它。

我們通過普通的方式來 隱藏 dispatch 和 actionCreator 試試

const reducer = combineReducers({
  counter: counterReducer,
  info: infoReducer
});
const store = createStore(reducer);

// 返回 action 的函式就叫 actionCreator
function increment() {
  return {
    type: 'INCREMENT'
  }
}

function getName() {
  return {
    type: 'FULL_NAME',
  }
}

const actions = {
  increment: function () {
    return store.dispatch(increment.apply(this, arguments))
  },
  getName: function () {
    return store.dispatch(getName.apply(this, arguments))
  }
}
// 其他地方在實現自增的時候,根本不知道 dispatch,actionCreator等細節
actions.increment(); // 自增
actions.getName(); // 獲得全名

把actions生成時候的公共程式碼提取出來:

const actions = bindActionCreators({ increment, getName }, store.dispatch)

bindActionCreators 的實現如下:

// 返回包裹 dispatch 的函式, 將 actionCreator 轉化成 dispatch 形式
// eg. { addNumAction }  =>  (...args) => dispatch(addNumAction(args))
export function bindActionCreator(actionCreator, dispatch) {
  return function (...args) {
    return dispatch(actionCreator.apply(this, args))
  }
}

export function bindActionCreators(actionCreators, dispatch) {
  if (typeof actionCreators === 'function') {
    return bindActionCreator(actionCreators, dispatch)
  }
  // actionCreators 必須是 function 或者 object
  if (typeof actionCreators !== 'object' || actionCreators === null) {
    throw new Error()
  }

  const keys = Object.keys(actionCreators)
  const boundActionCreators = {}
  for (let i = 0; i < keys.length; i++) {
    const key = keys[i]
    const actionCreator = actionCreators[key]
    if (typeof actionCreator === 'function') {
      boundActionCreators[key] = bindActionCreator(actionCreator, dispatch)
    }
  }
  return boundActionCreators
}

可能大家看到這裡有點懵逼,讓我們來回憶下 react-redux 中 connect 函式的用法,
比如有這樣一個 actionCreators

// actionCreators.js
function addNumAction() {
    return { type: 'ADD_NUM' }
}
// Demo.js:在需要用到 store 資料的元件,如 Demo 元件底部我們用 connect 函式連線,如下:
import { addNumAction } from './actionCreators'
const mapDispatchToProps = (dispatch) => ({
  addNum() {
    dispatch(addNumAction())
  }
})
export default connect(mapStateToProps, mapDispatchToProps)(Demo)

然後通過頁面的按鈕來出發 action 為 ADD_NUM 對應事件

<button onClick={this.props.addNum}>增加1</button>

但除了上面的用法,mapDispatchToProps 也可以這樣用,直接傳入一個物件,都沒有 dispatch 方法

export default connect(mapStateToProps, { addNumAction })(Demo)

然後只需觸發 addNumAction 就能實現和上面一樣的效果。

為什麼可以不傳,當你傳入物件的時候, connect 函式會判斷,大致程式碼如下:

let dispatchToProps

if (typeof mapDispatchToProps === 'function') {
    dispatchToProps = mapDispatchToProps(store.dispatch)
} else {
    // 傳遞了一個 actionCreator 物件過來
    dispatchToProps = bindActionCreators(mapDispatchToProps, store.dispatch)
}

這裡就使用了 bindActionCreators 函式,它就是把你傳入的 actionCreator 再包一層 dispatch方法,即

{ addNumAction }  =>  (...args) => dispatch(addNumAction(args))

總結

Redux 實現講到這裡就結束了,把原理搞懂了確實對 Redux 的理解加深了好多,之後會繼續寫相關外掛的實現,如 react-redux 等。

參考資料:

完全理解 redux(從零實現一個 redux)


相關文章