跳至主要内容

使用 TypeScript

您将学到什么
  • 使用 TypeScript 设置 Redux 应用程序的标准模式
  • 正确为 Redux 逻辑部分进行类型化的技巧
先决条件

概述

TypeScript 是 JavaScript 的类型化超集,它提供源代码的编译时检查。当与 Redux 一起使用时,TypeScript 可以帮助提供

  1. 对 reducer、状态和 action creator 以及 UI 组件的类型安全
  2. 轻松重构类型化代码
  3. 在团队环境中提供更优越的开发体验

我们强烈建议在 Redux 应用程序中使用 TypeScript。但是,与所有工具一样,TypeScript 也有权衡取舍。它在编写额外代码、理解 TS 语法和构建应用程序方面增加了复杂性。同时,它通过在开发的早期阶段捕获错误、实现更安全和更高效的重构以及充当现有源代码的文档来提供价值。

我们相信 TypeScript 的务实使用 提供了足够的价值和益处来证明额外的开销是合理的,尤其是在更大的代码库中,但您应该花时间 评估权衡取舍并决定是否值得在自己的应用程序中使用 TS

有多种方法可以对 Redux 代码进行类型检查。此页面展示了我们使用 Redux 和 TypeScript 协同工作的标准推荐模式,并非详尽的指南。遵循这些模式应该可以带来良好的 TS 使用体验,并 在类型安全和您必须添加到代码库中的类型声明数量之间取得最佳权衡

使用 TypeScript 的标准 Redux Toolkit 项目设置

我们假设一个典型的 Redux 项目同时使用 Redux Toolkit 和 React Redux。

Redux Toolkit (RTK) 是编写现代 Redux 逻辑的标准方法。RTK 已经用 TypeScript 编写,其 API 旨在为 TypeScript 使用提供良好的体验。

React Redux 在一个单独的 @types/react-redux 类型定义包 中包含其类型定义。除了对库函数进行类型化之外,这些类型还导出了一些帮助程序,使编写 Redux 存储和 React 组件之间类型安全的接口变得更加容易。

从 React Redux v7.2.3 开始,react-redux 包依赖于 @types/react-redux,因此类型定义将与库一起自动安装。否则,您需要手动安装它们(通常是 npm install @types/react-redux)。

用于 Create-React-App 的 Redux+TS 模板 附带了已配置的这些模式的工作示例。

定义根状态和调度类型

使用 configureStore 不需要任何额外的类型化。但是,您需要提取 RootState 类型和 Dispatch 类型,以便可以根据需要引用它们。从存储本身推断这些类型意味着它们会在您添加更多状态切片或修改中间件设置时正确更新。

由于这些是类型,因此可以安全地将它们直接从存储设置文件(例如 app/store.ts)导出,并直接导入到其他文件中。

app/store.ts
import { configureStore } from '@reduxjs/toolkit'
// ...

export const store = configureStore({
reducer: {
posts: postsReducer,
comments: commentsReducer,
users: usersReducer
}
})

// Infer the `RootState` and `AppDispatch` types from the store itself
export type RootState = ReturnType<typeof store.getState>
// Inferred type: {posts: PostsState, comments: CommentsState, users: UsersState}
export type AppDispatch = typeof store.dispatch

定义类型化钩子

虽然可以将 RootStateAppDispatch 类型导入到每个组件中,但最好为应用程序中的使用创建 useDispatchuseSelector 钩子的预类型化版本。这对于几个原因很重要

  • 对于 useSelector,它可以节省您每次都键入 (state: RootState) 的需要
  • 对于 useDispatch,默认的 Dispatch 类型不知道 thunk 或其他中间件。为了正确调度 thunk,您需要使用来自存储的特定自定义 AppDispatch 类型(包括 thunk 中间件类型),并将其与 useDispatch 一起使用。添加预类型化的 useDispatch 钩子可以防止您忘记在需要的地方导入 AppDispatch

由于这些是实际的变量,而不是类型,因此在单独的文件(例如 app/hooks.ts)中定义它们很重要,而不是存储设置文件。这使您可以将它们导入到需要使用这些钩子的任何组件文件中,并避免潜在的循环导入依赖关系问题。

.withTypes()

以前,使用应用程序设置“预类型化”钩子的方法略有不同。结果看起来像下面的代码片段

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

// Use throughout your app instead of plain `useDispatch` and `useSelector`
export const useAppDispatch: () => AppDispatch = useDispatch
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector
export const useAppStore: () => AppStore = useStore

React Redux v9.1.0 为这些钩子中的每一个添加了一个新的 .withTypes 方法,类似于 Redux Toolkit 的 createAsyncThunk 上的 .withTypes 方法。

现在设置变为

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

// Use throughout your app instead of plain `useDispatch` and `useSelector`
export const useAppDispatch = useDispatch.withTypes<AppDispatch>()
export const useAppSelector = useSelector.withTypes<RootState>()
export const useAppStore = useStore.withTypes<AppStore>()

应用程序使用

定义切片状态和操作类型

每个切片文件都应该为其初始状态值定义一个类型,以便 createSlice 可以正确推断每个 case reducer 中 state 的类型。

所有生成的 action 应该使用 Redux Toolkit 中的 PayloadAction<T> 类型定义,该类型将 action.payload 字段的类型作为其泛型参数。

您可以安全地从 store 文件中导入 RootState 类型。这是一个循环导入,但 TypeScript 编译器可以正确处理类型。这可能需要用于编写选择器函数等用例。

features/counter/counterSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
import type { RootState } from '../../app/store'

// Define a type for the slice state
interface CounterState {
value: number
}

// Define the initial state using that type
const initialState: CounterState = {
value: 0
}

export const counterSlice = createSlice({
name: 'counter',
// `createSlice` will infer the state type from the `initialState` argument
initialState,
reducers: {
increment: state => {
state.value += 1
},
decrement: state => {
state.value -= 1
},
// Use the PayloadAction type to declare the contents of `action.payload`
incrementByAmount: (state, action: PayloadAction<number>) => {
state.value += action.payload
}
}
})

export const { increment, decrement, incrementByAmount } = counterSlice.actions

// Other code such as selectors can use the imported `RootState` type
export const selectCount = (state: RootState) => state.counter.value

export default counterSlice.reducer

生成的 action 创建者将被正确类型化为接受一个 payload 参数,该参数基于您为 reducer 提供的 PayloadAction<T> 类型。例如,incrementByAmount 需要一个 number 作为其参数。

在某些情况下,TypeScript 可能会不必要地收紧初始状态的类型。如果发生这种情况,您可以通过使用 as 转换初始状态来解决它,而不是声明变量的类型

// Workaround: cast state instead of declaring variable type
const initialState = {
value: 0
} as CounterState

在组件中使用类型化钩子

在组件文件中,导入预类型化的钩子,而不是从 React Redux 中导入标准钩子。

features/counter/Counter.tsx
import React, { useState } from 'react'

import { useAppSelector, useAppDispatch } from 'app/hooks'

import { decrement, increment } from './counterSlice'

export function Counter() {
// The `state` arg is correctly typed as `RootState` already
const count = useAppSelector(state => state.counter.value)
const dispatch = useAppDispatch()

// omit rendering logic
}
警告错误的导入

ESLint 可以帮助您的团队轻松导入正确的钩子。 typescript-eslint/no-restricted-imports 规则可以在意外使用错误导入时显示警告。

您可以将此添加到您的 ESLint 配置中作为示例

"no-restricted-imports": "off",
"@typescript-eslint/no-restricted-imports": [
"warn",
{
"name": "react-redux",
"importNames": ["useSelector", "useDispatch"],
"message": "Use typed hooks `useAppDispatch` and `useAppSelector` instead."
}
],

键入其他 Redux 逻辑

类型检查 Reducer

Reducer 是纯函数,接收当前 state 和传入的 action 作为参数,并返回一个新的状态。

如果您使用的是 Redux Toolkit 的 createSlice,您应该很少需要单独专门键入 reducer。如果您确实编写了一个独立的 reducer,通常只需声明 initialState 值的类型,并将 action 键入为 UnknownAction

import { UnknownAction } from 'redux'

interface CounterState {
value: number
}

const initialState: CounterState = {
value: 0
}

export default function counterReducer(
state = initialState,
action: UnknownAction
) {
// logic here
}

但是,Redux 核心确实导出了一个 Reducer<State, Action> 类型,您也可以使用它。

类型检查中间件

中间件 是 Redux 存储的扩展机制。中间件被组合成一个管道,它包装了存储的 dispatch 方法,并可以访问存储的 dispatchgetState 方法。

Redux 核心导出一个 Middleware 类型,可用于正确键入中间件函数

export interface Middleware<
DispatchExt = {}, // optional override return behavior of `dispatch`
S = any, // type of the Redux store state
D extends Dispatch = Dispatch // type of the dispatch method
>

自定义中间件应该使用 `Middleware` 类型,并在需要时传递 `S`(状态)和 `D`(调度)的泛型参数。

import { Middleware } from 'redux'

import { RootState } from '../store'

export const exampleMiddleware: Middleware<
{}, // Most middleware do not modify the dispatch return value
RootState
> = storeApi => next => action => {
const state = storeApi.getState() // correctly typed as RootState
}
注意

如果您使用的是 `typescript-eslint`,`@typescript-eslint/ban-types` 规则可能会在您对调度值使用 `{}` 时报告错误。它建议的更改是不正确的,会导致您的 Redux 存储类型出现问题,您应该为该行禁用该规则并继续使用 `{}`。

调度泛型可能只在您在中间件中调度额外的 thunk 时才需要。

在使用 `type RootState = ReturnType<typeof store.getState>` 的情况下,可以通过将 `RootState` 的类型定义更改为以下方式来避免中间件和存储定义之间的 循环类型引用

const rootReducer = combineReducers({ ... });
type RootState = ReturnType<typeof rootReducer>;

使用 Redux Toolkit 的示例更改 `RootState` 的类型定义

//instead of defining the reducers in the reducer field of configureStore, combine them here:
const rootReducer = combineReducers({ counter: counterReducer })

//then set rootReducer as the reducer object of configureStore
const store = configureStore({
reducer: rootReducer,
middleware: getDefaultMiddleware =>
getDefaultMiddleware().concat(yourMiddleware)
})

type RootState = ReturnType<typeof rootReducer>

类型检查 Redux Thunk

Redux Thunk 是用于编写与 Redux 存储交互的同步和异步逻辑的标准中间件。thunk 函数接收 `dispatch` 和 `getState` 作为其参数。Redux Thunk 具有内置的 `ThunkAction` 类型,我们可以使用它来定义这些参数的类型。

export type ThunkAction<
R, // Return type of the thunk function
S, // state type used by getState
E, // any "extra argument" injected into the thunk
A extends Action // known types of actions that can be dispatched
> = (dispatch: ThunkDispatch<S, E, A>, getState: () => S, extraArgument: E) => R

您通常需要提供 `R`(返回值类型)和 `S`(状态)泛型参数。不幸的是,TS 不允许只提供一些泛型参数,因此其他参数的常用值为 `unknown`(对于 `E`)和 `UnknownAction`(对于 `A`)。

import { UnknownAction } from 'redux'
import { sendMessage } from './store/chat/actions'
import { RootState } from './store'
import { ThunkAction } from 'redux-thunk'

export const thunkSendMessage =
(message: string): ThunkAction<void, RootState, unknown, UnknownAction> =>
async dispatch => {
const asyncResp = await exampleAPI()
dispatch(
sendMessage({
message,
user: asyncResp,
timestamp: new Date().getTime()
})
)
}

function exampleAPI() {
return Promise.resolve('Async Chat Bot')
}

为了减少重复,您可能希望在您的存储文件中定义一个可重用的 `AppThunk` 类型,然后在编写 thunk 时使用该类型。

export type AppThunk<ReturnType = void> = ThunkAction<
ReturnType,
RootState,
unknown,
UnknownAction
>

请注意,这假设 thunk 没有有意义的返回值。如果您的 thunk 返回一个 promise,并且您希望 在调度 thunk 后使用返回的 promise,您需要将其用作 `AppThunk<Promise<SomeReturnType>>`。

注意

不要忘记**默认的useDispatch钩子不知道thunk**,因此分派thunk会导致类型错误。请务必在您的组件中使用更新的Dispatch形式,该形式将thunk识别为可接受的派发类型

与 React Redux 一起使用

虽然React Redux是一个独立于 Redux 本身的库,但它通常与 React 一起使用。

有关如何在 TypeScript 中正确使用 React Redux 的完整指南,请参阅React Redux 文档中的“静态类型”页面。本节将重点介绍标准模式。

如果您使用的是 TypeScript,React Redux 类型在 DefinitelyTyped 中单独维护,但作为 react-redux 包的依赖项包含在内,因此它们应该自动安装。如果您仍然需要手动安装它们,请运行

npm install @types/react-redux

useSelector 钩子添加类型

在选择器函数中声明state参数的类型,useSelector的返回类型将被推断为与选择器的返回类型匹配

interface RootState {
isOn: boolean
}

// TS infers type: (state: RootState) => boolean
const selectIsOn = (state: RootState) => state.isOn

// TS infers `isOn` is boolean
const isOn = useSelector(selectIsOn)

这也可以在内联中完成

const isOn = useSelector((state: RootState) => state.isOn)

但是,最好使用预先添加类型的useAppSelector钩子,其中内置了正确的state类型。

useDispatch 钩子添加类型

默认情况下,useDispatch的返回值是 Redux 核心类型定义的标准Dispatch类型,因此不需要声明

const dispatch = useDispatch()

但是,最好使用预先添加类型的useAppDispatch钩子,其中内置了正确的Dispatch类型。

connect 高阶组件添加类型

如果您仍然使用connect,您应该使用@types/react-redux^7.1.2导出的ConnectedProps<T>类型来自动推断connect的 props 类型。这需要将connect(mapState, mapDispatch)(MyComponent)调用拆分为两个部分

import { connect, ConnectedProps } from 'react-redux'

interface RootState {
isOn: boolean
}

const mapState = (state: RootState) => ({
isOn: state.isOn
})

const mapDispatch = {
toggleOn: () => ({ type: 'TOGGLE_IS_ON' })
}

const connector = connect(mapState, mapDispatch)

// The inferred type will look like:
// {isOn: boolean, toggleOn: () => void}
type PropsFromRedux = ConnectedProps<typeof connector>

type Props = PropsFromRedux & {
backgroundColor: string
}

const MyComponent = (props: Props) => (
<div style={{ backgroundColor: props.backgroundColor }}>
<button onClick={props.toggleOn}>
Toggle is {props.isOn ? 'ON' : 'OFF'}
</button>
</div>
)

export default connector(MyComponent)

与 Redux Toolkit 一起使用

使用 TypeScript 的标准 Redux Toolkit 项目设置部分已经涵盖了configureStorecreateSlice的正常使用模式,以及Redux Toolkit 的“使用 TypeScript”页面详细介绍了所有 RTK API。

以下是使用 RTK 时您会经常看到的一些额外输入模式。

输入 configureStore

configureStore 从提供的根 reducer 函数推断状态值的类型,因此不需要任何特定的类型声明。

如果您想向商店添加额外的中间件,请务必使用 getDefaultMiddleware() 返回的数组中包含的专用 .concat().prepend() 方法,因为这些方法将正确保留您添加的中间件的类型。(使用普通的 JS 数组展开通常会丢失这些类型。)

const store = configureStore({
reducer: rootReducer,
middleware: getDefaultMiddleware =>
getDefaultMiddleware()
.prepend(
// correctly typed middlewares can just be used
additionalMiddleware,
// you can also type middlewares manually
untypedMiddleware as Middleware<
(action: Action<'specialAction'>) => number,
RootState
>
)
// prepend and concat calls can be chained
.concat(logger)
})

匹配操作

RTK 生成的操作创建者有一个 match 方法,它充当 类型谓词。调用 someActionCreator.match(action) 将对 action.type 字符串进行字符串比较,如果用作条件,则将 action 的类型缩小为正确的 TS 类型

const increment = createAction<number>('increment')
function test(action: Action) {
if (increment.match(action)) {
// action.payload inferred correctly here
const num = 5 + action.payload
}
}

这在 Redux 中间件(例如自定义中间件、redux-observable 和 RxJS 的 filter 方法)中检查操作类型时特别有用。

输入 createSlice

定义单独的案例 reducer

如果您有太多案例 reducer,并且内联定义它们会很混乱,或者您想在切片之间重用案例 reducer,您也可以在 createSlice 调用之外定义它们,并将它们类型化为 CaseReducer

type State = number
const increment: CaseReducer<State, PayloadAction<number>> = (state, action) =>
state + action.payload

createSlice({
name: 'test',
initialState: 0,
reducers: {
increment
}
})

输入 extraReducers

如果您在 createSlice 中添加了 extraReducers 字段,请务必使用“构建器回调”形式,因为“普通对象”形式无法正确推断操作类型。将 RTK 生成的操作创建者传递给 builder.addCase() 将正确推断 action 的类型

const usersSlice = createSlice({
name: 'users',
initialState,
reducers: {
// fill in primary logic here
},
extraReducers: builder => {
builder.addCase(fetchUserById.pending, (state, action) => {
// both `state` and `action` are now correctly typed
// based on the slice state and the `pending` action creator
})
}
})

输入 prepare 回调

如果您想向操作添加 metaerror 属性,或自定义操作的 payload,则必须使用 prepare 符号来定义案例 reducer。在 TypeScript 中使用此符号看起来像

const blogSlice = createSlice({
name: 'blogData',
initialState,
reducers: {
receivedAll: {
reducer(
state,
action: PayloadAction<Page[], string, { currentPage: number }>
) {
state.all = action.payload
state.meta = action.meta
},
prepare(payload: Page[], currentPage: number) {
return { payload, meta: { currentPage } }
}
}
}
})

修复导出切片中的循环类型

最后,在极少数情况下,您可能需要使用特定类型导出切片 reducer,以解决循环类型依赖问题。这可能看起来像

export default counterSlice.reducer as Reducer<Counter>

输入 createAsyncThunk

对于基本用法,您只需要为createAsyncThunk提供其负载创建回调的单个参数的类型。您还应确保回调的返回值类型正确。

const fetchUserById = createAsyncThunk(
'users/fetchById',
// Declare the type your function argument here:
async (userId: number) => {
const response = await fetch(`https://reqres.in/api/users/${userId}`)
// Inferred return type: Promise<MyData>
return (await response.json()) as MyData
}
)

// the parameter of `fetchUserById` is automatically inferred to `number` here
// and dispatching the resulting thunkAction will return a Promise of a correctly
// typed "fulfilled" or "rejected" action.
const lastReturnedAction = await store.dispatch(fetchUserById(3))

如果您需要修改thunkApi参数的类型,例如提供getState()返回的state的类型,则必须提供返回类型和负载参数的前两个泛型参数,以及对象中相关的“thunkApi 参数字段”。

const fetchUserById = createAsyncThunk<
// Return type of the payload creator
MyData,
// First argument to the payload creator
number,
{
// Optional fields for defining thunkApi field types
dispatch: AppDispatch
state: State
extra: {
jwt: string
}
}
>('users/fetchById', async (userId, thunkApi) => {
const response = await fetch(`https://reqres.in/api/users/${userId}`, {
headers: {
Authorization: `Bearer ${thunkApi.extra.jwt}`
}
})
return (await response.json()) as MyData
})

createEntityAdapter添加类型

createEntityAdapter添加类型只需要您指定实体类型作为唯一的泛型参数。这通常看起来像

interface Book {
bookId: number
title: string
// ...
}

const booksAdapter = createEntityAdapter({
selectId: (book: Book) => book.bookId,
sortComparer: (a, b) => a.title.localeCompare(b.title)
})

const booksSlice = createSlice({
name: 'books',
// The type of the state is inferred here
initialState: booksAdapter.getInitialState(),
reducers: {
bookAdded: booksAdapter.addOne,
booksReceived(state, action: PayloadAction<{ books: Book[] }>) {
booksAdapter.setAll(state, action.payload.books)
}
}
})

其他建议

使用 React Redux Hooks API

我们建议使用 React Redux hooks API 作为默认方法。hooks API 在 TypeScript 中使用起来要简单得多,因为useSelector是一个简单的 hook,它接受一个选择器函数,返回值类型可以从state参数的类型轻松推断出来。

虽然connect仍然可以正常工作,并且可以添加类型,但正确添加类型要困难得多。

避免操作类型联合

我们特别建议不要尝试创建操作类型的联合,因为它没有实际的好处,实际上在某些方面会误导编译器。有关这为什么是一个问题的解释,请参阅 RTK 维护者 Lenz Weber 的文章 不要使用 Redux 操作类型创建联合类型

此外,如果您使用的是createSlice,您已经知道该切片定义的所有操作都已正确处理。

资源

有关更多信息,请参阅以下其他资源