上一篇文章主要介紹了 redux 文檔裏所用到的基本優化方案,但是很多都是手工實現的,不夠自動化。這篇文章主要講的是怎麼用 redux-toolkit 組織 redux 代碼。
先來回顧一下,我們所用到除 JS 之外的有:
- react-redux
- Provider 組件
- useSelector
- useDispatch'
- redux
- createStore
- combineReducers
- applyMiddleware
- redux-thunk
最終得到的代碼大概如下(因爲篇幅有限,就只顯示其中一部分,詳細代碼可以看這裏)
todos/store.ts
// todos/store.ts
import ...
const reducer = combineReducers({
todos: todosReducer,
filter: filterReducer,
loading: loadingReducer
})
const enhancer = process.env.NODE_ENV === 'development' ? composeWithDevTools(
applyMiddleware(ReduxThunk)
) :applyMiddleware(ReduxThunk)
const store = createStore(reducer, enhancer)
export default store
todos/reducer.ts
// todos/reducer.ts
import ...
type THandlerMapper = {[key: string]: (todoState: TTodoStore, action: TTodoAction) => TTodoStore}
const initTodos: TTodoStore = {
ids: [],
entities: {}
}
const todosReducer = (todoState: TTodoStore = initTodos, action: any) => {
const handlerMapper: THandlerMapper = {
[SET_TODOS]: (todoState, action) => {
const {payload: todos} = action as TSetTodosAction
const entities = produce<TTodoEntities>({}, draft => {
todos.forEach(t => {
draft[t.id] = t
})
})
return {
ids: todos.map(t => t.id),
entities
}
},
...
}
const handler = handlerMapper[action.type]
return handler ? handler(todoState, action) : todoState
}
export default todosReducer
todos/selectors.ts
// todos/selectors
export const selectFilteredTodos = (state: TStore): TTodo[] => {
const todos = Object.values(state.todos.entities)
if (state.filter === 'all') {
return todos
}
return todos.filter(todo => todo.state === state.filter)
}
export const selectTodoNeeded = (state: TStore): number => {
return Object.values(state.todos.entities).filter(todo => todo.state === 'todo').length
}
todos/actionCreators.ts
// todos/actionCreators.ts
export const fetchTodos = () => async (dispatch: Dispatch) => {
dispatch(setLoading({status: true, tip: '加載中...'}))
const response: TTodo = await fetch('/fetchTodos', () => dbTodos)
dispatch({ type: SET_TODOS, payload: response })
dispatch(setLoading({status: false, tip: ''}))
}
todos/actionTypes.ts
// todos/actionTypes.ts
export const SET_TODOS = 'setTodos'
export type SET_TODOS = typeof SET_TODOS
以前的做法
- 手動配置常用中間件和 Chrome 的 dev tool
- 手動將 slice 分類,並暴露 reducer
- 手動 Normalization: 將 todos 數據結構變成
{ids: [], entities: {}}
結構 - 使用 redux-thunk 來做異步,手動返回函數
- 手動使用表驅動來替換 reducer 的 switch-case 模式
- 手動將 selector 進行封裝成函數
- 手動引入 immer,並使用 mutable 寫法
以前的寫法理解起來真的不難,因爲這種做法是非常純粹的,基本就是 JavaScript 。不過,帶來的問題就是每次都這麼寫,累不累?
因此這裏隆重介紹 redux 一直在推薦的 redux-toolkit,這是官方提供的一攬子工具,這些工具並不能帶來很多功能,只是將上面的手動檔都變成自動檔了。
安裝:
$ yarn add @reduxjs/toolkit
configureStore
最重要的 API 就是 configureStore 了:
// store.ts
const reducer = combineReducers({
todos: todosSlice.reducer,
filter: filterSlice.reducer,
loading: loadingSlice.reducer
})
const store = configureStore({
reducer,
devTools: true
})
可以和之前的 createStore 對比一下,configureStore 帶來的好處是直接內置了 redux-thunk 和 redux-devtools-extension,這個 devtools 只要將 devTools: true
就可以直接使用。兩個字:簡潔。
createSlice
上面的代碼我們看到是用 combineReducers
來組裝大 reducer 的,前文也說過 todos, filter, loading 其實都是各自的 slice,redux-toolkit 提供了 createSlice
來更方便創建 reducer:
// todos/slice.ts
const todosSlice = createSlice({
name: 'todos',
initialState: initTodos,
reducers: {
[SET_TODOS]: (todoState, action) => {
const {payload: todos} = action
const entities = produce<TTodoEntities>({}, draft => {
todos.forEach(t => {
draft[t.id] = t
})
})
return {
ids: todos.map(t => t.id),
entities
}
}
...
}
})
這裏其實會發現 reducers 字段裏面就是我們所用的表驅動呀。name 就相當於 namespace 了。
異步
之前我們用 redux-thunk 都是 action creator 返回函數的方式來寫代碼,redux-toolkit 提供一個 createAsyncThunk
直接可以創建 thunk(其實就是返回函數的 action creator,MD,不知道起這麼多名字幹啥),直接看代碼
// todos/actionCreators.ts
import loadingSlice from '../loading/slice'
const {setLoading} = loadingSlice.actions
export const fetchTodos = createAsyncThunk<TTodo[]>(
'todos/' + FETCH_TODOS,
async (_, {dispatch}) => {
dispatch(setLoading({status: true, tip: '加載中...'}))
const response: TTodo[] = await fetch('/fetchTodos', () => dbTodos)
dispatch(setLoading({status: false, tip: ''}))
return response
}
)
可以發現使用 createSlice 的另一個好處就是可以直接獲取 action,不再需要每次都引入常量,不得不說,使用字符串來 dispatch 真的太 low 了。
這其實還沒完,我們再來看 todos/slice.ts 又變成什麼樣子:
// todos/slice.ts
const todosSlice = createSlice({
name: 'todos',
initialState: initTodos,
reducers: {},
extraReducers: {
[fetchTodos.fulfilled.toString()]: (state, action) => {
const {payload: todos} = action as TSetTodosAction
const entities = produce<TTodoEntities>({}, draft => {
todos.forEach(t => {
draft[t.id] = t
})
})
state.ids = todos.map(t => t.id)
state.entities = entities
}
}
})
這裏我們發現,key 變成了 fetchTodos.fulfilled.toString()
了,這就不需要每次都要創建一堆常量。直接使用字符串來 dispatch 是非常容易出錯的,而且對 TS 非常不友好。
注意:createSlice 裏的 reducer 裏可以直接寫 mutable 語法,這裏其實是內置了 immer。
我們再來看組件是怎麼 dispatch 的:
// TodosApp.tsx
import {fetchTodos} from './store/todos/actionCreators'
const TodoApp: FC = () => {
const dispatch = useDispatch()
useEffect(() => {
dispatch(fetchTodos())
}, [dispatch])
...
}
其實還是和以前一樣,直接 dispatch(actionCreator())
函數完事。
builder
其實到這裏我們對 [fetchTodos.fulfilled.toString()]
的寫法還是不滿意,爲啥要搞個 toString()
出來?真醜。這裏主要因爲不 toString()
會報 TS 類型錯誤,官方的推薦寫法是這樣的:
// todos/slice.ts
const todosSlice = createSlice({
name: 'todos',
initialState: initTodos,
reducers: {},
extraReducers: builder => {
builder.addCase(fetchTodos.fulfilled, (state, action) => {
const {payload: todos} = action as TSetTodosAction
const entities = produce<TTodoEntities>({}, draft => {
todos.forEach(t => {
draft[t.id] = t
})
})
state.ids = todos.map(t => t.id)
state.entities = entities
})
builder.addCase...
})
使用 builder.addCase 來添加 extraReducer 的 case,這種做法僅僅是爲了 TS 服務的,所以你喜歡之前的 toString 寫法也是沒問題的。
Normalization
之前我們使用的 Normalization 是需要我們自己去造 {ids: [], entities: {}}
的格式的,無論增,刪,改,查,最終還是要變成這樣的格式,這樣的手工代碼寫得不好看,而且容易把自己累死,所以 redux-toolkit 提供了一個 createEntitiyAdapter 的函數來封裝這個 Normalization 的思路。
// todos/slice.ts
const todosAdapter = createEntityAdapter<TTodo>({
selectId: todo => todo.id,
sortComparer: (aTodo, bTodo) => aTodo.id.localeCompare(bTodo.id), // 對 ids 數組排序
})
const todosSlice = createSlice({
name: 'todos',
initialState: todosAdapter.getInitialState(),
reducers: {},
extraReducers: builder => {
builder.addCase(fetchTodos.fulfilled, (state, action: TSetTodosAction) => {
todosAdapter.setAll(state, action.payload);
})
...
builder.addCase(toggleTodo.fulfilled, (state, action: TToggleTodoAction) => {
const {payload: id} = action as TToggleTodoAction
const todo = state.entities[id]
todo!.state = todo!.state === 'todo' ? 'done' : 'todo'
})
}
})
創建出來的 todosAdapter
就厲害了,它除了上面的 setAll
還有 updateOne
, upsertOne
, removeOne
等等的方法,這些 API 用起來就和用 Sequlize 這個庫來操作數據庫沒什麼區別,不足的地方是 payload 一定要按照它規定的格式,如 updateOne 的 payload 類型就得這樣的
export declare type Update<T> = {
id: EntityId;
changes: Partial<T>;
};
這時 TS 的強大威力就體現出來了,只要你去看裏面的 typing.d.ts,使用這些 API 就跟切菜一樣簡單,還要這個🐍皮 redux 文檔有個🐔兒用。
createSelector
我們之前雖然封裝好了 selector,但是隻要別的地方更新使得組件被更新後,useSelector 就會被執行,而 todos.filter(...) 都會返回一個新的數組,如果有組件依賴 filteredTodos,則那個小組件也會被更新。
說白了,todos.filter(...) 這個 selector 其實就是依賴了 todos 和 filter 嘛,那能不能實現 useCallback 那樣,只要 todos 和 filter 不變,那就不需要 todos.filter(..) 了,用回以前的數組,這個過程就是 Memorization。
市面上也有這種庫來做 Memorization,叫 Reselect。不過 redux-toolkit 提供了一個 createSelector
,那還用個屁的 Reselect。
// todos/selectors.ts
export const selectFilteredTodos = createSelector<TStore, TTodo[], TFilter, TTodo[]>(
selectTodos,
selectFilter,
(todos: TTodo[], filter: TFilter) => {
if (filter === 'all') {
return todos
}
return todos.filter(todo => todo.state === filter)
}
)
上面的 createSelector 第一個參數是獲取 selectTodos 的 selector,selectFilter 返回 filter,然後第三個參數是函數,頭兩個參數就是所依賴的 todos 和 filter。這就完成了 memorization 了。
createReducer + createAction
其實 redux-toolkit 裏面有挺多好的東西的,上面所說的 API 大概覆蓋了 80% 了,剩下的還有 createReducer 和 createAction 沒有說。沒有說的原因是 createReducer + createAction 約等於 createSlice。
這裏一定要注意:createAction 和 createReducer 是並列的,createSlice 類似於前兩個的結合,createSlice 更強大一些。網上有些聲音是討論該用 createAction + createReducer 還是直接上 createSlice 的。如果分不清哪個好,那就用 createSlice
。
總結
到這裏會發現真正我們用到的東西就是 redux + react-redux + redux-toolkit 就可以寫一個最佳實踐出來了。
市面上還有很多諸如 redux-action, redux-promise, reduce-reducers等等的 redux 衍生品(redux 都快變一個 IP 了)。這些東西要不就是更好規範 redux 代碼,要不就是在dispatch(action) -> UI 更新 這個流程再多加流程,它們的最終目的都是爲了更自動化地管理狀態/數據,相信理解了這個思路再看那些 redux 衍生品就更容易上手了。