这是用户在 2025-1-18 9:44 为 https://kdon.notion.site/Redux-Toolkit-Redux-593381e430094857a2ffaed851881869 保存的双语快照页面,由 沉浸式翻译 提供双语支持。了解如何保存?
//
Redux Toolkit,让 Redux 开发更加简洁高效
搭建于
页面图标

Redux Toolkit,让 Redux 开发更加简洁高效

Date
空白
Status
空白
修改时间
2021年10月12日 20:28
发布时间
2021年3月3日 00:13
评论
Kingsley Don
2021/03/03
2021-03-12 知乎社区前端分享文档

起因

在本文写作之时,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
ALT
虽然这里的代码还没有到不可读的程度,但流程却不够直观:
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
ALT
configureStore 接受一个 object 参数:
middleware 和 enhancer 分开配置,并预置了一些 default middlewares:
redux-thunk:可能是被使用最多的 middleware;
Immutability Middleware开发环境下,尝试直接修改 state 时报错(FAQ: Why is immutability required by Redux?);
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 里使用 ifswitch 会使 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
ALT
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
ALT
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 中的条件;
createReducer 内部使用了 immer 让你可以直接修改 state,再也不用手写 immutable 操作或者 produce 了!

createSlice

回想一下日常写过的 ducks 结构的 Redux 代码,即使有了 createActioncreateReducer 的帮助,还是需要手动声明 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
ALT
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
ALT
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
ALT

总结

尽管我们已经实现了 RTK 中一些功能,但 RTK 的设计更加完备,使用 RTK 中的部分 API 也不需要对 store 进行大改造,可以考虑在实际项目中使用。
ALT
RTK 真香
ALT