资讯动态

TypeScript+React-Redux完整类型方案:从action到组件不出错

发布时间:2026/10/15 12:11:58 来源:尧图企业网站定制
直接说结论TypeScript 搭配 React-Redux 做数据操作最核心的价值不是“能跑”而是在代码写错之前就让错误暴露。很多项目上了 TypeScript 之后Redux 反而变成了最容易让新手崩溃的部分——action 类型定义得稀碎、reducer 里 state 莫名其妙变 any、dispatch 一个不存在的 action 也没人提醒。这篇文章我按自己实际开发中的路径把从类型设计到异步处理再到组件接入的完整流程捋一遍都是可以直接抄的写法。这个方案适合谁看适合已经会用 Redux 写简单 counter、但一碰到 TypeScript 就不知道怎么把类型和状态流给接起来的开发者。也适合团队里准备把 JavaScript 老项目迁到 TypeScript、又不想把 Redux 改成另一种状态库的负责人。下面所有代码我都用真实项目里跑过的写法不是从文档里抄的片段。1. 整体设计思路先想清楚类型要解决什么问题1.1 为什么 Redux 搭配 TypeScript 会让人头疼很多人以为 TypeScript 只是给 JavaScript 加个类型标注实际使用 Redux 时才发现事情没那么简单。Redux 的数据流是“组件 dispatch action → reducer 算新 state → 组件订阅 state”这四个环节里每一环都可能出现类型断层。常见的断层有几种你在组件里 dispatch 一个 actionTypeScript 不知道这个 action 是否存在于你的代码里因为 dispatch 的类型默认是宽松的你在 reducer 里处理action.payload如果 action 被推断成通用的Action类型payload就直接报错你用mapStateToProps接数据的时候state参数如果不是推导出来的而是手写 any那整个状态的类型保护就全部失效了。所以搭配 TypeScript 的第一个原则不要试图把类型写在 Redux 的“外面”而是让类型从 action 的定义开始自动流向 reducer、store、以及组件的 props。这句话是后面所有做法的底层逻辑。1.2 方案选型手写 Redux 还是 Redux Toolkit写这篇文章的时候我必须先说一个事实如果你是新项目我建议直接用 Redux Toolkit。官方的createSlice和createAsyncThunk已经把大量类型推导做进了 API 内部手写 action type 常量的场景少了很多。但我还是要花大量篇幅讲手写 action 和 reducer 的类型化。原因有两个一是很多老项目还在用传统createStore action types的结构你迁移到 TypeScript 时必须知道每个类型注解填在哪二是如果你不理解手写类型流你根本看不懂 Toolkit 到底帮你推导了什么。用一句不太恰当的话讲你得先见过没被工具化的样子才能理解工具的价值。还有一个小选择要做是否使用redux-thunk。如果你用 ToolkitcreateAsyncThunk内部已经集成如果你手写 store需要单独装redux-thunk。我下面的异步部分两种都覆盖。1.3 项目结构按什么组织类型我做项目喜欢按功能模块组织目录而不是按文件类型。比如一个用户模块不会把actions.ts、reducer.ts、types.ts全部平铺而是在store/user/目录下面放这些文件因为改用户功能的时候大概率动作、reducer、类型需要一起改。TypeScript 项目里这样做还有一个好处每个模块的类型声明可以被 module 自己导入导出不会出现一个全局types.d.ts塞满几百个互不相干接口的情况。类型安全的关键不是类型文件有多少而是哪些类型该被导出、哪些类型应该只在模块内部使用。这个想清楚之后后面的每一步都不会乱。2. 核心类型设计从 action 定义到 redux 状态流落地2.1 用字符串字面量定义 action type 常量第一步永远是从定义 action type 常量开始。老项目里最常见的是直接用字符串写死在 action creator 里然后 reducer 的switch里靠手敲字符串匹配。这是 TypeScript 模式下最容易出错的地方因为字符串只要拼错一个单词代码不会报错但 reducer 永远匹配不到。我推荐的做法是先把 type 常量定义成一个as const的对象让 TypeScript 把这些字符串自动收窄成字面量类型export const UserActionTypes { FETCH_USER_REQUEST: user/fetch_request, FETCH_USER_SUCCESS: user/fetch_success, FETCH_USER_FAILURE: user/fetch_failure, UPDATE_USER: user/update, } as const; export type UserActionType (typeof UserActionTypes)[keyof typeof UserActionTypes];这里as const是完整方案里不太起眼但是绝对关键的部分。如果你不加这个UserActionTypes.FETCH_USER_REQUEST的类型是stringreducer 里 switch 的 case 和 action.type 都收窄不了加了之后这个字段的类型就是user/fetch_request这个具体的字面量TypeScript 才能在 switch 里精确判断 branch。注意action type 的前缀建议用模块名/动作名的格式比如user/fetch_request。这样状态很大、模块很多的时候在 DevTools 里一眼能看出 action 来自哪个功能域避免出现两个模块的 action type 碰撞。2.2 创建 action 函数的返回类型定义action creator 的类型看起来简单实际上有个地方很多人会忽略它必须明确标注返回一个可以被 redux 识别的Action子类型。我通常的做法是先定义一个可辨识联合类型discriminated union把所有 action 集中放在一个类型里export interface FetchUserRequestAction { type: typeof UserActionTypes.FETCH_USER_REQUEST; } export interface FetchUserSuccessAction { type: typeof UserActionTypes.FETCH_USER_SUCCESS; payload: UserProfile; } export interface FetchUserFailureAction { type: typeof UserActionTypes.FETCH_USER_FAILURE; payload: string; } export type UserAction | FetchUserRequestAction | FetchUserSuccessAction | FetchUserFailureAction;为什么用 interface 而不是直接用 type因为这个联合类型里的每个成员都需要清晰的 payload 字段定义interface 的可读性更好而且描述对象结构本身就是 interface 的原始用途。联合之后UserAction就变成了一个带type属性的可辨识联合——你拿到一个UserAction只要检查action.typeTypeScript 就能自动推断出这个 action 的其余字段是什么。写 action creator 的时候返回类型直接标成UserAction对应的成员export const fetchUserRequest (): FetchUserRequestAction ({ type: UserActionTypes.FETCH_USER_REQUEST, }); export const fetchUserSuccess (user: UserProfile): FetchUserSuccessAction ({ type: UserActionTypes.FETCH_USER_SUCCESS, payload: user, });这样写的好处是当你在组件里dispatch(fetchUserSuccess(someUser))的时候dispatching 的函数已经不需要依赖任何外部类型校验——函数的入参和返回都由 action creator 自己保证。2.3 reducer 如何推导出完整的 state 类型reducer 的 state 类型通常不是手写一个 interface 就完事而是要尽量从初始 state 推导。原因很现实state 的默认字段经常在联调阶段临时增减如果每次改字段都手改 interface很容易出现 initial state 和 interface 不一致的情况。export interface UserState { profile: UserProfile | null; loading: boolean; error: string | null; } const initialState: UserState { profile: null, loading: false, error: null, };这边有两个细节要特别说。第一profile的类型是UserProfile | null不是UserProfile。因为初始请求没返回时这个字段就是空的用 null 比空对象{}更符合真实数据流。第二error用string | null不建议在 state 里直接用Error对象因为 Redux 的 state 需要可序列化Error实例在 DevTools 里展示不方便而且网络错误通常我们只关心 message 字段。reducer 本体用 switch 加类型守卫export default function userReducer( state initialState, action: UserAction ): UserState { switch (action.type) { case UserActionTypes.FETCH_USER_REQUEST: return { ...state, loading: true, error: null, }; case UserActionTypes.FETCH_USER_SUCCESS: return { ...state, profile: action.payload, loading: false, }; case UserActionTypes.FETCH_USER_FAILURE: return { ...state, error: action.payload, loading: false, }; default: return state; } }这里最关键的一步state参数的类型是UserStateaction参数的类型是UserAction。有了UserAction这个可辨识联合switch 的每个 case 里action.type会被 TypeScript 自动收窄成对应的具体 action 类型所以action.payload在 success case 里是UserProfile在 failure case 里是string完全不需要手动断言。2.4 组合根 reducer 并推导全局 RootState模块多了之后用combineReducers组合起来。这一步有个非常实用的技巧用ReturnType从根 reducer 函数推导出全局状态类型。import { combineReducers } from redux; import userReducer from ./user/reducer; import articleReducer from ./article/reducer; const rootReducer combineReducers({ user: userReducer, article: articleReducer, }); export type RootState ReturnTypetypeof rootReducer;有人会困惑rootReducer不是 createStore 之后才能用类型吗为什么 ReturnType 可以直接在 store 创建之前取这其实是 TypeScript 很基础但又很强大的特性——ReturnTypeT可以直接对一个函数类型取出返回值类型。combineReducers返回的是一个函数这个函数的返回值就是整个状态树的类型所以ReturnTypetypeof rootReducer就是全局状态类型。这样做的意义在于以后你新增一个模块 reducer不需要去任何全局类型文件里手动补充字段RootState 会自动把新模块加进去。我见过太多手写RootStateinterface 的项目每次加模块都要同步改一旦漏掉组件里的state.xxx就全部报错了。提示用ReturnType推导 RootState 是这个方案里性价比最高的一招强烈建议所有项目都这么做。3. store 创建与 dispatch、getState 的类型签名3.1 创建 store 数组时如何正确配置中间件类型store 的创建看起来简单实际上中间件类型是最容易出问题的点。如果你直接用createStore(rootReducer, applyMiddleware(thunk))dispatch函数的类型默认只能接受同步 action你在组件里 dispatch 一个 thunk 函数就会报类型错误。正确做法是创建 store 之后导出两个核心类型import { createStore, applyMiddleware } from redux; import thunk from redux-thunk; import rootReducer from ./reducers; const store createStore(rootReducer, applyMiddleware(thunk)); export type AppDispatch typeof store.dispatch; export type RootState ReturnTypetypeof rootReducer;AppDispatch这个类型的含义很丰富。typeof store.dispatch取的是 store 实例上 dispatch 方法的实际类型当中间件进来之后dispatch 的能力已经从“只能 dispatch Action”扩展成“可以 dispatch 函数 action”的形态。你在组件里用这个类型去 dispatchTypeScript 才会允许dispatch(loadUser())这种异步写法。3.2 手工编写 thunk action creator 的类型不用 Toolkit 的情况下写一个异步 thunk 需要手动定义返回函数的签名。这个签名很多初学者完全不知道怎么下手。拆开看其实只有三个人函数本身返回一个函数这个函数接收dispatch和getState两个参数然后做异步操作。import { AppDispatch, RootState } from ../store; export const loadUser (userId: string) { return async (dispatch: AppDispatch, getState: () RootState) { dispatch(fetchUserRequest()); try { const user await api.fetchUser(userId); dispatch(fetchUserSuccess(user)); } catch (err) { dispatch(fetchUserFailure(err.message)); } }; };这里dispatch的类型是AppDispatchgetState是个函数返回RootState。值得注意的小细节getState的类型我写成() RootState而不是直接RootState因为 redux-thunk 传入的这个参数是一个函数需要调用getState()才能拿到状态。如果你写了getState: RootStateTypeScript 会直接报类型不匹配。为什么getState在这个例子里没有实际用到因为很多异步 action 确实不需要读当前状态。但建议还是把参数写上因为后续如果要做“根据当前状态决定是否发请求”的逻辑这个参数就派上用场了。3.3 用 Redux Toolkit 的 createAsyncThunk 简化异步类型如果你能接受 Toolkit那异步流程可以大幅简化。createAsyncThunk帮你自动生成三个 action typepending、fulfilled、rejected并且 payload 的类型完全由你的 async 函数返回值推导。import { createAsyncThunk, createSlice } from reduxjs/toolkit; export const fetchUser createAsyncThunk( user/fetch, async (userId: string, { rejectWithValue }) { try { const response await api.fetchUser(userId); return response.data; } catch (err) { return rejectWithValue(err.response?.data?.message ?? 请求失败); } } );这里不需要手动写 request/success/failure 三个 action creatorfetchUser.pending、fetchUser.fulfilled、fetchUser.rejected就是三个现成的 action creatorreducer 里直接匹配它们就行const userSlice createSlice({ name: user, initialState, reducers: {}, extraReducers: (builder) { builder .addCase(fetchUser.pending, (state) { state.loading true; state.error null; }) .addCase(fetchUser.fulfilled, (state, action) { state.loading false; state.profile action.payload; }) .addCase(fetchUser.rejected, (state, action) { state.loading false; state.error action.payload as string; }); }, });需要注意的一点createAsyncThunk的rejectWithValue返回的类型是unknown或SerializedError如果你自定义了错误 payload在rejected里拿到action.payload时可能需要一次类型收窄或断言。别的部分基本都是全自动类型推导。4. 组件侧的数据接入让组件里的每个类型都干干净净4.1 类组件与函数组件不同的类型方式现在新项目基本都是函数组件加 hooks但老项目里类组件还大量存在。先说说类组件的连接方式再讲 hooks因为不少迁移项目是还残存着类组件的。类组件里如果用connect最标准的类型做法是定义三个接口分别是OwnProps父组件传入的 props、StateProps从 state 映射的 props、DispatchPropsdispatch 映射的 propsinterface OwnProps { userId: string; } interface StateProps { profile: UserProfile | null; loading: boolean; } interface DispatchProps { loadUser: (userId: string) void; } type Props StateProps DispatchProps OwnProps; class UserProfileView extends React.ComponentProps { componentDidMount() { this.props.loadUser(this.props.userId); } render() { const { profile, loading } this.props; // ... } } const mapStateToProps (state: RootState): StateProps ({ profile: state.user.profile, loading: state.user.loading, }); const mapDispatchToProps (dispatch: AppDispatch): DispatchProps ({ loadUser: (userId) dispatch(loadUser(userId)), }); export default connect(mapStateToProps, mapDispatchToProps)(UserProfileView);这个写法最核心的好处是mapStateToProps的返回值类型被显式标注为StateProps如果 state 里字段名写错了TypeScript 会直接告诉你是字段名不匹配还是类型不对。mapDispatchToProps里的dispatch类型用AppDispatch否则 dispatch 一个 thunk action 又报错。4.2 函数组件用 typed hooks 替代 connect函数组件用 hooks 要顺手得多。但有一个强烈的建议不要在组件文件里直接const dispatch useDispatch()然后到处用而是先在 store 目录里定义好 typed hooks。// store/hooks.ts import { TypedUseSelectorHook, useDispatch, useSelector } from react-redux; import type { RootState, AppDispatch } from ./store; export const useAppDispatch () useDispatchAppDispatch(); export const useAppSelector: TypedUseSelectorHookRootState useSelector;这一步的意义在于useSelector默认是不知道你的 RootState 类型的你每次使用都要手动传泛型useSelectorRootState, ...写多了烦且容易漏。用TypedUseSelectorHook包装一次之后所有组件里只需要 是 export const useAppSelector: TypedUseSelectorHook useSelector; 这行。这样组件里const profile useAppSelector((state) state.user.profile); const loading useAppSelector((state) state.user.loading);state.user.profile的profile字段类型自动就是UserProfile | null完全不需要任何注解。dispatch也是如此const dispatch useAppDispatch(); dispatch(fetchUser(123));这里dispatch的类型是AppDispatchfetchUser这个 thunk action 的参数类型由createAsyncThunk自动推导传错类型比如传 number会立即标红。4.3 组件中处理异步状态的条件渲染类型保护组件里处理loading、error、profile三态时类型设计会影响条件渲染代码的复杂度。最常见的误区是直接判断profile是否存在就渲染完全忽略 loading 状态。const UserProfileComponent: React.FC () { const dispatch useAppDispatch(); const profile useAppSelector((state) state.user.profile); const loading useAppSelector((state) state.user.loading); const error useAppSelector((state) state.user.error); useEffect(() { dispatch(fetchUser(123)); }, [dispatch]); if (loading) { return div加载中.../div; } if (error) { return div加载失败{error}/div; } if (!profile) { return div暂无用户数据/div; } return ( div h1{profile.name}/h1 p{profile.email}/p /div ); };注意最后那段 return 之前TypeScript 已经通过if (!profile)把profile从UserProfile | null收窄成UserProfile所以profile.name和profile.email是安全的不会有空指针类型错误。这是类型收窄在真实 UI 代码里最直接的应用写完基本不用为运行时错误担惊受怕。5. 常见问题与排查技巧我踩过的几个比较典型的坑5.1 action.type 永远匹配不上的隐性问题一个最容易踩的坑是 action type 常量写了两套。比如某个模块里既有export const FETCH_USER user/fetch又有export const fetchUser () ({ type: user/fetch })你觉得自己没写错但FETCH_USER的类型是string而 action creator 返回的type字段在字面量推断下是user/fetch这个特定字符串类型。reducer 里case FETCH_USER:看着没问题实际上 TypeScript 根本不会帮你发现这两个 type 是否一致。我的排查方法很土但有效把 action type 全部集中到一个as const对象然后在 action creator 和 reducer 里都从同一个对象引用。如果 type 拼错了TypeScript 会直接报错。不要相信自己的记忆只相信同一来源的常量。5.2 dispatch 接收 thunk 函数时报类型错误这个问题几乎每个从 JS 迁到 TS 的人都会遇到明明装了redux-thunkdispatch一个 async action 时 TypeScript 提示该参数不可赋值给AnyAction。原因十有八九是你在创建 store 之后没有重新导出dispatch类型或者你在组件里用的是import { useDispatch } from react-redux而不是自定义的useAppDispatch。useDispatch的默认类型是DispatchAnyAction它不认识函数类型的 action。解决办法就是前面说的两个动作一是创建 store 后导出AppDispatch typeof store.dispatch二是自定义useAppDispatch并显式指定泛型。两步都做这个问题就彻底消失了。5.3 RootState 类型缺失导致 useSelector 报错很多人在定义useSelector时手动写(state: RootState) ...但如果 RootState 没有被导出或者是从哪个不存在的路径导入就会报“RootState 类型找不到”的错误。还有一个隐蔽情况如果你用的是combineReducers并且 store 文件里没有显式导出 RootState外部文件根本拿不到这个类型。标准做法是把 RootState 和 AppDispatch 放在 store 文件里显式export然后所有 tsx 文件从 store 目录导入。尽量少用把类型定义堆在types.ts文件里的方式因为很容易出现循环导入。5.4 不要让 action 类型里出现 any这里我必须说一句可能不太好听的话如果在 TypeScript Redux 项目里你的 action 类型里出现了any那基本等于你前面的类型设计白做了。action.payload: any会直接污染 reducer 里对 payload 的处理TypeScript 不再帮你检查任何结构问题。如果你觉得某个 action 的 payload 数据结构还没定宁可用unknown加用户自定义类型守卫也别用any。至少这样后续收窄的时候编译器还能帮你确认逻辑分支是否完整。提示很多人在写useSelector的 selector 时习惯把state标成any来偷懒这会让你永远拿不到类型提示是最不划算的做法。正确方式是让 RootState 推导出来然后尽情享受编辑器补全。5.5 异步 action 已经返回但 UI 迟迟不更新这个问题的现象是数据请求成功了DevTools 里 state 也已经变化但 UI 不刷新。排除了 React 层问题后我见过最多的原因是 reducer 直接改写了旧 state 对象的某个字段而不是返回一个新对象。// 错误写法state 被原位修改 case UserActionTypes.FETCH_USER_SUCCESS: state.profile action.payload; state.loading false; return state; // 正确写法返回新对象 case UserActionTypes.FETCH_USER_SUCCESS: return { ...state, profile: action.payload, loading: false, };这类问题 JavaScript 时代靠自觉TypeScript 时代可以靠 lint 规则辅助比如在 ESLint 配置里开启redux/reducer-in-mutate规则。每次提交代码前先跑一次 lint大量这类坑能提前暴露。6. 项目迁移的几个实用建议6.1 渐进式迁移比一次性推翻更稳妥如果你是一个老的 JavaScript Redux 项目要迁到 TypeScript最忌讳的是花一两周把所有文件全部改成 .ts 然后 commit 一次巨大的 diff。我建议按模块拆分第一个星期先迁 store 和 reducer把 RootState 和 AppDispatch 的类型定义好第二个星期迁功能模块的 action 和业务代码最后再统一处理组件文件的 import 类型报错。这样做的最大好处是每次报错范围都可控而且 RootState 一旦被定义好后续组件迁移的时候类型补全效率会直线上升因为你的编辑器能持续从 RootState 里拿到 state 的字段提示。6.2 用 createSlice 之后需要注意的 reducer 命名用 Toolkit 的createSlice后不需要再手写switch但它生成的 action type 是${name}/${reducerName}不如手写的字符串直观。比如createSlice({ name: user, reducers: { setUser(state, action) {...} } })生成的 action type 是user/setUser。如果你和别的库有联动需要手动判断这个 action type 的时候记得用常量引用 action creator 的.type属性不要硬编码字符串。userSlice.actions.setUser.type才是稳定的来源。6.3 遇到复杂嵌套状态的类型处理不要硬刚有人喜欢把状态设计成很深的嵌套结构比如state.user.detail.address.city。这种结构在 Redux TypeScript 下维护成本非常高因为每一层都要有对应的 interface而更新任何一个内层字段都要写一整串展开操作。我的建议是尽量把这种嵌套状态扁平化。比如把address提取成独立的实体模块或者用combineReducers把地址模块单独管理。遇到getState()里需要多模块数据组合的时候写一个 selector 函数来拼接类型自然清晰很多。7. 编辑器与开发工具链的配置项7.1 tsconfig 里值得开启的几个编译选项Redux 项目对类型推导的依赖比较高tsconfig 的几个选项直接影响开发体验。strict: true是必须的没有它很多类型错误会静默通过noImplicitAny一般随 strict 自动开启你要注意 reducer 和 action creator 里不要让 TypeScript 悄悄给你推断成 any。另一个推荐是开启noUncheckedIndexedAccess。这个选项在做数组或字典类型的 state 时非常有用它会提醒你state.users[id]可能返回 undefined。很多人第一次开会比较难受因为代码里到处要处理 undefined但恰好是这个难受能逼着你处理边界情况而不是让运行时崩给你看。7.2 VSCode 环境下的提示效果最大化配合好的编辑器这套类型方案能发挥到极限。在 VSCode 里使用useAppSelector时你写state.后编辑器会自动列出user、article等根字段继续往后写字段的类型和注释也会跟着补全。如果看不到这些提示检查一下项目里jsconfig.json或tsconfig.json是不是被某种 exclude 配置覆盖了。还有一个日常习惯遇到某个类型不确定时先把鼠标悬停在变量上看推断结果不要急着写类型注解。很多时候 TypeScript 已经推导出正确类型你只需要 verify 一下自己的理解是否一致。写到这里其实 TypeScript 搭配 React-Redux 这条路是有固定章法的——action 是唯一信源reducer 只信推演的 action 联合类型store 导出 RootState 和 AppDispatch组件通过 typed hooks 消费。每层之间都用类型焊死少一个环节后面就会开始漏风。按照这个思路去改造或新建项目前期可能比纯 JavaScript 多花一点时间但后面维护的时候你会明显感觉到大部分低级错误在启动编译的那一秒钟就被拦下来了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑