Redux 笔记

Published on
15 min read

Redux 是什么

Redux是通过 actions 事件,管理与更新应用状态的工具。换一种说法就是,在某个地方统一管理整个应用的状态数据,避免了状态/事件等在组件之间来回传递的尴尬局面。举个例子:

我们希望 component A 中的按钮点击事件,会触发 count 值的更新,而 count 值的更新,又要触发 component B 的重新渲染。在传统的 React 项目中,我们的做法通常是将 count 的定义提升到父组件中,通过 props 传参的形式来影响各个子组件。同样的需求,使用 Redux 实现方式如下图。

从图中可以看出,应用状态都被放置在一个名为 store 的容器中。componetn A 需要通过 dispatch 方法,借助 reducer 来更新状态,而 component B 订阅了状态变化,就能够收到通知获取新的状态重新渲染 UI。

上图中,除了订阅方法之外,我们可以通过结合 React 自身提供的 Context 以及 useReducer 一起实现上图中类似的功能

Redux 术语

Actions

Actions 是一个包含 type 属性的空对象,用来描述应用内发生的事件。使用 useReducer 的时候,对于 action.type 的命名没有制定过规范,而 Redux 要求 type 的格式应当是 domain/eventName 格式的字符串。例如第一部分的自增事件,type 应当命名为 counter/increaseActions 还可以有一个额外参数 payload 作为事件的附加信息,用于传递数据等操作。

Reducers

reducer 是一个函数,函数定义大致如下

function xxxReducer(state = initialState, action): newState {}

Store

当前应用的所有状态信息都存储在 Store 中。通过 createStore 来创建 Store,通过 getState 方法来获取当前的状态信息。

Dispatch

dispatch 是 Redux 中唯一可以更新状态的方法,通过 store.dispatch(action) 进行调用。

Selectors

Selectors 的作用是用来读取存储在 Store 中一小部分数据,Selectors 我理解为是一种规范,当 Store 中的数据结构变的复杂后,Selectors 能够保证获取同一数据片段的一致性。

实践

实践部分就参考官网教程在 ttask 中实现一个简单的 Todo 应用。

依赖安装

pnpm add @reduxjs/toolkit react-redux --filter web

pnpm add @types/react-redux -D --filter web

项目结构

官方文档中的目录结构和 nextjs 推荐的目录结构类似,按照应用的功能模块,拆开统一放置在 features 目录下,大致目录结构参考如下

/src
  /app
    store.ts -- 创建 redux store 实例
  /features
    /todos   -- 操作 todo 事项功能,例如新建,删除等
      todosSlice.ts -- redux 分片
    /filter
      filterSlice.ts -- redux 分片

创建 Store

使用 Redux Toolkit 提供的 configureStore 方法创建 store

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

export default configureStore({
  reducer: {},
});

通过 Provider 在应用中共享 store

使用 React-redux 提供的 <Provider> 组件包装一下应用原来的组件,在 ttask 项目中,修改 _app.tsx 文件。

// _app.tsx
import { Provider } from 'react-redux';
import store from '@app/store';

const App = ({ children }: AppProps) => {
  return <Provider store={store}>{children}</Provider>;
};

创建 Redux 切片(slices) 与 actions

首先,定义 Todo 任务的数据结构包含如下字段

export enum Status {
  // 任务待处理
  Pending,
  // 任务进行中
  OnProgress,
  // 任务完成
  Completed,
}

// 筛选任务状态类型,包含了 'all'
export type AllStatus = 'all' | Status;

const priority = ['low', 'medium', 'high', 'urgent'] as const;
type Priority = typeof priority[number];

interface Todo {
  id: string;
  title: string;
  status: Status;
  priority?: Priority;
}

Todo list 的 actions 大致包括下面的几个

action.typeaction.payload描述
todos/added{ title, proority? }添加新任务
todos/statusChanged{ todoId, status }修改任务状态
todos/priorityChanged{ todoId, priority? }修改任务优先级

Readux Toolkit 提供了 createSlice API,用来拆分 reducer 的逻辑和 actions 操作。

// features/todos/todoSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { Status, Todo } from '@models';
import { v4 as uuidv4 } from 'uuid';

type Todos = { entities: Todo[] };

const initialState: Todos = {
  entities: [],
};

const todosSlice = createSlice({
  name: 'todos',
  initialState,
  reducers: {
    added(state, action: PayloadAction<Pick<Todo, 'title' | 'priority'>>) {
      const { title, priority } = action.payload;
      const newTodo: Todo = {
        id: uuidv4(),
        status: Status.Pending,
        title,
        priority,
      };
      state.entities.push(newTodo);
    },
    statusChanged(state, action: PayloadAction<Pick<Todo, 'id' | 'status'>>) {
      const { id: todoId, status } = action.payload;
      const todo = state.entities.find((entity) => entity.id === todoId);
      if (todo) todo.status = status;
    },
    priorityChanged(state, action: PayloadAction<Pick<Todo, 'id' | 'priority'>>) {
      const { id: todoId, priority } = action.payload;
      const todo = state.entities.find((entity) => entity.id === todoId);
      if (todo) todo.priority = priority;
    },
  },
});

export const { added, statusChanged, priorityChanged } = todosSlice.actions;

export default todosSlice.reducer;

上面的代码片段就是 createSlice 用法了,再回头看官方文档中介绍的 createSlice 的优势就能有更深的体会了

  • reducer 字段中,以函数的形式实现 reducer 的每个 case,告别传统的 switch/case
  • 状态的修改可以使用更简短的不可变更新逻辑,告别了传统对象展开方式,通过 immer 实现
  • 可以根据定义在 reducer 中的函数,自动生成相应的 action

同理,可以很容易的定义出任务过滤的 actions 以及 slice 的实现

action.typeaction.payload描述
filters/statusFilterChangedstatus按照任务状态过滤
// features/filters/filtersSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { AllStatus } from '@models';

type TodoFilter = { status: AllStatus };

const initialState: TodoFilter = {
  status: 'all',
};

const filtersSlice = createSlice({
  name: 'filters',
  initialState,
  reducers: {
    statusFilterChanged(state, action: PayloadAction<AllStatus>) {
      state.status = action.payload;
    },
  },
});

export const { statusFilterChanged } = filtersSlice.actions;

export default filtersSlice.reducer;

完成了 slice 实现之后,就需要将两个切片内容整合起来,修改 app/store.ts 文件,在 createStore 方法中添加定义好的两个切片。

import { configureStore } from '@reduxjs/toolkit';
import filtersSlice from '@features/filters/filtersSlice';
import todosSlice from '@features/todos/todosSlice';

const store = configureStore({
  reducer: {
    todos: todosSlice,
    filters: filtersSlice,
  },
});

// 导出 RootState 和 AppDispatch 两个类型,在 Typescript 项目中非常有用
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

export default store;

启动前端服务,在浏览器中,通过 redux-devtools 浏览器插件,就能看到 redux store 中的初始状态。

在组件中使用 Redux 的 State 与 Actions

参考 nextjs 中对 features 目录的定义,功能相关的组件也需要放在 features 目录下

features/filters/StatusFilter.tsx 文件,并添加一下代码片段,并将组件添加到页面上。

import { useSelector, useDispatch } from 'react-redux';
import { AllStatus, Status } from '@models';
import { formatFilterTag } from '@utils';
import { RootState, AppDispatch } from '@app/store';
import { statusFilterChanged } from './filtersSlice';

const StatusFilter = () => {
  // 1.
  const currentStatus = useSelector((state: RootState) => state.filters.status);
  // 2.
  const dispatch = useDispatch<AppDispatch>();

  return (
    <section className="flex items-center my-6">
      {['all', 0, 1, 2].map((key) => (
        <div
          key={key}
          className={`filter-tag ${currentStatus === key ? 'selected' : ''}`}
          // 3.
          onClick={() => dispatch(statusFilterChanged(key as AllStatus))}
        >
          {formatFilterTag(Status[key as Status] || 'all')}
        </div>
      ))}
    </section>
  );
};

export default StatusFilter;

简单说明一下代码片段中的注释标记

  1. 定义 RootState 可以帮助我们在使用 useSelectore hook 时,有更好的类型提示
  2. useDispath hook 中获取到 dispatch 函数
  3. statusFilterChanged 方法是有 reducer 函数生成,函数定义如下,可以说 redux-toolkit 大大简化了 dispatch 的调用行为
statusFilterChanged(payload: AllStatus): {
    payload: AllStatus;
    type: "filters/statusFilterChanged";
}

处理异步逻辑

上文过滤器的处理逻辑,都是同步的。筛选按钮的 onClick 方法中,调用了 dispatch 方法,store 运行 reducer 并返回新的状态。但大多数应用场景,都是异步操作,比如通过 API 获取数据。Redux 当然考虑好了异步场景,并引入了一个新的名词 thunk

thunk 一种特殊类型的 Redux 函数,thunk 函数能够处理异步逻辑。thunk 的内部返回了一个函数,该函数接收 dispatchgetState 两个参数,并直接返回该内部函数。

// 外部 "创建 thunk" 函数
const fetchUserById = (userId) => {
  // 内部 "thunk" 函数,接收 `dispatch` 和 `getState` 参数
  return async (dispatch, getState) => {
    try {
      const user = await userAPI.fetchById(userId);
      // 异步函数执行完毕,调用 `dispatch`
      dispatch(userLoaded(user));
    } catch (err) {
      // 错误处理
    }
  };
};

redux 需要使用 redux-thunk 中间件,才能使用 thunk 处理异步逻辑。RTK (Redux Toolkit) 已经默认启用了 redux-thunk 插件。

接下就在 ttask 项目中添加加载 todo list 的异步逻辑。

新建 API 模拟数据

server 项目中添加 /src/controllers/v1/todos.ts 文件,并定义一个 controllers

import { Controller, Get, RouterContext } from '@swizm/nflask';

@Controller('/todos')
class Todos {
  @Get('/')
  async getAllTodos(ctx: RouterContext) {
    ctx.body = {
      objects: [],
    };
    ctx.status = 200;
  }
}

export default Todos;

管理请求状态

修改定义在 todosSlice 中的 Todo 类型,新增如下 statuserror 字段,用于管理请求状态和请求失败时的错误信息。

type Todos = {
  entities: Todo[];
  status: 'idle' | 'loading' | 'succeeded' | 'failed';
  error: string | null;
};

使用 createAsyncThunk 方法请求数据

首先添加一个请求方法,通过 axios 请求本地启动的 server 服务 http://localhost:3000/api/v1/todos。接下来使用 createAsyncThunk 新建 thunk 函数。

export const fetchTodos = createAsyncThunk<Todo[]>('todos/fetchTodos', async () => {
  const response = await getAllTodos();
  return response.data.objects;
});

server 项目对跨域没有做任何处理,这里也就成功的触发了跨域,跨域不在本文的讨论范围之内,快速解决一下,详情还是参考 MDN 官方文档

注意 createAsyncThunk 的第一个参数,这个参数可以等价的认为是 actionstype ,当数据被请求时,开发工具里会显示以这个参数作为前缀的事件类型,如下图

通过 createAsyncThunk 定义了获取 API 数据的方法,但是并没有定义在 slicereducers 属性中,createSlice 提供了 extraReducers 属性,用来解决响应 未定义reducers 中的 reducer

const todosSlice = createSlice({
  name: 'todos',
  // ...
  reducers: {},
  extraReducers(builder) {
    builder
      .addCase(fetchTodos.pending, (state) => {
        state.status = 'loading';
      })
      .addCase(fetchTodos.fulfilled, (state, action) => {
        state.status = 'succeeded';
        state.entities = state.entities.concat(action.payload);
      })
      .addCase(fetchTodos.rejected, (state, action) => {
        state.status = 'failed';
        state.error = action.error.message ?? 'Fetch request failed';
      });
  },
});

extraReducers 是一个参数为 builder 的函数,我们通过 builder.addCase(actionCreator, reducer) 方法来处理每个异步 thunk 场景。

组件中获取 Todos 列表数据

还记得上文 StatusFilter 组件的开发吗?这里 Todo 列表组件也同理需要放置在 features/todos 目录下。

import { useEffect } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { AppDispatch, RootState } from '@app/store';
import { fetchTodos, selectAllTodos } from './todosSlice';

const TodosList = () => {
  const dispatch = useDispatch<AppDispatch>();
  const todos = useSelector(selectAllTodos);
  const todosStatus = useSelector((state: RootState) => state.todos.status);

  useEffect(() => {
    if (todosStatus === 'idle') {
      dispatch(fetchTodos());
    }
  }, [todosStatus, dispatch]);

  return (
    <section>
      {todos.map((todo) => (
        <div key={todo.id}>{todo.title}</div>
      ))}
    </section>
  );
};

export default TodosList;

单纯的 React 项目,都会使用 useFetch 这个知名度很高的 hooks 来处理,页面首次加载时请求数据的场景。在 Redux 中,也一样需要在 useEffect 中,当状态为 idle 时发起首次请求。

实现状态筛选

最后就是关于任务筛选状态之后,显示对应的任务列表了。修改一下 todoSlice 中的 getAllTodos 方法就可以了。

export const selectAllTodos = (state: RootState) => {
  const {
    filters: { status },
    todos,
  } = state;
  if (status === 'all') return todos.entities;
  return todos.entities.filter((todo) => todo.status === status);
};

总结

至此,跟着官方文档,对 Redux 有了个简单了解。对 Redux 的认知再也不是过去 React 的 Context 配合 useReducer 等于 Redux。Redux 不仅仅只是通过全局 Store 管理应用程序状态的简单工具,而更像是配合 React 一起协作的生态。redux-toolkit 提供的 createStore/createSlice/createAsyncThunk 等方法以及 react-redux 提供的一些 hook 简化了 Redux 的使用成本。同时我们也在 ttask 的 web 子项目中做了实践,不过还只是文档做的简单功能,一定存在更加高级且便利的用法,以后在继续学习补充吧。