起因
在本文写作之时,Redux 文档已经将 Redux Toolkit(RTK)放在了开篇的醒目位置,并且打上了「官方推荐」的标签。通过介绍可以了解到,RTK 的主要特性是解决了 Redux 开发中的一些具有代表性的问题,并集成了一些最佳实践,了解它或许可以给技术改进带来一些启发。
此外,团队项目中的 Redux 设计已经很久没有大变更了,显然是可以进一步优化的,RTK 有渐进式改造的可能性,在团队当前技术栈下,有不错的实践推广空间。
从 Redux 的痛点出发
配置繁琐
store 的配置可能是每一个新手的噩梦,一个典型的 store 配置大致如下:
JavaScript
拷贝
import { applyMiddleware, createStore } from 'redux'
import { composeWithDevTools } from 'redux-devtools-extension'
import thunkMiddleware from 'redux-thunk'
import monitorReducersEnhancer from './enhancers/monitorReducers'
import loggerMiddleware from './middleware/logger'
import rootReducer from './reducers'
export default function configureStore(preloadedState) {
const middlewares = [loggerMiddleware, thunkMiddleware]
const middlewareEnhancer = applyMiddleware(...middlewares)
const enhancers = [middlewareEnhancer, monitorReducersEnhancer]
const composedEnhancers = composeWithDevTools(...enhancers)
const store = createStore(rootReducer, preloadedState, composedEnhancers)
if (process.env.NODE_ENV !== 'production' && module.hot) {
module.hot.accept('./reducers', () => store.replaceReducer(rootReducer))
}
return store
}
https://redux-toolkit.js.org/usage/usage-guide#manual-store-setup
虽然这里的代码还没有到不可读的程度,但流程却不够直观:
createStore(rootReducer, preloadedState, composedEnhancers) 三个参数按照顺序排列,可能会混淆;
配置 middlewares 和 enhancers 的方式令人困惑;
devTools 需要单独的 package 介入,compose 的写法也增加了代码组织的复杂度。
而使用 RTK 的 configureStore 则非常清晰:
JavaScript
拷贝
import { configureStore } from '@reduxjs/toolkit'
import monitorReducersEnhancer from './enhancers/monitorReducers'
import loggerMiddleware from './middleware/logger'
import rootReducer from './reducers'
export default function configureAppStore(preloadedState) {
const store = configureStore({
reducer: rootReducer,
middleware: getDefaultMiddleware =>
getDefaultMiddleware().concat(loggerMiddleware),
preloadedState,
// devTools: true,
enhancers: [monitorReducersEnhancer]
})
if (process.env.NODE_ENV !== 'production' && module.hot) {
module.hot.accept('./reducers', () => store.replaceReducer(rootReducer))
}
return store
}
https://redux-toolkit.js.org/usage/usage-guide#simplifying-store-setup-with-configurestore
configureStore 接受一个 object 参数:
middleware 和 enhancer 分开配置,并预置了一些 default middlewares:
redux-thunk:可能是被使用最多的 middleware;
Serializability Middleware:检测 state 和 dispatched actions 中是否使用了 non-serializable values(FAQ: Can I put functions, promises, or other non-serializable items in my store state?);
预置了 devTools,并通过 boolean 控制开关。
样板代码太多
完成配置后,在写 Redux 相关代码时,也会遇到很多问题:
在 reducer 里使用 if 或 switch 会使 reducer 过于冗长(从而触发 eslint max-lines-per-function 错误),还可能出现忘在最后写 return state 这种问题;
不借助 immer 等工具,直接在 reducer 中 immutable 更新 state 很困难,很容易就会写出可读性差的代码:
JavaScript
拷贝
function updateVeryNestedField(state, action) {
return {
...state,
first: {
...state.first,
second: {
...state.first.second,
[action.someId]: {
...state.first.second[action.someId],
fourth: action.someValue
}
}
}
}
}
https://redux.js.org/recipes/structuring-reducers/immutable-update-patterns#updating-nested-objects
JavaScript
拷贝
function insertItem(array, action) {
return [
...array.slice(0, action.index),
action.item,
...array.slice(action.index)
]
}
function removeItem(array, action) {
return [...array.slice(0, action.index), ...array.slice(action.index + 1)]
}
https://redux.js.org/recipes/structuring-reducers/immutable-update-patterns#inserting-and-removing-items-in-arrays
action type 要在声明,action creator,reducer 出现三次,并且他们之间没有关联,依赖代码文本搜索;
哪怕是最简单的更新一个值的 action,也需要写一遍所有的样板代码。
RTK 提供了几个 API 来帮助我们简化这部分代码:
createAction
createAction 默认生成一个 action creator,接收一个参数把它当作 payload,同时 override 了 toString() 返回 action type,这一点在配合后文的 createReducer 时非常好用。
代码中大量存在这种需要修改某一个值的场景(setFoo):
JavaScript
拷贝
import { createAction } from '@reduxjs/toolkit'
const increment = createAction('counter/increment')
let action = increment()
// { type: 'counter/increment' }
action = increment(3)
// returns { type: 'counter/increment', payload: 3 }
console.log(increment.toString())
// 'counter/increment'
console.log(`The action type is: ${increment}`)
// 'The action type is: counter/increment'
createAction 还接收第二个可选参数 prepare callback,允许使用者自行构建 action:
JavaScript
拷贝
import { createAction, nanoid } from '@reduxjs/toolkit'
const addTodo = createAction('todos/add', function prepare(text) {
return {
payload: {
text,
id: nanoid(),
createdAt: new Date().toISOString(),
},
}
})
console.log(addTodo('Write more docs'))
/**
* {
* type: 'todos/add',
* payload: {
* text: 'Write more docs',
* id: '4AJvwMSWEHCchcWYga3dj',
* createdAt: '2019-10-03T07:53:36.581Z'
* }
* }
**/
createReducer
createReducer 有两种 overload,第一个参数都为 initialState。
当第二个参数为 builderCallback 时,可以进行链式调用:
JavaScript
拷贝
import { createAction, createReducer } from '@reduxjs/toolkit'
const initialState = {}
const resetAction = createAction('reset-tracked-loading-state')
function isPendingAction(action) {
return action.type.endsWith('/pending')
}
const reducer = createReducer(initialState, (builder) => {
builder
.addCase(resetAction, () => initialState)
// matcher can be defined outside as a type predicate function
.addMatcher(isPendingAction, (state, action) => {
state[action.meta.requestId] = 'pending'
})
.addMatcher(
// matcher can be defined inline as a type predicate function
(action) => action.type.endsWith('/rejected'),
(state, action) => {
state[action.meta.requestId] = 'rejected'
}
)
// matcher can just return boolean and the matcher can receive a generic argument
.addMatcher(
(action) => action.type.endsWith('/fulfilled'),
(state, action) => {
state[action.meta.requestId] = 'fulfilled'
}
)
})
当使用 Map Object 时,接受最多 4 个参数:
JavaScript
拷贝
const increment = createAction('increment')
const decrement = createAction('decrement')
const counterReducer = createReducer(0, {
[increment]: (state, action) => state + action.payload,
[decrement.type]: (state, action) => state - action.payload
})
JavaScript
拷贝
const isStringPayloadAction = action => typeof action.payload === 'string'
const lengthOfAllStringsReducer = createReducer(
// initial state
{ strLen: 0, nonStringActions: 0 },
// normal reducers
{
/*...*/
},
// array of matcher reducers
[
{
matcher: isStringPayloadAction,
reducer(state, action) {
state.strLen += action.payload.length
}
}
],
// default reducer
state => {
state.nonStringActions++
}
)
以上示例中可以关注到:
由于 createAction override 了 toString() ,所以 action creator 可以直接用来当作 createReducer 中的条件;
createSlice
回想一下日常写过的 ducks 结构的 Redux 代码,即使有了 createAction 和 createReducer 的帮助,还是需要手动声明 action types,将它们匹配起来,所以何不将他们都打包到一起?于是我们终于介绍到了 createSlice 这个集大成的 API。
JavaScript
拷贝
const postsSlice = createSlice({
name: 'posts',
initialState: [],
reducers: {
createPost(state, action) {},
updatePost(state, action) {},
deletePost(state, action) {}
}
})
console.log(postsSlice)
/*
{
name: 'posts',
actions : {
createPost,
updatePost,
deletePost,
},
reducer
}
*/
// Extract the action creators object and the reducer
const { actions, reducer } = postsSlice
// Extract and export each action creator by name
export const { createPost, updatePost, deletePost } = actions
// Export the reducer, either as a default or named export
export default reducer
console.log(createPost({ id: 123, title: 'Hello World' }))
// {type : "posts/createPost", payload : {id : 123, title : "Hello World"}}
https://redux-toolkit.js.org/usage/usage-guide#simplifying-slices-with-createslice
createSlice 会根据 slice 和 action 的名称自动创建 action types,并且导出 actions 和 reducer。
当然,不要被 slice 这个概念迷惑了,这里的 action 同样会被所有的 reducer 响应,同样,也有 extraReducer 用于创建响应外部 actions 的 reducer,由于他们都是通过 createReducer 方法创建的,也都有上文中提到的特性:
JavaScript
拷贝
import { createAction, createSlice } from '@reduxjs/toolkit'
const incrementBy = createAction('incrementBy')
const decrement = createAction('decrement')
function isRejectedAction(action) {
return action.type.endsWith('rejected')
}
createSlice({
name: 'counter',
initialState: 0,
reducers: {},
extraReducers: (builder) => {
builder
.addCase(incrementBy, (state, action) => {
// action is inferred correctly here if using TS
})
// You can chain calls, or have separate `builder.addCase()` lines each time
.addCase(decrement, (state, action) => {})
// You can match a range of action types
.addMatcher(
isRejectedAction,
// `action` will be inferred as a RejectedAction due to isRejectedAction being defined as a type guard
(state, action) => {}
)
// and provide a default case if no other handlers matched
.addDefaultCase((state, action) => {})
},
})
敏锐的读者可能会想到,当两个 slices 在两个文件中,并且他们互相 import actions,就会产生循环引用的问题,在实际开发中应该注意避免这种情况的发生。
到这里,基础的样板代码已经大大简化了,但还有一些复杂功能需要最佳实践,也是我认为 RTK 最酷的部分。
缺少最佳实践
异步逻辑与请求
类似于 createAPIActionTypes 做的事情一样,对于异步操作,我们希望有三个 action 分别对应 request,success,failure,RTK 提供了 createAsyncThunk API 简化了这部分的流程:
JavaScript
拷贝
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'
import { userAPI } from './userAPI'
// First, create the thunk
const fetchUserById = createAsyncThunk(
'users/fetchByIdStatus',
async (userId, thunkAPI) => {
const response = await userAPI.fetchById(userId)
return response.data
}
)
// Then, handle actions in your reducers:
const usersSlice = createSlice({
name: 'users',
initialState: { entities: [], loading: 'idle' },
reducers: {
// standard reducer logic, with auto-generated action types per reducer
},
extraReducers: {
// Add reducers for additional action types here, and handle loading state as needed
[fetchUserById.fulfilled]: (state, action) => {
// Add user to the state array
state.entities.push(action.payload)
}
}
})
// Later, dispatch the thunk as needed in the app
dispatch(fetchUserById(123))
createAsyncThunk 还有很酷的取消特性,比如为了避免重复的请求,在执行前使用 condition 取消:
JavaScript
拷贝
const fetchUserById = createAsyncThunk(
'users/fetchByIdStatus',
async (userId, thunkAPI) => {
const response = await userAPI.fetchById(userId)
return response.data
},
{
condition: (userId, { getState, extra }) => {
const { users } = getState()
const fetchStatus = users.requests[userId]
if (fetchStatus === 'fulfilled' || fetchStatus === 'loading') {
// Already fetched or in progress, don't need to re-fetch
return false
}
}
}
)
得益于现代浏览器对于 AbortSignal 的支持,我们还可以借助 createAsyncThunk 轻松实现取消一个请求:
JavaScript
拷贝
import { createAsyncThunk } from '@reduxjs/toolkit'
const fetchUserById = createAsyncThunk(
'users/fetchById',
async (userId, thunkAPI) => {
const response = await fetch(`https://reqres.in/api/users/${userId}`, {
signal: thunkAPI.signal,
})
return await response.json()
}
)
JavaScript
拷贝
import { fetchUserById } from './slice'
import { useAppDispatch } from './store'
import React from 'react'
function MyComponent(props) {
const dispatch = useAppDispatch()
React.useEffect(() => {
// Dispatching the thunk returns a promise
const promise = dispatch(fetchUserById(props.userId))
return () => {
// `createAsyncThunk` attaches an `abort()` method to the promise
promise.abort()
}
}, [props.userId])
}
https://redux-toolkit.js.org/api/createAsyncThunk#canceling-while-running
Normalizing Data 并不需要依赖特别的库,但为了方便通常我们会使用 normalizr 配合手写的 middleware 来处理,即便这样,还是有大量重复的 reducer 和 selector。
RTK 则提供了 createEntityAdaper ,返回:
getInitialState 默认结构是 {ids: [], entities: {}};
CRUD Functions:
addOne , addMany , setAll , removeOne , removeMany , updateOne , updateMany , upsertOne , upsertMany;
可以配合 createReducer 使用,代码非常简洁;
Selector Functions:selectIds , selectEntities , selectAll , selectTotal , selectById。
一个演示案例:
JavaScript
拷贝
import {
createEntityAdapter,
createSlice,
configureStore
} from '@reduxjs/toolkit'
// Since we don't provide `selectId`, it defaults to assuming `entity.id` is the right field
const booksAdapter = createEntityAdapter({
// Keep the "all IDs" array sorted based on book titles
sortComparer: (a, b) => a.title.localeCompare(b.title)
})
const booksSlice = createSlice({
name: 'books',
initialState: booksAdapter.getInitialState({
loading: 'idle'
}),
reducers: {
// Can pass adapter functions directly as case reducers. Because we're passing this
// as a value, `createSlice` will auto-generate the `bookAdded` action type / creator
bookAdded: booksAdapter.addOne,
booksLoading(state, action) {
if (state.loading === 'idle') {
state.loading = 'pending'
}
},
booksReceived(state, action) {
if (state.loading === 'pending') {
// Or, call them as "mutating" helpers in a case reducer
booksAdapter.setAll(state, action.payload)
state.loading = 'idle'
}
},
bookUpdated: booksAdapter.updateOne
}
})
const {
bookAdded,
booksLoading,
booksReceived,
bookUpdated
} = booksSlice.actions
const store = configureStore({
reducer: {
books: booksSlice.reducer
}
})
// Check the initial state:
console.log(store.getState().books)
// {ids: [], entities: {}, loading: 'idle' }
const booksSelectors = booksAdapter.getSelectors(state => state.books)
store.dispatch(bookAdded({ id: 'a', title: 'First' }))
console.log(store.getState().books)
// {ids: ["a"], entities: {a: {id: "a", title: "First"}}, loading: 'idle' }
store.dispatch(bookUpdated({ id: 'a', changes: { title: 'First (altered)' } }))
store.dispatch(booksLoading())
console.log(store.getState().books)
// {ids: ["a"], entities: {a: {id: "a", title: "First (altered)"}}, loading: 'pending' }
store.dispatch(
booksReceived([
{ id: 'b', title: 'Book 3' },
{ id: 'c', title: 'Book 2' }
])
)
console.log(booksSelectors.selectIds(store.getState()))
// "a" was removed due to the `setAll()` call
// Since they're sorted by title, "Book 2" comes before "Book 3"
// ["c", "b"]
console.log(booksSelectors.selectAll(store.getState()))
// All book entries in sorted order
// [{id: "c", title: "Book 2"}, {id: "b", title: "Book 3"}]
https://redux-toolkit.js.org/api/createEntityAdapter#examples
总结
尽管我们已经实现了 RTK 中一些功能,但 RTK 的设计更加完备,使用 RTK 中的部分 API 也不需要对 store 进行大改造,可以考虑在实际项目中使用。
RTK 真香