hacker0limbo/my-blog

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

hacker0limbo opened this issue · 0 comments

前言

上一篇文章讲了讲如何用 TypeScript + Redux 实现 Loading 切片部分的状态, 这篇文章主要想聊一聊关于 TodoFilter 这两个切片状态的具体实现, 以及关于 Redux Thunk 与 TypeScript 的结合使用.

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

最后的效果:
todoapp

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 一般是服务端返回的, 不做过多解释. texttodo 的具体内容, done 属性描述这个 todo 是否被完成

actions

actionTypes

还是和之前一样, 在写 action 之前先写好对应的类型, 包括每一个 actiontype 属性

根据上面的描述, 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 类型

看完发现, 其实 ThunkActionThunkDispatch 真的很像, 对应到具体的参数类型:

  • TState 我们是有的, 即之前写过的 RootState
  • TExtraThunkArg 我们没有用到, 可以直接给 void 或者 unknown
  • TBasicAction 我们还没定义, 我见过有用 ReduxAnyAction 来替代, 但是 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, 也是根据官网来的, 分别为 AppDispatchAppThunk

现在可以完善之前的 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, HooksTypeScript 以及 React Redux 里相关 Hooks 的使用

参考