vlambda博客
学习文章列表

简单用 React+Redux+TypeScript 实现一个 TodoApp (一)

前言

最近看到了一篇非常好的文章: redux 文档到底说了什么(上), 加之最近项目可能需要用到 Redux + Typescript. 所以仔细拜读了这篇博客, 受益匪浅. 看完后自己尝试又重新根据自己的理解实现了一遍, 部分代码和思路参考了原文, 所以首先还是非常感谢作者的这篇好文.

由于这篇文章比较注重于讲原生 Redux + TypeScript 的写法, 且不涉及到使用 Redux Toolkit 这个库, 如果对这个库使用有兴趣的可以同样可以参考作者的另一篇文章redux 文档到底说了什么(下)

另有些不重要的细节例如 css 等就不做深究了

在写的过程中其实发现还是有部分的写法以及问题文档里已经提到过了, 所以一般我会选择按照文档的套路来, 具体关于 React + Redux + TypeScript 的链接如下:

  • Usage with TypeScript | Redux
  • Static Typing | React Redux
  • Usage with TypeScript | Redux Toolkit

想跳过文章直接看代码的: 完整代码

最后的效果:

配置与实现思路

后端

使用了 mockapi 这个在线工具, 非常方便来模拟增删改查接口并且是免费的. 返回的响应格式如下:

Method Url Code Default Response
GET /todos 200 Array<Todo>
GET /todoss/:id 200 Todo
POST /todos 201 Todo
PUT /todos/:id 200 Todo
DELETE /todos/:id 200 Todo

我自己的 API 端点为: https://5d2d9b4343c343001498d272.mockapi.io/api/v1/todos

前端

用到的库有 React + Redux + Redux Thunk + TypeScript + Antd

目录结构为:

├── package.json
├── public
│   └── index.html
└── src
    ├── README.md
    ├── api
    │   └── index.ts
    ├── components
    │   ├── App.tsx
    │   ├── TodoApp.tsx
    │   └── TodoItem.tsx
    ├── index.tsx
    ├── store
    │   ├── filter
    │   │   ├── actionTypes.ts
    │   │   ├── actions.ts
    │   │   ├── constants.ts
    │   │   ├── reducer.ts
    │   │   └── types.ts
    │   ├── index.ts # store 的入口
    │   ├── loading
    │   │   ├── actionTypes.ts
    │   │   ├── actions.ts
    │   │   ├── constants.ts
    │   │   ├── reducer.ts
    │   │   ├── selectors.ts
    │   │   └── types.ts
    │   └── todo
    │       ├── actionTypes.ts # action 的类型
    │       ├── actions.ts # 所有的 action, 包括标准的 actionCreator 和 thunk 类型的 action
    │       ├── constants.ts # 常量, 主要存放 action type 的类型和值
    │       ├── reducer.ts # todo 的 reducer
    │       ├── selectors.ts # 选择器
    │       └── types.ts # 基本公用的类型
    └── style.css

可以看到其实组件部分没有分的很细, store 也就是 redux 部分分的比较细, 一共是有三个 slice, 每个 slice 都有大致相同的结构, 真实大项目可能不同, 这里只是为了演示.

思路

可以看到要实现的 TodoApp 有如下的操作(action):

  • 初始化拿到所有的 Todo
  • 增加/删除/修改/完成 一个 Todo
  • 底部可以选择展示 所有/完成/未完成 的 Todo

Loading

先从最简单的 loading 这个 slice 开始. 由于这个项目是需要和后端交互的, 交互过程需要时间, 因此可以在发送请求等待响应的过程中显示 loading 组件, 即这个时候的 loading 的状态应该是 true, 在得到响应后, loading 状态为 false, 相关的 loading 组件也不再展示

types

编写一下 loading 对应的状态, 这里的 status 表示当前 loading 的状态, tip 为可选, 在 loadingtrue 的时候显示加载的具体提示信息

// store/loading/types.ts

export type LoadingState = {
  status: boolean;
  tip?: string;
};

actions

actionTypes

action 部分也比较简单, 正常就是 setLoadingunsetLoading 两个状态. 如果是在写 JavaScript 直接写就行了, 但是在写 TypeScript 的时候需要先编写 action 的类型(actionTypes), 由于发出的 action 往往只有一种. 在这个 action 进入 reducer 的时候, reducer 其实是需要知道这个 action 是哪种类型的, 有具体的类型也有助于后面编写 reducer 有更好的类型提示

// store/loading/actionTypes.ts

export type SetLoading = {
  type'SET_LOADING';
  payload: string;
};

export type UnsetLoading = {
  type'UNSET_LOADING';
};

export type LoadingAction = SetLoading | UnsetLoading;

为了更好的组织代码, 这里将 type 字符串单独放到一个文件里, 导出具体的类型和值.

// store/loading/constants.ts

export const SET_LOADING = "SET_LOADING";
export type SET_LOADING = typeof SET_LOADING;

export const UNSET_LOADING = "UNSET_LOADING";
export type UNSET_LOADING = typeof UNSET_LOADING;

这里有一个很有意思的地方, TypeScript 中其实有些类型既可以表示值也可以表示变量, 具体是有一个术语的(但我忘了这个术语叫啥...). 仔细想想, 如果声明一个类, 这个类既不就是既可以当类型又可以当一个类使用么?

class Person {

}

const p: Person = new Person()

现在 actionTypes.ts 里需要改一下:

// store/loading/constants.ts

import { SET_LOADING, UNSET_LOADING } from "./constants";

export type SetLoading = {
  type: SET_LOADING;
  payload: string;
};

export type UnsetLoading = {
  type: UNSET_LOADING;
};

export type LoadingAction = SetLoading | UnsetLoading;

actionCreators

redux 有导出一个 ActionCreator<A> 的类型, 所以一开始我写出来是这样的:

// store/loading/actions.ts

import { SetLoading, UnsetLoading } from "./actionTypes";
import { SET_LOADING, UNSET_LOADING } from "./constants";
import { ActionCreator } from "redux";

export const setLoading: ActionCreator<SetLoading> = (tip: string) => {
  return {
    type: SET_LOADING,
    payload: tip
  };
};

export const unsetLoading: ActionCreator<UnsetLoading> = () => {
  return {
    type: UNSET_LOADING
  };
};

看上去好像没问题, 实际上是有很大问题的. 问题的根本在于 ActionCreator<A> 这个类型实际上非常不精确, 它是没有办法正确推断返回的 actionkey 类型的, 举个例子, 假设我改成这样写:

// store/loading/actions.ts

export const setLoading: ActionCreator<SetLoading> = (tip: string, a: number) => {
  return {
    type: SET_LOADING,
    payload: tip,
    a: a
  };
};

// ...

编译是通过的, 但是语义上完全是错误的. 这里给一下我当时搜到的相关 issue: [bug: typescript] ActionCreator lose it's arguments type, 这个 issue 到今天为止还是开着的...

我们可以看一下源码(虽然最新的源码和 issue 里引用的不同有更新, 但是最新的 redux 包里的貌似还是使用的旧的, 这里两种定义方式都列出)

// github 源代码 master 分支上最新的, 但在 4.0.5 发布的包里并未包含这种写法
export interface ActionCreator<A, P extends any[] = any[]> {
  (...args: P): A
}

// "旧的", 也为当前最新的 4.0.5 包里的类型定义
export interface ActionCreator<A> { 
  (...args: any[]): A 

解决办法也是有的, 而且非常简单, 官网包括该 issue 下的一个评论 都给出了写法, 简单讲就是定义好 actionCreator 返回的类型, 其余包括参数等让 TypeScript 自行推断, 具体写法如下:

// store/loading/actions.ts

import { SetLoading, UnsetLoading } from "./actionTypes";
import { SET_LOADING, UNSET_LOADING } from "./constants";

export const setLoading = (tip: string): SetLoading => {
  return {
    type: SET_LOADING,
    payload: tip
  };
};

export const unsetLoading = (): UnsetLoading => {
  return {
    type: UNSET_LOADING
  };
};

reducer

这里 redux 有提供一个工具类型 Reducer<S, A>, 我用下来没有什么坑, 不过用官网的写法也是没问题的

// store/loading/reducer.ts

import { Reducer } from "redux";
import { LoadingAction } from "./actionTypes";
import { SET_LOADING, UNSET_LOADING } from "./constants";
import { LoadingState } from "./types";

const initialState: LoadingState = {
  status: false
};

export const loadingReducer: Reducer<Readonly<LoadingState>, LoadingAction> = (
  state = initialState,
  action
) => {
  switch (action.type) {
    case SET_LOADING:
      return {
        status: true,
        tip: action.payload
      };
    case UNSET_LOADING:
      return {
        status: false
      };
    default:
      return state;
  }
};

这里稍微提一下 reducer, reducer 返回的就是当前 store 里的状态, 这个状态怎么涉及其实挺有讲究. 原文章 包括 Redux 官网都提到一个点就是可以对状态进行Normalize. 这个对于性能优化是挺有帮助的. 有兴趣可以了解

另一个点是, reducer 最后的 default: return state 在没有特殊情况下都应该这样写. 我之前就有昏了头写成 default: return initialState, 然后就因为这个 bug 我找了一下午...实际上每一个被 dispatchaction 都会在 reducer 里走一遍, 当所有 type 都匹配不到就会走最后一个 default 分支, 其状态也就不变维持之前的状态

selectors

最后部分是 selectors 部分, 如果用 hooks 的话会需要用到 useSelector() 来从 store 中拿对应的数据, 这部分我个人理解放在组件里写或者抽成一个单独文件其实都可以, 这里就放在一个单独文件里了

// store/loading/selectors.ts

import { RootState } from "../index";

export const selectLoading = (state: RootState) => {
  return state.loading;
};

这里其实有点跳跃了, RootState 其实就是整个 redux store 里的所有状态的合集. 具体看下面的章节的实现.

另外, 关于 selector 或者说衍生数据, redux 官网专门开了一章: Computing Derived Data来写怎么计算获取衍生状态数据, 涉及到性能优化等方面非常多, 这里不做深究.

Store

前面有提到需要定义 RootState 状态, 但之前需要先定义一下总的 reducer, 这里我们用 combineReducer() 来集成所有细分的 reducer. 而 RootState 其实就是 combineReducer() 最后返回的 rootReducer() 的返回值(有兴趣可以去看一下 combineReducer() 的源码, 很有意思, 面试可能会让你手写一个!)

// store/index.ts

const rootReducer = combineReducers({
  todos: todoReducer,
  // filter: filterReducer,
  // loading: loadingReducer
});

export type RootState = ReturnType<typeof rootReducer>;

总结

至此关于 Loadingstore 部分就写完了, 后续还会写两篇文章完成剩余的 TodoApp 部分:

  • todofilter 部分, 涉及到 Redux ThunkTypeScript 的结合使用
  • 组件的编写, React, HooksTypeScript 以及 React Redux 里相关 Hooks 的使用

参考

  • https://zhuanlan.zhihu.com/p/270583963
  • https://github.com/learn-redux/learn-redux
  • https://redux.js.org/recipes/usage-with-typescript
  • https://react-redux.js.org/using-react-redux/static-typing
  • https://redux-toolkit.js.org/usage/usage-with-typescript
- END -