React + Redux + TypeScript 实践三:Redux 设计

本文将介绍如何把 Redux 中优秀的设计模式和 TypeScript 这门语言结合起来,全文会围绕 Redux store 设计、Redux reducer 设计、Redux action 的设计以及常用异步中间件(redux-thunkredux-promise-middleware)的使用展开。

接着实践二讲,本文要实现的功能是,在页面加载完成之前,通过 Redux 请求 /user/detail 接口获取用户基本信息并把信息写入到对应的 store 中。

页面组件 User 的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 文件: client/pages/user/index.tsx
import {Spin, Input} from 'antd'
// User 组件
class User extends React.Component {
render() {
...
return (
<Spin spinning={ui.loading}>
<strong>用户姓名:</strong>
<Input value={user.name} />
<strong>用户年龄:</strong>
<Input value={user.age} />
<strong>所属部门:</strong>
<Input value={user.department} />
</Spin>
)
}
}
  • Redux store 设计

基于 Ducks 架构,我们会根据每个页面状态划分出一个独立的 state,将这些页面的 state 组合在一起形成一个完整的 store 状态树。以 User 页面为例,根据上述 User 页面组件的代码可以看出 User 组件依赖以下四个属性:

name type description
loading boolean 页面是否处于加载
name string 用户姓名
age string 用户年龄
department string 用户所属部门

其中 loading 属于页面 UI 状态,nameagedepartment 属于请求 /user/detail 接口获取到的一部分数据,我们可以简单地把没有业务属性UI 状态划分为一类数据,其他数据再按照各自的业务属性划分成不同类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 文件: client/types/store.d.ts
// User 组件状态接口
interface IUserState {
ui: {
loading: boolean
}
user: IUser | {}
}
// store 状态树接口
interface IStoreTree {
user: IUserState
}
// 状态树任意属性类型
type IStoreNode = IStoreTree[keyof IStoreTree]
  • Redux action 设计

先看 Redux 本身对于 action 的接口定义:

1
2
3
4
5
6
7
8
9
10
// 源码: https://github.com/reduxjs/redux/blob/master/index.d.ts
// action 接口
interface Action<T = any> {
type: T
}
// 任意 action 接口
interface AnyAction extends Action {
[extraProps: string]: any
}

Redux action 的原始接口定义很简单,必须要有一个任意类型的 type 属性,而其他属性可以让用户随意定制,在实际应用中,这种接口定义不够严格和规范,会出现各式各样的 action,因此,我们将采用 Flux action 标准来规范 Redux action 的使用。

Flux action 的属性有:

name type required description
type string action 标识
payload any action 数据载荷
error any 标志 action 是否是异常的,如果为真,则 payload 应该为 Error 对象
meta any action 中不属于 payload 的额外数据信息

根据 Flux action 的属性可以定义 FluxAction 接口:

1
2
3
4
5
6
7
8
9
10
// 文件: client/types/store.d.ts
import {Action} from 'redux'
// flux action 接口
interface FluxAction<P = any, M = any> extends Action {
type: string
payload?: P
error?: any
meta?: M
}

Redux 中,action 会由 action creator 函数生成,Redux 对于 action creator 的原始接口定义为:

1
2
3
4
5
// 源码: https://github.com/reduxjs/redux/blob/master/index.d.ts
// action creator 接口
interface ActionCreator<A>{
(...args: any[]): A
}

基于 ActionCreator,我们可以构造出专门生成 FluxAction 类型 actionaction creator

1
2
3
4
// 文件: client/types/store.d.ts
import {ActionCreator} from 'redux'
// flux action creator 接口
type FluxActionCreator<P=any, M=any> = ActionCreator<FluxAction<P,M>>

假设我们现在需要切换 User 组件上的 loading 状态,要求通过 action creator 发出对应的 FluxAction 类型的 action,那么我们可以这么定义这个 action creator

1
2
3
4
5
6
// 文件: client/pages/user/user.ts
// 切换 loading 状态
const toggleLoading: FluxActionCreator<boolean> = (loading: boolean) => ({
type: 'TOGGLE_LOADING',
payload: loading
})

当然,FluxActionCreator 类型定义稍显复杂,而复杂的类型定义会带来一定的理解成本,因此,我们也可以将 toggleLoading 简单定义为:

1
2
3
4
5
// 文件: client/pages/user/user.ts
const toggleLoading = (loading: boolean):FluxAction<boolean> => ({
type: 'TOGGLE_LOADING',
payload: loading
})

再比如修改 IUserState.user

1
2
3
4
5
// 文件: client/pages/user/user.ts
const updateUser = (user: IUser):FluxAction<IUser> => ({
type: 'UPDATE_USER',
payload: user
})
  • Redux 异步 action 设计

Redux 本身不支持异步 action,需要引入额外的中间件来支持,这里举两个常见的异步中间件 redux-thunkredux-promise-middleware,我们将分别讲解如何通过这两个中间件从 /user/detail 接口获取用户基本信息。

I. redux-thunk

通常情况下,action creator 函数会返回一个对象,而在引入 redux-thunk 中间件后,action creator 会返回一个函数,该函数参数如下表:

name type description
dispatch ThunkDispatch action creator 触发函数
getState function(): any 获取整个 store tree 函数
extraArgument any 额外的参数
1
2
3
4
5
6
7
8
9
10
11
12
13
// 源码: https://github.com/reduxjs/redux-thunk/blob/master/index.d.ts
import { Middleware, Action, AnyAction } from "redux";
export interface ThunkDispatch<S, E, A extends Action> {
<R>(thunkAction: ThunkAction<R, S, E, A>): R;
<T extends A>(action: T): T;
}
export type ThunkAction<R, S, E, A extends Action> = (
dispatch: ThunkDispatch<S, E, A>,
getState: () => S,
extraArgument: E
) => R;

基于以上源码,我们可以构造出 FluxAction 相关的 ThunkDispatchThunkAction

1
2
3
4
5
6
7
8
// 文件: client/types/store.d.ts
import {ThunkDispatch, ThunkAction} from 'redux-thunk'
// FluxThunkDispatch 接口
type FluxThunkDispatch<S, P, M=any, E=any> = ThunkDispatch<S, E, FluxAction<P, M>>
// FluxThunkAction 接口
type FluxThunkAction<S, P, M=any, R=any, E=any> = ThunkAction<R, S, E, FluxAction<P, M>>

FluxThunkDispatchFluxThunkAction 中,我们调整了泛型类型的定义顺序,并对 MRE 提供了默认的类型 any,这是因为在大多数情况下,我们更关心代表 store tree 类型的 S 和代表 action payload 类型的 P。来看个使用 FluxThunkAction 的例子:

1
2
const f: FluxThunkAction<IStoreTree, boolean> =
(dispatch: FluxThunkDispatch<IStoreTree, boolean>, getState: () => IStoreTree, extraArgument: any) => {}

通常情况下,一个应用只会配一个 store tree,因此 FluxThunkActionFluxThunkDispatch 可以简化为:

1
2
3
4
5
6
7
8
// 文件: client/types/store.d.ts
// 以 IStoreTree 为 S 的 FluxThunkDispatch 接口
type StoreFTD<P, M=any, E=any> =
FluxThunkDispatch<IStoreTree, P, M, E>
// 以 IStoreTree 为 S 的 FluxThunkAction 接口
type StoreFTA<P, M=any, R=any, E=any> =
FluxThunkAction<IStoreTree, P, M, R, E>

上述例子就可以简化为:

1
2
const f: StoreFTA<boolean> =
(dispatch: StoreFTD<boolean>, getState: () => IStoreTree, extraArgument: any) => {}

现在再看从 /user/detail 接口获取用户信息功能,要求服务端在响应过程中,前端要展示 loading 状态,等到服务端返回后前端再取消 loading 状态,通过 redux-thunk 可以这么实现 action creator

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 文件: client/pages/user/user.ts
import {getUserDetail as _getUserDetail} from 'services'
// 获取用户详情
const getUserDetail = (name: string):StoreFTA<boolean | IUser | {}> => (dispatch: StoreFTD<boolean | IUser | {}>) => {
// 显示 loading
dispatch(toggleLoading(true))
// 发起请求
_getUserDetail({name})
.then((data: IUser) => {
// 请求成功,更改 user 并取消 loading 状态
dispatch(updateUser(data))
dispatch(toggleLoading(false))
})
.catch((err: Error) => {
console.error(err.message)
// 请求失败,更改 user 为空并取消 loading 状态
dispatch(updateUser({}))
dispatch(toggleLoading(false))
})
}

II. redux-promise-middleware

redux-thunkaction creator 内部处理不同的异步状态,而 redux-promise-middleware 则把这些处理逻辑移至 reducer 中处理,action creator 应该尽量保持代码简洁,减少衍生计算逻辑,从这点看,redux-promise-middleware 要优于 redux-thunk 并且 redux-promise-middleware 对于 Promise 异步状态划分的更为清晰,代码可读性更强。

redux-promise-middleware 要求 action payload 类型为 Promise 类型或是包含以下两个属性的对象

name type description
promise Promise Promise 对象
data any 乐观更新的数据
1
2
3
4
5
6
// 文件: client/types/store.d.ts
// Promise payload 类型
type PromisePayload<P, T = any> = Promise<P> | {
promise: Promise<P>
data: T
}

/user/detail 接口获取用户信息的 action creator 接口可以写成:

1
2
3
4
5
6
// 文件: client/pages/user/user.ts
// 获取用户详情
const getUserDetail = (name: string): FluxAction<PromisePayload<IGetUserRes>> => ({
type: 'GET_USER_DETAIL',
payload: _getUserDetail({name})
})

相比于 redux-thunkredux-promise-middlewareaction creator 代码要简洁很多。

  • Redux reducer 设计

设计好 Redux store 后,还需要定义对应的 Redux reducer 来接收 Redux action 并对 Redux store 进行衍生计算

先看 ReduxReducer 类型的定义:

1
2
3
4
5
6
7
8
9
10
11
// 源码: https://github.com/reduxjs/redux/blob/master/index.d.ts
// reducer 类型
export type Reducer<S = any, A extends Action = AnyAction> = (
state: S | undefined,
action: A
) => S
// reducer 函数集合类型
export type ReducersMapObject<S = any, A extends Action = Action> = {
[K in keyof S]: Reducer<S[K], A>
}

结合 FluxActionIStoreTree 可以定义我们自己想要的 ReducerReducersMapObject

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 文件: client/types/store.d.ts
import {Reducer, ReducersMapObject} from 'redux'
// 结合 FluxAction 的 Reducer
type FluxReducer<S = any, P = any,M = any> = Reducer<S, FluxAction<P, M>>
// 结合 FluxAction 的 ReducersMapObject
type FluxReducerMap<S = any, P = any, M = any> = ReducersMapObject<S, FluxAction<P,M>>
// 结合 IStoreTree 的 FluxReducer
type StoreFR<P = any, M = any> = FluxReducer<IStoreTree, P, M>
// 结合 IStoreTree 的 FluxReducerMap
type StoreFRM<P = any, M = any> = FluxReducerMap<IStoreTree, P, M>

当涉及到组件状态时候,不可变数据总是绕不开的话题,社区里常用的不可变数据函数库有 immutableimmer 等等,由于 immutable 对于数据结构处理较为繁琐,需要在它提供的对象和原始 javascript 对象间来回切换,故打算采用更为轻量的 immer 库,为了让开发者对不可变数据无感知,需要对每个 reducer 做些额外处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 文件: client/store/create-immutable-reducer.ts
import produce from 'immer'
import {ReducersMapObject} from 'redux'
// 通过 immer 构建不可变 reducer
function createImReducer(reducer: StoreFR): StoreFR {
return (state: IStoreNode, action: FluxAction): IStoreNode =>
produce(state, (draftState: IStoreNode):IStoreNode => reducer(draftState, action))
}
// 构建不可变 reducer 集合
export default (reducers: StoreFRM) => {
const imReducers: StoreFRM = {}
Object.keys(reducers).forEach(
(key: string) => {
imReducers[key] = createImReducer(reducers[key])
})
return imReducers
}

构建好不可变状态后,可以开始实现 user reducer 的功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// 文件: client/pages/user/user.ts
// 初始状态
const initState: IUserState = {
ui: {
loading: false
},
user: {}
}
// user reducer
const reducer:StoreFR<IUserState> = (state: IUserState, action: FluxAction) => {
const {type, payload} = action
switch(type) {
// 切换 loading 状态
case 'TOGGLE_LOADING':
state.ui.loading = payload
return state
// 修改用户信息
case 'UPDATE_USER':
state.user = payload
return state
// 获取用户信息
case 'GET_USER_DETAIL_PENDING':
state.ui.loading = true
return state
case 'GET_USER_DETAIL_REJECTED':
state.ui.loading = false
state.user = {}
return state
case 'GET_USER_DETAIL_FULFILLED':
state.ui.loading = false
state.user = payload
return state
}
}

上述代码中 reducer 函数是标准的 Redux reducer,这种方式存在函数过长、功能划分不够细致、 action payload 类型不确定等弊端,可以尝试使用 type-to-reducer 库对其进行简化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// 文件: client/pages/user/user.ts
import TypeToReducer from 'type-to-reducer'
// 初始状态
const initState: IUserState = {
ui: {
loading: false
},
user: {}
}
// user reducer
const reducer = TypeToReducer(
{
TOGGLE_LOADING: (state: IUserState, action: FluxAction<boolean>): IUserState => {
state.ui.loading = action.payload
return state
},
UPDATE_USER: (state: IUserState, action: FluxAction<IUser>): IUserState => {
state.user = user
return state
},
GET_USER_DETAIL: {
PENDING: (state: IUserState): IUserState => {
state.ui.loading = true
return state
},
REJECTED: (state: IUserState): IUserState => {
state.ui.loading = false
state.user = {}
return state
},
FULFILLED: (state: IUserState, action: FluxAction<IUser>): IUserState => {
state.ui.loading = false
state.user = action.payload
return state
}
}
},
initState
)

至此还剩最后三个步骤:

  1. 把不可变 reducer 的创建和普通 reducer 结合起来

    1
    2
    3
    4
    5
    6
    7
    8
    // 文件: client/store/root-reducer.ts
    // reducer 集合
    import createImReducers from './create-immutable-reducers'
    import user from '../pages/user/user'
    export default createImReducers({
    user
    })
  2. 创建 store 并引入中间件

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    // 文件: client/store/create-store.ts
    import Thunk from 'redux-thunk'
    import promiseMiddleware from 'redux-promise-middleware'
    import { createStore, combineReducers, applyMiddleware } from 'redux'
    import reducers from './root-reducer'
    export default function(initialState:IStoreTree):any {
    return createStore(
    combineReducers(reducers),
    initialState,
    applyMiddleware(promiseMiddleware(), Thunk)
    )
    }
  3. User 页面组件连接 redux

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    // 文件: client/pages/user/index.tsx
    import {connect} from 'react-redux'
    import {bindActionCreators, Dispatch} from 'redux'
    import {Spin, Input} from 'antd'
    import {getUserDetail} from './user'
    // User 组件属性接口
    interface IProps extends IUserState {
    actions: any
    }
    // User 组件
    class User extends React.Component<IProps> {
    componentDidMount() {
    this.props.actions.getUserDetail('xxxx')
    }
    render() {
    const {ui, user} = this.props
    return (
    <Spin spinning={ui.loading}>
    <strong>用户姓名:</strong>
    <Input value={user.name} />
    <strong>用户年龄:</strong>
    <Input value={user.age} />
    <strong>所属部门:</strong>
    <Input value={user.department} />
    </Spin>
    )
    }
    }
    export default connect(
    (store: IStoreTree): IUserState => store.user,
    (dispatch: Dispatch<FluxAction>) => ({
    actions: bindActionCreators({getUserDetail}, dispatch)
    })
    )