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 做一个简单了解,更多内容参考官网 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;
};
创建一个 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 导出 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;
};
// 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,例如 ... 是不是有点之前 “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