Skip to main content

迁移到现代 Redux

你将学到什么
  • 如何将遗留的“手写”Redux 逻辑现代化,使用 Redux Toolkit
  • 如何将遗留的 React-Redux connect 组件现代化,使用 hooks API
  • 如何现代化使用 TypeScript 的 Redux 逻辑和 React-Redux 组件

概述

Redux 自 2015 年以来一直存在,我们推荐的编写 Redux 代码的模式多年来有了显著变化。就像 React 从 createClassReact.Component 再到带有 hooks 的函数组件一样,Redux 也经历了从手动创建 store + 手写 reducer 使用对象展开运算符 + React-Redux 的 connect,到 Redux Toolkit 的 configureStore + createSlice + React-Redux 的 hooks API 的演变。

许多用户都在维护早于这些“现代 Redux”模式出现的旧版 Redux 代码库。将这些代码库迁移到当今推荐的现代 Redux 模式,将使代码库更小且更易维护。

好消息是,你可以逐步、逐个部分地将代码迁移到现代 Redux,旧代码和新代码可以共存且协同工作!

本页涵盖了现代化现有遗留 Redux 代码库时可用的一般方法和技巧。

info

想了解更多“现代 Redux”如何使用 Redux Toolkit + React-Redux hooks 简化 Redux 使用的信息,请参考以下资源:

使用 Redux Toolkit 现代化 Redux 逻辑

迁移 Redux 逻辑的一般步骤是:

  • 用 Redux Toolkit 的 configureStore 替换现有的手动 Redux store 配置
  • 选出一个已有的 slice reducer 及其相关动作,用 RTK 的 createSlice 进行替换,按 reducer 一个一个替换
  • 根据需要,用 RTK Query 或 createAsyncThunk 替换已有数据获取逻辑
  • 根据需要使用 RTK 的其他 API,如 createListenerMiddlewarecreateEntityAdapter

你应该总是先用 configureStore 替换旧的 createStore 调用。这是一次性操作,且所有现有的 reducers 和 middleware 都将继续正常工作。configureStore 在开发模式下包含对常见错误的检测(如意外修改状态和非序列化值),启用它们有助于快速发现代码中产生此类错误的地方。

info

你可以在Redux 基础教程第 8 部分:使用 Redux Toolkit 的现代 Redux中看到这一通用方法的实际示例。

使用 configureStore 配置 Store

典型的旧版 Redux store 配置文件通常包括以下几个步骤:

  • 合并 slice reducers 成根 reducer
  • 创建包含 thunk 中间件的中间件增强器,开发时可能还有其它中间件,如 redux-logger
  • 添加 Redux DevTools 增强器,并组合所有 enhancer
  • 调用 createStore

下面是一个已有项目里可能出现的代码示例:

src/app/store.js
import { createStore, applyMiddleware, combineReducers, compose } from 'redux'
import { thunk } from 'redux-thunk'

import postsReducer from '../reducers/postsReducer'
import usersReducer from '../reducers/usersReducer'

const rootReducer = combineReducers({
posts: postsReducer,
users: usersReducer
})

const middlewareEnhancer = applyMiddleware(thunk)

const composeWithDevTools =
window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose

const composedEnhancers = composeWithDevTools(middlewareEnhancer)

const store = createStore(rootReducer, composedEnhancers)

所有这些步骤都可以简单地用一条 Redux Toolkit 的 configureStore 调用替换。

RTK 的 configureStore 是对原始 createStore 方法的封装,会自动为我们完成大部分 store 配置。实际上,我们可以简化为以下单步操作:

基础 store 配置:src/app/store.js
import { configureStore } from '@reduxjs/toolkit'

import postsReducer from '../reducers/postsReducer'
import usersReducer from '../reducers/usersReducer'

// 自动添加 thunk 中间件和 Redux DevTools 扩展
const store = configureStore({
// 自动调用 `combineReducers`
reducer: {
posts: postsReducer,
users: usersReducer
}
})

这条 configureStore 调用帮我们完成了所有工作:

  • 调用 combineReducers 合并了 postsReducerusersReducer,得到处理 {posts, users} 状态的根 reducer 函数
  • 调用 createStore 创建 Redux store,使用上述根 reducer
  • 自动添加 thunk 中间件,并调用 applyMiddleware
  • 自动添加了检查常见错误(如意外修改状态)状态的中间件
  • 自动设置 Redux DevTools 扩展连接

如果你的 store 配置需要更多步骤,比如添加其他中间件,传入 thunk 的 extra 参数,或者创建持久化的根 reducer,你也可以这样做。以下示例展示了自定义内置中间件和开启 Redux-Persist 的示例,展示了使用 configureStore 进行自定义的几种选项:

详细示例:带持久化和中间件的自定义 Store 设置

本示例展示设置 Redux store 时的几个常见需求:

  • 分开合并 reducers(有时基于架构限制需要)
  • 条件或无条件地添加额外中间件
  • 给 thunk 中间件传入“额外参数”,如 API 服务层对象
  • 使用 Redux-Persist 库,需特别处理其非序列化 action 类型
  • 生产环境关闭 devtools,开发环境开启并传入额外 devtools 选项

这些都不是必须,但在真实项目中经常会用到。

自定义 Store 设置:src/app/store.js
import { configureStore, combineReducers } from '@reduxjs/toolkit'
import {
persistStore,
persistReducer,
FLUSH,
REHYDRATE,
PAUSE,
PERSIST,
PURGE,
REGISTER
} from 'redux-persist'
import storage from 'redux-persist/lib/storage'
import { PersistGate } from 'redux-persist/integration/react'
import logger from 'redux-logger'

import postsReducer from '../features/posts/postsSlice'
import usersReducer from '../features/users/usersSlice'
import { api } from '../features/api/apiSlice'
import { serviceLayer } from '../features/api/serviceLayer'

import stateSanitizerForDevtools from './devtools'
import customMiddleware from './someCustomMiddleware'

// 如果需要,可以自己调用 `combineReducers`
const rootReducer = combineReducers({
posts: postsReducer,
users: usersReducer,
[api.reducerPath]: api.reducer
})

const persistConfig = {
key: 'root',
version: 1,
storage
}

const persistedReducer = persistReducer(persistConfig, rootReducer)

const store = configureStore({
// 传入持久化后的 reducer
reducer: persistedReducer,
middleware: getDefaultMiddleware => {
const middleware = getDefaultMiddleware({
// 给 thunk 中间件传入自定义的 `extra` 参数
thunk: {
extraArgument: { serviceLayer }
},
// 自定义内置的可序列化检查,忽略 Redux-Persist 特定动作
serializableCheck: {
ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER]
}
}).concat(customMiddleware, api.middleware)

// 在开发环境中条件地添加 logger 中间件
if (process.env.NODE_ENV !== 'production') {
middleware.push(logger)
}

return middleware
},
// 生产环境关闭 devtools,开发时传入额外选项
devTools:
process.env.NODE_ENV === 'production'
? false
: {
stateSanitizer: stateSanitizerForDevtools
}
})

使用 createSlice 创建 Reducers 和 Actions

典型的遗留 Redux 代码库中,reducer 逻辑、action 创建器和 action 类型分散在不同文件,且通常按类型分文件夹管理。reducer 逻辑用 switch 语句和手写不可变更新(使用对象展开和数组映射)实现:

src/constants/todos.js
export const ADD_TODO = 'ADD_TODO'
export const TOGGLE_TODO = 'TOGGLE_TODO'
src/actions/todos.js
import { ADD_TODO, TOGGLE_TODO } from '../constants/todos'

export const addTodo = (id, text) => ({
type: ADD_TODO,
text,
id
})

export const toggleTodo = id => ({
type: TOGGLE_TODO,
id
})
src/reducers/todos.js
import { ADD_TODO, TOGGLE_TODO } from '../constants/todos'

const initialState = []

export default function todosReducer(state = initialState, action) {
switch (action.type) {
case ADD_TODO: {
return state.concat({
id: action.id,
text: action.text,
completed: false
})
}
case TOGGLE_TODO: {
return state.map(todo => {
if (todo.id !== action.id) {
return todo
}

return {
...todo,
completed: !todo.completed
}
})
}
default:
return state
}
}

Redux Toolkit 的 createSlice API 旨在消除编写 reducer、actions 以及不可变更新时的大量“模板代码”!

借助 Redux Toolkit,遗留代码发生了多项变化:

  • createSlice 完全省去了手写 action 创建器和 action 类型
  • 原先那些如 action.textaction.id 的自定义字段都改用统一的 action.payload,要么是单个值,要么是包含相关字段的对象
  • 手写不可变更新被借助 Immer 实现的“可变”写法替代
  • 不再需要为各类型代码写多个文件
  • 建议把特定 reducer 的全部逻辑都放在单个“slice”文件里
  • 不再按“代码类型”分文件夹,建议按“功能”划分,各功能相关代码放同一文件夹
  • 推荐 reducer 和 action 命名使用过去式,描述“已发生的事件”,而非现在式命令,如用 todoAdded 代替 ADD_TODO

将常见的常量、actions、reducers 文件合并成单个“slice”文件后,现代化后的 slice 文件示例如下:

src/features/todos/todosSlice.js
import { createSlice } from '@reduxjs/toolkit'

const initialState = []

const todosSlice = createSlice({
name: 'todos',
initialState,
reducers: {
// 给 case reducer 起有意义的过去时“事件”风格名字
todoAdded(state, action) {
const { id, text } = action.payload
// 使用 Immer 允许“可变”写法,无需返回新状态
state.todos.push({
id,
text,
completed: false
})
},
todoToggled(state, action) {
// 查找具体要更新的嵌套对象
// 此处 `action.payload` 为 action 的默认字段
// 直接包含需要的 id 值,无需单独用 `action.id`
const matchingTodo = state.todos.find(todo => todo.id === action.payload)

if (matchingTodo) {
// 直接“修改”嵌套对象即可
matchingTodo.completed = !matchingTodo.completed
}
}
}
})

// `createSlice` 自动生成了同名的 action 创建函数
// 作为命名导出导出它们
export const { todoAdded, todoToggled } = todosSlice.actions

// 默认导出 slice reducer
export default todosSlice.reducer

调用 dispatch(todoAdded('Buy milk')) 时,传入的单个参数会自动用作 action.payload。如果要传入多个参数,放入对象中,如 dispatch(todoAdded({id, text}))。也可以使用 createSlice 中的“prepare”写法 以支持多参数,并手动构造 payload,它也适合于需要生成唯一 ID 的场景。

尽管 Redux Toolkit 不强制要求文件结构和动作命名,我们推荐最佳实践,因为这些方法能带来更可维护、清晰的代码。

使用 RTK Query 进行数据获取

在 React+Redux 项目中,传统遗留数据请求涉及许多代码和不同类型的逻辑:

  • 表示“请求开始”、“请求成功”、“请求失败”的动作类型和创建函数
  • 用于分发上述动作,并执行异步请求的 thunk
  • 追踪加载状态和缓存数据的 reducer
  • 从 store 读取这些请求状态的 selectors
  • 在组件挂载后通过 componentDidMountuseEffect 调度 thunk

这些代码通常分散在多个文件中:

src/constants/todos.js
export const FETCH_TODOS_STARTED = 'FETCH_TODOS_STARTED'
export const FETCH_TODOS_SUCCEEDED = 'FETCH_TODOS_SUCCEEDED'
export const FETCH_TODOS_FAILED = 'FETCH_TODOS_FAILED'
src/actions/todos.js
import axios from 'axios'
import {
FETCH_TODOS_STARTED,
FETCH_TODOS_SUCCEEDED,
FETCH_TODOS_FAILED
} from '../constants/todos'

export const fetchTodosStarted = () => ({
type: FETCH_TODOS_STARTED
})

export const fetchTodosSucceeded = todos => ({
type: FETCH_TODOS_SUCCEEDED,
todos
})

export const fetchTodosFailed = error => ({
type: FETCH_TODOS_FAILED,
error
})

export const fetchTodos = () => {
return async dispatch => {
dispatch(fetchTodosStarted())

try {
// Axios 常用,也可以用 `fetch` 或自定义 API 服务层
const res = await axios.get('/todos')
dispatch(fetchTodosSucceeded(res.data))
} catch (err) {
dispatch(fetchTodosFailed(err))
}
}
}
src/reducers/todos.js
import {
FETCH_TODOS_STARTED,
FETCH_TODOS_SUCCEEDED,
FETCH_TODOS_FAILED
} from '../constants/todos'

const initialState = {
status: 'uninitialized',
todos: [],
error: null
}

export default function todosReducer(state = initialState, action) {
switch (action.type) {
case FETCH_TODOS_STARTED: {
return {
...state,
status: 'loading'
}
}
case FETCH_TODOS_SUCCEEDED: {
return {
...state,
status: 'succeeded',
todos: action.todos
}
}
case FETCH_TODOS_FAILED: {
return {
...state,
status: 'failed',
todos: [],
error: action.error
}
}
default:
return state
}
}
src/selectors/todos.js
export const selectTodosStatus = state => state.todos.status
export const selectTodos = state => state.todos.todos
src/components/TodosList.js
import { useEffect } from 'react'
import { useSelector, useDispatch } from 'react-redux'
import { fetchTodos } from '../actions/todos'
import { selectTodosStatus, selectTodos } from '../selectors/todos'

export function TodosList() {
const dispatch = useDispatch()
const status = useSelector(selectTodosStatus)
const todos = useSelector(selectTodos)

useEffect(() => {
dispatch(fetchTodos())
}, [dispatch])

// 省略渲染逻辑
}

有些用户使用 redux-saga 管理数据请求,这种情况下可能在 saga 文件中有额外“信号”动作类型用来触发 saga,而非 thunk:

src/sagas/todos.js
import { put, takeEvery, call } from 'redux-saga/effects'
import {
FETCH_TODOS_BEGIN,
fetchTodosStarted,
fetchTodosSucceeded,
fetchTodosFailed
} from '../actions/todos'

// 真正请求数据的 saga
export function* fetchTodos() {
yield put(fetchTodosStarted())

try {
const res = yield call(axios.get, '/todos')
yield put(fetchTodosSucceeded(res.data))
} catch (err) {
yield put(fetchTodosFailed(err))
}
}

// 监听“信号”动作,触发请求逻辑
export function* fetchTodosSaga() {
yield takeEvery(FETCH_TODOS_BEGIN, fetchTodos)
}

以上所有代码都可以被 Redux Toolkit 的 RTK Query 数据获取和缓存层 取代!

RTK Query 省去了手写所有 action、thunk、reducer、selector 和副作用的需要。(实际上它内部使用了同样的工具。)此外,它自动跟踪加载状态,去重请求,管理缓存生命周期(包括移除不再需要的过期数据)。

迁移时,创建单个 RTK Query “API slice” 并将其生成的 reducer 和 middleware 添加到 store

src/features/api/apiSlice.js
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'

export const api = createApi({
baseQuery: fetchBaseQuery({
// 自己服务器的基础 URL 填这里
baseUrl: '/'
}),
endpoints: build => ({})
})
src/app/store.js
import { configureStore } from '@reduxjs/toolkit'

// 引入 API 对象
import { api } from '../features/api/apiSlice'
// 其他切片 reducer 照常导入
import usersReducer from '../features/users/usersSlice'

export const store = configureStore({
reducer: {
// 添加生成的 RTK Query API slice 缓存 reducer
[api.reducerPath]: api.reducer,
// 其它 reducer
users: usersReducer
},
// 添加 RTK Query API middleware
middleware: getDefaultMiddleware =>
getDefaultMiddleware().concat(api.middleware)
})

然后,添加代表具体请求数据的 “endpoints”,并导出自动生成的 React hooks:

src/features/api/apiSlice.js
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'

export const api = createApi({
baseQuery: fetchBaseQuery({
// 自己服务器的基础 URL
baseUrl: '/'
}),
endpoints: build => ({
// 无参数查询端点
getTodos: build.query({
query: () => '/todos'
}),
// 带参数查询端点
userById: build.query({
query: userId => `/users/${userId}`
}),
// 变更数据的 mutation 端点
updateTodo: build.mutation({
query: updatedTodo => ({
url: `/todos/${updatedTodo.id}`,
method: 'POST',
body: updatedTodo
})
})
})
})

export const { useGetTodosQuery, useUserByIdQuery, useUpdateTodoMutation } = api

最后,在组件中使用这些 hooks:

src/features/todos/TodoList.js
import { useGetTodosQuery } from '../api/apiSlice'

export function TodoList() {
const { data: todos, isFetching, isSuccess } = useGetTodosQuery()

// 省略渲染逻辑
}

使用 createAsyncThunk 进行数据获取

**我们明确推荐使用 RTK Query 进行数据请求。**不过,有用户表示尚未准备好采纳此方案。此时,使用 RTK 的 createAsyncThunk 可至少减少大量编写 thunk 和 reducer 的样板代码。它自动生成了动作创建函数和动作类型,执行你提供的异步函数来请求数据,并根据 promise 生命周期自动派发相应动作。使用 createAsyncThunk 的示例代码类似:

src/features/todos/todosSlice
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'
import axios from 'axios'

const initialState = {
status: 'uninitialized',
todos: [],
error: null
}

const fetchTodos = createAsyncThunk('todos/fetchTodos', async () => {
// 直接调用异步请求,返回结果。
// 这会先派发一个 `pending` 动作,
// 根据 promise 结果派发 `fulfilled` 或 `rejected`。
const res = await axios.get('/todos')
return res.data
})

export const todosSlice = createSlice({
name: 'todos',
initialState,
reducers: {
// 可添加额外普通 case reducers,
// 它们也会自动生成动作创建器
},
extraReducers: builder => {
// 监听 slice 外的动作,如 thunk 动作
builder
.addCase(fetchTodos.pending, (state, action) => {
state.status = 'loading'
})
// 使用自动生成的动作创建器
.addCase(fetchTodos.fulfilled, (state, action) => {
// 仍用 Immer 来写“可变”更新
state.status = 'succeeded'
state.todos = action.payload
})
.addCase(fetchTodos.rejected, (state, action) => {
state.status = 'failed'
state.todos = []
state.error = action.error
})
}
})

export default todosSlice.reducer

你仍需手写 selectors,并在 useEffect 中手动触发 fetchTodos thunk。

使用 createListenerMiddleware 实现响应式逻辑

许多 Redux 应用有“响应式”逻辑,需要监听特定动作或状态变更,并触发相应额外逻辑。传统实现通常使用 redux-sagaredux-observable 库。

这些库用途广泛。一个简单示例:saga 和 epic 监听动作,延迟一秒后再派发另一个动作:

src/sagas/ping.js
import { delay, put, takeEvery } from 'redux-saga/effects'

export function* ping() {
yield delay(1000)
yield put({ type: 'PONG' })
}

// 监听“信号”动作,只用于触发逻辑,不直接更新状态
export function* pingSaga() {
yield takeEvery('PING', ping)
}
src/epics/ping.js
import { filter, mapTo } from 'rxjs/operators'
import { ofType } from 'redux-observable'

const pingEpic = action$ =>
action$.pipe(ofType('PING'), delay(1000), mapTo({ type: 'PONG' }))
src/app/store.js
import { createStore, applyMiddleware } from 'redux'
import createSagaMiddleware from 'redux-saga'
import { combineEpics, createEpicMiddleware } from 'redux-observable';

// 省略 reducers

import { pingEpic } from '../sagas/ping'
import { pingSaga } from '../epics/ping'

function* rootSaga() {
yield pingSaga()
}

const rootEpic = combineEpics(
pingEpic
);

const sagaMiddleware = createSagaMiddleware()
const epicMiddleware = createEpicMiddleware()

const middlewareEnhancer = applyMiddleware(sagaMiddleware, epicMiddleware)

const store = createStore(rootReducer, middlewareEnhancer)

sagaMiddleware.run(rootSaga)
epicMiddleware.run(rootEpic)

RTK 的“listener”中间件旨在替代 saga 和 observable,提供更简单的 API、更小的包体积和更好的 TS 支持。

上述 saga 和 epic 代码可以用 listener 中间件替换,示例如下:

src/app/listenerMiddleware.js
import { createListenerMiddleware } from '@reduxjs/toolkit'

// 最好单独建文件定义,避免从 store 文件循环引用
export const listenerMiddleware = createListenerMiddleware()

export const { startListening, stopListening } = listenerMiddleware
src/features/ping/pingSlice.js
import { createSlice } from '@reduxjs/toolkit'
import { startListening } from '../../app/listenerMiddleware'

const pingSlice = createSlice({
name: 'ping',
initialState,
reducers: {
pong(state, action) {
// 这里更新状态
}
}
})

export const { pong } = pingSlice.actions
export default pingSlice.reducer

// `startListening()` 调用的位置可因项目结构而异,这里示范直接写在 slice 文件中
startListening({
// 监听特定动作
actionCreator: pong,
// 每当该动作派发时执行的回调函数
effect: async (action, listenerApi) => {
// listenerApi 提供了常用方法,例如延迟
await listenerApi.delay(1000)
listenerApi.dispatch(pong())
}
})
src/app/store.js
import { configureStore } from '@reduxjs/toolkit'

import { listenerMiddleware } from './listenerMiddleware'

// 省略 reducers

export const store = configureStore({
reducer: rootReducer,
// 在 thunk 和 dev checks 之前添加 listener middleware
middleware: getDefaultMiddleware =>
getDefaultMiddleware().prepend(listenerMiddleware.middleware)
})

现代化使用 TypeScript 的 Redux 逻辑

遗留 Redux TypeScript 代码典型做法非常冗长,特别是为每个动作手动定义 TS 类型,进而创建动作类型联合,限制 dispatch 可接收的动作。

我们强烈反对采用这些繁琐模式!

src/actions/todos.ts
import { ADD_TODO, TOGGLE_TODO } from '../constants/todos'

// ❌ 常用做法:手动定义每个动作对象的类型
interface AddTodoAction {
type: typeof ADD_TODO
text: string
id: string
}

interface ToggleTodoAction {
type: typeof TOGGLE_TODO
id: string
}

// ❌ 常用做法:动作类型联合涵盖所有可能的动作
export type TodoActions = AddTodoAction | ToggleTodoAction

export const addTodo = (id: string, text: string): AddTodoAction => ({
type: ADD_TODO,
text,
id
})

export const toggleTodo = (id: string): ToggleTodoAction => ({
type: TOGGLE_TODO,
id
})
src/reducers/todos.ts
import { ADD_TODO, TOGGLE_TODO, TodoActions } from '../constants/todos'

interface Todo {
id: string
text: string
completed: boolean
}

export type TodosState = Todo[]

const initialState: TodosState = []

export default function todosReducer(
state = initialState,
action: TodoActions
) {
switch (action.type) {
// 省略 reducer 逻辑
default:
return state
}
}
src/app/store.ts
import { createStore, Dispatch } from 'redux'

import { TodoActions } from '../actions/todos'
import { CounterActions } from '../actions/counter'
import { TodosState } from '../reducers/todos'
import { CounterState } from '../reducers/counter'

// 省略 reducer 配置

export const store = createStore(rootReducer)

// ❌ 常用做法:所有动作的联合类型
export type RootAction = TodoActions | CounterActions
// ❌ 常用做法:手动为各个 slice 声明根状态类型
export interface RootState {
todos: TodosState
counter: CounterState
}

// ❌ 常用做法:对 dispatch 可派发的动作做类型限制
export type AppDispatch = Dispatch<RootAction>

Redux Toolkit 设计时充分考虑简化 TS 使用,推荐尽量 自动推断 类型!

根据我们的标准 TypeScript 设置和使用指南,先配置 store 文件,从 store 本身推断 AppDispatchRootState 类型。它们会准确包含任何中间件注入的 dispatch 扩展(比如 thunk)、slice 状态定义变化等,无需手动维护。

app/store.ts
import { configureStore } from '@reduxjs/toolkit'
// 省略其他导入

const store = configureStore({
reducer: {
todos: todosReducer,
counter: counterReducer
}
})

// 从 store 自动推断 `RootState` 和 `AppDispatch` 类型

// 推断状态类型:{todos: TodosState, counter: CounterState}
export type RootState = ReturnType<typeof store.getState>

// 推断 dispatch 类型:Dispatch & ThunkDispatch<RootState, undefined, UnknownAction>
export type AppDispatch = typeof store.dispatch

每个 slice 文件应该声明并导出其 slice state 的类型。为 createSlice.reducers 中的 action 参数使用 PayloadAction 类型声明传入负载的类型。自动生成的动作创建器也会带有正确类型的参数和返回的 action.payload

src/features/todos/todosSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit'

interface Todo {
id: string
text: string
completed: boolean
}

// 声明并导出 slice 状态类型
export type TodosState = Todo[]

const initialState: TodosState = []

const todosSlice = createSlice({
name: 'todos',
// `state` 参数类型会根据 `initialState` 自动推断
initialState,
reducers: {
// 给 `action` 指定有效负载类型
todoAdded(state, action: PayloadAction<{ id: string; text: string }>) {
// 省略具体逻辑
},
todoToggled(state, action: PayloadAction<string>) {
// 省略具体逻辑
}
}
})

使用 React-Redux 现代化 React 组件

迁移 React-Redux 的一般步骤是:

  • 将旧的 React 类组件迁移到函数组件
  • useSelectoruseDispatch 钩子替换组件外层的 connect 包装

你可以针对每个组件单独迁移。带 connect 的组件和 hooks 组件可以同时并存。

本文不涉及类组件迁移为函数组件的过程,侧重于 React-Redux 相关改动。

connect 迁移为 Hooks

典型的遗留组件,使用 React-Redux 的 connect API,可能长这样:

src/features/todos/TodoListItem.js
import { connect } from 'react-redux'
import { bindActionCreators } from 'redux'
import {
todoToggled,
todoDeleted,
selectTodoById,
selectActiveTodoId
} from './todosSlice'

// mapState 函数,通常根据 `ownProps` 返回多个字段
const mapStateToProps = (state, ownProps) => {
return {
todo: selectTodoById(state, ownProps.todoId),
activeTodoId: selectActiveTodoId(state)
}
}

// mapDispatch 写法的多种示例:

// 1) 单独写函数,手动 dispatch 包裹
const mapDispatchToProps = dispatch => {
return {
todoDeleted: id => dispatch(todoDeleted(id)),
todoToggled: id => dispatch(todoToggled(id))
}
}

// 2) 单独的函数,使用 bindActionCreators 包裹
const mapDispatchToProps2 = dispatch => {
return bindActionCreators(
{
todoDeleted,
todoToggled
},
dispatch
)
}

// 3) 动作创建器对象
const mapDispatchToProps3 = {
todoDeleted,
todoToggled
}

// 组件,接收上述所有字段作为 props
function TodoListItem({ todo, activeTodoId, todoDeleted, todoToggled }) {
// 渲染逻辑
}

// 最后调用 connect 包装组件
export default connect(mapStateToProps, mapDispatchToProps)(TodoListItem)

用 React-Redux 钩子 API 后,connect 调用和 mapState/mapDispatch 参数用钩子替换!

  • mapState 返回的每个字段都用一个独立的 useSelector 实现
  • 通过 mapDispatch 传入的每个函数都变成组件内的回调,调用 dispatch 分发对应动作
src/features/todos/TodoListItem.js
import { useState } from 'react'
import { useSelector, useDispatch } from 'react-redux'
import {
todoAdded,
todoToggled,
selectTodoById,
selectActiveTodoId
} from './todosSlice'

export function TodoListItem({ todoId }) {
// 用 `useDispatch` 获取真正的 dispatch 函数
const dispatch = useDispatch()

// 用 `useSelector` 从状态中读取数据
const activeTodoId = useSelector(selectActiveTodoId)
// 用传入的 prop 选择具体数据
const todo = useSelector(state => selectTodoById(state, todoId))

// 声明触发 dispatch 的回调函数,带参数
const handleToggleClick = () => {
dispatch(todoToggled(todoId))
}

const handleDeleteClick = () => {
dispatch(todoDeleted(todoId))
}

// 省略渲染逻辑
}

一个不同点是,connect 会优化渲染性能,只有当 mapState / mapDispatch / ownProps 返回的新 props 发生变化时才会触发组件渲染。钩子因为在组件内,无法自动做到这一点。如果想阻止 React 的默认递归渲染,请自己用 React.memo(MyComponent) 包装组件。

TypeScript 下组件的迁移

connect 的主要缺点之一是非常难以正确使用 TS 类型,声明极其冗长。由于它是 HOC 并且参数多样灵活(4 个参数均可选,重载多),社区对此有多种不同策略。较简单的使用方式需要给 mapState 写 TS 类型,推断组件属性类型:

简单 connect TS 示例
import { connect } from 'react-redux'
import { RootState } from '../../app/store'
import {
todoToggled,
todoDeleted,
selectTodoById,
selectActiveTodoId
} from './todosSlice'

interface TodoListItemOwnProps {
todoId: string
}

const mapStateToProps = (state: RootState, ownProps) => {
return {
todo: selectTodoById(state, ownProps.todoId),
activeTodoId: selectActiveTodoId(state)
}
}

const mapDispatchToProps = {
todoDeleted,
todoToggled
}

type TodoListItemProps = TodoListItemOwnProps &
ReturnType<typeof mapStateToProps> &
typeof mapDispatchToProps

function TodoListItem({
todo,
activeTodoId,
todoDeleted,
todoToggled
}: TodoListItemProps) {}

export default connect(mapStateToProps, mapDispatchToProps)(TodoListItem)

使用 typeof mapDispatch 作为对象存在风险,因它对 thunk 不支持好。

更复杂的社区做法要求将 mapDispatch 写为函数,且用 bindActionCreators 传入带有 Dispatch<RootActions> 类型的 dispatch,或者手动推断所有组件 props 类型,作为泛型传入 connect

稍好一点的方案是 @types/react-redux v7.x 中加入的 ConnectedProps<T>,可推断 connect 传递给组件的所有 props 类型,但需要拆分 connect 调用以利类型推断:

ConnectedProps<T> TS 示例
import { connect, ConnectedProps } from 'react-redux'
import { RootState } from '../../app/store'
import {
todoToggled,
todoDeleted,
selectTodoById,
selectActiveTodoId
} from './todosSlice'

interface TodoListItemOwnProps {
todoId: string
}

const mapStateToProps = (state: RootState, ownProps) => {
return {
todo: selectTodoById(state, ownProps.todoId),
activeTodoId: selectActiveTodoId(state)
}
}

const mapDispatchToProps = {
todoDeleted,
todoToggled
}

// 先调用 connect,得到接收组件的函数
const connector = connect(mapStateToProps, mapDispatchToProps)
// ConnectedProps<T> 工具类型提取所有 Redux 传入的 props 类型
type PropsFromRedux = ConnectedProps<typeof connector>

// 最终组件 props 是 Redux props + 父组件传入的
type TodoListItemProps = PropsFromRedux & TodoListItemOwnProps

function TodoListItem({
todo,
activeTodoId,
todoDeleted,
todoToggled
}: TodoListItemProps) {}

// 导出最终 connect 包装的组件
export default connector(TodoListItem)

React-Redux hooks API 在 TypeScript 下用起来简单得多! 它们是独立函数,带入参数,返回结果,无需关注 HOC 包装层、类型推断、泛型等复杂用法。只需提供 RootStateAppDispatch 类型即可。

基于我们的标准 TypeScript 方案,推荐事先定义带类型的 hooks,保证全局使用的 hooks 类型正确,确保一致。

先定义 hooks:

src/app/hooks.ts
import { useDispatch, useSelector } from 'react-redux'
import type { AppDispatch, RootState } from './store'

// 代替直接使用原生 useDispatch 和 useSelector
export const useAppDispatch = useDispatch.withTypes<AppDispatch>()
export const useAppSelector = useSelector.withTypes<RootState>()

然后在组件中使用:

src/features/todos/TodoListItem.tsx
import { useAppSelector, useAppDispatch } from '../../app/hooks'
import {
todoToggled,
todoDeleted,
selectTodoById,
selectActiveTodoId
} from './todosSlice'

interface TodoListItemProps {
todoId: string
}

function TodoListItem({ todoId }: TodoListItemProps) {
// 用预先定义好的带类型 hooks
const dispatch = useAppDispatch()
const activeTodoId = useAppSelector(selectActiveTodoId)
const todo = useAppSelector(state => selectTodoById(state, todoId))

// 省略事件处理和渲染逻辑
}

更多信息

查阅以下文档和博客文章获取更多详细内容: