Redux Toolkit

Redux Toolkit

Redux Toolkit 包旨在成为编写 Redux 逻辑的标准方法。它最初的创建是为了帮助解决关于 Redux 的三个常见的担忧。

  • “配置一个 Redux Store 太复杂了”
  • “我必须添加很多包才能让 Redux 做任何有用的事情”
  • “Redux 需要太多的模板代码”

我们不可能解决所有的用例,但是本着 create-react-app 和 apollo-boost 的精神,我们可以尝试提供一些工具,对设置过程进行抽象,并处理最常见的用例,同时包含一些有用的实用工具,让用户简化他们的应用代码。正因为如此,这个软件包的范围被刻意限制。它没有涉及 “可重用封装的 Redux 模块”、数据缓存、文件夹或文件结构、管理存储中的实体关系等概念。尽管如此,这些工具应该对所有 Redux 用户有益。无论你是一个全新的 Redux 用户建立你的第一个项目,还是一个有经验的用户想要简化现有的应用程序,Redux Toolkit 都可以帮助你让你的 Redux 代码变得更好。

Redux Toolkit 包含这些 API。

  • configureStore(): 包装 createStore,提供简化的配置选项和良好的默认值。它可以自动组合你的分片还原器,添加你提供的任何 Redux 中间件,默认包含 redux-thunk,并能使用 Redux DevTools 扩展。
  • createReducer(): 可以让你为 case reducer 函数提供一个动作类型的查找表,而不是编写 switch 语句。此外,它还自动使用 immer 库,让你用正常的 Mutation 代码编写更简单的不可变更新,比如 state.todos[3].completed = true。
  • createAction(): 为给定的动作类型字符串生成一个动作创建函数。函数本身定义了 toString(),所以可以用它来代替类型常量。
  • createSlice(): 接受一个减速函数对象、一个片名和一个初始状态值,并自动生成一个具有相应 actionCreator 和动作类型的片减速函数。
  • createAsyncThunk: 接受一个动作类型字符串和一个返回承诺的函数,并根据该承诺生成一个 thunk,调度待定/已完成/已拒绝的动作类型。
  • createEntityAdapter: 生成一组可重用的还原器和选择器,以管理存储中的标准化数据。
  • createSelector: Reselect 库中的 createSelector 实用程序,为了方便使用,重新导出。

典型使用

以评论为例

典型的库使用方式如下:

// store.ts
import { configureStore } from "@reduxjs/toolkit";

import rootReducer from "./rootReducer";

const store = configureStore({
  reducer: rootReducer,
});

if (process.env.NODE_ENV === "development" && module.hot) {
  module.hot.accept("./rootReducer", () => {
    const newRootReducer = require("./rootReducer").default;
    store.replaceReducer(newRootReducer);
  });
}

export type AppDispatch = typeof store.dispatch;

export default store;

// commentsSlice
import { createSlice, PayloadAction } from "@reduxjs/toolkit";

import { Comment, getComments, Issue } from "api/githubAPI";
import { AppThunk } from "app/store";

interface CommentsState {
  commentsByIssue: Record<number, Comment[] | undefined>;
  loading: boolean;
  error: string | null;
}

interface CommentLoaded {
  issueId: number;
  comments: Comment[];
}

const initialState: CommentsState = {
  commentsByIssue: {},
  loading: false,
  error: null,
};

const comments = createSlice({
  name: "comments",
  initialState,
  reducers: {
    getCommentsStart(state) {
      state.loading = true;
      state.error = null;
    },
    getCommentsSuccess(state, action: PayloadAction<CommentLoaded>) {
      const { comments, issueId } = action.payload;
      state.commentsByIssue[issueId] = comments;
      state.loading = false;
      state.error = null;
    },
    getCommentsFailure(state, action: PayloadAction<string>) {
      state.loading = false;
      state.error = action.payload;
    },
  },
});

export const {
  getCommentsStart,
  getCommentsSuccess,
  getCommentsFailure,
} = comments.actions;
export default comments.reducer;

export const fetchComments = (issue: Issue): AppThunk => async (dispatch) => {
  try {
    dispatch(getCommentsStart());
    const comments = await getComments(issue.comments_url);
    dispatch(getCommentsSuccess({ issueId: issue.number, comments }));
  } catch (err) {
    dispatch(getCommentsFailure(err));
  }
};

Todos

import {
  createSlice,
  createSelector,
  createAsyncThunk,
  createEntityAdapter,
} from "@reduxjs/toolkit";
import { client } from "../../api/client";
import { StatusFilters } from "../filters/filtersSlice";

const todosAdapter = createEntityAdapter();

const initialState = todosAdapter.getInitialState({
  status: "idle",
});

// Thunk functions
export const fetchTodos = createAsyncThunk("todos/fetchTodos", async () => {
  const response = await client.get("/fakeApi/todos");
  return response.todos;
});

export const saveNewTodo = createAsyncThunk(
  "todos/saveNewTodo",
  async (text) => {
    const initialTodo = { text };
    const response = await client.post("/fakeApi/todos", { todo: initialTodo });
    return response.todo;
  }
);

const todosSlice = createSlice({
  name: "todos",
  initialState,
  reducers: {
    todoToggled(state, action) {
      const todoId = action.payload;
      const todo = state.entities[todoId];
      todo.completed = !todo.completed;
    },
    todoColorSelected: {
      reducer(state, action) {
        const { color, todoId } = action.payload;
        state.entities[todoId].color = color;
      },
      prepare(todoId, color) {
        return {
          payload: { todoId, color },
        };
      },
    },
    todoDeleted: todosAdapter.removeOne,
    allTodosCompleted(state, action) {
      Object.values(state.entities).forEach((todo) => {
        todo.completed = true;
      });
    },
    completedTodosCleared(state, action) {
      const completedIds = Object.values(state.entities)
        .filter((todo) => todo.completed)
        .map((todo) => todo.id);
      todosAdapter.removeMany(state, completedIds);
    },
  },
  extraReducers: (builder) => {
    builder
      .addCase(fetchTodos.pending, (state, action) => {
        state.status = "loading";
      })
      .addCase(fetchTodos.fulfilled, (state, action) => {
        todosAdapter.setAll(state, action.payload);
        state.status = "idle";
      })
      .addCase(saveNewTodo.fulfilled, todosAdapter.addOne);
  },
});

export const {
  allTodosCompleted,
  completedTodosCleared,
  todoAdded,
  todoColorSelected,
  todoDeleted,
  todoToggled,
} = todosSlice.actions;

export default todosSlice.reducer;

export const {
  selectAll: selectTodos,
  selectById: selectTodoById,
} = todosAdapter.getSelectors((state) => state.todos);

export const selectTodoIds = createSelector(
  // First, pass one or more "input selector" functions:
  selectTodos,
  // Then, an "output selector" that receives all the input results as arguments
  // and returns a final result value
  (todos) => todos.map((todo) => todo.id)
);

export const selectFilteredTodos = createSelector(
  // First input selector: all todos
  selectTodos,
  // Second input selector: all filter values
  (state) => state.filters,
  // Output selector: receives both values
  (todos, filters) => {
    const { status, colors } = filters;
    const showAllCompletions = status === StatusFilters.All;
    if (showAllCompletions && colors.length === 0) {
      return todos;
    }

    const completedStatus = status === StatusFilters.Completed;
    // Return either active or completed todos based on filter
    return todos.filter((todo) => {
      const statusMatches =
        showAllCompletions || todo.completed === completedStatus;
      const colorMatches = colors.length === 0 || colors.includes(todo.color);
      return statusMatches && colorMatches;
    });
  }
);

export const selectFilteredTodoIds = createSelector(
  // Pass our other memoized selector as an input
  selectFilteredTodos,
  // And derive data in the output selector
  (filteredTodos) => filteredTodos.map((todo) => todo.id)
);