Store 模式 React 状态管理 library —— Redux 与 Zustand

735 阅读12分钟

Redux 和 Zustand 都使用 Store 模式,而且两者目前在 npm trends 中也处于领先

通过一个 Todo 的 demo 演示下两者在 Store 模式下如何管理状态数据,处理异步任务

回顾 Store 模式

Store 模式通过集中式的存储来管理应用的状态,这种模式的基本思想是将应用的所有状态存储在一个单一的地方,通常称为“store”。组件通过与 store 的交互来读取和更新状态,而不是直接在组件内部管理状态。Store 模式有几个核心概念

  • Store:保存应用所有 state 的对象。它是只读的,唯一改变 state 的方法是触发 actions
  • Actions:描述应用行为的普通对象。它们是 store 进行状态更新的唯一途径,Actions 通常包含一个 type 字段来标识动作的类型,以及其他必要的数据
  • Dispatch:触发 action 的过程。组件通过 dispatch 来发送 actions 给 store,以触发 state 更新
  • Reducers:接受当前的 state 和一个 action,然后返回一个新的 state,Reducers 描述了 action 如何改变 state
  • Selectors:用于从 store 中选择片段数据,使组件获得最少依赖的状态数据

Redux

安装依赖

npm install @reduxjs/toolkit react-redux
  • react-redux:将 React 应用与 Redux 状态管理结合起来,Provider 由其提供
  • @reduxjs/toolkit:减少直接使用 redux 需要手写的“样板代码”,提供简化标准的 Redux 任务的API

为什么 Redux Toolkit 是如今使用 Redux 的方式

创建文件结构

src/
├── features/
│   └── todos/
│       ├── filterSlice.ts
│       ├── TodoApp.tsx
│       ├── todoFilter.ts
│       ├── TodoList.tsx
│       └── TodoSlice.tsx
└── store/
    └── store.ts
└── index.tsx

定义 state 和 reducers

Redux 在诞生之初就因为样板代码过多而饱受诟病,Redux Toolkit(RTK)诞生的一个重要目的就是简化 Redux 应用的开发过程,减少样板代码、改进开发体验

在 Redux Tookit 中把特定功能的 initalState、reducers 和 action creators 集成在了一个文件内,这个文件一般根据其功能被称为 xxxSlice,整棵状态树(store)由多个小片段(state slices)拼成,最终可以通过 configureStore 整合成一个集中的 Store

createSlice 方法的参数

  • name: string: 定义这个 slice 的名字,它将作为生成的 action types 的前缀
  • initialState: T: 定义该 slice 的初始状态,类型 T 表示初始状态的类型
  • reducers: { [key: string]: (state: T, action: PayloadAction) => void }:
    • 定义这个 slice 处理的 reducers,这些 reducers 是一组处理特定 slice 状态的纯函数
    • 每个 reducer 函数接收两个参数:当前的状态 stateaction,返回新的 state

createSlice 为 Redux 应用提供了良好的类型支持,对在写代码过程中问题发现特别有帮助

createSlice 返回的对象

  • reducer: 把所有的 reducer 组合在一起,供 store 使用
  • actions: 自动生成的 action creators,每个 action creator 的名字与对应的 reducer 的 key 一致
  • name: slice 的名称,用于作用域 action types

filterSlice

首先定义一个 Todo 状态筛选的 slice

// features/todos/filterSlice.ts
import { createSlice, PayloadAction } from "@reduxjs/toolkit";

export enum IFilter {
  ALL = 'ALL',
  COMPLETED = 'COMPLETED',
  ACTIVE = 'ACTIVE',
}

const initialState = {
  filter: IFilter.ALL,
}

const filterSlice = createSlice({
   // 定义 slice 的名字,该 slice 下的 action type 前缀默认设置为 filter,避免命名冲突
  name: 'filter',
  initialState,
  reducers: {
    setFilter: (state, action: PayloadAction<IFilter>) => {
      state.filter = action.payload;
    },
  },
});

export const { setFilter } = filterSlice.actions;
export default filterSlice.reducer;

createSlice 极大的简化了 Action types 定义、Action Creators 定义、reducer 中的 switch(actions type) 样板代码。如果不使用 Redux Toolkit,上面代码需要这么写

// src/store/actionTypes.ts
export const SET_FILTER = 'SET_FILTER'; // action 类型定义
export enum IFilter {
  ALL = 'ALL',
  COMPLETED = 'COMPLETED',
  ACTIVE = 'ACTIVE',
}

// src/store/actions.ts
import { SET_FILTER, IFilter } from './actionTypes';

// action creator
export const setFilter = (filter: IFilter) => ({
  type: SET_FILTER,
  payload: filter,
});

// src/store/reducer.ts
import { SET_FILTER, IFilter } from './actionTypes';
interface FilterState {
  filter: IFilter;
}

const initialState: FilterState = {
  filter: IFilter.ALL,
};

const filterReducer = (
  state = initialState, 
  action: { type: string; payload: IFilter }): FilterState => {
  switch (action.type) {
    case SET_FILTER:
      return {
        ...state,
        filter: action.payload,
      };
    default:
      return state;
  }
};

export default filterReducer;

虽然使用了 Redux Toolkit 后 Action 看起来还是很繁琐,但 Redux 很坚持 Action 的使用

Actions 是用来描述在 app 中发生了什么的普通对象,并且是描述突变数据意图的唯一途径。很重要的一点是不得不 dispatch 的 action 对象并非是一个样板代码,而是 Redux 的一个 基本设计原则。

不少框架声称自己和 Flux 很像,只不过缺少了 action 对象的概念,在可预测性方面这是从 Flux 或 Redux 的倒退。如果没有可序列化的普通对象 action,便无法记录或重演用户会话,也无法实现 带有时间旅行的热重载。如果你更喜欢直接修改数据,那你并不需要使用 Redux 。

todoSlice

todoSlice 中需要实现 todo 的添加、修改删除功能,而这些 action 都是异步的,就 Redux 本身而言,Redux store 对异步逻辑一无所知,它只知道如何同步 dispatch action,通过调用 reducer 函数更新状态,并通知 UI 某些事情发生了变化,因此任何异步都必须发生在 store 之外,这就是 Redux middleware 的作用

Thunk middleware 拦截所有通过 dispatch 发出的 actions,并检查 action 是否是一个函数

  • 如果 action 是一个对象(普通的 action),Thunks 中间件会直接将其传递给下一个 middleware 或者 reducer
  • 如果 action 是一个函数(Thunk),中间件将执行这个函数并传递 dispatchgetState 函数作为参数给它。函数的逻辑可以同步或异步地进行一系列动作,包括额外的 dispatch 操作或状态读取

当所有中间件处理完成后,最终的 actions 会传递给 Redux store 的 reducer,从而更新状态,流程如下所示

Redux Toolkit 的默认设置了支持异步的 thunk middleware,通过createAsyncThunk方法可以定义和处理异步 actions,其接受一个字符串类型的 action type 和一个返回 promise 的回调函数。这个回调函数是异步操作的核心(例如,API 请求)。createAsyncThunk 会生成一个异步操作,并自动生成对应的 action types 和 action creators

// features/todos/todoSlice.ts
import { createSlice, createAsyncThunk, PayloadAction } from "@reduxjs/toolkit";

// 类型统一使用 IXxxZzz 的格式,方便识别
export type ITodo = {
  id: number;
  title: string;
  completed: boolean;
}

export enum IStatus {
  IDLE = 'idle',
  LOADING = 'loading',
  SUCCEEDED = 'succeeded',
  FAILED = 'failed',
}

type ITodoState = {
  todos: ITodo[];
  status: IStatus;
}

const initialState: ITodoState = {
  todos: [],
  status: IStatus.IDLE,
};

// 模拟异步请求
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));

// 使用 createAsyncThunk 创建异步 Action
export const addTodoAsync = createAsyncThunk<ITodo, string>(
  'todos/addTodoAsync',
  async (title: string) => {
    await delay(1000);
    return { id: Date.now(), title, completed: false };
  }
);

export const removeTodoAsync = createAsyncThunk(
  'todos/removeTodoAsync',
  async (id: number) => {
    await delay(1000);
    return id;
  }
);

export const toggleTodoAsync = createAsyncThunk(
  'todos/toggleTodoAsync',
  async (id: number) => {
    await delay(1000);
    return id;
  }
);

export const todoSlice = createSlice({
  name: 'todos',
  initialState,
  reducers: {},
  // 异步 action 的处理在 extraReducers 中
  extraReducers: (builder) => {
    // builder.addCase 可以理解为 switch...case 的简写
    builder
      .addCase(addTodoAsync.pending, (state) => {
        state.status = IStatus.LOADING;
      })
      .addCase(addTodoAsync.fulfilled, (state, action: PayloadAction<ITodo>) => {
        state.status = IStatus.SUCCEEDED;
        // 直接操作当前 state 对象,而不是返回新的 state 对象
        state.todos.push(action.payload);
      })
      .addCase(removeTodoAsync.pending, (state) => {
        state.status = IStatus.LOADING;
      })
      .addCase(removeTodoAsync.fulfilled, (state, action: PayloadAction<number>) => {
        state.status = IStatus.SUCCEEDED;
        state.todos = state.todos.filter((todo) => todo.id !== action.payload);
      })
      .addCase(toggleTodoAsync.pending, (state) => {
        state.status = IStatus.LOADING;
      })
      .addCase(toggleTodoAsync.fulfilled, (state, action: PayloadAction<number>) => {
        state.status = IStatus.SUCCEEDED;
        const todo = state.todos.find((todo) => todo.id === action.payload);
        if (todo) {
          todo.completed = !todo.completed;
        }
      })
  },
});

export default todoSlice.reducer;

Redux 强调在 reducer 中不允许改变当前的 State,需要生成新的 State,这也是 Redux 用户最常犯的一个错误,而 Redux Toolkit 中集成 Immer,这样就可以“直接”操作 State,Immer 完成将代码转换为安全不可变更新的工作

配置 store

Redux Tookit 提供了 configureStore 完成 reducer 的合并,并且启用了 thunk 等常用中间件

// store/store.ts
import { configureStore } from '@reduxjs/toolkit';
import todoReducer from '../features/todos/todoSlice';
import filterReducer from '../features/todos/filterSlice';

// 合并多个 reducer,state 的初始值也随着 reducer 被转入
const store = configureStore({
  reducer: {
    todos: todoReducer,
    filter: filterReducer,
  },
});

export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
export default store;

创建组件

TodoFilter

// features/todos/TodoFilter.tsx
import { IFilter } from './filterSlice';

type IProps = {
  setFilter: (filter: IFilter) => void;
};

export default function TodoFilter({ setFilter }: IProps) {
  return (
    <div>
      <label>
        <input type="radio" defaultChecked name="filter" onChange={() => setFilter(IFilter.ALL)}/> All 
      </label>
      <label>
        <input type="radio" name="filter" onChange={() => setFilter(IFilter.ACTIVE)} /> Active 
        </label>
      <label>
        <input type="radio" name="filter" onChange={() => setFilter(IFilter.COMPLETED)} /> Completed 
      </label>
    </div>
  );
};

TodoList

在组件中调用 useDispatch 会返回 Redux store 的 dispatch 函数,通过 dispatch 函数,可以发送(dispatch)actions 来触发 state 的更新

// features/todos/TodoList.tsx
import { useDispatch } from 'react-redux';
import { toggleTodoAsync, removeTodoAsync, ITodo } from './todoSlice';
import { AppDispatch } from '../../store/store';

export default function TodoList({todos}: {todos: ITodo[]}){
  const dispatch = useDispatch<AppDispatch>();
  return (
    <ul>
      {
        todos.map(todo => (
          <li key={todo.id} style={{textDecoration: todo.completed ? 'line-through' : 'none'}}>
            <input 
              type="checkbox" 
              checked={todo.completed} 
              onChange={() => dispatch(toggleTodoAsync(todo.id))} />
            <span>{todo.title}</span>
            <button 
              onClick={() => dispatch(removeTodoAsync(todo.id))}>
              Remove
            </button>
          </li>
        ))
      }
    </ul>
  );
}

TodoApp

通过 useSelector 可以从 Redux store 中访问组件所需的部分 state,useSelector 会自动订阅 Redux store 的更新,当所选择的 state 更新时,组件会重新渲染,这样通过选择性地从 Redux store 获取所需的 state,避免了不必要的 re-renders,提升了组件的性能,通过 useSelector 来根据当前的状态派生出组件需要用的状态,可以显著提升代码可维护性,在后续其它状态管理 library 后会反复出现

// features/todos/TodoApp.tsx
import { useState } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { RootState, AppDispatch } from '../../store/store';
import { addTodoAsync, IStatus } from './todoSlice';
import { setFilter, IFilter } from './filterSlice';
import TodoList from './TodoList';
import TodoFilter from './TodoFilter';

export default function TodoApp() {
  const dispatch = useDispatch<AppDispatch>();
  const todos = useSelector((state: RootState) => state.todos.todos);
  const filter = useSelector((state: RootState) => state.filter.filter);
  const status = useSelector((state: RootState) => state.todos.status);
  const [title, setTitle] = useState('');

  const handleAddTodo = () => {
    if (title.trim() !== '') {
      dispatch(addTodoAsync(title));
      setTitle('');
    }
  };

  const handleFilterChange = (filter: IFilter) => {
    dispatch(setFilter(filter));
  };

  const filterdTodos = todos.filter((todo) => {
    switch (filter) {
      case IFilter.ALL:
        return true;
      case IFilter.ACTIVE:
        return !todo.completed;
      case IFilter.COMPLETED:
        return todo.completed;
      default:
        return true;
    }
  });

  return (
    <div className="todos-app">
      <h1>Redux Todo</h1>
      <input
        value={title}
        placeholder="Add a new todo..."
        onKeyDown={(e) => {
          if (e.key === 'Enter') {
            handleAddTodo();
          }
        }}
        onChange={(e) => setTitle(e.target.value)} />
      <button onClick={handleAddTodo}>Add Todo</button>
      {status === IStatus.LOADING && <p>Loading...</p>}
      <TodoFilter setFilter={handleFilterChange} />
      <TodoList todos={filterdTodos} />
    </div>
  );
};

将一切连接到应用程序

Provider 是 React-Redux 库提供的一个高阶组件,用于将 Redux store 和 React 应用连接起来。它的主要作用是将 Redux store 提供给整个组件树中的所有组件,使得组件可以通过 React-Redux 提供的 hooks(useSelectoruseDispatch 可以访问 store 的数据、派发 action 正是因为 Provider)来访问 Redux store 和触发 actions。组件数据的共享就是这样实现的

// ./index.tsx
import { Provider } from'react-redux';
import store from './store/store';
import TodoApp from './features/todos/TodoApp';

export default function() {
  return (
    <Provider store={store}>
      <TodoApp />
    </Provider>
  );
}

Zustand

理解了 Redux 的各种概念和数据流程之后,再看其他状态管理 library 会简单很多,尤其是同样使用 Store 模式的 Zustand

安装依赖

npm install zustand

创建目录结构

src/
├── components/
│   ├── TodoApp.tsx
│   ├── TodoList.tsx
│   └── TodoFilter.tsx
└── store/
    └── todoStore.ts
└── index.tsx

定义 store

Zustand 中 store 的定义和 Redux 的 slice 非常相似,虽然支持多 Store 模式不强制单一数据源,但 Zustand 鼓励使用 Single Store 模式,对于复杂的业务逻辑,和 Redux 一样支持 Slice Pattern

Your applications global state should be located in a single Zustand store. If you have a large application, Zustand supports splitting the store into slices.

Zustand 代码简洁之处还来自于同步/异步 Action 书写方式保持了一致性

// store/todoStore.ts
import { create } from 'zustand';

export type ITodo = {
  id: number;
  title: string;
  completed: boolean;
}

export enum IFilter {
  ALL = 'ALL',
  ACTIVE = 'ACTIVE',
  COMPLETED = 'COMPLETED',
}

export enum IStatus {
  IDLE = 'idle',
  LOADING = 'loading',
  SUCCEEDED = 'succeeded',
  FAILED = 'failed',
}

type ITodoStore = {
  todos: ITodo[];
  status: IStatus;
  filter: IFilter;
  addTodo: (title: string) => void;
  toggleTodo: (id: number) => void;
  removeTodo: (id: number) => void;
  setFilter: (filter: IFilter) => void;
}

// 模拟异步
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));

export const useTodoStore = create<ITodoStore>(set => ({
  todos: [],
  status: IStatus.IDLE,
  filter: IFilter.ALL,
  addTodo: async (title: string) => {
    set({ status: IStatus.LOADING });
    await delay(1000);
    set(state => ({
      todos: [...state.todos, { id: Date.now(), title, completed: false }],
      status: IStatus.SUCCEEDED,
    }));
  },
  toggleTodo: async (id: number) => {
    set({ status: IStatus.LOADING });
    await delay(1000);
    set(state => ({
      todos: state.todos.map(todo => todo.id === id ? { ...todo, completed: !todo.completed } : todo),
      status: IStatus.SUCCEEDED,
    }));
  },
  removeTodo: async (id: number) => {
    set({ status: IStatus.LOADING });
    await delay(1000);
    set(state => ({
      todos: state.todos.filter(todo => todo.id !== id),
      status: IStatus.SUCCEEDED,
    }));
  },
  setFilter: (filter: IFilter) => set({ filter }),
}));

创建组件

TodoFilter

// components/TodoFilter.tsx
import { IFilter } from '../store/todoStore';

type IProps = {
  setFilter: (filter: IFilter) => void;
}

export default function TodoFilter({ setFilter }: IProps) {
  return (
    <div>
      <label>
        <input type="radio" defaultChecked name="filter" onChange={() => setFilter(IFilter.ALL)} /> All
      </label>
      <label>
        <input type="radio" name="filter" onChange={() => setFilter(IFilter.ACTIVE)} /> Active
      </label>
      <label>
        <input type="radio" name="filter" onChange={() => setFilter(IFilter.COMPLETED)} /> Completed
      </label>
    </div>
  );
}

TodoList

import { useTodoStore, ITodo } from "../store/todoStore";

export default function TodoList({todos}: {todos: ITodo[]}) {
  // 可以理解为一个 selector,获取组件需要的状态数据与方法
  const { toggleTodo, removeTodo } = useTodoStore(state => ({
    toggleTodo: state.toggleTodo,
    removeTodo: state.removeTodo
  }));

  return (
    <ul>
      {
        todos.map(todo => (
          <li key={todo.id} style={{textDecoration: todo.completed ? 'line-through' : 'none'}}>
            <input 
              type="checkbox" 
              checked={todo.completed} 
              onChange={() => toggleTodo(todo.id)} />
            <span>{todo.title}</span>
            <button onClick={() => removeTodo(todo.id)}>Remove</button>
          </li>
        ))
      }
    </ul>
  );
}

TodoApp

import { useState } from 'react';
import { useTodoStore, IFilter, IStatus } from '../store/todoStore';
import TodoList from './TodoList';
import TodoFilter from './TodoFilter';

export default function TodoApp() {
  const [title, setTitle] = useState('');
  // 再一次使用 selector 获取需要的状态和方法
  const { todos, filter, status, addTodo, setFilter } = useTodoStore(state => ({
    todos: state.todos,
    filter: state.filter,
    status: state.status,
    addTodo: state.addTodo,
    setFilter: state.setFilter,
  }));

  const handleAddTodo = async () => {
    if (!title.trim()) return;
    await addTodo(title);
    setTitle('');
  }

  const filteredTodos = todos.filter(todo => {
    switch (filter) {
      case IFilter.ALL:
        return true;
      case IFilter.ACTIVE:
        return!todo.completed;
      case IFilter.COMPLETED:
        return todo.completed;
      default:
        return true;
    }
  });

  return (
    <div>
      <h1>Zustand Todo</h1>
      <input
        value={title}
        placeholder="Add a new todo..."
        onKeyDown={(e) => {
          if (e.key === 'Enter') {
            handleAddTodo();
          }
        }}
        onChange={(e) => setTitle(e.target.value)} />
      <button onClick={handleAddTodo}>Add Todo</button>
      {status === IStatus.LOADING && <p>Loading...</p>}
      <TodoFilter setFilter={setFilter} />
      <TodoList todos={filteredTodos} />
    </div>
  );
}

Zustand 没有和 Redux 一样使用 Context API,因此不需要 Provider 包裹,TodoApp 可以直接使用

Redux VS Zustand

虽然同样使用 Store 模式和不可变数据模式,但可以看出来即使使用了可以减少样板代码的 Redux Toolkit,Zustand 的代码量仍然远远少于使用了 Redux。Zustand 没有使用 Context API 不用多一层包裹 HOC,而且除了 Store 几乎没有其它概念,因此代码也不需要理解就能直接使用

Redux 提供了一个明确的架构和严格的约定来管理应用状态,这对大型应用尤为重要。它规定了状态管理中的各个部分应该如何组织和交互,包括 actions、reducers 和 store。通过这些约定大大提高了代码的可读性和一致性。只要了解了 Redux 的基础概念(成本确实有点高)任何开发人员都可以快速理解和上手一个新的 Redux 项目,因为它们知道在哪里查找状态逻辑和如何扩展功能

同样的 UI 功能 Zustand 可以用更少的代码实现也并不是因为 library 本身做了很多,毕竟 Zustand gzip 后只有 1.2k。Zustand 的简单来自于对样板代码的缩减,在数据流程中移除了 Action、和 dispatch,修改为了函数传递和调用,但没有样板代码的代价就是样板代码解决的问题,可以被开发者写到任意地方,个人对 Redux 的看法也几经波折

  • 在 Redux 出世不久时惊为天人,从其它状态管理 library 的相似概念中也能看到 redux 的影响力,而且切实解决了复杂单页应用的状态管理问题
  • 过了两三年进入疲劳期,没完没了的样板代码
  • 最近维护的代码越来越多,使用了不同的解决方案,每天不停的寻找状态为什么变了,当前的状态究竟是什么,越是想念 Redux 的好,因此才萌生了编写本文的念头

相信 Zustand 的作者对这个问题也应该有同感,在 Zustand 中可以通过中间件支持 Redux 的时间旅行、使用 Slice Pattern & 合并 Store,甚至有一个 Redux-like patterns 的支持。流行的状态管理 library 之间没有高低,但开发人员的水平有,如果认同 Redux 三大原则

  • 单一数据源:整个应用的 全局 state 被储存在一棵 object tree 中,并且这个 object tree 只存在于唯一 store 中
  • 不可变数据:State 是只读的 唯一改变 state 的方法就是触发 action,action 是一个用于描述已发生事件的普通对象
  • 使用纯函数来执行修改:为了描述 action 如何改变 state tree,需要编写纯函数的 reducers

在职业生涯初期应该尽可能多使用 Redux,这样可以保证应用可读性的下限,当掌握了其数据流转过程时候再和团队做好约定,切换到 Zustand 编码提速