理解 Redux 原始碼(三)- combineReducers

  這篇要來聊的是放在 store 裡面的第一個參數combineReducers,在探究程式碼之前想先聊一下,其實 combineReducers 並不是必須使用的。

  combineReducers 回傳的方法和一般的 reducer 方法一樣,都接受 state, action 兩個參數並根據內部邏輯回傳 state,因此只要 reducer 格式正確是可以直接傳入 createStore 裡面的,就像以下的例子。

import { createStore } from 'redux'
import rootReducer from './reducer'

const counterReducer = (state = { keyOne: 0, keyTwo: 'string' }, action) => {
return state
}

const store = createStore(counterReducer)

// 返回的初始 state 長這樣
// {
// keyOne: 0,
// keyTwo: 'string'
// }

  這樣一來就是用一個 reducer 來管理所有的全域狀態,好處是可以隨時拿到 state 裡面所有 key 的值,壞處是當專案規模增大時管理會變困難,到時就需要考慮使用 combineReducers,但這時就要規劃一下要怎麼管理不同的 reducer,因為一旦使用 combineReducers 後每個 reducer 是無法拿到別的 key 值的 state 的。

  回到使用 combineReducer 的場景

import { createStore, combineReducers } from 'redux'
import rootReducer from './reducer'

const counterReducer = (state = { keyOne: 0, keyTwo: 'string' }, action) => {
// some logic
return state
}

const postReducer = (state = { keyOne: 1, keyTwo: 'string2' }, action) => {
// some logic
return state
}

const rootReducer = combineReducers({
counter: counterReducer,
post: postReducer
})

const store = createStore(rootReducer)

// 返回的初始 state 長這樣
// {
// counter: {
// keyOne: 0,
// keyTwo: 'string'
// },
// post: {
// keyOne: 1,
// keyTwo: 'string2'
// }
// }

  由於狀態被細分為了 counter, post 兩個部分(slice)為了方便說明,以下會用 state slice 來代表每一個 key 值所對應的 state,用 reducer slice 代表每一個 key 值對應的 reducer。


正文

複製一份 Reducers

export default function combineReducers(reducers) {
const reducerKeys = Object.keys(reducers)
const finalReducers = {}
for (let i = 0; i < reducerKeys.length; i++) {
const key = reducerKeys[i]

if (process.env.NODE_ENV !== 'production') {
if (typeof reducers[key] === 'undefined') {
warning(`No reducer provided for key "${key}"`)
}
}

if (typeof reducers[key] === 'function') {
finalReducers[key] = reducers[key]
}
}
const finalReducerKeys = Object.keys(finalReducers)

// ...
}

  一開始主要在處理型別檢查及過濾。用 Object.keys 方法將 keys 抓出來,並新增一個 finalReducers 變數將 reducers 遍歷且複製進來,並在同時檢查如果有缺少 reducer slice,如果在 production 以外的環境就給予警告,最後再過濾掉型別不正確的 reducer slice,將過濾後結果的 key 抓出來放到 finalReducerKeys 變數裡。


檢驗 reducer

let shapeAssertionError
try {
assertReducerShape(finalReducers)
} catch (e) {
shapeAssertionError = e
}

  接著先看這段 code,將 finalReducers 帶入 assertReducerShape 去呼叫並做了一個 try cache,來看看 assertReducerShape 在做什麼。

function assertReducerShape(reducers) {
Object.keys(reducers).forEach((key) => {
const reducer = reducers[key]
const initialState = reducer(undefined, { type: ActionTypes.INIT })

if (typeof initialState === 'undefined') {
throw new Error(
`The slice reducer for key "${key}" returned undefined during initialization. ` +
`If the state passed to the reducer is undefined, you must ` +
`explicitly return the initial state. The initial state may ` +
`not be undefined. If you don't want to set a value for this reducer, ` +
`you can use null instead of undefined.`
)
}

if (
typeof reducer(undefined, {
type: ActionTypes.PROBE_UNKNOWN_ACTION(),
}) === 'undefined'
) {
throw new Error(
`The slice reducer for key "${key}" returned undefined when probed with a random type. ` +
`Don't try to handle '${ActionTypes.INIT}' or other actions in "redux/*" ` +
`namespace. They are considered private. Instead, you must return the ` +
`current state for any unknown actions, unless it is undefined, ` +
`in which case you must return the initial state, regardless of the ` +
`action type. The initial state may not be undefined, but can be null.`
)
}
})
}

  將參數遍歷一次,把裡面將每個 reducer 的 state 參數都帶入 undefined 與 action 參數帶入包含亂數的 type 的物件並呼叫兩次,這樣做確認 user 的每個 reducer slice 都有正確帶入預設的 state 值,並且即便遇到未知的 action type 也能返回一個 state。

  而呼叫兩次正是在模擬初始化狀態以及後續呼叫 dispatch 這兩個環節都沒有問題。

  於是整段的重點在於檢驗 reducer 功能正常,如有錯誤就先抓出來存到 shapeAssertionError 變數中等後續拋出。


返回 rootReducer

// This is used to make sure we don't warn about the same
// keys multiple times.
let unexpectedKeyCache
if (process.env.NODE_ENV !== 'production') {
unexpectedKeyCache = {}
}

return function combination(state = {}, action) {
if (shapeAssertionError) {
throw shapeAssertionError
}

if (process.env.NODE_ENV !== 'production') {
const warningMessage = getUnexpectedStateShapeWarningMessage(
state,
finalReducers,
action,
unexpectedKeyCache
)
if (warningMessage) {
warning(warningMessage)
}
}

//...
}

  最後返回的結果是一個 combination 方法,一般被稱為 rootReducer ,也就是在上一篇中提到呼叫 dispatch 時會被呼叫的 currentReducer ,每當在 dispatch 呼叫時若剛才檢驗 reducer sliceassertReducerShape 有拋錯的話就會在這邊被丟出。


檢查是否 state keys 與 reducerKeys 相呼應

  若沒拋錯會先執行 getUnexpectedStateShapeWarningMessage ,看一下這邊在做什麼

function getUnexpectedStateShapeWarningMessage(
inputState,
reducers,
action,
unexpectedKeyCache
) {
const reducerKeys = Object.keys(reducers)
const argumentName =
action && action.type === ActionTypes.INIT
? 'preloadedState argument passed to createStore'
: 'previous state received by the reducer'

if (reducerKeys.length === 0) {
return (
'Store does not have a valid reducer. Make sure the argument passed ' +
'to combineReducers is an object whose values are reducers.'
)
}

if (!isPlainObject(inputState)) {
return (
`The ${argumentName} has unexpected type of "${kindOf(
inputState
)}". Expected argument to be an object with the following ` +
`keys: "${reducerKeys.join('", "')}"`
)
}

const unexpectedKeys = Object.keys(inputState).filter(
(key) => !reducers.hasOwnProperty(key) && !unexpectedKeyCache[key]
)

unexpectedKeys.forEach((key) => {
unexpectedKeyCache[key] = true
})

if (action && action.type === ActionTypes.REPLACE) return

if (unexpectedKeys.length > 0) {
return (
`Unexpected ${unexpectedKeys.length > 1 ? 'keys' : 'key'} ` +
`"${unexpectedKeys.join('", "')}" found in ${argumentName}. ` +
`Expected to find one of the known reducer keys instead: ` +
`"${reducerKeys.join('", "')}". Unexpected keys will be ignored.`
)
}
}

  這段的大意是,由於 rootReducer 不會只被呼叫一次,Redux 要確保每次呼叫時 reducerKeys 與 state 的 keys 都能夠對應,如果無法對應則找出是哪些 key 不一樣並發出警告。

  另外由於在呼叫 replaceReducer 方法替換 reducers 時, state 現有的 key 與新的 reducerKeys 確實有可能不一樣,於是就直接 return 不發出警告。

  而 unexpectedKeyCache 這個變數的作用是拿來紀錄這次 rootReducer 被呼叫時已經出現過的非預期的 key,並避免在後續呼叫時重複拋出警告。


比對呼叫 reducer 的前後狀態


return function combination(state = {}, action) {
//...

let hasChanged = false
const nextState = {}
for (let i = 0; i < finalReducerKeys.length; i++) {
const key = finalReducerKeys[i]
const reducer = finalReducers[key]
const previousStateForKey = state[key]
const nextStateForKey = reducer(previousStateForKey, action)
if (typeof nextStateForKey === 'undefined') {
const actionType = action && action.type
throw new Error(
`When called with an action of type ${
actionType ? `"${String(actionType)}"` : '(unknown type)'
}, the slice reducer for key "${key}" returned undefined. ` +
`To ignore an action, you must explicitly return the previous state. ` +
`If you want this reducer to hold no value, you can return null instead of undefined.`
)
}
nextState[key] = nextStateForKey
hasChanged = hasChanged || nextStateForKey !== previousStateForKey
}
hasChanged =
hasChanged || finalReducerKeys.length !== Object.keys(state).length
return hasChanged ? nextState : state
}

  先說明一下變數的意義

hasChange 是紀錄前後狀態是否相等的 flag

previousStateForKey 是指呼叫對應的 reducer slice 之前的 state slice

nextStateForKey 是指呼叫對應的 reducer slice 之後的 state slice

nextState 是一個收集所有 nextStateForKey 的值的物件

  這段主要在做的事情是,迴圈呼叫每個 reducer slice,並且去比較呼叫前後的 state slice 是否有不同,只要有任何一個 slice 結果不同則 hasChanged 會改為 true,代表最後必須要返回 nextState ,相反地如果迴圈跑完沒有發現不同的話,則返回原本的 state 就好。

  值得注意的地方是,比較的方法是用 nextStateForKey !== previousStateForKey 來確認,這也是為什麼 Redux 強調不應該直接在 reducer 內 mutate state,因為這樣會比較不出 state 的差異而永遠返回舊的 state 造成沒有渲染的問題。

  這是一個為了增進效能帶來的限制,如果遞迴比較每一層的話當然就不用在意 immutable 的問題,但試想每次呼叫 dispatch 時都要將所有 reducer slice 跑一次並且遞迴比較所有的 state slice,那會將時間複雜度(效能)大大的提高啊~


小結

  在探討 combineReducers 後知道它做了幾件事情

  1. 檢查 user 傳入的 reducer slice 能否在遇到未知的 action type 時以及 state 為 undefined 時仍正常運作
  2. 確保初始化及後續呼叫 dispatch 時 reducer 都能正常運作
  3. 返回 rootReducer,在每次 dispatch 呼叫時比對前後狀態是否相等,若不相等才會返回新的 state

  同時也了解了比較狀態是用 nextStateForKey !== previousStateForKey 的方式,也因此 Redux 才強調 immutable 的概念。

  下一篇會透過看 applyMiddleware 的內容了解 middleware 是如何幫助 user 達成特定目的的。



參考文獻

Redux 官方文件