Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
qufei1993 opened this issue Mar 29, 2022 · 0 comments

Comments

@qufei1993
Copy link
Owner

默认标题_公众号封面首图_2022-03-26+17_20_05.jpeg

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);

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

<MyContext.Provider value={color: 'blue'}>
  {children}
</MyContext.Provider>

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

<MyContext.Consumer>
  {value => <span>{value}</span>}}
</MyContext.Consumer>

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

const App = () => {
  const value = useContext(newContext);
  console.log(value); // this will return { color: 'black' }
  
  return <div></div>
}

以上我们对 Context 做一个简单了解,更多内容参考官网 ContextuseContext 文档描述,下面我们通过两个例子来学习如何使用 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;
};

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

import { ThemeProvider } from './ThemeContext';

const AppProviders = ({ children }) => {
  return <ThemeProvider>
    { children }
  </ThemeProvider>
}
export default AppProviders;

实现 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;

在 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;

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;

Context 跨组件数据共享

定义 TodoContext 导出 statedispatch,结合 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;
};
// src/contexts/index.js
import { TodoProvider } from './TodoContext';

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

export default AppProviders;

实现 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;

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,例如 <ThemeProvider> <UserProvider> ... </UserProvider> </ThemeProvider> 是不是有点之前 “callback 回调地狱” 的意思了。 这里有个解决思路是创建一个 store container,参考 The best practice to combine containers to have it as "global" stateApps with many containers
  • provider 父组件重新渲染可能导致 consumers 组件的意外渲染问题,参考 Context 注意事项

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

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

Reference

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

1 participant