Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
hacker0limbo opened this issue Nov 15, 2020 · 0 comments
Open

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

hacker0limbo opened this issue Nov 15, 2020 · 0 comments
Labels
react react 笔记整理 redux redux 笔记整理 typescript typescript 笔记整理

Comments

@hacker0limbo
Copy link
Owner

hacker0limbo commented Nov 15, 2020

前言

上一篇文章讲了讲如何用 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 的使用

参考

@hacker0limbo hacker0limbo added react react 笔记整理 redux redux 笔记整理 typescript typescript 笔记整理 labels Nov 22, 2020
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
react react 笔记整理 redux redux 笔记整理 typescript typescript 笔记整理
Projects
None yet
Development

No branches or pull requests

1 participant