React共享状态管理完全指南:从Prop Drilling到Context与Redux

本指南深入探讨React中的共享状态管理,涵盖Prop Drilling问题、Context API解决方案、Redux状态库使用、性能优化策略及测试方法,帮助开发者构建可维护的大型应用。

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 } = personconst [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.nameprops.ageprops.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是一个使其数据对所有子组件可用的组件:

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计