深圳幻海软件技术有限公司 欢迎您!

React 状态管理 - useState/useReducer + useContext 实现全局状态管理

2023-02-28

useReducer是useState的替代方案,用来处理复杂的状态或逻辑。当与其它Hooks(useContext)结合使用,有时也是一个好的选择,不需要引入一些第三方状态管理库,例如Redux、Mobx。目标在本文结束时,您将了解:ContextAPI的使用。在哪些场景下可以使用Context而

useReducer 是 useState 的替代方案,用来处理复杂的状态或逻辑。当与其它 Hooks(useContext)结合使用,有时也是一个好的选择,不需要引入一些第三方状态管理库,例如 Redux、Mobx。

目标

在本文结束时,您将了解:

  • Context API 的使用。
  • 在哪些场景下可以使用 Context 而不是类似于 Redux 这些第三方的状态管理库。
  • 如何使用 useState + useContext 实现暗黑模式切换。
  • 如何使用 useReducer + useContext 实现 todos。

什么是 Context?

Context 解决了跨组件之间的通信,也是官方默认提供的一个方案,无需引入第三方库,适用于存储对组件树而言是全局的数据状态,并且不要有频繁的数据更新。例如:主题、当前认证的用户、首选语言。

使用 React.createContext 方法创建一个上下文,该方法接收一个参数做为其默认值,返回 MyContext.Provider、MyContext.Consumer React 组件。

const MyContext = React.createContext(defaultValue);
  • 1.

MyContext.Provider 组件接收 value 属性用于传递给子组件(使用 MyContext.Consumer 消费的组件),无论嵌套多深都可以接收到。

<MyContext.Provider value={color: 'blue'}>
  {children}
</MyContext.Provider>
  • 1.
  • 2.
  • 3.

将我们的内容包装在 MyContext.Consumer 组件中,以便订阅 context 的变更,类组件中通常会这样写。

<MyContext.Consumer>
  {value => <span>{value}</span>}}
</MyContext.Consumer>
  • 1.
  • 2.
  • 3.

以上引入不必要的代码嵌套也增加了代码的复杂性,React Hooks 提供的 useContext 使得访问上下文状态变得更简单。

const App = () => {
  const value = useContext(newContext);
  console.log(value); // this will return { color: 'black' }
  
  return <div></div>
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.

以上我们对 Context 做一个简单了解,更多内容参考官网 Context、useContext 文档描述,下面我们通过两个例子来学习如何使用 useContext 管理全局状态。

useState + useContext 主题切换

本节的第一个示例是使用 React hooks 的 useState 和 useContext API 实现暗黑主题切换。

实现 Context 的 Provider

在 ThemeContext 组件中我们定义主题为 light、dark。定义 ThemeProvider 在上下文维护两个属性:当前选择的主题 theme、切换主题的函数 toggleTheme()。

通过 useContext hook 可以在其它组件中获取到 ThemeProvider 维护的两个属性,在使用 useContext 时需要确保传入 React.createContext 创建的对象,在这里我们可以自定义一个 hook useTheme 便于在其它组件中直接使用。

代码位置:src/contexts/ThemeContext.js。

import React, { useState, useContext } from "react";

export const themes = {
  light: {
    type: 'light',
    background: '#ffffff',
    color: '#000000',
  },
  dark: {
    type: 'dark',
    background: '#000000',
    color: '#ffffff',
  },
};
const ThemeContext = React.createContext({
  theme: themes.dark,
  toggleTheme: () => {},
});

export const ThemeProvider = ({ children }) => {
  const [theme, setTheme] = useState(themes.dark);
  const context = {
    theme,
    toggleTheme: () => setTheme(theme === themes.dark
      ? themes.light
      : themes.dark)
  }
  return <ThemeContext.Provider value={context}>
    { children }
  </ThemeContext.Provider>
}

export const useTheme = () => {
  const context = useContext(ThemeContext);
  return context;
};
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.
  • 27.
  • 28.
  • 29.
  • 30.
  • 31.
  • 32.
  • 33.
  • 34.
  • 35.
  • 36.

创建一个 AppProviders,用来组装创建的多个上下文。代码位置:src/contexts/index.js。


import { ThemeProvider } from './ThemeContext';

const AppProviders = ({ children }) => {
  return <ThemeProvider>
    { children }
  </ThemeProvider>
}
export default AppProviders;
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.

实现 ToggleTheme 组件

在 App.js 文件中,将 AppProviders 组件做为根组件放在最顶层,这样被包裹的组件都可以使用 AppProviders 组件提供的属性。

代码位置:src/App.js。

import AppProviders from './contexts';
import ToggleTheme from './components/ToggleTheme';
import './App.css';

const App = () => (
  <AppProviders>
    <ToggleTheme />
  </AppProviders>
);

export default App;
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.

在 ToggleTheme 组件中,我们使用自定义的 useTheme hook 访问 theme 对象和 toggleTheme 函数,以下创建了一个简单主题切换,用来设置背景颜色和文字颜色。

代码位置:src/components/ToggleTheme.jsx。

import { useTheme } from '../contexts/ThemeContext'
const ToggleTheme = () => {
  const { theme, toggleTheme } = useTheme();
  return <div style={{
    backgroundColor: theme.background,
    color: theme.color,
    width: '100%',
    height: '100vh',
    textAlign: 'center',
  }}>
    <h2 className="theme-title"> Toggling Light/Dark Theme </h2>
    <p className="theme-desc"> Toggling Light/Dark Theme in React with useState and useContext </p>
    <button className="theme-btn" onClick={toggleTheme}>
      Switch to { theme.type } mode
    </button>
  </div>
}
export default ToggleTheme;
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.

Demo 演示

​​视频​​

示例代码地址:https://github.com/qufei1993/react-state-example/tree/usestate-usecontext-theme。

useReducer + useContext 实现 Todos

使用 useReducer 和 useContext 完成一个 Todos。这个例子很简单,可以帮助我们学习如何实现一个简单的状态管理工具,类似 Redux 这样可以跨组件共享数据状态。

reducer 实现

在 src/reducers 目录下实现 reducer 需要的逻辑,定义的 initialState 变量、reducer 函数都是为 useReducer 这个 Hook 做准备的,在这个地方需要都导出下,reducer 函数是一个纯函数,了解 Redux 的小伙伴对这个概念应该不陌生。

// src/reducers/todos-reducer.jsx
export const TODO_LIST_ADD = 'TODO_LIST_ADD';
export const TODO_LIST_EDIT = 'TODO_LIST_EDIT';
export const TODO_LIST_REMOVE = 'TODO_LIST_REMOVE';

const randomID = () => Math.floor(Math.random() * 10000);
export const initialState = {
  todos: [{ id: randomID(), content: 'todo list' }],
};

const reducer = (state, action) => {
  switch (action.type) {
    case TODO_LIST_ADD: {
      const newTodo = {
        id: randomID(),
        content: action.payload.content
      };
      return {
        todos: [ ...state.todos, newTodo ],
      }
    }
    case TODO_LIST_EDIT: {
      return {
        todos: state.todos.map(item => {
          const newTodo = { ...item };
          if (item.id === action.payload.id) {
            newTodo.content = action.payload.content;
          }
          return newTodo;
        })
      }
    }
    case TODO_LIST_REMOVE: {
      return {
        todos: state.todos.filter(item => item.id !== action.payload.id),
      }
    }
    default: return state;
  }
}

export default reducer;
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.
  • 27.
  • 28.
  • 29.
  • 30.
  • 31.
  • 32.
  • 33.
  • 34.
  • 35.
  • 36.
  • 37.
  • 38.
  • 39.
  • 40.
  • 41.
  • 42.

Context 跨组件数据共享

定义 TodoContext 导出 state、dispatch,结合 useContext 自定义一个 useTodo hook 获取信息。

// src/contexts/TodoContext.js
import React, { useReducer, useContext } from "react";
import reducer, { initialState } from "../reducers/todos-reducer";

const TodoContext = React.createContext(null);

export const TodoProvider = ({ children }) => {
  const [state, dispatch] = useReducer(reducer, initialState);
  const context = {
    state,
    dispatch
  }
  return <TodoContext.Provider value={context}>
    { children }
  </TodoContext.Provider>
}

export const useTodo = () => {
  const context = useContext(TodoContext);
  return context;
};
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
// src/contexts/index.js
import { TodoProvider } from './TodoContext';

const AppProviders = ({ children }) => {
  return <TodoProvider>
    { children }
  </TodoProvider>
}

export default AppProviders;
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.

实现 Todos 组件

在 TodoAdd、Todo、Todos 三个组件内分别都可以通过 useTodo() hook 获取到 state、dispatch。

import { useState } from "react";
import { useTodo } from "../../contexts/TodoContext";
import { TODO_LIST_ADD, TODO_LIST_EDIT, TODO_LIST_REMOVE } from "../../reducers/todos-reducer";

const TodoAdd = () => {
  console.log('TodoAdd render');
  const [content, setContent] = useState('');
  const { dispatch } = useTodo();

  return <div className="todo-add">
    <input className="input" type="text" onChange={e => setContent(e.target.value)} />
    <button className="btn btn-lg" onClick={() => {
      dispatch({ type: TODO_LIST_ADD, payload: { content } })
    }}>
      添加
    </button>
  </div>
};

const Todo = ({ todo }) => {
  console.log('Todo render');
  const { dispatch } = useTodo();
  const [isEdit, setIsEdit] = useState(false);
  const [content, setContent] = useState(todo.content);

  return <div className="todo-list-item">
    {
      !isEdit ? <>
        <div className="todo-list-item-content">{todo.content}</div>
        <button className="btn" onClick={() => setIsEdit(true)}> 编辑 </button>
        <button className="btn" onClick={() => dispatch({ type: TODO_LIST_REMOVE, payload: { id: todo.id } })}> 删除 </button>
      </> : <>
        <div className="todo-list-item-content">
          <input className="input" value={content} type="text" onChange={ e => setContent(e.target.value) } />
        </div>
        <button className="btn" onClick={() => {
          setIsEdit(false);
          dispatch({ type: TODO_LIST_EDIT, payload: { id: todo.id, content } })
        }}> 更新 </button>
        <button className="btn" onClick={() => setIsEdit(false)}> 取消 </button>
      </>
    }
  </div>
}

const Todos = () => {
  console.log('Todos render');
  const { state } = useTodo();
  
  return <div className="todos">
    <h2 className="todos-title"> Todos App </h2>
    <p className="todos-desc"> useReducer + useContent 实现 todos </p>
    <TodoAdd />
    <div className="todo-list">
      {
        state.todos.map(todo => <Todo key={todo.id} todo={todo} />)
      }
    </div>
  </div>
}

export default Todos;
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.
  • 27.
  • 28.
  • 29.
  • 30.
  • 31.
  • 32.
  • 33.
  • 34.
  • 35.
  • 36.
  • 37.
  • 38.
  • 39.
  • 40.
  • 41.
  • 42.
  • 43.
  • 44.
  • 45.
  • 46.
  • 47.
  • 48.
  • 49.
  • 50.
  • 51.
  • 52.
  • 53.
  • 54.
  • 55.
  • 56.
  • 57.
  • 58.
  • 59.
  • 60.
  • 61.
  • 62.

Demo 演示

上面代码实现需求是没问题,但是存在一个性能问题,如果 Context 中的某个熟悉发生变化,所有依赖该 Context 的组件也会被重新渲染,观看以下视频演示:

​​视频​​

示例代码地址:https://github.com/qufei1993/react-state-example/tree/usereducer-usecontext-todos。

Context 小结

useState/useReducer 管理的是组件的状态,如果子组件想获取根组件的状态一种简单的做法是通过 Props 层层传递,另外一种是把需要传递的数据封装进 Context 的 Provider 中,子组件通过 useContext 获取来实现全局状态共享。

Context 对于构建小型应用程序时,相较于 Redux,实现起来会更容易且不需要依赖第三方库,同时还要看下适用场景。在官网也有说明,适用于存储对组件树而言是全局的数据状态,并且不要有频繁的数据更新(例如:主题、当前认证的用户、首选语言)。

以下是使用 Context 会遇到的几个问题:

  • Context 中的某个属性一旦变化,所有依赖该 Context 的组件也都会重新渲染,尽管对组件做了 React.memo() 或 shouldComponentUpdate() 优化,还是会触发强制更新。
  • 过多的 context 如何维护?因为子组件需要被 Context.Provider 包裹才能获取到上下文的值,过多的 Context,例如 ... 是不是有点之前 “callback 回调地狱” 的意思了。这里有个解决思路是创建一个 store container,参考 The best practice to combine containers to have it as "global" state、Apps with many containers。
  • provider 父组件重新渲染可能导致 consumers 组件的意外渲染问题,参考 Context 注意事项。

在我们实际的 React 项目中没有一个 Hook 或 API 能解决我们所有的问题,根据应用程序的大小和架构来选择适合于您的方法是最重要的。

介绍完 React 官方提供的状态管理工具外,下一节介绍一下社区状态管理界的 “老大哥 Redux”。

文末阅读原文查看文中两个示例代码!

  • Referencehttps://blog.logrocket.com/guide-to-react-usereducer-hook/
  • https://zh-hans.reactjs.org/docs/context.html