Description
前言
上一篇文章讲了讲如何用 TypeScript + Redux 实现 Loading
切片部分的状态, 这篇文章主要想聊一聊关于 Todo
和 Filter
这两个切片状态的具体实现, 以及关于 Redux Thunk 与 TypeScript 的结合使用.
想跳过文章直接看代码的: 完整代码
Todo
首先思考一下 Todo
应该是怎样的状态, 以及可能需要涉及到的 action
.
页面上的每一个 todo
实例都对应一个状态, 合起来总的状态就应该是一个数组, 这也应该是 reducer
最后返回的状态形式. 同时, 考虑 action
, 应该有以下几种操作:
- 初始化页面的时候从服务端拿数据设置所有的
todos
- 增加一个
todo
- 删除一个
todo
- 更新一个
todo
- 完成 / 未完成一个
todo
这里需要注意的是, 所有的操作都需要和服务端交互, 因此我们的 action
是 "不纯的", 涉及到异步操作. 这里会使用 Redux Thunk 这个库来加持一下. Action Creator
写法也会变成对应的 Thunk
形式的 Action Creator
types
每一个 todo
的状态类型应该如下:
// store/todo/types.ts
export type TodoState = {
id: string;
text: string;
done: boolean;
};
id
一般是服务端返回的, 不做过多解释. text
是 todo
的具体内容, done
属性描述这个 todo
是否被完成
actions
actionTypes
还是和之前一样, 在写 action
之前先写好对应的类型, 包括每一个 action
的 type
属性
根据上面的描述, type
有如下几种:
// store/todo/constants.ts
export const SET_TODOS = "SET_TODOS";
export type SET_TODOS = typeof SET_TODOS;
export const ADD_TODO = "ADD_TODO";
export type ADD_TODO = typeof ADD_TODO;
export const REMOVE_TODO = "REMOVE_TODO";
export type REMOVE_TODO = typeof REMOVE_TODO;
export const UPDATE_TODO = "UPDATE_TODO";
export type UPDATE_TODO = typeof UPDATE_TODO;
export const TOGGLE_TODO = "TOGGLE_TODO";
export type TOGGLE_TODO = typeof TOGGLE_TODO;
对应的 actionTypes
, 就可以引用写好的常量类型了:
// store/todo/actionTypes.ts
import { TodoState } from "./types";
import {
SET_TODOS,
ADD_TODO,
REMOVE_TODO,
UPDATE_TODO,
TOGGLE_TODO
} from "./constants";
export type SetTodosAction = {
type: SET_TODOS;
payload: TodoState[];
};
export type AddTodoAction = {
type: ADD_TODO;
payload: TodoState;
};
export type RemoveTodoAction = {
type: REMOVE_TODO;
payload: {
id: string;
};
};
export type UpdateTodoAction = {
type: UPDATE_TODO;
payload: {
id: string;
text: string;
};
};
export type ToggleTodoAction = {
type: TOGGLE_TODO;
payload: {
id: string;
};
};
export type TodoAction =
| SetTodosAction
| AddTodoAction
| RemoveTodoAction
| UpdateTodoAction
| ToggleTodoAction;
actionCreators
这里需要注意, todo
部分的 actions
分为同步和异步, 先来看同步的:
// store/todo/actions.ts
import {
AddTodoAction,
RemoveTodoAction,
SetTodosAction,
ToggleTodoAction,
UpdateTodoAction
} from "./actionTypes";
import {
ADD_TODO,
REMOVE_TODO,
SET_TODOS,
TOGGLE_TODO,
UPDATE_TODO
} from "./constants";
import { TodoState } from "./types";
export const addTodo = (newTodo: TodoState): AddTodoAction => {
return {
type: ADD_TODO,
payload: newTodo
};
};
export const removeTodo = (id: string): RemoveTodoAction => {
return {
type: REMOVE_TODO,
payload: {
id
}
};
};
export const setTodos = (todos: TodoState[]): SetTodosAction => {
return {
type: SET_TODOS,
payload: todos
};
};
export const toggleTodo = (id: string): ToggleTodoAction => {
return {
type: TOGGLE_TODO,
payload: {
id
}
};
};
export const updateTodo = (id: string, text: string): UpdateTodoAction => {
return {
type: UPDATE_TODO,
payload: {
id,
text
}
};
};
同步部分没什么好说的, 核心是异步部分, 我们用 Redux Thunk 这个中间件帮助我们编写 Thunk 类型的 Action
. 这种 Action
不再是纯的, 同时这个 Action
是一个函数而不再是一个对象, 因为存在往服务端请求数据的副作用逻辑. 这也是 Redux 和 Flow 的一个小区别(Flow 规定 Action
必须是纯的)
首先我们需要配置一下 thunk
, 以及初始化一下 store
// store/index.ts
import { combineReducers, createStore, applyMiddleware } from "redux";
import thunk from "redux-thunk";
import { loadingReducer } from "./loading/reducer";
import { todoReducer } from "./todo/reducer";
const rootReducer = combineReducers({
todos: todoReducer,
loading: loadingReducer,
// filter: filterReducer,
});
export type RootState = ReturnType<typeof rootReducer>;
export const store = createStore(rootReducer, applyMiddleware(thunk));
Thunk Action Creator
不考虑类型, 如果纯用 JavaScript 写一个 Thunk ActionCreator
, 如下:
export const setTodosRequest = () => {
return dispatch => {
dispatch(setLoading("加载中..."));
return fetch(baseURL)
.then(res => res.json())
.then(data => {
dispatch(setTodos(data));
dispatch(unsetLoading());
});
};
};
这里的 baseURL
在我第一章有说, 用了 mock api 模拟后端的数据, 具体地址可以看文章或者看源码, 同时为了方便, 我直接用浏览器原生的 fetch
做 http 请求了, 当然用 axios
等别的库也是可以的
关于这个函数简单说明一下, 这里的 setTodosRequest
就是一个 Thunk ActionCreator
, 返回的 (dispatch) => {}
就是我们需要的 Thunk Action
, 可以看到这个 Thunk Action
是一个函数, Redux Thunk 允许我们将 Action 写成这种模式
下面为这个 Thunk ActionCreator
添加类型, Redux Thunk 导出的包里有提供两个很重要的泛型类型:
首先是 ThunkDispatch
, 具体定义如下
/**
* The dispatch method as modified by React-Thunk; overloaded so that you can
* dispatch:
* - standard (object) actions: `dispatch()` returns the action itself
* - thunk actions: `dispatch()` returns the thunk's return value
*
* @template TState The redux state
* @template TExtraThunkArg The extra argument passed to the inner function of
* thunks (if specified when setting up the Thunk middleware)
* @template TBasicAction The (non-thunk) actions that can be dispatched.
*/
export interface ThunkDispatch<
TState,
TExtraThunkArg,
TBasicAction extends Action
> {
<TReturnType>(
thunkAction: ThunkAction<TReturnType, TState, TExtraThunkArg, TBasicAction>,
): TReturnType;
<A extends TBasicAction>(action: A): A;
// This overload is the union of the two above (see TS issue #14107).
<TReturnType, TAction extends TBasicAction>(
action:
| TAction
| ThunkAction<TReturnType, TState, TExtraThunkArg, TBasicAction>,
): TAction | TReturnType;
}
至于具体怎么实现我不关心, 我关心的是这个东西是啥以及这个泛型接受哪些类型参数, 整理一下如下:
- 这个
dispatch
类型是由 Redux Thunk 修改过的类型, 你可以用它dispatch
:- 标准的
action
(一个对象),dispatch()
函数返回这个对象action
本身 thunk action
(一个函数),dispatch()
函数返回这个thunk action
函数的返回值
- 标准的
- 接受三个参数:
TState
,TExtraThunkArg
,TBasicAction
TState
: Redux store 的状态(RootState
)TExtraThunkArg
: 初始化 thunk 中间件时, 传个 thunk 的额外参数(这个项目我们没用到)TBasicAction
: 非 Thunk 类型的 action, 即标准的对象 action 类型
再看一下 ThunkAction
:
/**
* A "thunk" action (a callback function that can be dispatched to the Redux
* store.)
*
* Also known as the "thunk inner function", when used with the typical pattern
* of an action creator function that returns a thunk action.
*
* @template TReturnType The return type of the thunk's inner function
* @template TState The redux state
* @template TExtraThunkARg Optional extra argument passed to the inner function
* (if specified when setting up the Thunk middleware)
* @template TBasicAction The (non-thunk) actions that can be dispatched.
*/
export type ThunkAction<
TReturnType,
TState,
TExtraThunkArg,
TBasicAction extends Action
> = (
dispatch: ThunkDispatch<TState, TExtraThunkArg, TBasicAction>,
getState: () => TState,
extraArgument: TExtraThunkArg,
) => TReturnType;
整理一下参数类型和代表的意思:
ThunkAction
指代的是一个thunk action
, 或者也叫做thunk inner function
- 四个类型参数:
TReturnType
,TState
,TExtraThunkArg
,TBasicAction
TReturnType
: 这个thunk action
函数最后的返回值TState
: Redux store 的状态(RootState
)TExtraThunkArg
: 初始化 thunk 中间件时, 传个 thunk 的额外参数(这个项目我们没用到)TBasicAction
: 非 Thunk 类型的 action, 即标准的对象 action 类型
看完发现, 其实 ThunkAction
和 ThunkDispatch
真的很像, 对应到具体的参数类型:
TState
我们是有的, 即之前写过的RootState
TExtraThunkArg
我们没有用到, 可以直接给void
或者unknown
TBasicAction
我们还没定义, 我见过有用Redux
的AnyAction
来替代, 但是AnyAction
这个 any 有点过分...我搜索了一下没找到官方的最佳实践, 就打算用所有的 Redux 的 Action 类型集合
以及, Redux 官网的 Usage with Redux Thunk 其实已经有写怎么配置类型了. 现在需要做的事情其实就很简单:
- 增加一个
RootAction
类型, 为所有的非 Thunk 类型的Action
的类型的集合 - 给
ThunkDispatch
这个泛型传入正确类型 - 给
ThunkAction
这个泛型传入正确类型
store
部分的代码如下:
// store/index.ts
import { combineReducers, createStore, applyMiddleware } from "redux";
import { todoReducer } from "./todo/reducer";
import { loadingReducer } from "./loading/reducer";
import thunk, { ThunkDispatch, ThunkAction } from "redux-thunk";
import { LoadingAction } from "./loading/actionTypes";
import { TodoAction } from "./todo/actionTypes";
const rootReducer = combineReducers({
todos: todoReducer,
loading: loadingReducer,
// filter: filterReducer,
});
export type RootState = ReturnType<typeof rootReducer>;
export type RootAction = LoadingAction | TodoAction;
export const store = createStore(rootReducer, applyMiddleware(thunk));
export type AppDispatch = ThunkDispatch<RootState, void, RootAction>;
export type AppThunk<ReturnType = void> = ThunkAction<
ReturnType,
RootState,
void,
RootAction
>;
为了方便, 这里给了两个 alias, 也是根据官网来的, 分别为 AppDispatch
和 AppThunk
现在可以完善之前的 Thunk ActionCreator
的类型了:
export const setTodosRequest = (): AppThunk<Promise<void>> => {
return dispatch => {
dispatch(setLoading("加载中..."));
return fetch(baseURL)
.then(res => res.json())
.then(data => {
dispatch(setTodos(data));
dispatch(unsetLoading());
});
};
};
这里注意一下, 由于我们的 thunk action
, 是有返回值的, 这里是 return fetch()
返回的是一个 promise
, 不过这个 promise
并没有 resolve
任何值, 所以即为 Promise<void>
最后完善一下所有的 actionCreator
:
// store/todo/actions.ts
import {
AddTodoAction,
RemoveTodoAction,
SetTodosAction,
ToggleTodoAction,
UpdateTodoAction
} from "./actionTypes";
import { setLoading, unsetLoading } from "../loading/actions";
import {
ADD_TODO,
REMOVE_TODO,
SET_TODOS,
TOGGLE_TODO,
UPDATE_TODO
} from "./constants";
import { TodoState } from "./types";
import { AppThunk } from "../index";
import { baseURL } from "../../api";
// https://github.com/reduxjs/redux/issues/3455
export const addTodo = (newTodo: TodoState): AddTodoAction => {
return {
type: ADD_TODO,
payload: newTodo
};
};
export const removeTodo = (id: string): RemoveTodoAction => {
return {
type: REMOVE_TODO,
payload: {
id
}
};
};
export const setTodos = (todos: TodoState[]): SetTodosAction => {
return {
type: SET_TODOS,
payload: todos
};
};
export const toggleTodo = (id: string): ToggleTodoAction => {
return {
type: TOGGLE_TODO,
payload: {
id
}
};
};
export const updateTodo = (id: string, text: string): UpdateTodoAction => {
return {
type: UPDATE_TODO,
payload: {
id,
text
}
};
};
export const setTodosRequest = (): AppThunk<Promise<void>> => {
return dispatch => {
dispatch(setLoading("加载中..."));
return fetch(baseURL)
.then(res => res.json())
.then(data => {
dispatch(setTodos(data));
dispatch(unsetLoading());
});
};
};
export const addTodoRequest = (text: string): AppThunk<Promise<void>> => {
return dispatch => {
return fetch(baseURL, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ text, done: false })
})
.then(res => res.json())
.then((data: TodoState) => {
dispatch(addTodo(data));
});
};
};
export const removeTodoRequest = (todoId: string): AppThunk<Promise<void>> => {
return dispatch => {
return fetch(`${baseURL}/${todoId}`, {
method: "DELETE"
})
.then(res => res.json())
.then(({ id }: TodoState) => {
dispatch(removeTodo(id));
});
};
};
export const updateTodoRequest = (
todoId: string,
text: string
): AppThunk<Promise<void>> => {
return dispatch => {
return fetch(`${baseURL}/${todoId}`, {
method: "PUT",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ text })
})
.then(res => res.json())
.then(({ id, text }: TodoState) => {
dispatch(updateTodo(id, text));
});
};
};
export const toogleTodoRequest = (
todoId: string,
done: boolean
): AppThunk<Promise<void>> => {
return dispatch => {
return fetch(`${baseURL}/${todoId}`, {
method: "PUT",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ done })
})
.then(res => res.json())
.then(({ id }: TodoState) => {
dispatch(toggleTodo(id));
});
};
};
这里说一点题外话, 其实 Redux 不用 Thunk 这种 middleware 来做异步请求也是可以的, 但是为啥还会有 Redux Thunk
这些库存在呢. 具体细节我之前写过一个回答, 有兴趣可以看一看: redux中间件对于异步action的意义是什么?
reducer
编写完复杂的 ActionCreator
, reducer
相比就简单很多了, 这里直接贴代码了:
// store/todo/reducer.ts
import { Reducer } from "redux";
import { TodoAction } from "./actionTypes";
import {
ADD_TODO,
REMOVE_TODO,
SET_TODOS,
TOGGLE_TODO,
UPDATE_TODO
} from "./constants";
import { TodoState } from "./types";
const initialState = [];
export const todoReducer: Reducer<Readonly<TodoState>[], TodoAction> = (
state = initialState,
action
) => {
switch (action.type) {
case SET_TODOS:
return action.payload;
case ADD_TODO:
return [...state, action.payload];
case REMOVE_TODO:
return state.filter(todo => todo.id !== action.payload.id);
case UPDATE_TODO:
return state.map(todo => {
if (todo.id === action.payload.id) {
return { ...todo, text: action.payload.text };
}
return todo;
});
case TOGGLE_TODO:
return state.map(todo => {
if (todo.id === action.payload.id) {
return { ...todo, done: !todo.done };
}
return todo;
});
default:
return state;
}
};
写完 reducer
记得在 store
中写入 combineReducer()
selectors
最后是 selectors
, 由于这部分是需要和 filter
切片进行协作, filter
部分下面会讲, 这里先贴代码, 最后可以再回顾
// store/todo/selectors.ts
import { RootState } from "../index";
export const selectFilteredTodos = (state: RootState) => {
switch (state.filter.status) {
case "all":
return state.todos;
case "active":
return state.todos.filter(todo => todo.done === false);
case "done":
return state.todos.filter(todo => todo.done === true);
default:
return state.todos;
}
};
export const selectUncompletedTodos = (state: RootState) => {
return state.todos.filter(todo => todo.done === false);
};
todo 部分基本完成了, 最后有一个点, Redux 文档中其实一直有提到, 不过之前我一直忽略, 这次看了 redux 文档到底说了什么(上) 文章才有注意到, 就是 Normalizing State Shape. 这部分是关于性能优化的, 我自己的项目包括实习的公司项目其实从来都没有做过这一部分, 因此实战经验为 0. 有兴趣的可以去看看
Filter
最后一个状态切片 filter
, 这部分主要是为了帮助选择展示的 todo 部分. 由于这部分较为简单, 和 loading
部分类似, 居多为代码的罗列
types
回顾之前想要实现的效果, TodoApp 底部是一个类似 tab 的组件, 点击展示不同状态的 todos. 总共是三部分:
- 全部(默认)
- 未完成
- 已完成
编写一下具体的类型:
// store/filter/types.ts
export type FilterStatus = "all" | "active" | "done";
export type FilterState = {
status: FilterStatus;
};
actions
actionTypes
// store/filter/constants.ts
export const SET_FILTER = "SET_FILTER";
export type SET_FILTER = typeof SET_FILTER;
export const RESET_FILTER = "RESET_FILTER";
export type RESET_FILTER = typeof RESET_FILTER;
// store/filter/actionTypes.ts
import { SET_FILTER, RESET_FILTER } from "./constants";
import { FilterStatus } from "./types";
export type SetFilterAction = {
type: SET_FILTER;
payload: FilterStatus;
};
export type ResetFilterAction = {
type: RESET_FILTER;
};
export type FilterAction = SetFilterAction | ResetFilterAction;
actions
// store/filter/actions.ts
import { SetFilterAction, ResetFilterAction } from "./actionTypes";
import { SET_FILTER, RESET_FILTER } from "./constants";
import { FilterStatus } from "./types";
export const setFilter = (filterStatus: FilterStatus): SetFilterAction => {
return {
type: SET_FILTER,
payload: filterStatus
};
};
export const resetFilter = (): ResetFilterAction => {
return {
type: RESET_FILTER
};
};
reducer
import { Reducer } from "redux";
import { FilterAction } from "./actionTypes";
import { SET_FILTER, RESET_FILTER } from "./constants";
import { FilterState } from "./types";
const initialState: FilterState = {
status: "all"
};
export const filterReducer: Reducer<Readonly<FilterState>, FilterAction> = (
state = initialState,
action
) => {
switch (action.type) {
case SET_FILTER:
return {
status: action.payload
};
case RESET_FILTER:
return {
status: "done"
};
default:
return state;
}
};
Store
最后将所有 store
底下的 actions
, reducers
集成一下, store
文件如下:
import { combineReducers, createStore, applyMiddleware } from "redux";
import { todoReducer } from "./todo/reducer";
import { filterReducer } from "./filter/reducer";
import { loadingReducer } from "./loading/reducer";
import thunk, { ThunkDispatch, ThunkAction } from "redux-thunk";
import { FilterAction } from "./filter/actionTypes";
import { LoadingAction } from "./loading/actionTypes";
import { TodoAction } from "./todo/actionTypes";
const rootReducer = combineReducers({
todos: todoReducer,
filter: filterReducer,
loading: loadingReducer
});
export type RootState = ReturnType<typeof rootReducer>;
export type RootAction = FilterAction | LoadingAction | TodoAction;
export const store = createStore(rootReducer, applyMiddleware(thunk));
export type AppDispatch = ThunkDispatch<RootState, void, RootAction>;
export type AppThunk<ReturnType = void> = ThunkAction<
ReturnType,
RootState,
void,
RootAction
>;
总结
至此所有关于 store
部分的代码已经全部完成了. 下一篇文章也就是最后一篇文章会完成 UI 部分, 讲一讲关于 React
, Hooks
和 TypeScript
以及 React Redux
里相关 Hooks
的使用