React中级教程(四)– 全局状态管理

本文介绍一些用于管理全局状态的工具和技术,状态数据存在于整个应用程序中,不仅仅在于某个组件内部。内容包括以下:

  • 使用Reducer整合状态逻辑
  • 使用React Context共享数据
  • 何时使用 React Context
  • React Context 与 Redux的比较
  • 使用 Zustand 管理应用程序状态

使用Reducer整合状态逻辑

Reducer 是一个允许我们将状态更新集中在某个模块中的函数。

打开src/state-management/Counter.tsx,我们能看到一个简单的组件和State控制,它有两个按钮,当按Increment和Reset时,分别由setValue来设置状态,这个状态更改的逻辑是分开的。

现在要做的,就是将状态的逻辑整合一起。

来看看如何做?

新建文件夹 src/state-management/reducers,并在其下新建countReducer.ts。

conterReducer是一个函数,有两个参数:state是传入的状态;action是识别的动作,可以是任何类型比如string,按照传统的做法,是用interface包装一下。函数返回更新后的状态。

// countReducer.ts
​
interface Action{
    type:'INCREMENT'|'RESET';
}
const counterReducer = (state:number, action:Action):number=>{
    if(action.type === 'INCREMENT') state = state +1;
    if(action.type === 'RESET') state = 0;
    return state;
}
export default counterReducer;

再回到Counter.tsx进行修改。

这里使用了useReducer,需要提供一个Reducer以及一个初始值,这里的Reducer是counterReducer,初始值是0。useReducer定义了一个状态(这里是value)和 分配的动作(dispatch)。

在按扭点击时,我们只需调用 dispatch,并将相应的Action值传过去即可,不用实现任何更改逻辑。

这样带来的好处是可以分离逻辑实现,并可重用Reducer。

// Counter.tsx
​
import { useReducer } from "react";
import counterReducer from "./reducers/counterReducer";
​
const Counter = () => {
  const [value, dispatch] = useReducer(counterReducer, 0);
​
  return (
    <div>
      Counter ({value})
      <button
        onClick={() => dispatch({ type: "INCREMENT" })}
        className="btn btn-primary mx-1"
      >
        Increment
      </button>
      <button
        onClick={() => dispatch({ type: "RESET" })}
        className="btn btn-primary mx-1"
      >
        Reset
      </button>
    </div>
  );
};
​
export default Counter;
​

接着在App.tsx里调用,看看效果。

// App.tsx
​
import "./App.css";
import Counter from "./state-management/Counter";
​
function App() {
  return <Counter />;
}
​
export default App;
​

效果与之前是一样的。


创建复杂的Action

再来看一个实例TaskList.tsx。

import { useState } from 'react';
​
interface Task {
  id: number;
  title: string;
}
​
const TaskList = () => {
  const [tasks, setTasks] = useState<Task[]>([]);
​
  return (
    <>
      <button
        onClick={() =>
          setTasks([
            { id: Date.now(), title: 'Task ' + Date.now() },
            ...tasks,
          ])
        }
        className="btn btn-primary my-3"
      >
        Add Task
      </button>
      <ul className="list-group">
        {tasks.map((task) => (
          <li
            key={task.id}
            className="list-group-item d-flex justify-content-between align-items-center"
          >
            <span className="flex-grow-1">{task.title}</span>
            <button
              className="btn btn-outline-danger"
              onClick={() =>
                setTasks(tasks.filter((t) => t.id !== task.id))
              }
            >
              Delete
            </button>
          </li>
        ))}
      </ul>
    </>
  );
};
​
export default TaskList;
​

实际效果如图。

很明显,Add和Delete操作是分离的。

我们将这两个操作的逻辑合在一个组件里。

新建taskRecuder.ts。将interface Task移至该模块。由于添加、删除带的数据是不一样的,分别设置了AddAction和DeleteAction的interface。

在taskReducer里,通过 switch语法判断Action的type。

// taskRecuder.ts
​
interface Task {
    id: number;
    title: string;
}
​
interface AddAction{
    type:'ADD';
    task:Task;
}
​
interface DeleteAction{
    type:'DELETE';
    taskID:number;
}
​
type TaskAction = AddAction | DeleteAction;
​
const taskReducer = (tasks:Task[],action:TaskAction):Task[]=>{
    switch(action.type){
        case 'ADD':
            return [action.task,...tasks];
        case  'DELETE':
            return tasks.filter(t=>t.id!==action.taskID);
    }
}
export default taskReducer;

再回到TaskList.tsx进行修改。用useReducer代替useState。

// TaskList.tsx
​
......
const TaskList = () => {
  const [tasks, dispatch] = useReducer(taskReducer, []);
​
  return (
    <>
      <button
        onClick={() =>
          dispatch({
            type: "ADD",
            task: { id: Date.now(), title: "Task " + Date.now() },
          })
        }
        className="btn btn-primary my-3"
      >
        Add Task
      </button>
      ......
            <button
              className="btn btn-outline-danger"
              onClick={() => dispatch({ type: "DELETE", taskID: task.id })}
            >
              Delete
            </button>
......
​

练习:使用Reducers

再看LoginStatus.tsx。使用Reducer来改造它。

创建 authReducer.ts

// authReducer.ts
​
​
interface LoginAction{
    type:'LOGIN';
    value:string;
}
interface LogoutAction{
    type:'LOGOUT';
}
type AuthAction = LoginAction|LogoutAction
const authReducer = (state:string,action:AuthAction):string=>{
    if(action.type==='LOGIN') return action.value;
    if(action.type==='LOGOUT') return '';
    return state;
}
export default authReducer;

再在LoginStatus.tsx中调用。

// LoginStatus.tsx
​
......
import authReducer from "./reducers/authReducer";
​
const LoginStatus = () => {
  const [user, dispatch] = useReducer(authReducer, "");
​
  if (user)
    ......
          <a onClick={() => dispatch({ type: "LOGOUT" })} href="#">
            Logout
          </a>
    ......
    );
  return (
    <div>
      <a onClick={() => dispatch({ type: "LOGIN", value: "Kelemi" })} href="#">
        Login
      </a>
    </div>
  );
};
​
export default LoginStatus;
​

使用React Context共享状态

以前我们谈过共享状态。

如下图,TaskList的状态如果想与NavBar共享,就需要将状态提升到 App,再通过Props传给TaskList和NavBar。但有个问题,中间要经过HomePage,而实际上,HomePage根本没必要了解这个状态。当应用程序越来越复杂,树的层处越来越深,这就成了一个负担。

使用React Context,我们可以避免将状态层层传递经过中间的组件。

可以将Context想像成一辆卡车,而要传递的状态数据就是卡车的一个箱子。各个组件都可以取出卡车里的箱子使用。

首先定义箱子的结构。

新建文件夹 contexts,再在其下新建文件taskContext.ts。定义箱子数据的结构 TaskContextType,之前的Reducer结构一个是Task[],另一个是Dispatch<TaskAction>。接着创建TaskContext的实例。

// taskContext.ts
​
import { Dispatch, createContext } from "react";
import { Task, TaskAction } from "../reducers/taskReducer";
​
interface TaskContextType{
    tasks:Task[];
    dispatch: Dispatch<TaskAction>;
}
​
export default createContext<TaskContextType>({} as TaskContextType);

前面说的useReducer或传统的useState只能维持本组件的状态,所以我们将taskReduer提升放在 App.tsx。然后用TaskContext.Provider 将要用该Reducer的组件包起来(这里是HomePage和NavBar),value为定义好的箱子结构{tasks, dispatch}。

// App.tsx
​
import { useReducer } from "react";
import "./App.css";
import taskReducer from "./state-management/reducers/taskReducer";
import TaskContext from "./state-management/contexts/taskContext";
import NavBar from "./state-management/NavBar";
import HomePage from "./state-management/HomePage";
​
function App() {
  const [tasks, dispatch] = useReducer(taskReducer, []);
  return (
    <TaskContext.Provider value={{ tasks, dispatch }}>
      <NavBar />
      <HomePage />
    </TaskContext.Provider>
  );
}
​
export default App;
​

然后,就可以在各个组件里使用了,首先是TaskList,它是HomePage的子组件。

// TaskList.tsx
​
import { useContext } from "react";
import taskContext from "./contexts/taskContext";
​
const TaskList = () => {
  const { tasks, dispatch } = useContext(taskContext);
​
  return (
    ......

然后是 NavBar.tsx。

// NavBar.tsx
​
......
​
const NavBar = () => {
  const { tasks } = useContext(taskContext);
  return (
    <nav className="navbar d-flex justify-content-between">
      <span className="badge text-bg-secondary">{tasks.length}</span>
      <LoginStatus />
    </nav>
  );
};
​
export default NavBar;
​

查看效果,可以看到TaskList 和 NavBar都反馈 Tasks列表的状态了。


练习:使用Context

我们将authReducer也提升到App.tsx,以便可以在所有组件可以使用。

跟taskReducer一样,由于有两个dipatch, 分别命名为taskDispatch和authDispatch.

接着用AuthContext.Provider将相关组件包起来。

// App.tsx
​
......
function App() {
  const [tasks, taskDispatch] = useReducer(taskReducer, []);
  const [user, authDispatch] = useReducer(authReducer, "");
  return (
    <AuthContext.Provider value={{ user, dispatch: authDispatch }}>
      <TaskContext.Provider value={{ tasks, dispatch: taskDispatch }}>
        <NavBar />
        <HomePage />
      </TaskContext.Provider>
    </AuthContext.Provider>
  );
}
​
export default App;

AuthContext的做法也基本一致。

// autoContext.ts
​
import React, { Dispatch } from "react";
import { AuthAction } from "../reducers/authReducer";
​
interface AuthContextType{
    user:string;
    dispatch: Dispatch<AuthAction>;
}
​
const AuthContext = React.createContext<AuthContextType>({} as AuthContextType)
export default AuthContext;

再在LoginStatus.tsx和TaskList.tsx中使用。

// LoginStatus.tsx
​
......
​
const LoginStatus = () => {
  const { user, dispatch } = useContext(AuthContext);
​
......
// TaskList.tsx
​
......
​
const TaskList = () => {
  const { tasks, dispatch } = useContext(TaskContext);
  const { user } = useContext(AuthContext);
  return (
    <>
      <p>{user}</p>
​
......

使用 React DevTools 进行调试

查看React开发工具,能看到相关Context的信息。


创建自定义提供程序

我们在App.tsx里使用了taskReducer和authReducer,为了区别,重命名了dispatch用以唯一标识。这种实现是比较丑的。

为了更清晰及模块化设计,可以自定义提供程序,这种方式在React Query和其他库也比较常见。

新建 state-management/AuthProvider.tsx. 将 user相关的 Reducer和Context相关整合一起。

// AuthProvider.tsx
​
import { ReactNode, useReducer } from "react";
import AuthContext from "./contexts/authContext";
import authReducer from "./reducers/authReducer";
​
interface Props {
  children: ReactNode;
}
const AuthProvider = ({ children }: Props) => {
  const [user, dispatch] = useReducer(authReducer, "");
  return (
    <AuthContext.Provider value={{ user, dispatch }}>
      {children}
    </AuthContext.Provider>
  );
};
​
export default AuthProvider;
​

然后只需要 App.tsx包一下就好。

// App.tsx
​
......
import AuthProvider from "./state-management/AuthProvider";
......
​
  return (
    <AuthProvider>
      .....
      
    </AuthProvider>
  );
}
​
export default App;
​

创建一个 Hook 来访问Context

像之前一样,创建一个hooks文件夹,新建useAuth.ts文件。

// useAuth.ts
​
import { useContext } from "react";
import AuthContext from "../contexts/authContext";
​
const useAuth = ()=>useContext(AuthContext);
export default useAuth;

这样,在用到authContext的地方直接使用useAuth即可,不必关心用哪个authContext。比如在 LoginStatus.tsx里。

// LoginStatus.tsx
​
import useAuth from "./hooks/useAuth";
......
  const { user, dispatch } = useAuth();
​
......

练习:创建一个Provider

我们将tasks也改造成一个Provider以及创建hook,跟auth几乎一样,很简单。代码如下。

TasksProvider.tsx.

// TasksProider.tsx
​
import { ReactNode, useReducer } from "react";
import TaskContext from "./contexts/taskContext";
import taskReducer from "./reducers/taskReducer";
​
interface Props {
  children: ReactNode;
}
const TasksProvider = ({ children }: Props) => {
  const [tasks, dispatch] = useReducer(taskReducer, []);
  return (
    <TaskContext.Provider value={{ tasks, dispatch }}>
      {children}
    </TaskContext.Provider>
  );
};
​
export default TasksProvider;
​

修改App.tsx。经过修改的App.tsx没有本地变量,非常简法明了。

// App.tsx
​
import "./App.css";
import AuthProvider from "./state-management/AuthProvider";
import HomePage from "./state-management/HomePage";
import NavBar from "./state-management/NavBar";
import TasksProvider from "./state-management/TasksProvider";
​
function App() {
  return (
    <AuthProvider>
      <TasksProvider>
        <NavBar />
        <HomePage />
      </TasksProvider>
    </AuthProvider>
  );
}
​
export default App;
​

再添加 hooks/useTasks.ts。

// useTasks.ts
​
import { useContext } from "react";
import TaskContext from "../contexts/taskContext";
​
const useTasks = ()=> useContext(TaskContext);
export default useTasks;

在 TaskList.tsx中调用useTasks。

// TaskList.tsx
​
import useAuth from "./hooks/useAuth";
import useTasks from "./hooks/useTasks";
​
const TaskList = () => {
  const { tasks, dispatch } = useTasks();
  const { user } = useAuth();
​
......
​

可扩展性和可维护性

tasks相关的组件和模块是高度相关的,但目前是分散在各处的。为了增加模块化、可扩展性及可维护性,我们将他们放在一起。

新建 tasks 文件夹,将相 useTasks.ts、taskReducer.ts、taskContext.ts、TaskList.tsx、TasksProvider.tsx移到这个文件夹里面。

这还没完。我们看下外界需要用到的是什么?应该就是TaskList.tsx和TasksProvider.tsx这两个组件,其他的都是细节实现,没必要对外公布。

再查看useTasks,它只在TaskList.tsx中使用到,没必要分成两个文件,可以合在TasksList.tsx中。

同样,taskReducer也只被TasksProvider.tsx用到,也可合并。

合并后的TaskList.tsx如下:

// TaskList.tsx
​
import { useContext } from "react";
import useAuth from "../hooks/useAuth";
import TaskContext from "./taskContext";
​
const useTasks = () => useContext(TaskContext);
const TaskList = () => {
  const { tasks, dispatch } = useTasks();
  const { user } = useAuth();
  return (
  
  ......

合并后的TasksProvider.tsx:

// TasksProvider.tsx
​
import { ReactNode, useReducer } from "react";
import TaskContext from "./taskContext";
​
export interface Task {
  id: number;
  title: string;
}
​
interface AddAction {
  type: "ADD";
  task: Task;
}
​
interface DeleteAction {
  type: "DELETE";
  taskID: number;
}
​
export type TaskAction = AddAction | DeleteAction;
​
const taskReducer = (tasks: Task[], action: TaskAction): Task[] => {
  switch (action.type) {
    case "ADD":
      return [action.task, ...tasks];
    case "DELETE":
      return tasks.filter((t) => t.id !== action.taskID);
  }
};
interface Props {
  children: ReactNode;
}
const TasksProvider = ({ children }: Props) => {
  const [tasks, dispatch] = useReducer(taskReducer, []);
  return (
    <TaskContext.Provider value={{ tasks, dispatch }}>
      {children}
    </TaskContext.Provider>
  );
};
​
export default TasksProvider;
​

进一步,我们在tasks文件夹(包)增加 index.ts。

// index.ts
​
export {default as TasksProvider} from "./TasksProvider";
export {default as TaskList} from "./TaskList"

有了index.ts后,就可能直接从tasks包 import即可,而不用指明哪个模块。比如 App.tsx可以简化调用。

// App.tsx
​
......
​
import { TasksProvider } from "./state-management/tasks";
​
......

练习:组织代码

同样的方法,我们来组织下auth 和 counter。过程一样,就不详列了。

形成的包(文件夹)结构如下。

注意,在auth包里,我们未将useAuth跟LoginStatus合并,因为useAuth是个通用的模块,还有其他组件会使用到它。

经过组织后,原contexts,reducers,hooks文件夹的文件已移位,将这三个文件夹进行删除。


分割Contexts以提高效率

Context变化时,使用该Context的组件都将进行重新渲染。所以一般而言,我们要将Context 分隔成更小更集中,每一个Context都仅有单一职责。

但也不要粒度太细,比如下面这个就要避免,因为这两个是紧密相关的State。


何时使用Context

应用程序的状态数据有服务器和客户端。服务器的数据不应由React Context来维持。

服务器的数据应该由React Query来维持。客户端的状态数据由本地状态+React Context来维护,当逻辑简单时,可以用useState();而当逻辑复杂并可能分散在各处时,可以用useReducer集中处理。

如果遇到分割Context也没有效果,还需要处理不必要的重新渲染。有很多状态管理库工具允许我们的组件观察一部分状态并仅对这部分状态的改变进行重新渲染,这些工具的特性及控制状态的方式各不相同,但用途基本一致,其中最简单的就是 Zustand,但已足够应付绝大多数场景。


Context 与 Redux

Context和Redux都能共享状态数据,但Context只是提供了一个传输机制,本身不存储数据。Redux则是会存储状态数据。这个对比就像比较箱子与卡车运输箱子的优劣,没什么意义。

Context可以代替Redux吗?

这就要看问谁了,如果问一个Redux的忠实拥护者,他会告诉你一大堆Redux的功能。

但,使用Redux必须要用随之而来的各种特性有必要吗?它基本是一堆复杂的工具集。

我们不能专注于工具而应专注于解决问题,Redux确实在之前使用了很长时间,但现在,是时候改变了。客户端状态使用Zustand,服务器数据用React Query完全可以更好更轻量地解决了。


使用 Zustand 管理应用程序状态

安装Zustand。

npm i zustand@4.3.7

先在Counter上使用Zustand,在counter文件夹下新建store.ts文件。

首先定义CounterStore的结构,具体是counter的值以及increment和reset操作。

接着创建useCounterStore的hook,调用zustand的create函数,参数set也是一个函数,返回相应CouterStore结构的对象。counter设置初始值0,再设置increment和reset的逻辑,其中调用set时提供的参数 store代表原存储的状态数据。

// store.ts
​
import { create } from "zustand";
​
interface CounterStore{
    counter:number;
    increment:()=>void;
    reset:()=>void;
}
const useCounterStore = create<CounterStore>(set=>({
    counter:0,
    increment:()=>set(store=>({counter:store.counter+1})),
    reset:()=>set(()=>({counter:0}))
}))
​
export default useCounterStore;

接着,回到Counter.tsx,调用useCounterStore获取并解构counter, increment, reset,然后直接使用即可。

// Counter.tsx
​
​
import useCounterStore from "./store";
​
const Counter = () => {
  const { counter, increment, reset } = useCounterStore();
  return (
    <div>
      Counter ({counter})
      <button onClick={() => increment()} className="btn btn-primary mx-1">
        Increment
      </button>
      <button onClick={() => reset()} className="btn btn-primary mx-1">
        Reset
      </button>
    </div>
  );
};
​
export default Counter;
​

使用Zustand后,我们不再需要Reducer, Context, 原创建的hook,不需要包裹Provider,更不需要Redux一些复杂没必要的东西,一切变得非常简单。

我们在其他组件也可使用,比如NavBar,也可以便捷使用。

// NavBar.tsx
​
......
​
import useCounterStore from "./counter/store";
​
const NavBar = () => {
  const { counter } = useCounterStore();
  return (
    <nav className="navbar d-flex justify-content-between">
      <span className="badge text-bg-secondary">{counter}</span>
      <LoginStatus />
    </nav>
  );
};

练习:使用 Zustand

我们将Auth也进行改造,一模一样的方法。

auth/store.ts

import { create } from "zustand";
​
interface AuthStore{
    user:string,
    login:(user:string)=>void;
    logout:()=>void;
}
​
const useAuthStore = create<AuthStore>(set=>({
    user:'',
    login:(user:string)=>set(store=>({user:user})),
    logout:()=>set(()=>({user:''}))
}))
​
export default useAuthStore;

然后再在LoginStatus.tsx 和 TaskList.tsx中调用即可。


指定属性防止不必要的渲染

看一下counter/store.ts,CounterStore结构里的属性发生改变时,都会进行渲染。我们添加一个属性 max,并且修改reset函数,用于将max设置为某个值。

// counter/store.ts
​
import { create } from "zustand";
​
interface CounterStore{
    counter:number;
    max:number;
    increment:()=>void;
    reset:()=>void;
}
const useCounterStore = create<CounterStore>(set=>({
    counter:0,
    max:5,
    increment:()=>set(store=>({counter:store.counter+1})),
    reset:()=>set(()=>({max:10}))
}))
​
export default useCounterStore;

接着到NavBar.tsx,添加一行代码:

console.log(“Render NavBar”)

用于在渲染时输出信息。

// NavBar.tsx
......
​
const NavBar = () => {
  const {counter} = useCounterStore();
  console.log("Render NavBar");
  return (
    <nav className="navbar d-flex justify-content-between">
      <span className="badge text-bg-secondary">{counter}</span>
      <LoginStatus />
    </nav>
  );
};
​
export default NavBar;
​

实际查看,我们在点击Increment或 Reset时,都有输出 “Render Navbar”。

我们将Zustand加上限制只有counter属性更改才渲染。注意它返回值就是counter值,而不是解构再得到counter。

// NavBar.tsx
......
  const counter = useCounterStore((s) => s.counter);
  console.log("Render NavBar");
​
......
​

这样,当我们点击Reset按钮时,是没有反应的,因为Reset按钮未改变counter属性。


使用 Zustand DevTools 检查存储的数据

有时候我们需要检查一下存储的状态数据,尤其是出现错误的时候。

用到工具simple-zustand-devtools,另外还需要安装 @types/node以便识别相关符号,-D 表示只在开发环境生效。

npm i simple-zustand-devtools@1.1.0
npm i -D @types/node

再到 counter/store.ts。在最后export语句前,判断是否为开发环境,是的话就执行mountStoreDevtool函数将相关信息显示在React开发工具上,它有两个参数,第1个为显示在开发工具上的名字,第2个参数为该 store。

// counter/store.ts
​
​
import { mountStoreDevtool } from 'simple-zustand-devtools'
​
......
​
if(process.env.NODE_ENV === 'development')
    mountStoreDevtool('Counter Store', useCounterStore)
​
export default useCounterStore;

回到页面上,我们就能看到store的相关信息了。可以直接修改store,比如我们在Devtools上将counter改成10,页面上也渲染成10了。


小结

本文介绍了如何管理全局状态,包括Reducer、Context以及zustand的相关用法。利用这些工具,可以简化我们管理状态。

下一篇我们使用这些工具来改善我们的game-hub项目。

发表评论

您的邮箱地址不会被公开。 必填项已用 * 标注