React Context
1. Context 的引入
在 React 中,数据流通常是自上而下的(通过 props)。但当应用规模变大,组件层级变深时,在组件之间传递数据,尤其是全局状态(如用户登录信息、UI 主题),会变得非常繁琐。一个典型的例子就是 Props 钻探,它有下面的问题:
- 代码冗余和可读性差:许多组件的 props 列表会变得臃肿,包含了大量与自身无关的属性,使得组件的真实依赖关系模糊不清。
- 重构困难:如果数据源的 props 名称或结构发生改变,所有中间传递路径上的组件都需要修改,维护成本极高。
- 过度渲染:某些情况下,不必要的 props 传递可能导致中间组件发生不必要的重渲染。
2. Context API
基本概念
Context 提供了一种让子组件可以“订阅”全局数据的方式,而无需显式地通过 props 一层层传递。
使用 Context 的过程可以清晰地分为三个步骤:
- 创建 (Create): 使用
createContext
创建一个 Context 对象。 - 提供 (Provide): 使用
Context.Provider
组件在组件树中“提供”数据。 - 消费 (Consume): 在任何子组件中使用
useContext
Hook 来“消费”或读取数据。
createContext
createContext
函数用于创建 Context。它接收一个默认值作为参数。这个默认值只有在组件没有被相应的 Provider
包裹时才会被使用。
// contexts/CartContext.js
import { createContext } from 'react';
// 提供一个有意义的默认值,有助于测试和 IDE 自动补全
export const CartContext = createContext({
cartItemsCount: 0,
setCartItemsCount: () => {}, // 空函数作为默认值,防止调用时出错
});
Provider
每个 Context 对象都有一个名为 Provider
的组件,用于提供数据。它接收一个 value
属性,所有嵌套在它内部的子组件(无论层级多深)都能访问到这个 value
。
// App.js
import { useState } from 'react';
import { CartContext } from './contexts/CartContext';
import Header from './Header';
function App() {
const [cartItemsCount, setCartItemsCount] = useState(5);
// 将 state 和更新函数作为 value 传递下去
const providerValue = { cartItemsCount, setCartItemsCount };
return (
<CartContext.Provider value={providerValue}>
{/* Header 及其所有子组件现在都能访问到 cartItemsCount */}
<Header />
</CartContext.Provider>
);
}
useContext
useContext
Hook 是在函数组件中订阅 Context 的最简单方式,它负责提供数据。它接收 Context 对象作为参数,并返回 Provider
提供的 value
。
// Links.js (Header 的子组件)
import { useContext } from 'react';
import { CartContext } from './contexts/CartContext';
function Links() {
// 直接从 Context 中获取数据,不再需要 props
const { cartItemsCount } = useContext(CartContext);
return <div>购物车: {cartItemsCount}</div>;
}
通过这种方式,我们彻底消除了 Header
组件作为中间人的角色,实现了 App
和 Links
之间的直接通信。
3. 封装自定义 Provider
一个常见的最佳实践是,将 Context 的状态逻辑(useState
或 useReducer
)和 Provider
本身封装在一个自定义的组件中。这使得状态管理逻辑集中,并且更易于复用。
// contexts/CartContext.js
import { createContext, useState, useContext } from 'react';
// 1. 创建 Context
const CartContext = createContext(null);
// 2. 创建自定义 Provider 组件
export function CartProvider({ children }) {
const [cartItemsCount, setCartItemsCount] = useState(0);
// 在这里可以添加更多购物车相关的逻辑,如 addToCart, removeFromCart 等
const value = { cartItemsCount, setCartItemsCount };
return <CartContext.Provider value={value}>{children}</CartContext.Provider>;
}
// 3. 创建自定义 Hook,简化消费过程
export function useCart() {
const context = useContext(CartContext);
if (context === null) {
throw new Error('useCart 必须在 CartProvider 内部使用');
}
return context;
}
这样封装后,我们就可以如下使用:
- 在应用的顶层用
CartProvider
包裹:
// App.js
import { CartProvider } from './contexts/CartContext';
function App() {
return (
<CartProvider>
{/* ...其他组件... */}
</CartProvider>
);
}
- 在任何子组件中用
useCart
Hook 消费数据:
// Links.js
import { useCart } from './contexts/CartContext';
function Links() {
const { cartItemsCount } = useCart();
return <div>购物车: {cartItemsCount}</div>;
}
4. Context 性能优化
重渲染问题
Context 的一个主要缺点是:只要 Provider
的 value
发生变化,所有消费该 Context 的组件都会重渲染,即使组件只用到了 value
对象中的一小部分,或者它用到的那部分数据根本没有改变。
const value = { user, theme }; // 当 theme 改变时,使用 user 的组件也会重渲染
解决这个问题有两个主要策略。
拆分 Context
最有效的优化方法是将一个大的、包含多个值的 Context 拆分成多个更小、更专注的 Context。这样,当一部分状态更新时,只有订阅了那部分状态的组件才会重渲染。
// 不要这样做
const AppContext = createContext({ user, theme, settings });
// 推荐这样做
const UserContext = createContext(user);
const ThemeContext = createContext(theme);
const SettingsContext = createContext(settings);
React.memo
与 useMemo
-
useMemo
: 当传递给Provider
的value
是一个对象或数组时,父组件的每次渲染都会创建一个新的对象,导致value
的引用变化,从而触发所有消费者的重渲染。使用useMemo
可以确保只有在value
的依赖项真正改变时,才创建新的value
对象。function App() { const [cartItemsCount, setCartItemsCount] = useState(5); const [user, setUser] = useState('Guest'); // 只有当 cartItemsCount 变化时,providerValue 才会重新计算 const providerValue = useMemo(() => ({ cartItemsCount, setCartItemsCount }), [cartItemsCount]); return ( <CartContext.Provider value={providerValue}> <Header /> </CartContext.Provider> ); }
如果一个消费 Context 的组件本身也接收 props,并且它的重渲染成本很高,可以用
React.memo
包裹它。这样,即使 Context 的value
改变,只要该组件自身的 props 没有改变,它就可以避免重渲染(但这需要结合拆分 Context 等其他策略才更有效)。
5. Context 替代方法
组件组合
在许多情况下,所谓的“Props 钻探”问题其实是组件结构设计不佳的信号。React 的核心思想之一就是组件组合,通过将组件作为 props(尤其是 children
)传递,可以构建出更灵活、解耦的 UI 结构,从而自然地避免钻探。
假设我们有一个 Page
组件,需要显示用户信息 user
。Page
组件内部有一个 PageLayout
负责布局,而 PageLayout
内部有一个 NavigationBar
,最终 NavigationBar
里的 Avatar
组件需要 user
数据来显示头像。下面是一般的组件钻探写法:
// 钻探:Page -> PageLayout -> NavigationBar -> Avatar
function Page() {
const [user, setUser] = useState({ name: 'Alice', avatarUrl: '...' });
return <PageLayout user={user} />;
}
function PageLayout({ user }) {
// PageLayout 自己不用 user,只是为了往下传
return <NavigationBar user={user} />;
}
function NavigationBar({ user }) {
// NavigationBar 自己也不用 user,只是为了往下传
return <Avatar user={user} />;
}
function Avatar({ user }) {
return <img src={user.avatarUrl} alt={user.name} />;
}
这里的 PageLayout
和 NavigationBar
都被迫接收和传递了它们自己并不关心的 user
prop。
作为替代,我们可以不传递 user
数据,而是直接将已经包含了数据的最终组件 Avatar
作为 children
或其他 prop 传递下去。
// 组合:中间组件不再关心具体数据
function Page() {
const [user, setUser] = useState({ name: 'Alice', avatarUrl: '...' });
const userAvatar = <Avatar user={user} />;
// 直接将最终的 UI (userAvatar) 传递给 NavigationBar
return <PageLayout navigationBar={<NavigationBar avatar={userAvatar} />} />;
}
function PageLayout({ navigationBar }) {
// PageLayout 只负责布局,不关心 navigationBar 是什么
return <div>{navigationBar}</div>;
}
function NavigationBar({ avatar }) {
// NavigationBar 只负责布局,不关心 avatar 是什么
return <nav>{/*...*/} {avatar}</nav>;
}
function Avatar({ user }) {
return <img src={user.avatarUrl} alt={user.name} />;
}
在这个重构后的版本中,PageLayout
和 NavigationBar
变得更加通用和解耦。它们不再需要知道 user
的存在,只负责渲染传递给它们的 JSX,然后父组件 Page 负责把这些东西统一组装起来。这样就避免了属性的传递。
同时,现在的 PageLayout
和 NavigationBar
复用性也提高了:
PageLayout
可以接收任何东西作为navigationBar
prop,甚至可以不传,显示一个空的 div。NavigationBar
也可以接收一个搜索框、一个 Logo 或者任何其他组件作为avatar
prop 的替代品。
这里体现了 “控制反转” 的思想:在 props 钻探模式中,
PageLayout
控制着NavigationBar
,并告诉它:“用这个 user 数据去构建你的内容”。而在组合模式中,控制权被反转了:Page
直接告诉PageLayout
:“别管里面是什么,这是已经准备好的内容 (navigationBar),你只要把它显示出来就行。”
更进一步,我们可以使用 children
这个特殊的 props 来让这些组件变得更通用:
// PageLayout 现在是一个通用的“页面框架”
function PageLayout({ children }) {
return (
<div className="page-container">
{/* children 会被渲染在这里 */}
{children}
</div>
);
}
// NavigationBar 也是一个通用的“导航框架”
function NavigationBar({ children }) {
return (
<nav className="nav-bar">
<span>Logo</span>
{/* 其他导航项 */}
<div className="nav-right-section">
{children} {/* 把右侧的内容插在这里 */}
</div>
</nav>
);
}
// Page 组件负责组合一切
function Page() {
const [user, setUser] = useState({ name: "Alice", avatarUrl: "..." });
return (
<PageLayout>
{/* PageLayout 的 children 开始 */}
<h1>Welcome to the Page</h1>
<NavigationBar>
{/* NavigationBar 的 children 开始 */}
<Avatar user={user} />
{/* NavigationBar 的 children 结束 */}
</NavigationBar>
<p>Here is some page content...</p>
{/* PageLayout 的 children 结束 */}
</PageLayout>
);
}
function Avatar({ user }) {
return <img src={user.avatarUrl} alt={user.name} />;
}
第三方状态管理库
对于大型应用中复杂、高频更新的全局状态,Context 的性能问题(所有消费者都重渲染)会变得很突出。这时,专用的状态管理库是更好的选择。
Redux (& Redux Toolkit)
Redux 是一个历史悠久、生态成熟的状态管理库,它遵循严格的单向数据流,核心理念是“单一数据源”。它包含如下核心概念:
- Store: 整个应用只有一个 Store,存储着所有的 state。
- Action: 与
useReducer
中的 Action 类似,是描述事件的普通对象。 - Reducer: 纯函数,根据 action 计算新 state。
组件通过 useSelector
Hook 从 Store 中“选择”自己需要的数据。当需要更新状态时,组件通过 useDispatch
Hook 分发一个 action,触发 reducer 计算新状态,然后 useSelector
会智能地检测组件依赖的数据是否变化,只有变化时才触发组件重渲染。
Redux 有如下优点:
- 可预测性强:严格的数据流和不可变性使得状态变化易于追踪。
- 强大的开发工具:Redux DevTools 提供了时间旅行调试、action 日志等强大功能。
- 丰富的中间件 (Middleware):可以处理异步操作(如 Redux Thunk/Saga)、日志、路由等。
Redux Toolkit
(RTK) 是官方推荐的 Redux 开发工具集,它极大地简化了 Redux 的模板代码,内置了 Immer 来实现“不可变更新”,并集成了 Thunk 进行异步操作,使得 Redux 的使用体验大幅提升。下面是一个简单的计数器例子:
// store.js (使用 Redux Toolkit)
import { configureStore, createSlice } from '@reduxjs/toolkit';
const counterSlice = createSlice({
name: 'counter',
initialState: { value: 0 },
reducers: {
increment: state => { state.value += 1; }, // RTK 内部使用 Immer,可以直接修改
},
});
export const { increment } = counterSlice.actions;
export const store = configureStore({ reducer: { counter: counterSlice.reducer } });
// Component.js
import { useSelector, useDispatch } from 'react-redux';
import { increment } from './store';
function Counter() {
const count = useSelector(state => state.counter.value); // 只订阅 count
const dispatch = useDispatch();
return <button onClick={() => dispatch(increment())}>{count}</button>;
}
在点击按钮后,下面的事件会依次发生:
- 点击事件: 按钮的 onClick 被触发。
- Dispatch Action:
dispatch(increment())
被调用。increment()
创建一个action ({type: 'counter/increment'})
,dispatch 将它发送出去。 - Reducer 执行: Redux store 接收到这个 action,并找到对应的 reducer——也就是 counterSlice 中 increment 对应的那个函数。
- 状态更新: reducer 函数执行
state.value += 1
。在 Immer 的帮助下,一个新的 state 对象被创建,其中state.counter.value
的值增加了 1。 - 组件重新渲染: useSelector 检测到它所订阅的
state.counter.value
发生了变化。 - UI 更新: React 重新渲染 Counter 组件,按钮上显示的数字
{count}
从 0 变成了 1。
Zustand
Zustand 是一个近年来非常流行的轻量级状态管理库,它借鉴了 Redux 的思想,但 API 设计得更简洁、更符合 Hooks 的直觉。
Zustand 通过 create
函数创建一个“Store”,这个 Store 本身就是一个 Hook。组件直接调用这个 Store Hook,并传入一个“选择器”函数来获取所需的状态切片。当状态更新时,Zustand 会比较选择器返回的值,只有值发生变化时才触发组件重渲染。
Zustand 具有如下优点:
- 极简 API:几乎没有模板代码,上手非常快。
- 默认优化:基于选择器的订阅机制天然地避免了 Context 的重渲染问题,无需额外优化。
- 对异步操作友好:可以直接在 Store 的 action 中使用
async/await
。
// store.js
import { create } from 'zustand';
const useCounterStore = create(set => ({
count: 0,
increment: () => set(state => ({ count: state.count + 1 })),
}));
// Component.js
import useCounterStore from './store';
function Counter() {
// 从 store hook 中选择你需要的数据
const count = useCounterStore(state => state.count);
const increment = useCounterStore(state => state.increment);
return <button onClick={increment}>{count}</button>;
}
特性 | React Context | Redux Toolkit | Zustand |
---|---|---|---|
核心思想 | 依赖注入 | 单一数据源 (Flux) | Hooks 式状态 |
性能 | 默认较差 (全员更新) | 优秀 (选择器) | 优秀 (选择器) |
API 复杂度 | 简单 | 较复杂 | 非常简单 |
DevTools | 无内置 | 强大 | 需中间件支持 |
适用场景 | 低频更新的全局数据 | 大型、复杂应用 | 中小型应用,或作为大型应用的一部分 |