React共享状态复杂性完全指南
想象场景:构建购物网站
假设您正在构建一个简单的购物网站。您有一个产品页面,用户可以将商品添加到购物车,还有一个头部显示购物车中的商品数量。听起来很简单,对吧?但挑战在于:头部如何知道用户在页面的完全不同的部分添加了商品?
这就是共享状态问题,当应用程序的不同部分需要访问和更新相同信息时就会出现。在小型应用中,这不是什么大问题。但随着应用的增长,管理共享状态成为React开发中最复杂和令人沮丧的部分之一。
在本手册中,您将学习:
- 什么是props和prop drilling,以及它们为何会成为问题
- 如何识别何时存在共享状态问题
- 有效管理共享状态的多种解决方案
- 何时使用每种解决方案
- 如何避免即使是经验丰富的开发人员也会犯的常见错误
到最后,您将了解如何构建随着增长而保持组织和可维护性的React应用程序。
前提条件:阅读本指南前应了解的内容
基本React知识
React基础(必需)
- 函数组件:您应该熟悉编写和使用React函数组件
- JSX语法:了解如何编写JSX,使用花括号处理JavaScript表达式和处理事件
- 基本props:了解如何在父组件和子组件之间传递和接收props
- useState钩子:您应该了解useState的工作原理,包括状态更新和重新渲染
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
// 您应该熟悉这样的代码:
function MyComponent({ title }) {
const [count, setCount] = useState(0);
return (
<div>
<h1>{title}</h1>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>
Increment
</button>
</div>
);
}
|
useEffect钩子(推荐)
- React中副作用的基本理解
- 何时以及为何使用useEffect
- 依赖数组如何工作
- 这有助于理解性能优化部分
JavaScript前提条件
ES6+功能(必需)
- 箭头函数:
const myFunc = () => {}
- 解构:
const { name, age } = person
和 const [first, second] = array
- 扩展运算符:
...array
和 ...object
- 模板字面量:使用反引号和
${variable}
语法
- 数组方法:
map()
、filter()
、find()
、reduce()
- 这些在状态更新中经常出现
1
2
3
4
5
6
|
// 您应该理解这种语法:
const newItems = [...existingItems, newItem];
const { name, price } = product;
const updatedItems = items.map(item =>
item.id === productId ? { ...item, quantity: item.quantity + 1 } : item
);
|
异步JavaScript(有帮助)
- Promise和async/await:用于理解状态管理中的API调用
- 基本错误处理:try/catch块
对象和数组(必需)
- 如何创建、修改和访问嵌套对象和数组
- 理解引用与值相等性
- 为什么在React中直接突变是有问题的
您将遇到的React概念
组件层次结构(必需)
- 父组件和子组件如何关联
- 从父组件到子组件的数据流
- 为什么数据不能轻易在兄弟组件之间"横向"流动
重新渲染行为(重要)
- React组件何时重新渲染
- 为什么更改状态会导致重新渲染
- 创建新对象/函数会导致重新渲染的基本理解
事件处理(必需)
1
2
3
|
// 您应该熟悉:
<button onClick={() => handleClick(item.id)}>
<input onChange={(e) => setValue(e.target.value)} />
|
开发环境
您应该拥有的工具
- React DevTools:用于调试React组件的浏览器扩展
- 代码编辑器:VS Code、WebStorm或具有React语法高亮的类似工具
- Node.js和npm/yarn:用于安装示例中提到的包
有帮助但不是必需的
- TypeScript基础:一些示例提到TypeScript的好处
- 测试知识:测试部分假定对Jest/React Testing Library有些熟悉
- 构建工具:对Create React App或Vite的基本理解
概念理解
为什么状态管理很重要
您应该经历过或理解这些痛点:
- 通过多个组件级别传递数据
- 保持应用程序不同部分的数据同步
- 管理复杂的应用程序状态
基本性能意识
- 理解不必要的重新渲染会减慢应用程序速度
- 意识到某些操作比其他操作更昂贵
您不需要知道的内容
高级React模式
- 高阶组件(HOCs)
- 渲染props(尽管我们在文章中解释了它们)
- 类组件或生命周期方法
- 像useLayoutEffect或useImperativeHandle这样的高级钩子
复杂状态管理
- 您不需要事先有Redux、Context API或其他状态库的经验。我将从头开始解释一切
高级JavaScript
- 闭包、原型或高级函数式编程概念
- 超出基本promise的复杂异步模式
自我评估问题
在深入之前,问自己:
- 我能用多个组件构建一个简单的React应用吗?
- 我理解如何通过props将数据从父组件传递到子组件吗?
- 我能用useState处理表单输入吗?
- 我知道React组件何时重新渲染吗?
- 我对map()和filter()这样的数组方法感到舒适吗?
如果您对大多数问题回答"是",那么您已经准备好阅读本手册了!
推荐准备
如果您需要复习React基础:
- 完成官方React教程(井字游戏)
- 使用本地状态构建一个简单的待办事项应用
- 练习在组件之间传递props
如果您需要JavaScript复习:
- 练习数组解构和扩展语法
- 复习箭头函数和数组方法
- 熟悉async/await
快速热身练习:尝试构建一个简单的计数器应用,其中:
您将很快看到为什么prop drilling会成为问题!
本指南将教您什么
到最后,您将理解:
- 为什么以及何时共享状态变得复杂
- 如何使用Context API解决prop drilling
- 何时使用Redux、Zustand或其他状态库
- 如何使用共享状态优化性能
- 状态管理的测试策略
- 可维护代码的最佳实践
本指南旨在通过大量示例和解释,将您从"我了解基本React"带到"我可以为复杂应用程序架构状态管理"。
理解构建块:React中的Props
在我们进入复杂的状态管理之前,让我们先了解基础知识。
什么是props?
Props(“properties"的缩写)是React组件相互通信的方式。将props想象成在学校教室之间传递纸条 - 它们将信息从一个组件传递到另一个组件。
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
|
// 这是一个显示个人信息的简单组件
function PersonCard(props) {
// props是一个包含传递给此组件的所有数据的对象
return (
<div className="person-card">
{/* 我们使用props.propertyName访问数据 */}
<h2>{props.name}</h2> {/* 显示人员姓名 */}
<p>Age: {props.age}</p> {/* 显示人员年龄 */}
<p>Job: {props.job}</p> {/* 显示人员工作 */}
</div>
);
}
// 这是我们如何使用PersonCard组件并传递props给它
function App() {
return (
<div>
{/*
我们创建一个PersonCard组件并传递三个props:
- name: "Sarah"
- age: 28
- job: "Developer"
*/}
<PersonCard
name="Sarah"
age={28}
job="Developer"
/>
{/* 我们可以用不同的props创建另一个PersonCard */}
<PersonCard
name="Mike"
age={35}
job="Designer"
/>
</div>
);
}
|
让我们分解一下发生了什么:
- PersonCard是一个接收props作为参数的函数
- props是一个包含我们传递的所有数据的JavaScript对象:
{name: "Sarah", age: 28, job: "Developer"}
- 我们使用点表示法访问单个数据:
props.name
、props.age
、props.job
- 花括号
{}
告诉React"这是JavaScript代码,不是常规文本”
- 当我们使用
<PersonCard name="Sarah" age={28} job="Developer" />
时,React会自动创建props对象
更现代的方式:解构props
与其每次都写props.name
,我们可以使用解构直接提取值:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
// 而不是这样:
function PersonCard(props) {
return (
<div className="person-card">
<h2>{props.name}</h2>
<p>Age: {props.age}</p>
<p>Job: {props.job}</p>
</div>
);
}
// 我们可以这样写(解构props对象):
function PersonCard({ name, age, job }) {
// JavaScript解构从props对象中提取name、age和job
// 就像说:"获取props对象并创建单独的变量"
return (
<div className="person-card">
<h2>{name}</h2> {/* 不再需要props.name */}
<p>Age: {age}</p> {/* 直接使用变量 */}
<p>Job: {job}</p>
</div>
);
}
|
解构的作用:
{ name, age, job }
告诉JavaScript:“从props对象中提取name、age和job属性”
- 它创建具有这些名称的单独变量
- 这使我们的代码更清晰、更易读
什么是Prop Drilling以及为什么它是一个问题?
Prop drilling发生在您需要通过多个组件层传递数据时,即使中间组件不使用该数据。这就像通过几个不关心消息的人玩电话游戏。
简单示例:传递用户名
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
|
// 假设我们想在深层嵌套的组件中显示用户名
function App() {
const userName = "Alice"; // 这些数据从这里开始
return (
<div>
<h1>My Shopping App</h1>
{/* 我们将userName传递给Header */}
<Header userName={userName} />
</div>
);
}
function Header({ userName }) {
// Header接收userName但实际上不显示它
// 它只是将其传递给Navigation
return (
<header>
<div className="logo">ShopSmart</div>
{/* Header将userName传递给Navigation */}
<Navigation userName={userName} />
</header>
);
}
function Navigation({ userName }) {
// Navigation也不显示userName
// 它只是将其传递给UserMenu
return (
<nav>
<a href="/">Home</a>
<a href="/products">Products</a>
{/* Navigation将userName传递给UserMenu */}
<UserMenu userName={userName} />
</nav>
);
}
function UserMenu({ userName }) {
// 终于!这个组件实际使用了userName
return (
<div className="user-menu">
<span>Welcome, {userName}!</span> {/* userName在这里显示 */}
</div>
);
}
|
这里的问题是什么?
- 不必要的复杂性:Header和Navigation不关心userName,但它们必须知道它
- 紧耦合:如果我们想更改userName的工作方式,我们需要更新多个组件
- 维护负担:添加新的用户数据意味着更新四个不同的组件
- 令人困惑的代码:很难跟踪数据实际在哪里使用
这是一个只有一个数据的简单示例。想象一下有5-10个不同数据的情况!
现实示例:购物车prop drilling
现在让我们看看这在购物车中如何变成真正的噩梦:
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
|
// 主要的App组件 - 这是我们购物车数据所在的地方
function App() {
// useState是一个React钩子,用于创建状态(可以更改的数据)
// 它返回一个包含两个项目的数组:
// 1. 当前值(cartItems)
// 2. 用于更新值的函数(setCartItems)
const [cartItems, setCartItems] = useState([]); // 从空数组开始
// 另一个用于总价的状态
const [cartTotal, setCartTotal] = useState(0); // 从0开始
// 将商品添加到购物车的函数
const addToCart = (product) => {
// 扩展运算符(...)创建一个包含所有现有项目加上新项目的新数组
const newCartItems = [...cartItems, product];
setCartItems(newCartItems); // 更新购物车项目
setCartTotal(cartTotal + product.price); // 更新总计
};
// 从购物车中移除商品的函数
const removeFromCart = (productId) => {
// filter()创建一个仅包含不匹配ID的项目的新数组
const updatedItems = cartItems.filter(item => item.id !== productId);
// find()定位我们要移除的项目,以便我们可以减去其价格
const removedItem = cartItems.find(item => item.id === productId);
setCartItems(updatedItems); // 更新项目
setCartTotal(cartTotal - removedItem.price); // 更新总计
};
return (
<div className="app">
{/*
我们需要将购物车数据传递给Header,以便它可以显示购物车计数
看我们需要传递多少props!
*/}
<Header
cartItems={cartItems} // 传递整个购物车数组
cartTotal={cartTotal} // 传递总价
addToCart={addToCart} // 传递添加函数
removeFromCart={removeFromCart} // 传递移除函数
/>
{/* MainContent也需要所有购物车功能 */}
<MainContent
cartItems={cartItems}
cartTotal={cartTotal}
addToCart={addToCart}
removeFromCart={removeFromCart}
/>
</div>
);
}
|
现在让我们看看Header组件中发生了什么:
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
|
function Header({ cartItems, cartTotal, addToCart, removeFromCart }) {
// Header接收所有这些props但只使用其中一些
// 它需要将它们传递给其他组件
return (
<header className="header">
<div className="logo">ShopSmart</div>
{/*
Navigation需要显示购物车计数,因此我们传递cartItems
但它不需要addToCart或removeFromCart
然而,我们可能会传递它们"以防万一"
*/}
<Navigation
cartItems={cartItems}
cartTotal={cartTotal}
addToCart={addToCart} // Navigation不使用这个
removeFromCart={removeFromCart} // Navigation也不使用这个
/>
{/* UserMenu可能想显示购物车总计 */}
<UserMenu
cartTotal={cartTotal}
addToCart={addToCart} // UserMenu不使用这个
removeFromCart={removeFromCart} // UserMenu也不使用这个
/>
</header>
);
}
function Navigation({ cartItems, cartTotal, addToCart, removeFromCart }) {
// Navigation只关心显示购物车计数
// 但它无论如何接收所有购物车props
const itemCount = cartItems.length; // 计算购物车中有多少商品
return (
<nav className="navigation">
<a href="/">Home</a>
<a href="/products">Products</a>
{/* 这是Navigation实际使用购物车数据的唯一地方 */}
<a href="/cart">
Cart
{/* 只有在有商品时才显示徽章 */}
{itemCount > 0 && (
<span className="cart-badge">{itemCount}</span>
)}
</a>
</nav>
);
}
|
问题正在成倍增加:
- Props污染:组件接收它们不使用的props
- 令人困惑的接口:很难分辨每个组件实际需要什么
- 更改连锁效应:修改购物车功能可能需要更改6个以上的组件
- 测试复杂性:测试Navigation需要模拟它甚至不使用的购物车函数
- 性能问题:更改购物车数据会导致链中的所有组件重新渲染
为什么会发生这种情况并变得更糟
这种模式自然出现是因为:
- React是单向数据流:数据只能从父组件向下流向子组件
- 组件层次结构:您的UI结构决定了您的数据流
- 没有内置的共享机制:React没有提供远距离组件直接共享数据的方式
随着您的应用增长,您最终会得到:
- 10多个props通过5个以上级别传递
- 仅用于传递props而存在的组件
- 开发人员害怕重构,因为他们可能会破坏prop链
- 新功能需要对不相关的组件进行更改
解决方案1:React Context API - 理解概念
Context API是React的内置解决方案,用于在组件之间共享数据而无需prop drilling。将其想象成一个广播信息的广播电台,任何组件都可以调谐收听。
广播电台类比
传统的prop drilling就像通过一连串的人传递纸条:
- 人A告诉人B
- 人B告诉人C
- 人C告诉人D
- 只有人D实际需要信息
React Context就像广播:
- 广播电台广播信息
- 任何有收音机的人都可以直接收听
- 不需要通过中介传递消息
什么是createContext()?
createContext()
是一个React函数,为您的数据创建一个"广播系统"。它返回两件事:
- Provider:广播数据的"广播电台"
- Consumer:组件用于收听数据的"收音机"
1
2
3
4
5
6
7
8
9
|
import { createContext } from 'react';
// createContext()创建我们的"广播电台"
// 我们可以传递一个默认值(如默认广播频率)
const CartContext = createContext();
// CartContext现在包含:
// - CartContext.Provider(广播器)
// - CartContext.Consumer(监听器,虽然我们很少直接使用这个)
|
createContext()
实际做什么:
- 创建一个可以共享数据的特殊React对象
- 当组件尝试访问上下文但不在Provider内部时使用默认值
- 返回具有Provider和Consumer组件的对象
创建基本Context Provider
Provider是一个使其数据对所有子组件可用的组件: