React-挂钩的微状态管理指南-全-

React 挂钩的微状态管理指南(全)

原文:zh.annas-archive.org/md5/7e6e225b9750cc8a58fca8416ed5f31d

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

状态管理是 React 中最复杂的概念之一。传统上,开发者们使用单一的状态管理解决方案。多亏了 React Hooks,微状态管理是一种针对将应用程序从单体迁移到微服务而调整的解决方案。

本书提供了一种实用的微状态管理实现方法,让您能够迅速上手并高效工作。您将学习 React 中状态管理的基本模式,并了解如何在需要使状态全局时克服遇到的挑战。后续章节将展示如何将状态分割成片段是克服限制的方法。使用 hooks,您将看到如何轻松重用逻辑,并为特定领域提供多种解决方案,例如表单状态和服务器缓存状态。最后,您将探索如何使用 Zustand、Jotai 和 Valtio 等库来组织状态并高效地管理开发。

在本 React 书籍结束时,您将学会如何为您的应用程序需求选择正确的全局状态管理解决方案。

本书面向对象

如果您是处理复杂全局状态管理解决方案的 React 开发者,并想了解如何根据您的需求选择最佳替代方案,这本书适合您。假设您具备 JavaScript、React Hooks 和 TypeScript 的基本知识。

本书涵盖的内容

第一章, React Hooks 中的微状态管理是什么?,解释了 React Hooks 如何帮助处理状态。这使我们能够拥有更多目的特定的解决方案。

第二章, 使用局部和全局状态,讨论了两种状态类型。局部状态经常被使用且更受欢迎。全局状态用于在多个组件之间共享状态。

第三章, 使用上下文共享组件状态,描述了上下文是处理全局状态的主要方法,以及它是如何在 React 生命周期内工作的。我们需要一些模式来避免额外的重新渲染。

第四章, 使用订阅共享模块状态,解释了模块状态是全局状态的另一种方法。它工作在 React 生命周期之外。我们需要将模块状态连接到 React 组件,但订阅模块状态使得优化重新渲染变得更加容易。

第五章, 使用上下文和订阅共享组件状态,展示了通过使用上下文和订阅来使用全局状态的另一种方法。它适用于 React 的生命周期内,并避免了额外的重新渲染。

第六章, 介绍全局状态库,介绍了用于解决全局状态中常见问题的各种方法的库。

第七章用例场景 1 – Zustand,讨论了一个名为 Zustand 的库,用于创建可以在 React 中使用的模块状态。

第八章用例场景 2 – Jotai,介绍了一个基于 Context 和原子数据模型的库 Jotai。它也可以优化重新渲染。

第九章用例场景 3 – Valtio,讨论了一个名为 Valtio 的库,用于可变模块状态。它自动优化重新渲染。

第十章用例场景 4 – React Tracked,讨论了一个名为 React Tracked 的库,用于为某些其他库(如 Context、Zustand 和 React-Redux)启用自动渲染优化。

第十一章三个全局状态库之间的相似之处和不同之处,比较了三个全局状态库——Zustand、Jotai 和 Valtio。

为了充分利用本书

您需要在计算机上安装 Node.js 的版本——v14 或更高版本,以及 create-react-app 包。

或者,可以使用在线代码编辑器,如 CodeSandbox。

如果您正在使用本书的数字版,我们建议您亲自输入代码或从本书的 GitHub 仓库(下一节中提供链接)获取代码。这样做将有助于您避免与代码复制和粘贴相关的任何潜在错误。

强烈建议您基于本书所学内容创建一个小型应用程序。

下载示例代码文件

您可以从 GitHub(github.com/PacktPublishing/Micro-State-Management-with-React-Hooks)下载本书的示例代码文件。如果代码有更新,它将在 GitHub 仓库中更新。

我们还有其他丰富的图书和视频的代码包,可在github.com/PacktPublishing/找到。查看它们!

使用的约定

本书使用了多种文本约定。

文本中的代码:表示文本中的代码词汇、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“最好重用Counter组件来为不同的存储提供支持。”

代码块设置如下:

const ThemeContext = createContext('light');
const Component = () => {
  const theme = useContext(ThemeContext);
  return <div>Theme: {theme}</div>
};

粗体:表示新术语、重要词汇或屏幕上看到的词汇。例如,菜单或对话框中的词汇以粗体显示。以下是一个示例:“如果你点击+1按钮,在使用默认存储中,你会看到使用默认存储中的两个计数器一起更新。”

小贴士或重要提示

看起来像这样。

联系我们

欢迎读者反馈。

一般反馈:如果您对这本书的任何方面有疑问,请通过 mailto:customercare@packtpub.com 给我们发邮件,并在邮件主题中提及书名。

勘误:尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将非常感激您能向我们报告。请访问www.packtpub.com/support/errata并填写表格。

盗版:如果您在互联网上发现我们作品的任何形式的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过 mailto:copyright@packt.com 与我们联系,并附上材料的链接。

如果您有兴趣成为作者:如果您在某个领域有专业知识,并且对撰写或参与一本书籍感兴趣,请访问authors.packtpub.com

第一部分:React Hooks 和微状态管理

在本部分中,我们介绍了微状态管理的概念,该概念随着 React Hooks 的出现而受到关注。我们还涵盖了 useStateuseReducer Hooks 的技术方面,为下一部分做准备。

本部分包括以下章节:

  • 第一章, 什么是使用 React Hooks 的微状态管理?

第一章: 什么是使用 React 钩子的微状态管理?

状态管理是开发 React 应用程序中最重要的话题之一。传统上,React 中的状态管理是某种单体结构,提供了一个通用的状态管理框架,并且开发者在框架内创建特定目的的解决方案。

React hooks 的引入改变了情况。我们现在有用于状态管理的原始钩子,它们是可重用的,可以用作构建更丰富功能的基石。这使得我们可以使状态管理变得轻量级,换句话说,就是微状态管理。微状态管理更具有目的性,并与特定的编码模式一起使用,而单体状态管理则更通用。

在这本书中,我们将探讨使用 React 钩子的各种状态管理模式。我们的重点是全局状态,其中多个组件可以共享状态。React 钩子已经为局部状态提供了良好的功能——即单个组件或小型组件树内的状态。全局状态是 React 中的一个难题,因为 React 钩子缺少直接提供全局状态的能力;相反,这留给了社区和生态系统来处理。我们还将探讨一些现有的微状态管理库,每个库都有不同的用途和模式;在这本书中,我们将讨论 Zustand、Jotai、Valtio 和 React Tracked。

重要提示

这本书专注于全局状态,不讨论“通用”状态管理,这是一个独立的话题。最受欢迎的状态管理库之一是 Redux (redux.js.org),它使用单向数据模型进行状态管理。另一个流行的库是 XState (xstate.js.org),它是状态图的实现,是复杂状态的视觉表示。两者都提供了管理状态的高级方法,但这本书的范围之外。另一方面,这些库也具备全局状态的能力。例如,React Redux (react-redux.js.org) 是一个将 React 和 Redux 绑定以用于全局状态的库,这属于本书的范围。为了使本书只关注全局状态,我们不特别讨论与 Redux 相关的 React Redux。

在本章中,我们将定义什么是微状态管理,讨论 React 钩子如何允许微状态管理,以及为什么全局状态具有挑战性。我们还将回顾两个用于状态管理的基本钩子的用法,并比较它们的相似之处和不同之处。

在本章中,我们将涵盖以下主题:

  • 理解微状态管理

  • 使用钩子

  • 探索全局状态

  • 使用 useState

  • 使用 useReducer

  • 探索 useStateuseReducer 之间的相似之处和不同之处

技术要求

要运行代码片段,你需要一个 React 环境——例如,Create React App (create-react-app.dev) 或 CodeSandbox (codesandbox.io)。

预期你已经具备 React 和 React hooks 的基本知识。更准确地说,你应该已经熟悉官方的 React 文档,你可以在这里找到:reactjs.org/docs/getting-started.html

我们不使用类组件,除非你需要学习带有类组件的现有代码,否则没有必要学习它们。

本章的代码可在 GitHub 上找到:github.com/PacktPublishing/Micro-State-Management-with-React-Hooks/tree/main/chapter_01

理解微状态管理

什么是微状态管理?目前还没有官方确立的定义;然而,让我们在这里尝试定义一个。

重要提示

这个定义可能不会在未来反映社区标准。

在 React 中,状态是指任何代表用户界面UI)的数据。状态会随时间变化,React 负责确保组件根据状态进行渲染。

在我们拥有 React hooks 之前,使用单体状态库是一种流行的模式。单一状态覆盖了许多用途,从而提高了开发者的体验,但有时这可能是过度设计,因为单体状态库可能包含未使用的功能。有了 hooks,我们有了创建状态的新方法。这使我们能够为每个特定的目的提供不同的解决方案。以下是一些例子:

  • 表单状态应该与全局状态分开处理,而单状态解决方案是无法做到这一点的。

  • 服务器缓存状态有一些独特的特性,例如重新获取,这是与其他状态不同的功能。

  • 导航状态有特殊要求,即原始状态位于浏览器端,再次强调,单状态解决方案不适用。

解决这些问题是 React hooks 的一个目标。React hooks 的趋势是针对各种状态使用专门的解决方案。有许多基于 hooks 的库可以解决诸如表单状态、服务器缓存状态等问题。

仍然需要通用状态管理,因为我们还需要处理那些未被目的导向解决方案覆盖的状态。通用状态管理的工作量在应用程序中各不相同。例如,主要处理服务器状态的应用程序可能只需要一个或几个小的全局状态。另一方面,与该应用程序中所需的服务器状态相比,一个丰富的图形应用程序可能需要更多的全局状态。

因此,通用状态管理的解决方案应该是轻量级的,开发者可以根据自己的需求选择一个。这就是我们所说的微状态管理。为了定义这个概念,它是在 React 中的轻量级状态管理,其中每个解决方案都有几个不同的特性,开发者可以根据应用需求从可能的解决方案中选择一个。

微状态管理可能有多个要求,以满足开发者各种需求。有基础状态管理需求,例如做以下这些事情:

  • 读取状态

  • 更新状态

  • 使用状态渲染

但是,可能还有其他额外的要求,例如以下这些:

  • 优化重新渲染

  • 与其他系统交互

  • 异步支持

  • 派生状态

  • 简单的语法;等等

然而,我们不需要所有功能,其中一些可能存在冲突。因此,微状态管理解决方案也不能是一个单一的解决方案。针对不同的需求,有多个解决方案。

关于微状态管理和其库的另一个需要提及的方面是其学习曲线。易于学习对于通用状态管理也很重要,但由于微状态管理覆盖的使用案例可能较小,因此应该更容易学习。更简单的学习曲线将导致更好的开发者体验和更高的生产力。

在本节中,我们讨论了什么是微状态管理。接下来,我们将看到一些处理状态的钩子的概述。

与钩子一起工作

React 钩子对于微状态管理至关重要。React 钩子包括一些原始钩子,用于实现状态管理解决方案,例如以下这些:

  • useState 钩子是一个创建局部状态的基本函数。得益于 React 钩子的可组合性,我们可以创建一个自定义钩子,它可以基于 useState 添加各种功能。

  • useReducer 钩子也可以创建局部状态,通常用作 useState 的替代品。我们将在本章后面重新审视这些钩子,以了解 useStateuseReducer 之间的相似之处和不同之处。

  • useEffect 钩子允许我们在 React 渲染过程之外运行逻辑。对于开发全局状态管理库来说,这尤为重要,因为它使我们能够实现与 React 组件生命周期协同工作的功能。

React 钩子之所以新颖,是因为它们允许你从 UI 组件中提取逻辑。例如,以下是一个 useState 钩子简单使用的计数器示例:

const Component = () => {
  const [count, setCount] = useState(0);
  return (
    <div>
      {count}
      <button onClick={() => setCount((c) => c + 1)}>+1
      </button>
    </div>
  );
};

现在,让我们看看如何提取逻辑。使用相同的计数器示例,我们将创建一个自定义钩子,命名为 useCount,如下所示:

const useCount = () => {
  const [count, setCount] = useState(0);
  return [count, setCount];
};
const Component = () => {
  const [count, setCount] = useCount();
  return (
    <div>
      {count}
      <button onClick={() => setCount((c) => c + 1)}>
        +1
      </button>
    </div>
  );
};

它的变化不大,有些人可能会认为这过于复杂。然而,有两个要点需要注意,如下所述:

  • 我们现在有一个更清晰的名字——useCount

  • ComponentuseCount 的实现无关。

第一点对于一般编程来说非常重要。如果我们正确命名自定义钩子,代码就更容易阅读。例如,你可能会将 useCount 命名为 useScoreusePercentageusePrice。尽管它们有相同的实现,但如果名称不同,我们就会将其视为不同的钩子。命名事物非常重要。

当涉及到微状态管理库时,第二点也很重要。由于 useCount 是从 Component 中提取出来的,我们可以添加功能而不会破坏组件。

例如,当计数改变时,我们想在控制台输出一个调试信息。为此,我们将执行以下代码:

const useCount = () => {
  const [count, setCount] = useState(0);
  useEffect(() => {
    console.log('count is changed to', count);
  }, [count]);
  return [count, setCount];
};

只需更改 useCount,我们就可以添加显示调试信息的特性。我们不需要更改组件。这就是将逻辑提取为自定义钩子的好处。

我们还可以添加一条新规则。假设我们不想允许计数任意改变,而只想通过每次增加一来改变。以下自定义钩子完成了这项工作:

const useCount = () => {
  const [count, setCount] = useState(0);
  const inc = () => setCount((c) => c + 1);
  return [count, inc];
};

这为整个生态系统提供了提供各种目的的自定义钩子打开了大门。它们可以是添加微小功能的包装器,也可以是执行更大任务的巨大钩子。

你将在公共领域找到许多自定义钩子,这些钩子可以在 Node 包管理器npm)(www.npmjs.com/search?q=react%20hooks) 或 GitHub (github.com/search?q=react+hooks&type=repositories) 上找到。

我们还应该稍微讨论一下 suspense 和并发渲染,因为 React hooks 是设计和开发来与这些模式一起工作的。

数据获取和并发渲染的 suspense

数据获取和并发渲染的 suspense 尚未由 React 发布,但简要提及它们是很重要的。

重要提示

数据获取和并发渲染的 suspense 在正式发布时可能会有不同的名称,但这些都是写作时的名称。

async

并发渲染是一种将渲染过程分割成块以避免长时间阻塞中央处理器CPU)的机制。

React hooks 是设计来与这些机制一起工作的;然而,你需要避免误用它们。

例如,一条规则是你不应该修改现有的 state 对象或 ref 对象。这样做可能会导致意外的行为,例如不触发重新渲染、触发过多的重新渲染,以及触发部分重新渲染(意味着当应该重新渲染时,一些组件重新渲染而其他组件没有)。

钩子函数和组件函数可以被多次调用。因此,另一个规则是这些函数必须足够“纯”,以便它们在多次调用时表现一致。

这些是人们经常违反的两个主要规则。在实践中,这是一个难题,因为即使你的代码违反了这些规则,在非并发渲染中也可能只是工作,人们就不会注意到误用。即使在并发渲染中,也可能在一定程度上没有问题,人们只会偶尔看到问题。这使得对于第一次使用 React 的初学者来说尤其困难。

除非你熟悉这些概念,否则最好使用经过良好设计和实战检验的(微)状态管理库来处理 React 的未来/较新版本。

重要提示

到目前为止,并发渲染在React 18 工作组中有所描述,您可以在以下链接中了解更多信息:github.com/reactwg/react-18/discussions

在本节中,我们回顾了基本的 React 钩子,并对概念有了更深入的理解。接下来,我们将开始探索全局状态,这是本书的主要内容。

探索全局状态

React 为在组件中定义并在组件树内使用的状态提供了原始的钩子,如useState。这些通常被称为局部状态。

以下示例使用局部状态:

const Component = () => {
  const [state, setState] = useState();
  return (
    <div>
      {JSON.stringify(state)}
      <Child state={state} setState={setState} />
    </div>
  );
};
const Child = ({ state, setState }) => {
  const setFoo = () => setState(
    (prev) => ({ ...prev, foo: 'foo' })
  );
  return (
    <div>
      {JSON.stringify(state)}
      <button onClick={setFoo}>Set Foo</button>
    </div>
  );
};

另一方面,全局状态是一种在多个组件中使用的状态,通常在应用程序中相隔甚远。全局状态不一定是单例的,我们可能将全局状态称为共享状态,以明确它不是单例。

以下代码片段提供了一个 React 组件具有全局状态的示例:

const Component1 = () => {
  const [state, setState] = useGlobalState();
  return (
    <div>
      {JSON.stringify(state)}
    </div>
  );
};
const Component2 = () => {
  const [state, setState] = useGlobalState();
  return (
    <div>
      {JSON.stringify(state)}
    </div>
  );
};

由于我们尚未定义useGlobalState,它将不起作用。在这种情况下,我们希望Component1Component2具有相同的状态。

在 React 中实现全局状态并非易事。这主要是因为 React 基于组件模型。在组件模型中,局部性很重要,这意味着组件应该是隔离的,并且应该是可重用的。

关于组件模型的注意事项

组件是单元的可重用部分,就像一个函数。如果你定义了一个组件,它可以被多次使用。这只有在组件定义是自包含的情况下才可能。如果组件依赖于外部的东西,它可能不可重用,因为它的行为可能不一致。技术上,组件本身不应该依赖于全局状态。

React 没有提供全局状态的直接解决方案,这似乎取决于开发者和社区。已经提出了许多解决方案,每个都有其优缺点。本书的目标是展示典型解决方案并讨论这些优缺点,我们将在接下来的章节中这样做:

  • 第三章使用上下文共享组件状态

  • 第四章使用订阅共享模块状态

  • 第五章使用上下文和订阅共享组件状态

在本节中,我们学习了使用 React hooks 的全局状态会是什么样子。接下来,我们将学习一些useState的基础知识,为下一章的讨论做准备。

使用 useState

在本节中,我们将学习如何使用useState,从基本用法到高级用法。我们从一个最简单的形式开始,即使用新值更新状态,然后是使用函数更新,这是一个非常强大的功能,最后我们将讨论懒初始化。

使用值更新状态值

使用useState更新状态值的一种方法是通过提供一个新值。你可以向useState返回的函数传递一个新值,这将最终用新值替换状态值。

这里是一个显示使用值更新的反例:

const Component = () => {
  const [count, setCount] = useState(0);
  return (
    <div>
      {count}
      <button onClick={() => setCount(1)}>
        Set Count to 1
      </button>
    </div>
  );
};

你在onClick处理程序中将值1传递给setCount。如果你点击按钮,它将触发Component重新渲染,count=1

如果你再次点击按钮会发生什么?它将再次调用setCount(1),但由于值相同,它“退出”并且组件不会重新渲染。退出是 React 中的一个技术术语,基本上意味着避免触发重新渲染。

让我们在这里看看另一个例子:

const Component = () => {
  const [state, setState] = useState({ count: 0 });
  return (
    <div>
      {state.count}
      <button onClick={() => setState({ count: 1 })}>
        Set Count to 1
      </button>
    </div>
  );
};

对于第一次点击,这与前面的例子行为完全相同;然而,如果你再次点击按钮,组件将重新渲染。你不会在屏幕上看到任何差异,因为计数没有变化。这是因为第二次点击创建了一个新的对象,{ count: 1 },它与前一个对象不同。

现在,这会导致以下不良做法:

const Component = () => {
  const [state, setState] = useState({ count: 0 });
  return (
    <div>
      {state.count}
      <button
        onClick={() => { state.count = 1; setState(state); }
      >
        Set Count to 1
      </button>
    </div>
  );
};

这不符合预期。即使你点击按钮,它也不会重新渲染。这是因为状态对象在引用上没有改变,并且它退出了,这意味着这本身不会触发重新渲染。

最后,有一个关于值更新的有趣用法,我们在这里可以看到:

const Component = () => {
  const [count, setCount] = useState(0);
  return (
    <div>
      {count}
      <button onClick={() => setCount(count + 1)}>
        Set Count to {count + 1}
      </button>
    </div>
  );
};

点击按钮将增加计数;然而,如果你足够快地连续点击两次按钮,它将只增加一个数字。这有时是可取的,因为它与按钮标题匹配,但有时不是,如果你期望计算按钮实际被点击的次数。这需要函数更新。

使用函数更新状态值

使用useState更新状态的另一种方法称为函数更新。

这里是一个显示使用函数更新的反例:

const Component = () => {
  const [count, setCount] = useState(0);
  return (
    <div>
      {count}
      <button onClick={() => setCount((c) => c + 1)}>
        Increment Count
      </button>
    </div>
  );
};

这实际上计算了按钮被点击的次数,因为(c) => c + 1是按顺序调用的。正如我们在前面的章节中看到的,值更新与Set Count to {count + 1}功能具有相同的使用场景。在大多数情况下,如果更新基于前一个值,函数更新工作得更好。Set Count to {count + 1}功能实际上意味着它不依赖于前一个值,而是依赖于显示的值。

函数更新也可能发生退出。以下是一个演示此点的例子:

const Component = () => {
  const [count, setCount] = useState(0);
  useEffect(() => {
    const id = setInterval(
      () => setCount((c) => c + 1),
      1000,
    );
    return () => clearInterval(id);
  }, []);
  return (
    <div>
      {count}
      <button
        onClick={() =>
          setCount((c) => c % 2 === 0 ? c : c + 1)}
      >
        Increment Count if it makes the result even
      </button>
    </div>
  );
};

如果更新函数返回与上一个状态完全相同的状态,它将退出,并且这个组件不会重新渲染。例如,如果你调用 setCount((c) => c),它将永远不会重新渲染。

懒初始化

useState 可以接收一个初始化函数,该函数只会在第一次渲染时评估。我们可以这样做:

const init = () => 0;
const Component = () => {
  const [count, setCount] = useState(init);
  return (
    <div>
      {count}
      <button onClick={() => setCount((c) => c + 1)}>
        Increment Count
      </button>
    </div>
  );
};

在此示例中,init 的使用并不非常有效,因为返回 0 不需要太多的计算,但重点是 init 函数可以包含复杂的计算,并且只被调用以获取初始状态。init 函数是懒加载的,不是在调用 useState 之前评估;换句话说,它只在 mount 时调用一次。

我们现在已经学会了如何使用 useState;接下来是 useReducer

使用 useReducer

在本节中,我们将学习如何使用 useReducer。我们将了解其典型用法、如何退出、使用原始值以及懒初始化。

典型用法

Reducer 对于复杂的状态很有帮助。这里是一个具有两个属性对象的简单示例:

const reducer = (state, action) => {
  switch (action.type) {
    case 'INCREMENT':
      return { ...state, count: state.count + 1 };
    case 'SET_TEXT':
      return { ...state, text: action.text };
    default:
      throw new Error('unknown action type');
  }
};
const Component = () => {
  const [state, dispatch] = useReducer(
    reducer,
    { count: 0, text: 'hi' },
  );
  return (
    <div>
      {state.count}
      <button
        onClick={() => dispatch({ type: 'INCREMENT' })}
      >
        Increment count
      </button>
      <input
        value={state.text}
        onChange={(e) =>
          dispatch({ type: 'SET_TEXT', text: e.target.value })}
      />
    </div>
  );
};

useReducer 允许我们通过将定义的 reducer 函数和初始状态作为参数来预先定义一个 reducer 函数。定义 reducer 函数在 hook 之外的好处是能够分离代码和可测试性。因为 reducer 函数是一个纯函数,所以更容易测试其行为。

退出

useState 一样,退出也适用于 useReducer。使用之前的示例,让我们修改 reducer,使其在 action.text 为空时退出,如下所示:

const reducer = (state, action) => {
  switch (action.type) {
    case 'INCREMENT':
      return { ...state, count: state.count + 1 };
    case 'SET_TEXT':
      if (!action.text) {
        // bail out
        return state
      }
      return { ...state, text: action.text };
    default:
      throw new Error('unknown action type');
  }
};

注意返回 state 本身是很重要的。如果你返回 { ...state, text: action.text || state.text } 而不是,它不会退出,因为它正在创建一个新的对象。

原始值

useReducer 适用于非对象值,如数字和字符串等原始值。使用原始值的 useReducer 仍然很有用,因为我们可以在它之外定义复杂的 reducer 逻辑。

这里是一个只有一个数字的 reducer 示例:

const reducer = (count, delta) => {
  if (delta < 0) {
    throw new Error('delta cannot be negative');
  }
  if (delta > 10) {
    // too big, just ignore
    return count
  }
  if (count < 100) {
    // add bonus
    return count + delta + 10
  }
  return count + delta
}

注意,动作(即 delta)也不一定需要是对象。在这个 reducer 示例中,状态值是一个数字——一个原始值,但逻辑要复杂一些,条件比仅仅加法要多。

懒初始化(init)

useReducer 需要两个参数。第一个是一个 reducer 函数,第二个是一个初始状态。useReducer 接受一个可选的第三个参数,称为 init,用于懒初始化。

例如,useReducer 可以这样使用:

const init = (count) => ({ count, text: 'hi' });
const reducer = (state, action) => {
  switch (action.type) {
    case 'INCREMENT':
      return { ...state, count: state.count + 1 };
    case 'SET_TEXT':
      return { ...state, text: action.text };
    default:
      throw new Error('unknown action type');
  }
};
const Component = () => {
  const [state, dispatch] = useReducer(reducer, 0, init);
  return (
    <div>
      {state.count}
      <button
        onClick={() => dispatch({ type: 'INCREMENT' })}
      >
        Increment count
      </button>
      <input
        value={state.text}
        onChange={(e) => dispatch({ 
          type: 'SET_TEXT', 
          text: e.target.value,
        })}
      />
    </div>
  );
};

init 函数只在 mount 时调用一次,因此它可以包含复杂的计算。与 useState 不同,init 函数在 useReducer 中接受第二个参数——initialArg——在之前的示例中是 0

现在我们已经单独探讨了 useStateuseReducer,是时候比较它们了。

探索 useState 和 useReducer 之间的相似之处和不同之处

在本节中,我们展示了 useStateuseReducer 之间的相似之处和不同之处。

使用 useReducer 实现 useState

useReducer 替代 useState 实现 100% 是可能的。实际上,已知 useState 在 React 中是用 useReducer 实现的。

重要提示

这可能在未来不会成立,因为 useState 可能会被更有效地实现。

以下示例展示了如何使用 useReducer 实现 useState

const useState = (initialState) => {
  const [state, dispatch] = useReducer(
    (prev, action) =>
      typeof action === 'function' ? action(prev) : action,
    initialState
  );
  return [state, dispatch];
};

这可以简化并改进如下:

const reducer = (prev, action) =>
  typeof action === 'function' ? action(prev): prev;
const useState = (initialState) =>
  useReducer(reducer, initialState);

在这里,我们证明了你可以用 useReducer 实现用 useState 能做到的事情。所以,无论你在哪里使用 useState,你都可以直接替换成 useReducer

使用 useState 实现 useReducer

现在,让我们探索是否可能相反——我们能否用 useState 替换所有 useReducer 的实例?令人惊讶的是,几乎可以。这里的“几乎”意味着存在细微的区别。但总的来说,人们期望 useReducer 比起 useState 更灵活,让我们看看 useState 在现实中是否足够灵活。

以下示例说明了如何使用 useState 实现基本 useReducer 功能:

const useReducer = (reducer, initialState) => {
  const [state, setState] = useState(initialState);
  const dispatch = (action) =>
    setState(prev => reducer(prev, action));
  return [state, dispatch];
};

除了这个基本功能外,我们还可以实现懒初始化。让我们也使用 useCallback 来有一个稳定的 dispatch 函数,如下所示:

const useReducer = (reducer, initialArg, init) => {
  const [state, setState] = useState(
    init ? () => init(initialArg) : initialArg,
  );
  const dispatch = useCallback(
    (action) => setState(prev => reducer(prev, action)),
    [reducer]
  );
  return [state, dispatch];
};

这种实现几乎完美地作为 useReducer 的替代品。你使用 useReducer 的用例很可能被这个实现处理。

然而,我们有两个细微的区别。由于它们很微妙,我们通常不会过多地考虑它们。让我们在以下两个小节中了解它们,以获得更深入的理解。

使用 init 函数

一个区别是我们可以在 hooks 或组件外部定义 reducerinit。这只有在 useReducer 中才可能,而不是在 useState 中。

这里有一个简单的计数示例:

const init = (count) => ({ count });
const reducer = (prev, delta) => prev + delta;
const ComponentWithUseReducer = ({ initialCount }) => {
  const [state, dispatch] = useReducer(
    reducer,
    initialCount,
    init
  );
  return (
    <div>
      {state}
      <button onClick={() => dispatch(1)}>+1</button>
    </div>
  );
};
const ComponentWithUseState = ({ initialCount }) => {
  const [state, setState] = useState(() => 
    init(initialCount));
  const dispatch = (delta) =>
    setState((prev) => reducer(prev, delta));
  return [state, dispatch];
};

正如你在 ComponentWithUseState 中所看到的,useState 需要两个内联函数,而 ComponentWithUseReducer 没有内联函数。这是一件小事,但一些解释器或编译器可以在没有内联函数的情况下进行更好的优化。

使用内联 reducer

内联 reducer 函数可以依赖于外部变量。这只有在 useReducer 中才可能,而不是在 useState 中。这是 useReducer 的一个特殊功能。

重要提示

这种能力通常不使用,除非真的有必要,否则不建议使用。

因此,以下代码在技术上是可以的:

const useScore = (bonus) =>
  useReducer((prev, delta) => prev + delta + bonus, 0);

即使 bonusdelta 都被更新,这也能正确工作。

使用 useState 模拟时,这不会正确工作。它会在前一个渲染中使用旧的 bonus 值。这是因为 useReducer 在渲染阶段调用 reducer 函数。

正如所注,这通常不常用,所以总的来说,如果我们忽略这种特殊行为,我们可以说 useReduceruseState 基本上是相同的,可以互换。你可以根据你的偏好或编程风格选择任何一个。

摘要

在本章中,我们讨论了状态管理并定义了微状态管理,其中 React 钩子在微状态管理中扮演着重要角色。为了为后续章节做准备,我们学习了用于状态管理解决方案的一些 React 钩子,包括 useStateuseReducer,同时也探讨了它们的相似之处和不同之处。

在下一章中,我们将学习更多关于全局状态的知识。为此,我们将讨论局部状态以及何时局部状态有效,然后我们将探讨何时需要全局状态。

第二部分:全局状态的基本方法

在 React 中,有效使用全局状态有几种方法。我们的重点是优化重新渲染。这很重要,因为全局状态可以被多个组件使用。我们描述了三种模式——使用 Context、使用 Subscription 以及同时使用 Context 和 Subscription。我们讨论了这些模式如何解决优化重新渲染的问题。

本部分包括以下章节:

  • 第二章使用本地和全局状态

  • 第三章使用上下文共享组件状态

  • 第四章使用订阅共享模块状态

  • 第五章使用上下文和订阅共享组件状态

第二章:使用局部和全局状态

React 组件形成一个树结构。在树结构中,在整个子树中创建状态是直接的;您只需在树中的较高组件中创建一个局部状态,并在该组件及其子组件中使用该状态。这在局部性和可重用性方面是好的,这也是为什么通常推荐遵循这种策略的原因。

然而,在某些场景中,我们在树中相隔甚远的两个或多个组件中有一个状态。在这种情况下,这就是全局状态发挥作用的地方。与局部状态不同,全局状态在概念上不属于特定的组件,因此我们存储全局状态是一个需要考虑的重要点。

在本章中,我们将学习关于局部状态的知识,包括一些值得考虑的提升模式。提升是一种将信息放在组件树更高的位置的技巧。然后,我们将深入研究全局状态,并考虑何时使用它们。

我们将涵盖以下主题:

  • 理解何时使用局部状态

  • 有效使用局部状态

  • 使用全局状态

技术要求

要运行本章中的代码片段,您需要一个 React 环境——例如,Create React App (create-react-app.dev) 或 CodeSandbox (codesandbox.io)。

预期您对 React 和 React hooks 有基本了解,特别是关于组件树 (reactjs.org/docs/components-and-props.html) 和 useState 钩子 (reactjs.org/docs/hooks-reference.html#usestate) 的概念。

本章中的代码可在 GitHub 上找到,地址为 github.com/PacktPublishing/Micro-State-Management-with-React-Hooks/tree/main/chapter_02

理解何时使用局部状态

在我们考虑 React 之前,让我们看看 JavaScript 函数是如何工作的。JavaScript 函数可以是纯函数或不纯函数。纯函数只依赖于其参数,只要参数相同,就返回相同的值。状态持有参数之外的价值,依赖于状态的函数变为不纯。React 组件也是函数,可以是纯函数。如果我们在一个 React 组件中使用状态,它将是不纯的。然而,如果状态是组件本地的,它不会影响其他组件,我们称这种特性为“封装”。

在本节中,我们将学习 JavaScript 函数,以及 React 组件与 JavaScript 函数的相似之处。然后,我们将讨论局部状态的概念实现方式。

函数和参数

在 JavaScript 中,一个函数接受一个参数并返回一个值。例如,这里有一个简单的函数:

const addOne = (n) => n + 1;

这是一个总是为相同的参数返回相同值的纯函数。通常情况下,纯函数更受欢迎,因为它们的行为是可预测的。

函数可以依赖于全局变量,如下所示:

let base = 1;
const addBase = (n) => n + base;

只要base没有改变,addBase函数的工作方式与addOne完全相同。然而,如果在某个时刻我们将base改为base=2,它的行为就会不同。这根本不是一件坏事,实际上这是一个强大的特性,因为你可以从外部改变函数的行为。缺点是,如果你不知道它依赖于外部变量,就不能简单地抓取addBase函数并在其他地方随意使用。正如你所看到的,这是一个权衡。

如果base是一个单例(内存中的单个值),则这不是一个首选模式,因为代码的可重用性会降低。为了避免单例并稍微减轻其缺点,可以采用更模块化的方法来创建一个容器对象,如下所示:

const createContainer = () => {
  let base = 1;
  const addBase = (n) => n + base;
  const changeBase = (b) => { base = b; };
  return { addBase, changeBase };
};
const { addBase, changeBase } = createContainer();

这不再是一个单例,你可以创建任意数量的容器。与使用作为单例的base全局变量不同,容器是隔离的,并且更易于重用。你可以在代码的一部分使用一个容器,而不会影响到使用不同容器的代码的其他部分。

简短说明:尽管容器中的addBase不是一个数学上纯函数,但如果base没有改变,你可以通过调用addBase得到相同的结果(这个特性有时被称为幂等)。

React 组件和 props

React 在概念上是一个将状态转换为用户界面UI)的函数。当你用 React 编码时,React 组件实际上是一个 JavaScript 函数,其参数被称为 props。

显示数字的函数组件看起来如下:

const Component = ({ number }) => {
  return <div>{number}</div>;
};

此组件接受一个number参数,并在屏幕上返回一个number

什么是 JSX 元素?

JSX 是一种带有尖括号的语法,用于生成 React 元素。React 元素是一个数据结构,用于表示 UI 的一部分。我们可能将 React 元素称为 JSX 元素,尤其是在 React 元素使用 JSX 语法时。

现在,让我们创建另一个组件,它显示number + 1,如下所示:

const AddOne = ({ number }) => {
  return <div>{number + 1}</div>;
};

此组件接受number并返回number + 1。这与上一节中的addOne行为完全相同,这是一个纯函数。唯一的区别是参数是一个 props 对象,返回值是 JSX 格式。

理解 useState 用于局部状态

如果我们使用useState来处理局部状态会怎样?让我们将base设为状态,并显示一个可以添加到其中的number,如下所示:

const AddBase = ({ number }) => {
  const [base, changeBase] = useState(1);
  return <div>{number + base}</div>;
};

此函数在技术上不是纯函数,因为它依赖于base,而base不在函数参数中。

AddBase 中的 useState 做了什么?让我们回顾一下上一节中的 createContainer。由于 createContainer 返回 basechangeBaseuseState 返回一个元组(意味着两个或更多值的结构——在这种情况下,两个)。我们在这个代码中并没有明确看到 basechangeBase 是如何创建的,但从概念上讲是相似的。

如果我们假设 useState 的行为,即除非更改,否则返回 base,那么 AddBase 函数是幂等的,就像我们看到的 createContainer 一样。

这个使用 useStateAddBase 函数被包含在内,因为 changeBase 只在函数声明的范围内可用。在函数外部无法更改 base。这种 useState 的用法是本地状态,因为它被包含并且不影响组件外部的内容,这确保了局部性;在适当的时候,这种用法是首选的。

本地状态的限制

何时本地状态不合适?当我们想要打破局部性时,它就不合适。在 AddBase 组件示例中,这是当我们想要从完全不同的代码部分更改 base 时。如果你需要从函数组件外部更改 state,那么全局状态就出现了。

状态变量在概念上是一个全局变量。全局变量对于从函数外部控制 JavaScript 函数的行为是有用的。同样,全局状态对于从组件外部控制 React 组件的行为也是有用的。然而,使用全局状态会使组件行为变得不那么可预测。这是一个权衡。我们不应该比需要的时候更多地使用全局状态。考虑将本地状态作为主要手段,并且仅将全局状态作为次要手段。在这种情况下,了解本地状态可以覆盖多少使用案例是很重要的。

在本节中,我们学习了 React 中的本地状态,以及 JavaScript 函数。接下来,我们将学习一些使用本地状态的模式。

有效使用本地状态

为了能够有效地使用本地状态,你应该知道一些模式。在本节中,我们将学习如何提升状态,这意味着在组件树中定义一个更高的状态,以及提升内容,这意味着在组件树中定义一个更高的内容。

提升状态

假设我们有两个计数器组件,如下所示:

const Component1 = () => {
  const [count, setCount] = useState(0);
  return (
    <div>
      {count}
      <button onClick={() => setCount((c) => c + 1)}>
        Increment Count
      </button>
    </div>
  );
};
const Component2 = () => {
  const [count, setCount] = useState(0);
  return (
    <div>
      {count}
      <button onClick={() => setCount((c) => c + 1)}>
        Increment Count
      </button>
    </div>
  );
};

由于在两个组件中定义了两个单独的本地状态,这两个计数器是独立工作的。如果我们想共享状态并使其为一个共享计数器工作,我们可以创建一个父组件并将状态提升上去。

下面是一个包含 Component1Component2 作为子组件的单个父组件示例,并将属性传递给它们:

const Component1 = ({ count, setCount }) => {
  return (
    <div>
      {count}
      <button onClick={() => setCount((c) => c + 1)}>
        Increment Count
      </button>
    </div>
  );
};
const Component2 = ({ count, setCount }) => {
  return (
    <div>
      {count}
      <button onClick={() => setCount((c) => c + 1)}>
        Increment Count
      </button>
    </div>
  );
};
const Parent = () => {
  const [count, setCount] = useState(0);
  return (
    <>
      <Component1 count={count} setCount={setCount} />
      <Component2 count={count} setCount={setCount} />
    </>
  );
};

由于计数状态在 Parent 中只定义了一次,因此状态在 Component1Component2 之间是共享的。这仍然是在组件中的本地状态;其子组件可以使用父组件的状态。

这种模式在大多数使用局部状态的场景中都会有效;然而,有一点关于性能的担忧。如果我们提升状态,Parent 将会重新渲染,以及整个子树,包括所有子组件。在某些用例中,这可能会成为性能问题。

提升内容向上

在复杂的组件树中,我们可能有一个不依赖于我们提升的状态的组件。

在以下示例中,我们向前面示例中的 Component1 添加一个新的 AdditionalInfo 组件:

const AdditionalInfo = () => {
  return <p>Some information</p>
};
const Component1 = ({ count, setCount }) => {
  return (
    <div>
      {count}
      <button onClick={() => setCount((c) => c + 1)}>
        Increment Count
      </button>
      <AdditionalInfo />
    </div>
  );
};
const Parent = () => {
  const [count, setCount] = useState(0);
  return (
    <>
      <Component1 count={count} setCount={setCount} />
      <Component2 count={count} setCount={setCount} />
    </>
  );
};

如果计数发生变化,Parent 会重新渲染,然后 Component1Component2AdditionalInfo 也会重新渲染。然而,在这种情况下,AdditionalInfo 不必重新渲染,因为它不依赖于 count。这是一个额外的重新渲染,如果它对性能有影响,应该避免。

为了避免额外的重新渲染,我们可以提升内容。在这种情况下,Parent 组件会随着 count 的变化而重新渲染,因此,我们创建 GrandParent,如下所示:

const AdditionalInfo = () => {
  return <p>Some information</p>
};
const Component1 = ({ count, setCount, additionalInfo }) => {
  return (
    <div>
      {count}
      <button onClick={() => setCount((c) => c + 1)}>
        Increment Count
      </button>
      {additionalInfo}
    </div>
  );
};
const Parent = ({ additionalInfo }) => {
  const [count, setCount] = useState(0);
  return (
    <>
      <Component1
        count={count}
        setCount={setCount}
        additionalInfo={additionalInfo}
      />
      <Component2 count={count} setCount={setCount} />
    </>
  );
};
const GrandParent = () => {
  return <Parent additionalInfo={<AdditionalInfo />} />;
};

GrandParent 组件有 additionalInfo(一个 JSX 元素),它被传递给子组件。通过这样做,当 count 变化时,AdditionalInfo 不会重新渲染。这是一种我们不仅应该考虑性能,还应该考虑组织组件树结构的技巧。

这种方法的变体是使用 children 属性。以下使用 children 属性的示例与前面的示例等效,但具有不同的编码风格:

const AdditionalInfo = () => {
  return <p>Some information</p>
};
const Component1 = ({ count, setCount, children }) => {
  return (
    <div>
      {count}
      <button onClick={() => setCount((c) => c + 1)}>
        Increment Count
      </button>
      {children}
    </div>
  );
};
const Parent = ({ children }) => {
  const [count, setCount] = useState(0);
  return (
    <>
      <Component1 count={count} setCount={setCount}>
        {children}
      </Component1>
      <Component2 count={count} setCount={setCount} />
    </>
  );
};
const GrandParent = () => {
  return (
    <Parent>
      <AdditionalInfo />
    </Parent>
  );
};

children 是一个特殊的属性名,在 JSX 格式中表示为嵌套的子元素。如果你有多个元素要传递,给属性命名会更好。这主要是一个风格选择,开发者可以采取他们喜欢的任何方法。

在本节中,我们学习了如何有效地使用局部状态的一些模式。如果我们正确地提升状态和内容,我们应该能够仅使用局部状态解决各种用例。接下来,我们将学习如何使用全局状态。

使用全局状态

在本节中,我们将再次学习什么是全局状态以及何时应该使用它。

什么是全局状态?

在这本书中,全局状态仅仅意味着它不是局部状态。如果一个状态在概念上属于单个组件并且被该组件封装,那么它是一个局部状态。因此,如果一个状态不属于单个组件并且可以被多个组件使用,那么它是一个全局状态。

可能存在一个应用范围内的局部状态,所有组件都依赖于它。在这种情况下,应用范围内的局部状态可以被视为全局状态。从这个意义上说,我们无法清楚地划分局部状态和全局状态。在大多数情况下,如果你考虑状态在概念上属于哪里,你可以确定它是局部还是全局。

当人们谈论全局状态时,有两个方面,如下所述:

  • 其中一个是单例,意味着在某些上下文中,状态只有一个值。

  • 另一个是共享状态,这意味着状态值在多个组件之间共享,但不必是 JavaScript 内存中的单个值。非单例的全局状态可以有多个值。

为了说明非单例全局状态的工作原理,这里有一个例子来展示 JavaScript 中的非单例变量:

const createContainer = () => {
  let base = 1;
  const addBase = (n) => n + base;
  const changeBase = (b) => { base = b; };
  return { addBase, changeBase };
};
const container1 = createContainer();
const container2 = createContainer();
container1.changeBase(10);
console.log(container1.addBase(2)); // shows "3"
console.log(container2.addBase(2)); // shows "12"

在这个例子中,base是一个容器中的局部变量。由于base在每个容器中都是隔离的,所以在container1中更改base不会影响container2中的base

在 React 中,概念是相似的。如果全局状态是一个单例,我们在内存中只有一个值。如果全局状态不是单例,我们可能为组件树的不同部分(子树)有多个值。

何时使用全局状态

当我们需要在 React 中使用全局状态时,有两个指导原则,如下:

  • 当传递属性不希望时

  • 当我们已经在 React 外部有一个状态时

让我们讨论每一个。

属性传递是不希望的

如果你需要在组件树中相距较远的两个组件中使用状态,在公共根组件中放置状态并将状态传递到这两个组件可能不是最佳选择。

例如,如果我们的树有三层深,并且需要将状态提升到顶部,它看起来会是这样:

const Component1 = ({ count, setCount }) => {
  return (
    <div>
      {count}
      <button onClick={() => setCount((c) => c + 1)}>
        Increment Count
      </button>
    </div>
  );
};
const Parent = ({ count, setCount }) => {
  return (
    <>
      <Component1 count={count} setCount={setCount} />
    </>
  );
};
const GrandParent = ({ count, setCount }) => {
  return (
    <>
      <Parent count={count} setCount={setCount} />
    </>
  );
};
const Root = () => {
  const [count, setCount] = useState(0);
  return (
    <>
      <GrandParent count={count} setCount={setCount} />
    </>
  );
};

这完全没问题,并且推荐用于局部性;然而,让你的中间组件用于传递属性可能会变得过于繁琐。通过多层中间组件传递属性可能不会带来良好的开发者体验,因为这可能看起来像是额外的不必要工作。此外,当状态更新时,中间组件会重新渲染,这可能会影响性能。

在这种情况下,拥有全局状态更为合适,并且不需要中间组件处理状态传递。

这里有一些伪代码展示了如何使用上一个例子中的全局状态:

const Component1 = () => {
  // useGlobalCountState is a pseudo hook 
  const [count, setCount] = useGlobalCountState();
  return (
    <div>
      {count}
      <button onClick={() => setCount((c) => c + 1)}>
        Increment Count
      </button>
    </div>
  );
};
const Parent = () => {
  return (
    <>
      <Component1 />
    </>
  );
};
const GrandParent = () => {
  return (
    <>
      <Parent />
    </>
  );
};
const Root = () => {
  return (
    <>
      <GrandParent />
    </>
  );
};

在这个例子中,唯一使用全局状态的组件是Component1。与局部状态和属性传递不同,中间组件ParentGrandParent并不知道全局状态。

已经在 React 外部有一个状态

在某些情况下,你已经在 React 外部有一个全局状态,因为在外部拥有全局状态更直接。例如,你的应用可能有一些通过某种方式获得的用户认证信息,而不是通过 React。在这种情况下,全局状态应该存在于 React 外部,认证信息可以存储在全局状态中。

这里有一些伪代码展示了这样的例子:

const globalState = {
  authInfo: { name: 'React' },
};
const Component1 = () => {
  // useGlobalState is a pseudo hook
  const { authInfo } = useGlobalState();
  return (
    <div>
      {authInfo.name}
    </div>
  );
};

在这个例子中,globalState存在并且是在 React 外部定义的。useGlobalState是一个钩子,它会连接到globalState,并且可以在Component1中提供authInfo

在本节中,我们了解到全局状态是一种不能是局部状态的状态。全局状态主要作为局部状态的补充使用,并且有两种情况下使用全局状态效果良好:一种是在属性传递没有意义的情况下,另一种是在应用中已经存在全局状态的情况下。

摘要

在本章中,我们讨论了局部状态和全局状态。尽可能情况下,局部状态是首选的,并且我们学习了一些有效使用局部状态的技术。然而,全局状态在局部状态无法发挥作用的地方扮演着角色,这就是为什么我们要探讨何时应该使用全局状态而不是局部状态。

在接下来的三章中,我们将学习三种在 React 中实现全局状态的模式;在下一章中,我们将具体从利用 React 上下文开始。

第三章:使用 Context 共享组件状态

React 自 16.3 版本以来提供了 Context。Context 与状态无关,但它是一种从组件到组件传递数据的机制,而不是使用 props。通过将 Context 与组件状态结合,我们可以提供全局状态。

除了自 React 16.3 版本以来提供的 Context 支持,React 16.8 引入了 useContext 钩子。通过使用 useContextuseState(或 useReducer),我们可以为全局状态创建自定义 hooks。

Context 并非完全为全局状态而设计。其中一个已知的限制是,所有 Context 消费者在更新时都会重新渲染,这可能导致额外的重新渲染。通常建议将全局状态拆分成多个部分。

本章中,我们讨论了使用 Context 的一般建议,并展示了具体的示例。我们还讨论了一些使用 TypeScript 与 Context 结合的技术。目标是让你对使用 Context 进行全局状态管理感到自信。

本章中,我们将涵盖以下主题:

  • 探索 useStateuseContext

  • 理解 Context

  • 为全局状态创建 Context

  • 使用 Context 的最佳实践

技术要求

如果你刚接触 React Context,强烈建议学习一些基础知识;查看官方文档(reactjs.org/docs/context.html)和官方博客(reactjs.org/blog/2018/03/29/react-v-16-3.html)。

你还应该对 React 有一定的了解,包括 React hooks;你可以参考官方网站(reactjs.org)了解更多信息。

在某些代码中,我们使用了 TypeScript,你应该对其有基本的了解;你可以在这里了解更多:www.typescriptlang.org

本章中的代码可在 GitHub 上找到,链接为 github.com/PacktPublishing/Micro-State-Management-with-React-Hooks/tree/main/chapter_03

要运行本章中的代码片段,你需要一个 React 环境——例如,Create React App (create-react-app.dev)或 CodeSandbox (codesandbox.io)。

探索 useState 和 useContext

通过结合 useStateuseContext,我们可以创建一个简单的全局状态。让我们回顾一下如何在不使用 useContext 的情况下使用 useStateuseContext 对于静态值是如何工作的,以及我们如何结合 useStateuseContext

不使用 useContext 使用 useState

在深入 useContext 之前,让我们回顾一下如何使用 useState,以下是一个具体的示例。这个示例将成为本章后续示例的参考。

在这里,我们定义了一个在组件树中较高的 count 状态,并通过状态值和更新函数将其传递到树中。

App组件中,我们使用useState获取countsetCount,这些被传递给Parent组件。代码在下面的代码片段中展示:

const App = () => {
  const [count, setCount] = useState(0);
  return <Parent count={count} setCount={setCount} />;
};

这是一个非常基本的模式,我们称之为提升状态,来自第二章使用本地和全局状态

现在,让我们定义一个Parent组件。它将两个 props 传递给Component1Component2,如下所示:

const Parent = ({ count, setCount }) => (
  <>
    <Component1 count={count} setCount={setCount} />
    <Component2 count={count} setCount={setCount} />
  </>
);

从父组件到子组件的这种 props 传递是一个重复的任务,通常被称为属性钻取

Component1Component2显示count状态和一个按钮,用于通过setCount增加count状态,如下面的代码片段所示:

const Component1 = ({ count, setCount }) => (
  <div>
    {count}
    <button onClick={() => setCount((c) => c + 1)}>
      +1
    </button>
  </div>
);
const Component2 = ({ count, setCount }) => (
  <div>
    {count}
    <button onClick={() => setCount((c) => c + 2)}>
      +2
    </button>
  </div>
);

这两个组件是纯组件,这意味着它们只根据接收到的 props 接收并显示内容。Component2Component1略有不同,它将计数增加两次。如果它们是相同的,我们就不需要定义两个组件。

这个例子没有问题。只有当应用变大,向下传递 props 时,这才会没有意义。在这种情况下,Parent组件不一定需要知道count状态,并且隐藏Parent组件中count状态的存在可能是合理的。

使用静态值useContext

React 上下文有助于消除 props。它是一种从父组件向其子组件传递值的方法,而不使用 props。

以下示例展示了如何使用具有静态值的 React 上下文。它有多个提供者来提供不同的值。提供者可以是嵌套的,并且消费者组件(消费者组件意味着具有useContext的组件)将选择组件树中最接近的提供者以获取上下文值。只有一个具有useContext的组件来消费上下文,该组件在多个地方使用。

首先,我们使用createContext定义一个颜色上下文,它接受一个默认值,如下所示:

const ColorContext = createContext('black');

在这种情况下,颜色上下文的默认值是'black'。如果组件不在任何提供者中,则使用默认值。

现在,我们定义一个消费者组件。它读取颜色上下文并显示该颜色的文本。代码在下面的代码片段中展示:

const Component = () => {
  const color = useContext(ColorContext);
  return <div style={{ color }}>Hello {color}</div>;
};

Component读取color上下文值,但在此阶段,我们不知道颜色是什么,它实际上依赖于上下文。

最后,我们定义一个App组件。在App组件中的组件树有多个不同颜色的ColorContext.Provider组件。代码在下面的代码片段中展示:

const App = () => (
  <>
    <Component />
    <ColorContext.Provider value="red">
      <Component />
    </ColorContext.Provider>
    <ColorContext.Provider value="green">
      <Component />
    </ColorContext.Provider>
    <ColorContext.Provider value="blue">
      <Component />
      <ColorContext.Provider value="skyblue">
        <Component />
      </ColorContext.Provider>
    </ColorContext.Provider>
  </>
);

第一个Component实例显示颜色"black",因为它没有被任何提供者包裹。第二个和第三个分别显示"red""green"。第四个Component实例显示"blue",最后一个Component实例显示"skyblue",因为最近的提供者的值是"skyblue",即使它位于包含"blue"的提供者内部。

多个提供者和重用消费者组件是 React Context 的重要功能。如果这个功能对你的用例不重要,你可能不需要 React Context。我们将在第四章 使用 Context 的最佳实践部分讨论没有 Context 的订阅方法。

使用 useState 与 useContext 结合

现在,让我们学习如何将useStateuseContext结合来结构化我们的代码。我们可以在上下文中传递state值和update函数,而不是通过 props。

以下示例使用useStateuseContext实现了一个简单的count状态。我们定义了一个上下文,它包含count状态值和setCount更新函数。Parent组件不接收 props,Component1Component2使用useContext获取状态。

首先,我们为count状态创建一个上下文。默认值包含一个静态的count值和一个回退的空setCount函数。代码在下面的代码片段中展示:

const CountStateContext = createContext({
  count: 0,
  setCount: () => {},
});

默认值有助于在 TypeScript 中推断类型。然而,在大多数情况下,我们需要状态而不是静态值,因为默认值并不很有用。在这种情况下使用默认值几乎是无意识的,所以我们可能会抛出一个错误。我们将在使用 Context 的最佳实践部分稍后讨论一些最佳实践。

App组件使用useState有一个状态,并将countsetCount传递给创建的上下文提供者组件,如下面的代码片段所示:

const App = () => {
  const [count, setCount] = useState(0);
  return (
    <CountStateContext.Provider 
      value={{ count, setCount }}
    >
      <Parent />
    </CountStateContext.Provider>
  );
};

我们传递给CountStateContext.Provider的上下文值是一个包含countsetCount的对象。此对象结构与默认值相同。

我们定义了一个Parent组件。与上一节中的示例不同,我们不需要传递 props。代码在下面的代码片段中展示:

const Parent = () => (
  <>
    <Component1 />
    <Component2 />
  </>
);

尽管Parent组件位于App中的上下文提供者中,但它并不知道count状态的存在。Parent内部的组件仍然可以通过上下文使用count状态。

最后,我们定义了Component1Component2。它们从上下文值中获取countsetCount,而不是从 props 中获取。代码在下面的代码片段中展示:

const Component1 = () => {
  const { count, setCount } = 
    useContext(CountStateContext);
  return (
    <div>
      {count}
      <button onClick={() => setCount((c) => c + 1)}>
        +1
      </button>
    </div>
  );
};
const Component2 = () => {
  const { count, setCount } = 
    useContext(CountStateContext);
  return (
    <div>
      {count}
      <button onClick={() => setCount((c) => c + 2)}>
        +2
      </button>
    </div>
  );
};

这些组件获取的上下文值是什么?它们从最近的提供者获取上下文值。我们可以使用多个提供者来提供隔离的计数状态,这再次是使用 React Context 的重要功能。

在本节中,我们学习了 React Context 以及如何使用它创建一个简单的全局状态。接下来,我们将深入了解 React Context 的行为。

理解 Context

当一个 Context 提供者有一个新的 Context 值时,所有 Context 消费者都会接收到新的值并重新渲染。这意味着提供者中的值会传播到所有消费者。了解 Context 传播的工作方式和其限制对我们来说很重要。

Context 传播的工作方式

如果你使用 Context 提供者,你可以更新 Context 值。当 Context 提供者接收到新的 Context 值时,它会触发 所有 Context 消费者组件的重新渲染。

有时,子组件会因两个原因而重新渲染——一个是因为父组件,另一个是因为 Context。

要在不改变 Context 值的情况下停止重新渲染,在这种情况下,我们可以使用 提升内容向上 技术或 memomemo 是一个用于包裹组件的函数,用于防止在组件属性没有变化时重新渲染。

让我们通过将一些组件包裹在 memo 中来举一个例子,以了解其行为。

与之前的例子一样,我们再次使用一个简单的 Context,它包含一个颜色字符串,如下所示:

const ColorContext = createContext('black');

'black' 是默认值,如果组件树中没有找到 Context 提供者,将使用此值。

我们接着定义 ColorComponent,它类似于之前的例子,但它还有一个 renderCount 来显示这个组件被渲染了多少次,如下面的代码片段所示:

const ColorComponent = () => {
  const color = useContext(ColorContext);
  const renderCount = useRef(1);
  useEffect(() => {
    renderCount.current += 1;
  });
  return (
    <div style={{ color }}>
      Hello {color} (renders: {renderCount.current})
    </div>
  );
};

我们使用 useRefrenderCountrenderCount.current 是一个表示渲染次数的数字。renderCount.current 数字通过 useEffect 增加一。

接下来是 MemoedColorComponent,它是被 memo 包裹的 ColorComponent。代码在下面的代码片段中展示:

const MemoedColorComponent = memo(ColorComponent);

memo 函数用于从基础组件创建一个记忆化的组件。记忆化组件对相同的属性产生稳定的结果。

我们定义了另一个组件,DummyComponent,它没有使用 useContext。代码在下面的代码片段中展示:

const DummyComponent = () => {
  const renderCount = useRef(1);
  useEffect(() => {
    renderCount.current += 1;
  });
  return <div>Dummy (renders: {renderCount.current})</div>;
};

这个组件是为了与 ColorComponent 的行为进行比较。

我们还使用 memoDummyComponent 定义了 MemoedDummyComponent,如下所示:

const MemoedDummyComponent = memo(DummyComponent);

接下来,我们定义一个 Parent 组件;它包含我们之前定义的四种组件。代码在下面的代码片段中展示:

const Parent = () => (
  <ul>
    <li><DummyComponent /></li>
    <li><MemoedDummyComponent /></li>
    <li><ColorComponent /></li>
    <li><MemoedColorComponent /></li>
  </ul>
);

最后,App 组件使用 useState 有一个颜色状态,并将值传递给 ColorContext.Provider。它还显示一个文本字段来更改颜色状态。代码在下面的代码片段中展示:

const App = () => {
  const [color, setColor] = useState('red');
  return (
    <ColorContext.Provider value={color}>
      <input
        value={color}
        onChange={(e) => setColor(e.target.value)}
      />
      <Parent />
    </ColorContext.Provider>
  );
};

这个例子表现如下:

  1. 初始时,所有组件都会进行渲染。

  2. 如果你更改文本输入中的值,App 组件会由于 useState 而重新渲染。

  3. 然后,ColorContext.Provider 获得了一个新值,同时 Parent 组件也进行了渲染。

  4. DummyComponent 会渲染,但 MemoedDummyComponent 不会。

  5. ColorComponent 由于两个原因而渲染——首先,父组件渲染,其次,上下文发生变化。

  6. MemoedColorComponent 由于上下文变化而渲染。

这里重要的是要学习的是 memo 并不能阻止内部上下文消费者重新渲染。这显然是不可避免的,否则组件可能会有不一致的上下文值。

使用上下文处理对象时的限制

使用原始值作为上下文值是直观的,但使用对象值可能需要谨慎,因为它们的行为。一个对象可能包含多个值,而上下文消费者可能不会使用它们全部。

以下示例是为了重现这样一个案例,其中组件只使用对象的一部分。

首先,我们定义一个上下文,其值是一个包含两个计数 count1count2 的对象,如下所示:

const CountContext = createContext({ count1: 0, count2: 0 });

使用这个计数上下文,我们定义一个 Counter1 组件来显示 count1。我们有 renderCount 来显示渲染计数。我们还定义了一个 MemoedCounter1 组件,这是一个记忆化组件。代码如下所示:

const Counter1 = () => {
  const { count1 } = useContext(CountContext);
  const renderCount = useRef(1);
  useEffect(() => {
    renderCount.current += 1;
  });
  return (
    <div>
      Count1: {count1} (renders: {renderCount.current})
    </div>
  );
};
const MemoedCounter1 = memo(Counter1);

注意到 Counter1 组件只使用了上下文值中的 count1

同样,我们定义了一个显示 count2 和记忆化的 MemoCounter2 组件的 Counter2 组件,如下所示:

const Counter2 = () => {
  const { count2 } = useContext(CountContext);
  const renderCount = useRef(1);
  useEffect(() => {
    renderCount.current += 1;
  });
  return (
    <div>
      Count2: {count2} (renders: {renderCount.current})
    </div>
  );
};
const MemoCounter2 = memo(Counter2);

Parent 组件有两个记忆化的组件,如下面的代码片段所示:

const Parent = () => (
  <>
    <MemoCounter1 />
    <MemoCounter2 />
  </>
);

最后,App 组件有两个计数,使用两个 useState 钩子,并通过一个上下文提供这两个计数。它有两个按钮分别增加两个计数,如下面的代码片段所示:

const App = () => {
  const [count1, setCount1] = useState(0);
  const [count2, setCount2] = useState(0);
  return (
    <CountContext.Provider value={{ count1, count2 }}>
      <button onClick={() => setCount1((c) => c + 1)}>
        {count1}
      </button>
      <button onClick={() => setCount2((c) => c + 1)}>
        {count2}
      </button>
      <Parent />
    </CountContext.Provider>
  );
};

再次注意,两个按钮的位置并不重要。

两个计数 count1count2 完全独立——Counter1 只使用 count1,而 Counter2 只使用 count2。因此,理想情况下,Counter1 只应在 count1 发生变化时重新渲染。如果 Counter1 在不改变 count1 的情况下重新渲染,它会产生相同的结果,这意味着这只是额外的重新渲染。在这个例子中,即使只有 count2 发生变化,Counter1 也会重新渲染。

这是我们利用 React Context 时应该注意的额外重新渲染限制。

额外的重新渲染

额外的重新渲染是纯粹的开销,应该从技术上避免。然而,除非性能是一个大问题,否则这将是可行的,因为用户不会注意到几个额外的重新渲染。为了避免几个额外的重新渲染而过度设计可能在实际中不值得解决。

在本节中,我们学习了 React Context 的行为以及为什么它限制为只能与对象一起使用。接下来,我们将学习一些实现全局状态的典型模式。

为全局状态创建一个上下文

根据 React Context 的行为,我们将讨论两个关于使用全局状态与上下文结合的解决方案,如下:

  • 创建小的状态片段

  • 使用 useReducer 创建一个状态并使用多个上下文进行传播

让我们看看每个解决方案。

创建小的状态片段

第一种解决方案是将全局状态分割成片段。因此,而不是使用一个大型的组合对象,创建一个全局状态和每个片段的上下文。

下面的示例创建了两个 count 状态,每个状态都有一个上下文和提供者组件。

首先,我们定义了两个上下文,Count1ContextCount2Context,每个片段一个,如下所示:

type CountContextType = [
  number,
  Dispatch<SetStateAction<number>>
];

const Count1Context = createContext<CountContextType>([
  0,
  () => {}
]);
const Count2Context = createContext<CountContextType>([
  0,
  () => {}
]);

上下文值是一个包含 count 值和更新函数的元组。我们指定了一个静态值和一个占位函数作为默认值。

然后我们定义了一个仅使用 Count1ContextCounter1 组件,如下所示:

const Counter1 = () => {
  const [count1, setCount1] = useContext(Count1Context);
  return (
    <div>
      Count1: {count1}
      <button onClick={() => setCount1((c) => c + 1)}>
        +1
      </button>
    </div>
  );
};

注意到 Counter1 的实现仅依赖于 Count1Context,并且它不知道任何其他上下文。

同样,我们定义了一个仅使用 Count2ContextCounter2 组件,如下所示:

const Counter2 = () => {
  const [count2, setCount2] = useContext(Count2Context);
  return (
    <div>
      Count2: {count2}
      <button onClick={() => setCount2((c) => c + 1)}>
        +1
      </button>
    </div>
  );
};

Parent 组件包含 Counter1Counter2 组件,如下面的代码片段所示:

const Parent = () => (
  <div>
    <Counter1 />
    <Counter1 />
    <Counter2 />
    <Counter2 />
  </div>
);

Parent 组件有两个计数器,仅用于演示目的。

我们为 Count1Context 定义了一个 Count1Provider 组件。Count1Provider 组件使用 useState 有一个 count 状态,并将计数值和 update 函数传递给 Count1Context.Provider 组件,如下面的代码片段所示:

const Count1Provider = ({
  children
}: {
  children: ReactNode
}) => {
  const [count1, setCount1] = useState(0);
  return (
    <Count1Context.Provider value={[count1, setCount1]}>
      {children}
    </Count1Context.Provider>
  );
};

同样,我们为 Count2Context 定义了一个 Count2Provider 组件,如下所示:

const Count2Provider = ({
  children
}: {
  children: ReactNode
}) => {
  const [count2, setCount2] = useState(0);
  return (
    <Count2Context.Provider value={[count2, setCount2]}>
      {children}
    </Count2Context.Provider>
  );
};

Count1ProviderCount2Provider 组件类似;唯一的区别是提供值的上下文。

最后,App 组件有一个包含两个提供者组件的 Parent 组件,如下面的代码片段所示:

const App = () => (
  <Count1Provider>
    <Count2Provider>
      <Parent />
    </Count2Provider>
  </Count1Provider>
);

注意到 App 组件有两个嵌套的提供者组件。拥有更多的提供者组件会导致更深层次的嵌套。我们将在 使用上下文的最佳实践 部分讨论缓解嵌套的方法。

这个示例不受我们在上一节中描述的额外重新渲染限制的影响。这是因为上下文只持有原始值。Counter1Counter2 组件仅在 count1count2 分别更改时重新渲染。对于每个状态创建一个提供者是很必要的;否则,useState 会返回一个新的元组对象,上下文将触发重新渲染。

如果你确定一个对象一次就会被使用,并且使用不会触及上下文行为的限制,将对象作为上下文值是完全可接受的。以下是一个一次性使用的 user 对象的示例:

const [user, setUser] = useState({
  firstName: 'react',
  lastName: 'hooks'
});

在这种情况下,将其分割成上下文没有意义。对于 user 对象使用单个上下文会更好。

接下来,让我们看看另一个解决方案。

使用 useReducer 创建一个状态并通过多个上下文传播

第二种解决方案是创建一个单一的状态,并使用多个上下文来分配状态片段。在这种情况下,应该使用一个单独的上下文来分配更新状态的函数。

以下示例基于useReducer。它有三个上下文;两个用于状态片段,最后一个用于分发函数。

首先,我们为两个计数创建两个值上下文,并为将用于更新两个计数的分发函数创建一个上下文,如下所示:

type Action = { type: "INC1" } | { type: "INC2" };

const Count1Context = createContext<number>(0);
const Count2Context = createContext<number>(0);
const DispatchContext = createContext<Dispatch<Action>>(
  () => {}
);

在这种情况下,如果我们有更多的计数,我们将创建更多的计数上下文,但分发上下文将保持只有一个。

我们将在本例的后面定义分发函数的 reducer。

接下来,我们定义一个使用两个上下文的Counter1组件——一个用于值,另一个用于分发函数,如下所示:

const Counter1 = () => {
  const count1 = useContext(Count1Context);
  const dispatch = useContext(DispatchContext);
  return (
    <div>
      Count1: {count1}
      <button onClick={() => dispatch({ type: "INC1" })}>
        +1
      </button>
    </div>
  );
};

Counter1组件从Count1Context读取count1

我们定义一个Counter2组件,它与Counter1类似,只是从不同的上下文中读取count2。代码如下所示:

const Counter2 = () => {
  const count2 = useContext(Count2Context);
  const dispatch = useContext(DispatchContext);
  return (
    <div>
      Count2: {count2}
      <button onClick={() => dispatch({ type: "INC2" })}>
        +1
      </button>
    </div>
  );
};

Counter1Counter2组件都使用相同的DispatchContext上下文。

如下所示,Parent组件与之前的例子相同:

const Parent = () => (
  <div>
    <Counter1 />
    <Counter1 />
    <Counter2 />
    <Counter2 />
  </div>
);

现在,我们定义一个在本例中独特的Provider组件。Provider组件使用useReducer。reducer 函数处理两种动作类型——INC1INC2Provider组件包括我们之前定义的三个上下文的提供者。代码如下所示:

const Provider = ({ children }: { children: ReactNode }) => {
  const [state, dispatch] = useReducer(
    (
      prev: { count1: number; count2: number },
      action: Action
    ) => {
      if (action.type === "INC1") {
        return { ...prev, count1: prev.count1 + 1 };
      }
      if (action.type === "INC2") {
        return { ...prev, count2: prev.count2 + 1 };
      }
      throw new Error("no matching action");
    },
    {
      count1: 0,
      count2: 0,
    }
  );
  return (
    <DispatchContext.Provider value={dispatch}>
      <Count1Context.Provider value={state.count1}>
        <Count2Context.Provider value={state.count2}>
          {children}
        </Count2Context.Provider>
      </Count1Context.Provider>
    </DispatchContext.Provider>
  );
};

由于 reducer 的存在,代码稍微长了一些,它可能更复杂。重点是嵌套提供者,提供每个状态片段和一个分发函数。

最后,App组件中只包含Provider组件和Parent组件,如下面的代码片段所示:

const App = () => (
  <Provider>
    <Parent />
  </Provider>
);

本例也不受额外重新渲染限制的影响;在状态中更改count1只会触发Counter1重新渲染,而Counter2不受影响。

使用单个状态而不是使用多个状态在上一个示例中的好处是,单个状态可以通过一个动作更新多个片段。例如,你可以在 reducer 中添加如下内容:

      if (action.type === "INC_BOTH") {
        return {
          ...prev,
          count1: prev.count1 + 1,
          count2: prev.count2 + 1,
        };
      }

如我们在第一个解决方案中讨论的那样,在这个解决方案中也可以为对象(例如user对象)创建上下文。

在本节中,我们学习了两种使用上下文处理全局状态的方法。它们是典型的方法,但会有很多变体。关键是使用多个上下文来避免额外的重新渲染。在下一节中,我们将学习一些基于多个上下文处理全局状态的最佳实践。

使用上下文的最佳实践

在本节中,我们将学习三种处理全局状态上下文的模式,如下所示:

  • 创建自定义钩子和提供者组件

  • 带有自定义钩子的工厂模式

  • 使用reduceRight避免提供者嵌套

让我们逐一看看。

创建自定义钩子和提供者组件

在本章前面的示例中,我们直接使用useContext来获取上下文值。现在,我们将显式创建自定义钩子来访问上下文值以及提供者组件。这允许我们隐藏上下文并限制它们的用法。

下面的示例创建了自定义钩子和提供者组件。我们设置默认上下文值为null并在自定义钩子中检查该值是否为null。这检查自定义钩子是否在提供者下使用。

我们首先做的事情,就像往常一样,是创建一个上下文;这次,上下文的默认值是null,这表示默认值不能使用,并且总是需要提供者。代码在下面的代码片段中展示:

type CountContextType = [
  number,
  Dispatch<SetStateAction<number>>
];

const Count1Context = createContext<
  CountContextType | null
>(null);

然后,我们定义Count1Provider,使用useState创建一个状态并将其传递给Count1Context.Provider,如下面的代码片段所示:

export const Count1Provider = ({
  children
}: {
  children: ReactNode
}) => (
  <Count1Context.Provider value={useState(0)}>
    {children}
  </Count1Context.Provider>
);

注意,我们在const [count, setCount] = useState(0);return <Count1Context.Provider value={[count, setCount]}>一行中使用了useState(0)

接下来,我们定义一个useCount1钩子来从Count1Context返回一个值。在这里,我们检查从上下文值中抛出的null会引发一个有意义的错误。开发者经常犯错误,有明确的错误会使我们更容易检测到错误。代码在下面的代码片段中展示:

export const useCount1 = () => {
  const value = useContext(Count1Context);
  if (value === null) throw new Error("Provider missing");
  return value;
};

接着,我们创建Count2Context,定义一个Count2Provider组件和一个useCount2钩子(它们与Count1ContextCount1ProvideruseCount1相同,只是名称不同)。代码在下面的代码片段中展示:

const Count2Context = createContext<
  CountContextType | null
>(null);
export const Count2Provider = ({
  children
}: {
  children: ReactNode
}) => (
  <Count2Context.Provider value={useState(0)}>
    {children}
  </Count2Context.Provider>
);
export const useCount2 = () => {
  const value = useContext(Count2Context);
  if (value === null) throw new Error("Provider missing");
  return value;
};

接下来,我们定义一个Counter1组件来使用count1状态并显示计数和一个按钮。注意,在下面的代码片段中,这个组件不知道上下文的存在,它在useCount1钩子中被隐藏:

const Counter1 = () => {
  const [count1, setCount1] = useCount1();
  return (
    <div>
      Count1: {count1}
      <button onClick={() => setCount1((c) => c + 1)}>
        +1
      </button>
    </div>
  );
};

同样,我们定义一个Counter2组件,如下所示:

const Counter2 = () => {
  const [count2, setCount2] = useCount2();
  return (
    <div>
      Count2: {count2}
      <button onClick={() => setCount2((c) => c + 1)}>
        +1
      </button>
    </div>
  );
};

注意,Counter2组件几乎与Counter1组件相同。主要区别在于Counter2组件使用useCount2钩子而不是useCount1钩子。

我们定义一个Parent组件,该组件定义了之前定义的Counter1Counter2,如下所示:

const Parent = () => (
  <div>
    <Counter1 />
    <Counter1 />
    <Counter2 />
    <Counter2 />
  </div>
);

最后,定义一个App组件来完成示例。它用两个提供者组件包裹Parent组件,如下所示:

const App = () => (
  <Count1Provider>
    <Count2Provider>
      <Parent />
    </Count2Provider>
  </Count1Provider>
);

尽管这个片段不是很明确,我们通常为每个上下文有一个单独的文件,例如contexts/count1.jsx,并只导出自定义钩子,如useCount1和提供者组件,如Count1Provider。在这种情况下,Count1Context没有被导出。

带有自定义钩子的工厂模式

创建自定义钩子和提供者组件是一项相对重复的任务;然而,我们可以创建一个函数来完成这项任务。

下面的示例展示了createStateContext的具体实现。

createStateContext 函数接受一个 useValue 自定义钩子,该钩子接受一个初始值并返回一个状态。如果你使用 useState,它返回一个包含 state 值和 setState 函数的元组。createStateContext 函数返回一个包含提供者组件和用于获取状态的自定义钩子的元组。这是我们之前章节中学到的模式。

此外,这还提供了一个新特性;提供者组件接受一个可选的 initialValue 属性,该属性传递给 useValue。这允许你在运行时设置状态的初始值,而不是在创建时定义初始值。代码在下面的片段中展示:

const createStateContext = (
  useValue: (init) => State,
) => {
  const StateContext = createContext(null);
  const StateProvider = ({
    initialValue,
    children,
  }) => (
    <StateContext.Provider value={useValue(initialValue)}>
      {children}
    </StateContext.Provider>
  );
  const useContextState = () => {
    const value = useContext(StateContext);
    if (value === null) throw new Error("Provider
      missing");
    return value;
  };
  return [StateProvider, useContextState] as const;
};

现在,让我们看看如何使用 createStateContext。我们定义一个自定义钩子,useNumberState;它接受一个可选的 init 参数。然后我们像下面这样使用 useState

const useNumberState = (init) => useState(init || 0);

通过将 useNumberState 传递给 createStateContext,我们可以创建任意数量的状态上下文;我们创建了两组。useCount1useCount2 的类型是从 useNumberState 推断出来的。代码在下面的片段中展示:

const [Count1Provider, useCount1] =
  createStateContext(useNumberState);
const [Count2Provider, useCount2] =
  createStateContext(useNumberState);

注意我们通过 createStateContext 避免了重复的定义。

然后,我们定义 Counter1Counter2 组件。使用 useCount1useCount2 的方式与上一个示例相同,如下代码片段所示:

const Counter1 = () => {
  const [count1, setCount1] = useCount1();
  return (
    <div>
      Count1: {count1}
      <button onClick={() => setCount1((c) => c + 1)}>
        +1
      </button>
    </div>
  );
};
const Counter2 = () => {
  const [count2, setCount2] = useCount2();
  return (
    <div>
      Count2: {count2}
      <button onClick={() => setCount2((c) => c + 1)}>
        +1
      </button>
    </div>
  );
};

最后,我们创建 ParentApp 组件。使用 Count1ProviderCount2Provider 的方式也是相同的,如下所示:

const Parent = () => (
  <div>
    <Counter1 />
    <Counter1 />
    <Counter2 />
    <Counter2 />
  </div>
);
const App = () => (
  <Count1Provider>
    <Count2Provider>
      <Parent />
    </Count2Provider>
  </Count1Provider>
);

注意我们是如何从上一个示例中减少代码的。createStateContext 的整个目的就是为了避免重复代码并提供相同的功能。

与使用 useStateuseNumberState 不同,我们可以使用 useReducer 创建自定义钩子,如下所示:

const useMyState = () => useReducer({}, (prev, action) => {
  if (action.type === 'SET_FOO') {
    return { ...prev, foo: action.foo };
  }
  // ...
};

我们也可以创建一个更复杂的钩子。以下示例有 inc1inc2 自定义动作函数。它使用 useEffect 在控制台显示更新的日志:

const useMyState = (initialState = { count1: 0, count2: 0 }) => {
  const [state, setState] = useState(initialState);
  useEffect(() => {
    console.log('updated', state);
  });
  const inc1 = useCallback(() => {
    setState((prev) => ({
      ...prev,
      count1: prev.count1 + 1
    }));
  }, []);
  const inc2 = useCallback(() => {
    setState((prev) => ({
      ...prev,
      count2: prev.count2 + 1
    }));
  }, []);
  return [state, { inc1, inc2 }];
};

我们仍然可以使用 createStateContext 函数为这些 useMyState 钩子和任何其他自定义钩子。

值得注意的是,这种工厂模式在 TypeScript 中工作得很好。TypeScript 通过类型提供额外的检查,开发者可以从类型检查中获得更好的体验。下面的代码片段展示了 createStateContextuseNumberState 的类型化版本:

const createStateContext = <Value, State>(
  useValue: (init?: Value) => State
) => {
  const StateContext = createContext<State | null>(null);
  const StateProvider = ({
    initialValue,
    children,
  }: {
    initialValue?: Value;
    children?: ReactNode;
  }) => (
    <StateContext.Provider value={useValue(initialValue)}>
      {children}
    </StateContext.Provider>
  );
  const useContextState = () => {
    const value = useContext(StateContext);
    if (value === null){ 
     throw new Error("Provider missing");
    }
    return value;
  };
  return [StateProvider, useContextState] as const;
};
const useNumberState = (init?: number) => useState(init || 0);

如果我们使用 createStateContextuseNumberState 的类型化版本,结果也将是类型化的。

使用 reduceRight 避免提供者嵌套

使用 createStateContext 函数,创建多个状态非常容易。假设我们创建了五个,如下所示:

const [Count1Provider, useCount1] =
  createStateContext(useNumberState);
const [Count2Provider, useCount2] =
  createStateContext(useNumberState);
const [Count3Provider, useCount3] =
  createStateContext(useNumberState);
const [Count4Provider, useCount4] =
  createStateContext(useNumberState);
const [Count5Provider, useCount5] =
  createStateContext(useNumberState);

我们的 App 组件将看起来像这样:

const App = () => (
  <Count1Provider initialValue={10}>
    <Count2Provider initialValue={20}>
      <Count3Provider initialValue={30}>
        <Count4Provider initialValue={40}>
          <Count5Provider initialValue={50}>
            <Parent />
          </Count5Provider>
        </Count4Provider>
      </Count3Provider>
    </Count2Provider>
  </Count1Provider>
);

这完全正确,并且捕捉到了组件树的结构。然而,过多的嵌套在编码时并不舒适。为了减轻这种编码风格,我们可以使用 reduceRightApp 组件可以被重构,如下面的示例所示:

const App = () => {
  const providers = [
    [Count1Provider, { initialValue: 10 }],
    [Count2Provider, { initialValue: 20 }],
    [Count3Provider, { initialValue: 30 }],
    [Count4Provider, { initialValue: 40 }],
    [Count5Provider, { initialValue: 50 }],
  ] as const;
  return providers.reduceRight(
    (children, [Component, props]) =>
      createElement(Component, props, children),
    <Parent />,
  );
};

这种技术的变体可能包括创建一个 reduceRight 来构建提供者树。

这种技术不仅适用于使用 Context 的全局状态,也适用于任何组件。

在本节中,我们学习了一些与使用 Context 的全局状态相关的最佳实践。这些并不是你必须遵循的规则。只要你能理解 Context 的行为及其限制,任何模式都可以很好地工作。

摘要

在本章中,我们学习了如何使用 React Context 创建全局状态。Context 的传播旨在避免传递 props。如果你正确理解了 Context 的行为,使用 Context 实现全局状态将非常简单。基本上,我们应该为每个状态部分创建一个 Context,以避免额外的重新渲染。一些最佳实践将有助于使用 Context 实现全局状态,特别是 createStateContext 的具体实现,这将有助于组织你的应用代码。

在下一章中,我们将学习另一种使用订阅实现全局状态的模式。

第四章:通过订阅共享模块状态

在上一章中,我们学习了如何使用 Context 实现全局状态。正如讨论的那样,Context 并非为单例模式而设计;它是一个避免单例模式并提供不同子树不同值的机制。对于类似全局状态的单例,使用模块状态更有意义,因为它在内存中是一个单例值。本章的目标是学习如何使用 React 中的模块状态。它比 Context 少为人知,但经常用于集成现有的模块状态。

什么是模块状态?

模块状态的严格定义是一些在 ECMAScriptES) 模块作用域中定义的常量或变量。在这本书中,我们并不遵循严格的定义。你可以简单地假设模块状态是一个在全局范围内或在文件作用域内定义的变量。

我们将探讨如何在 React 中将模块状态用作全局状态。为了在 React 组件中使用模块状态,我们使用订阅机制。

在本章中,我们将涵盖以下主题:

  • 探索模块状态

  • 在 React 中使用模块状态作为全局状态

  • 添加基本订阅

  • 使用选择器和 useSubscription

技术要求

预期你具备一定的 React 知识,包括 React Hooks。请参考官方网站 reactjs.org 了解更多信息。

在某些代码中,我们使用 TypeScript (www.typescriptlang.org),你应该对此有基本的了解。

本章的代码可在 GitHub 上找到:

github.com/PacktPublishing/Micro-State-Management-with-React-Hooks/tree/main/chapter_04

要运行代码片段,你需要一个 React 环境,例如,Create React App (create-react-app.dev) 或 CodeSandbox (codesandbox.io)。

探索模块状态

模块状态是在模块级别定义的变量。这里的 Module 指的是 ES 模块或只是一个文件。为了简化,我们假设在函数外部定义的变量是模块状态。

例如,让我们定义 count 状态:

let count = 0;

假设这是在模块中定义的,这是一个模块状态。

通常,使用 React,我们希望有一个对象状态。以下定义了一个包含 count 的对象状态:

let state = {
  count: 0,
};

可以向对象添加更多属性。嵌套对象也是可能的。

现在,让我们定义函数来访问这个模块状态。getState 是一个读取 state 的函数,而 setState 是一个写入 state 的函数:

export const getState = () => state;
export const setState = (nextState) => {
  state = nextState;
};

注意,我们为这些函数添加了 export,以表达它们预期将在模块外部使用。

在 React 中,我们经常使用函数更新状态。让我们修改 setState 以允许使用 function 更新:

export const setState = (nextState) => {
  state = typeof nextState === 'function'
    ? nextState(state) : nextState;
};

你可以使用以下方式使用函数更新:

setState((prevState) => ({
  ...prevState,
  count: prevState.count + 1
}));

我们可以直接定义模块状态,而不是创建一个用于创建包含 state 和一些访问函数的容器的函数。

以下是这样函数的具体实现:

export const createContainer = (initialState) => {
  let state = initialState;
  const getState = () => state;
  const setState = (nextState) => {
    state = typeof nextState === 'function'
      ? nextState(state) : nextState;
  };
  return { getState, setState };
};

你可以这样使用它:

import { createContainer } from '...';
const { getState, setState } = createContainer({
  count: 0
});

到目前为止,模块状态与 React 没有任何关系。在下一节中,我们将学习如何使用模块状态与 React 一起使用。

在 React 中使用模块状态作为全局状态

正如我们在 第三章 中讨论的,使用 Context 共享组件状态,React Context 被设计为为不同的子树提供不同的值。使用 React Context 来实现单例全局状态是一个有效的操作,但它没有使用 Context 的全部功能。

如果你需要一个整个树的全球状态,模块状态可能更适合。然而,要在 React 组件中使用模块状态,我们需要自己处理重新渲染。

让我们从简单的例子开始。不幸的是,这是一个无效的例子:

let count = 0;
const Component1 = () => {
  const inc = () => {
    count += 1;
  }
  return (
    <div>{count} <button onClick={inc}>+1</button></div>
  );
};

你会在开始时看到 count0。点击 button 会增加 count 变量,但它不会触发组件重新渲染。

在撰写这本书的时候,React 只有 useStateuseReducer 两个 hooks 来触发重新渲染。我们需要使用这两个中的一个来使组件具有模块状态的反应性。

以下是对前一个示例的修改:

let count = 0;
const Component1 = () => {
  const [state, setState] = useState(count);
  const inc = () => {
    count += 1;
    setState(count);
  }
  return (
    <div>{state} <button onClick={inc}>+1</button></div>
  );
};

现在,如果你点击 button,它将增加 count 变量,并触发组件。

让我们看看如果我们有另一个如下所示的组件会发生什么:

const Component2 = () => {
  const [state, setState] = useState(count);
  const inc2 = () => {
    count += 2;
    setState(count);
  }
  return (
    <div>{state} <button onClick={inc2}>+2</button></div>
  );
};

即使你在 Component1 中点击 button,它也不会触发 Component2 重新渲染。只有当你点击 Component2 中的 button 时,它才会重新渲染并显示最新的模块状态。这是 Component1Component2 之间的不一致,我们的期望是两个组件都应该显示相同的值。两个 Component1 组件之间也会发生这种不一致。

解决这个问题的天真方法是同时在 Component1Component2 中调用 setState 函数。这需要在模块级别拥有 setState 函数。我们还应该考虑组件的生命周期,并使用 useEffect hook 来修改一个包含 setState 函数的集合,这些函数位于 React 之外。

以下是一个可能的解决方案示例。这是为了说明这个想法,并不非常实用:

let count = 0;
const setStateFunctions =
  new Set<(count: number) => void>();
const Component1 = () => {
  const [state, setState] = useState(count);
  useEffect(() => {
    setStateFunctions.add(setState);
    return () => { setStateFunctions.delete(setState); };
  }, []);
  const inc = () => {
    count += 1;
    setStateFunctions.forEach((fn) => {
      fn(count);
    });
  }
  return (
    <div>{state} <button onClick={inc}>+1</button></div>
  );
};

注意我们在 useEffect 中返回一个函数来清理效果。在 inc 函数中,我们调用 setStateFunctions 集合中的所有 setState 函数。

现在,Component2 也会像 Component1 一样被修改:

const Component2 = () => {
  const [state, setState] = useState(count);
  useEffect(() => {
    setStateFunctions.add(setState);
    return () => { setStateFunctions.delete(setState); };
  }, []);
  const inc2 = () => {
    count += 2;
    setStateFunctions.forEach((fn) => {
      fn(count);
    });
  }
  return (
    <div>{state} <button onClick={inc2}>+2</button></div>
  );
};

正如所提到的,这不是一个非常实用的解决方案。我们在 Component1Component2 中有一些重复的代码。

在下一节中,我们将介绍一个订阅机制并减少重复的代码。

添加基本订阅

在这里,我们将了解订阅机制以及如何将模块状态连接到 React 状态。

订阅是一种获取通知的方式,例如更新。一个典型的订阅使用情况如下:

const unsubscribe = store.subscribe(() => {
  console.log('store is updated');
});

在这里,我们假设一个store变量有一个subscribe方法,它接受一个callback函数并返回一个unsubscribe函数。

在这种情况下,预期的行为是每当store更新时,回调函数就会被调用,并显示控制台日志。

现在,让我们实现一个带有订阅的模块状态。我们将它称为store,它除了包含state值和subscribe方法外,还包括我们在探索模块状态部分中描述的getStatesetState方法。createStore是一个函数,用于使用初始状态值创建store

type Store<T> = {
  getState: () => T;
  setState: (action: T | ((prev: T) => T)) => void;
  subscribe: (callback: () => void) => () => void;
};

const createStore = <T extends unknown>(
  initialState: T
): Store<T> => {
  let state = initialState;
  const callbacks = new Set<() => void>();
  const getState = () => state;
  const setState = (nextState: T | ((prev: T) => T)) => {
    state =
      typeof nextState === "function"
        ? (nextState as (prev: T) => T)(state)
        : nextState;
    callbacks.forEach((callback) => callback());
  };
  const subscribe = (callback: () => void) => {
    callbacks.add(callback);
    return () => {
      callbacks.delete(callback);
    };
  };
  return { getState, setState, subscribe };
}; 

与我们在探索模块状态部分中实现的createContainer函数相比,createStoresubscribe方法和setState方法,它调用回调。

我们如下使用createStore

import { createStore } from '...';
const store = createStore({ count: 0 });
console.log(store.getState());
store.setState({ count: 1 });
store.subscribe(...);

store变量在其内部持有state,整个store变量可以看作是一个模块状态。

接下来是store变量在 React 中的使用。

我们定义一个新的钩子useStore,它将返回一个包含store状态值及其更新函数的元组:

const useStore = (store) => {
  const [state, setState] = useState(store.getState());
  useEffect(() => {
    const unsubscribe = store.subscribe(() => {
      setState(store.getState());
    });
    setState(store.getState()); // [1]
    return unsubscribe;
  }, [store]);
  return [state, store.setState];
};

你可能会在useEffect中看到一次setState()函数。这是因为useEffect是延迟的,并且有可能store已经有了新的状态。

下面的是一个带有useStore的组件:

const Component1 = () => {                            
  const [state, setState] = useStore(store);  
  const inc = () => {
    setState((prev) => ({                       
      ...prev,
      count: prev.count + 1,                   
    }));                    
  };              
  return (                       
    <div>
      {state.count} <button onClick={inc}>+1</button>
    </div>                                 
  );               
};

重要的是要不可变地更新模块状态,就像 React 状态一样,因为模块状态最终会设置在 React 状态中:

Component1类似,我们定义另一个,Component2,如下所示:

const Component2 = () => {
  const [state, setState] = useStore(store);
  const inc2 = () => {
    setState((prev) => ({
      ...prev,
      count: prev.count + 2,
    }));
  };
  return (
    <div>
      {state.count} <button onClick={inc2}>+2</button>
    </div>
  );
};

两个组件中的两个按钮都会更新store中的模块状态和两个组件中的状态是共享的。

最后,我们定义App组件:

const App = () => (
  <>
    <Component1 />
    <Component2 />
  </>
);

当你运行这个应用程序时,你会看到类似图 4.1的内容。如果你点击+1+2按钮,你会看到两个计数(显示为3)一起更新:

图 4.1 – 运行中的应用程序截图

图 4.1 – 运行中的应用程序截图

在本节中,我们使用订阅将模块状态连接到 React 组件。

在下一节中,我们将使用选择器函数仅使用状态的一部分,以及学习如何使用useSubscription

使用选择器和 useSubscription 一起工作

我们在上一节中创建的useStore钩子返回一个整个状态对象。这意味着状态对象的任何小部分变化都会通知所有useStore钩子,这可能导致额外的重新渲染。

为了避免额外的重新渲染,我们可以引入一个选择器来返回组件感兴趣的状态的一部分。

让我们先开发useStoreSelector

我们使用与上一节中定义的相同的createStore函数,并创建一个store变量,如下所示:

const store = createStore({ count1: 0, count2: 0 });

store中的状态有两个计数器 – count1count2

useStoreSelector 钩子类似于 useStore,但它接收一个额外的选择器函数。它使用选择器函数来限定状态:

const useStoreSelector = <T, S>(
  store: Store<T>,
  selector: (state: T) => S
) => {
  const [state, setState] =
    useState(() => selector(store.getState()));
  useEffect(() => {
    const unsubscribe = store.subscribe(() => {
      setState(selector(store.getState()));
    });
    setState(selector(store.getState()));
    return unsubscribe;
  }, [store, selector]);
  return state;
};

useStore 相比,useStoreSelector 中的 useState 钩子持有 selector 的返回值而不是整个状态。

现在我们定义一个组件来使用 useStoreSelectoruseStoreSelector 的返回值是一个计数器。为了更新状态,在这种情况下我们直接调用 store.setState()Component1 是一个用于在状态中显示 count1 的组件:

const Component1 = () => {
  const state = useStoreSelector(
    store,
    useCallback((state) => state.count1, []),
  );
  const inc = () => {
    store.setState((prev) => ({
      ...prev,
      count1: prev.count1 + 1,
    }));
  };
  return (
    <div>
      count1: {state} <button onClick={inc}>+1</button>
    </div>
  );
};

注意我们需要使用 useCallback 来获取一个稳定的选择器函数。否则,由于选择器指定在 useEffect 的第二个参数中,每次 Component1 渲染时,Component1 将会订阅 store 变量。

我们定义 Component2,用于显示 count2 而不是 count1。我们定义一个选择器函数在组件外部以避免这次使用 useCallback

const selectCount2 = (
  state: ReturnType<typeof store.getState>
) => state.count2;
const Component2 = () => {
  const state = useStoreSelector(store, selectCount2);
  const inc = () => {
    store.setState((prev) => ({
      ...prev,
      count2: prev.count2 + 1,
    }));
  };
  return (
    <div>
      count2: {state} <button onClick={inc}>+1</button>
    </div>
  );
};

最后,App 组件为每个 Component1 组件和 Component2 组件渲染两个组件以进行演示:

const App = () => (
  <>
    <Component1 />
    <Component1 />
    <Component2 />
    <Component2 />
  </>
);

图 4.2 是运行中的应用程序截图:

图 4.2 – 运行中的应用程序截图

图 4.2 – 运行中的应用程序截图

前图中的前两行是由 Component1 渲染的。如果你点击前两个 count1 中的任何一个,这将触发 Component1 重新渲染。然而,Component2(图 4.2 中的最后两行)不会重新渲染,因为 count2 没有改变。

虽然 useStoreSelector 钩子工作良好且在生产环境中可用,但当 storeselector 发生变化时,有一个需要注意的问题。因为 useEffect 会在稍后触发,它将返回一个过时的状态值,直到重新订阅完成。我们可以自己修复它,但这需要一点技术知识。

幸运的是,React 团队为这种情况提供了一个官方的钩子。它被称为 use-subscription (www.npmjs.com/package/use-subscription )。

让我们使用 useSubscription 重新定义 useStoreSelector。代码如下简单:

const useStoreSelector = (store, selector) => useSubscription(
  useMemo(() => ({
    getCurrentValue: () => selector(store.getState()),
    subscribe: store.subscribe,
  }), [store, selector])
);

应用程序仍然可以使用这个更改运行。

我们可以避免在 Component1 中使用 useStoreSelector 钩子,并直接使用 useSubscription

const Component1 = () => {
  const state = useSubscription(useMemo(() => ({
    getCurrentValue: () => store.getState().count1,
    subscribe: store.subscribe,
  }), []));
  const inc = () => {
    store.setState((prev) => ({
      ...prev,
      count1: prev.count1 + 1,
    }));
  };
  return (
    <div>
      count1: {state} <button onClick={inc}>+1</button>
    </div>
  );
};

在这种情况下,因为已经使用了 useMemo,所以不需要 useCallback

useSubscription 和 useSyncExternalStore

在 React 的未来版本中,将包含一个名为 useSyncExternalStore 的钩子。这是 useSubscription 的继任者。因此,使用模块状态将变得更加容易访问 (github.com/reactwg/react-18/discussions/86 )。

在本节中,我们学习了如何使用选择器来限定状态,以及官方的 useSubscription 钩子以获得更具体的解决方案。

摘要

在本章中,我们学习了如何创建模块状态并将其集成到 React 中。利用我们所学的内容,你可以将模块状态用作 React 中的全局状态。订阅在集成中扮演着重要的角色,因为它允许在模块状态改变时触发组件的重新渲染。除了在 React 中使用模块状态的基本订阅实现外,还有一个官方包。基本订阅和官方包都适用于生产环境的使用案例。

在下一章中,我们将学习实现全局状态的第三种模式,它是由第一种模式和第二种模式相结合而成的。

第五章: 使用上下文和订阅共享组件状态

在前两章中,我们学习了如何使用上下文和订阅来实现全局状态。每个都有不同的好处:上下文允许我们为不同的子树提供不同的值,而订阅可以防止额外的重新渲染。

在本章中,我们将学习一种新的方法:结合 React 上下文和订阅。这种结合将给我们带来各自的好处,这意味着:

  • 上下文可以为子树提供一个全局状态,并且上下文提供者可以嵌套。上下文允许我们在 React 组件的生命周期中控制全局状态,就像useState钩子一样。

  • 另一方面,订阅允许我们控制重新渲染,这是单个上下文无法实现的。

结合两者的好处可以是大应用的一个好解决方案——因为,如前所述,这意味着我们可以在不同的子树中拥有不同的值,我们还可以避免额外的重新渲染。

这种方法对于中等到大型应用很有用。在这些应用中,不同的子树可能具有不同的值,我们可以避免额外的重新渲染,这对我们的应用来说可能非常重要。

在本章中,我们将涵盖以下主题:

  • 探索模块状态的局限性

  • 理解何时使用上下文

  • 实现上下文和订阅模式

技术要求

预期你具备一定的 React 知识,包括 React Hooks。请参考官方网站reactjs.org以了解更多信息。

在某些代码中,我们使用 TypeScript (www.typescriptlang.org),你应该对其有基本了解。

本章中的代码可在 GitHub 上找到:github.com/PacktPublishing/Micro-State-Management-with-React-Hooks/tree/main/chapter_05

要运行代码片段,你需要一个 React 环境,例如,Create React App (create-react-app.dev)或 CodeSandbox (codesandbox.io)。

探索模块状态的局限性

因为模块状态位于 React 组件之外,存在一个限制:全局定义的模块状态是单例的,你不能为不同的组件树或子树有不同的状态。

让我们回顾一下第四章中关于使用订阅共享模块状态createStore实现:

const createStore = (initialState) => {
  let state = initialState;
  const callbacks = new Set();
  const getState = () => state;
  const setState = (nextState) => {
    state = typeof nextState === 'function'
      ? nextState(state) : nextState;
    callbacks.forEach((callback) => callback());
  };
  const subscribe =(callback) => {
    callbacks.add(callback);
    return () => { callbacks.delete(callback); };
  };
  return { getState, setState, subscribe };
};

使用这个createStore,让我们定义一个新的store。我们定义一个具有count属性的store

const store = createStore({ count: 0 });

注意,这个store是在 React 组件外部定义的。

要在 React 组件中使用store,我们使用useStore。以下是一个示例,其中包含两个组件,它们显示了来自同一store变量的共享计数。我们使用useStore,它是在第四章中定义的,使用订阅共享模块状态

const Counter = () => {
  const [state, setState] = useStore(store);
  const inc = () => {
    setState((prev) => ({                       
      ...prev,
      count: prev.count + 1,                   
    }));                    
  };              
  return (                       
    <div>
      {state.count} <button onClick={inc}>+1</button>
    </div>                                 
  );               
};
const Component = () => (
  <>
    <Counter />
    <Counter />
  </>
);

我们有一个Counter组件,用于在store对象中显示count数字,以及一个button来更新count值。由于这个Counter组件是可重用的,Component可以有两个Counter实例。这将显示一对共享相同状态的计数器。

现在,假设我们想显示另一对计数器。我们希望在Component中有两个新的组件,但新的一对应该显示与第一组不同的计数器。

让我们创建一个新的count值。我们可以在已经定义的store对象中添加一个新的属性,但我们假设还有其他属性,并希望隔离存储。因此,我们创建store2

const store2 = createStore({ count: 0 })

由于createStore是可重用的,创建一个新的store2对象很简单。

然后,我们需要创建组件来使用store2

const Counter2 = () => {
  const [state, setState] = useStore(store2);
  const inc = () => {
    setState((prev) => ({                       
      ...prev,
      count: prev.count + 1,                   
    }));                    
  };              
  return (                       
    <div>
      {state.count} <button onClick={inc}>+1</button>
    </div>                                 
  );               
};
const Component2 = () => (
  <>
    <Counter2 />
    <Counter2 />
  </>
);

你可能会注意到CounterCounter2之间的相似性——它们都是 14 行代码,唯一的区别是它们引用的store变量——Counter使用store,而Counter2使用store2。我们需要Counter3Counter4来支持更多的存储。理想情况下,Counter应该是可重用的。但是,由于模块状态是在 React 外部定义的,所以这是不可能的。这是模块状态的限制。

重要提示

你可能会注意到,如果我们把store放在props中,就可以使Counter组件可重用。然而,这将需要在组件深层嵌套时进行属性钻取,而引入模块状态的主要原因是避免属性钻取。

很好地重用Counter组件来为不同的存储提供支持。伪代码如下:

const Component = () => (
  <StoreProvider>
    <Counter />
    <Counter />
  </StoreProvider>
);
const Component2 = () => (
  <Store2Provider>
    <Counter />
    <Counter />
  </Store2Provider>
);
const Component3 = () => (
  <Store3Provider>
    <Counter />
    <Counter />
  </Store3Provider>
);

如果你查看代码,你会注意到ComponentComponent2Component3几乎相同。唯一的区别是Provider组件。这正是 React Context 发挥作用的地方。我们将在实现上下文和订阅模式部分详细讨论这一点。

现在你已经理解了模块状态的限制和多个存储的理想模式。接下来,我们将回顾 React Context 并探讨上下文的使用。

理解何时使用上下文

在深入学习如何结合上下文和订阅之前,让我们回顾一下上下文是如何工作的。

以下是一个简单的带有主题的 Context 示例。因此,我们为createContext指定一个默认值:

const ThemeContext = createContext("light");
const Component = () => {
  const theme = useContext(ThemeContext);
  return <div>Theme: {theme}</div>
};

useContext(ThemeContext)返回的内容取决于组件树中的上下文。

要更改上下文值,我们使用 Context 中的Provider组件如下:

<ThemeContext.Provider value="dark">
  <Component />
</ThemeContext.Provider>

在这种情况下,Component将显示主题为dark

提供者可以嵌套。它将使用最内层提供者的值:

<ThemeContext.Provider value="this value is not used">
  <ThemeContext.Provider value="this value is not used">
    <ThemeContext.Provider value="this is the value used">
      <Component />
    </ThemeContext.Provider>
  </ThemeContext.Provider>
</ThemeContext.Provider>

如果组件树中没有提供者,它将使用默认值。

例如,在这里,我们假设Root是一个根组件:

const Root = () => (
  <>
    <Component />
  </>
);

在这种情况下,Component也将显示主题为light

让我们看看一个示例,它有一个提供者在根处提供相同的默认值:

const Root = () => (
  <ThemeContext.Provider value="light">
    <Component />
  </ThemeContext.Provider>
);

在这种情况下,Component也将显示主题为light

因此,让我们讨论何时使用 Context。为此,考虑我们的示例:有提供者和没有提供者的这个示例之间有什么区别?我们可以这样说,没有区别。使用默认值会得到相同的结果。

为 Context 设置适当的默认值非常重要。Context 提供者可以被视为一种覆盖默认 Context 值或父提供者(如果存在)提供值的方法。

ThemeContext 的情况下,如果我们有适当的默认值,那么使用提供者的意义何在?将需要为整个组件树的一个子树提供不同的值。否则,我们只需使用 Context 的默认值。

对于使用 Context 的全局状态,你可能在根处只能使用一个提供者。这是一个有效的用例,但这个用例可以通过我们在第四章 使用 Subscription 共享模块状态中学到的模块状态来覆盖。鉴于模块状态涵盖了根处只有一个 Context 提供者的用例,因此,如果需要为不同的子树提供不同的值,才需要全局状态的 Context。

在本节中,我们回顾了 React Context 的使用,并学习了何时使用它。接下来,我们将学习如何结合 Context 和 Subscription。

实现 Context 和 Subscription 模式

正如我们所学的,使用一个 Context 来传播全局状态值有一个限制:它会导致额外的重新渲染。

带有 Subscription 的模块状态没有这样的限制,但还有一个限制:它只为整个组件树提供一个值。

我们希望结合 Context 和 Subscription 来克服两者的限制。让我们实现这个功能。我们将从 createStore 开始。这正是我们在第四章 使用 Subscription 共享模块状态中开发的实现:

type Store<T> = {
  getState: () => T;
  setState: (action: T | ((prev: T) => T)) => void;
  subscribe: (callback: () => void) => () => void;
};

const createStore = <T extends unknown>(
  initialState: T
): Store<T> => {
  let state = initialState;
  const callbacks = new Set<() => void>();
  const getState = () => state;
  const setState = (nextState: T | ((prev: T) => T)) => {
    state =
      typeof nextState === "function"
        ? (nextState as (prev: T) => T)(state)
        : nextState;
    callbacks.forEach((callback) => callback());
  };
  const subscribe = (callback: () => void) => {
    callbacks.add(callback);
    return () => {
      callbacks.delete(callback);
    };
  };
  return { getState, setState, subscribe };
};

第四章 使用 Subscription 共享模块状态中,我们使用了 createStore 来处理模块状态。这次,我们将使用 createStore 来设置 Context 的值。

以下是为创建 Context 编写的代码。默认值传递给 createContext,我们将其称为默认存储:

type State = { count: number; text?: string };

const StoreContext = createContext<Store<State>>(
  createStore<State>({ count: 0, text: "hello" })
);

在这种情况下,默认存储具有两个属性的状态:counttext

为了为子树提供不同的存储,我们实现了 StoreProvider,它是对 StoreContext.Provider 的小型包装:

const StoreProvider = ({
  initialState,
  children,
}: {
  initialState: State;
  children: ReactNode;
}) => {
  const storeRef = useRef<Store<State>>();
  if (!storeRef.current) {
    storeRef.current = createStore(initialState);
  }
  return (
    <StoreContext.Provider value={storeRef.current}>
      {children}
    </StoreContext.Provider>
  );
};

useRef 用于确保存储对象仅在第一次渲染时初始化一次。

要使用存储对象,我们实现了一个名为 useSelector 的钩子。与在 第四章使用选择器和 useSubscription 部分定义的 useStoreSelector 不同,useSelector 不在其参数中接受 store 对象。它从 StoreContext 中获取 store 对象:

const useSelector = <S extends unknown>(
  selector: (state: State) => S
) => {
  const store = useContext(StoreContext);
  return useSubscription(
    useMemo(
      () => ({
        getCurrentValue: () => selector(store.getState()),
        subscribe: store.subscribe,
      }),
      [store, selector]
    )
  );
};

useContextuseSubscription 结合使用是这种模式的关键点。这种组合使我们能够享受到 Context 和订阅的双重优势。

与模块状态不同,我们需要提供一种使用 Context 更新状态的方法。useSetState 是一个简单的钩子,用于在 store 中返回 setState 函数:

const useSetState = () => {
  const store = useContext(StoreContext);
  return store.setState;
};

现在,让我们使用我们所实现的功能。以下是一个显示 store 中的 count 并带有用于增加 countbutton 的组件。我们在 Component 外部定义 selectCount,否则我们需要用 useCallback 包装函数,这会引入额外的工作:

const selectCount = (state: State) => state.count;
const Component = () => {
  const count = useSelector(selectCount);
  const setState = useSetState();
  const inc = () => {
    setState((prev) => ({
      ...prev,
      count: prev.count + 1,
    }));
  };
  return (
    <div>
      count: {count} <button onClick={inc}>+1</button>
    </div>
  );
};

这里需要注意的是,这个 Component 组件并不绑定到任何特定的存储对象。Component 组件可以用于不同的存储。

我们也可以在各个地方使用 Component

  • 在任何提供者外部

  • 在第一个提供者内部

  • 在第二个提供者内部

以下 App 组件在三个地方包含了 Component 组件:1) 在 StoreProvider 外面,2) 在第一个 StoreProvider 组件内部,以及 3) 在第二个嵌套的 StoreProvider 组件内部。不同 StoreProvider 组件中的 Component 组件共享不同的 count 值:

const App = () => (
  <>
    <h1>Using default store</h1>
    <Component />
    <Component />
    <StoreProvider initialState={{ count: 10 }}>
      <h1>Using store provider</h1>
      <Component />
      <Component />
      <StoreProvider initialState={{ count: 20 }}>
        <h1>Using inner store provider</h1>
        <Component />
        <Component />
      </StoreProvider>
    </StoreProvider>
  </>
);

使用相同 store 对象的每个 Component 组件将共享 store 对象并显示相同的 count 值。在这种情况下,不同组件树级别的组件使用不同的 store,因此在不同位置显示不同的 count 值。当你运行这个应用程序时,你会看到以下内容:

图 5.1 – 运行中的应用程序截图

图 5.1 – 运行中的应用程序截图

如果你点击 使用默认存储 中的 +1 按钮,你将看到 使用默认存储 中的两个计数器一起更新。如果你点击 使用存储提供者 中的 +1 按钮,你将看到 使用存储提供者 中的两个计数器一起更新。同样适用于 使用内部存储提供者

在本节中,我们学习了如何利用 Context 和订阅实现全局状态,并利用相关的优势。由于 Context 的存在,我们可以将状态隔离在子树中,并且由于订阅的存在,我们可以避免额外的重新渲染。

摘要

在本章中,我们学习了一种新的方法:结合 React Context 和 Subscription。这种方法提供了两者的好处:在子树中提供隔离的值,并避免额外的重新渲染。这种方法对于中等到大型应用非常有用。在这些应用中,不同的子树可能具有不同的值,我们可以避免额外的重新渲染,这对我们的应用可能非常重要。

从下一章开始,我们将深入探讨各种全局状态库。我们将学习这些库是如何基于我们迄今为止所学的内容构建的。

第三部分:库的实现及其用法

在本部分中,我们介绍了四个用于微状态管理的库。我们讨论了它们优化重新渲染的方法以及它们的用法。我们解释了这四个库之间的相似之处和不同之处。最后,你将学习如何根据其需求和偏好选择库。

本部分包括以下章节:

  • 第六章, 介绍全局状态库

  • 第七章, 用例场景 1 – Zustand

  • 第八章, 用例场景 2 – Jotai

  • 第九章, 用例场景 3 – Valtio

  • 第十章, 用例场景 4 – React Tracked

  • 第十一章, 三个全局状态库之间的相似之处和不同之处

第六章:介绍全局状态库

我们已经学习了用于在组件间共享状态的几种模式。本书的剩余部分将介绍使用这些模式的各种全局状态库。

在深入探讨库之前,我们将回顾与全局状态相关的挑战,并讨论库的两个方面:状态存储的位置以及如何控制重新渲染。有了这些知识,我们将能够理解全局状态库的特点。

在本章中,我们将涵盖以下主题:

  • 处理全局状态管理问题

  • 使用以数据和组件为中心的方法

  • 优化重新渲染

技术要求

预期您对 React 有一定的了解,包括 React 钩子。请参考官方网站reactjs.org以获取更多信息。

要运行代码片段,您需要一个 React 环境,例如,Create React App (create-react-app.dev)或 CodeSandbox (codesandbox.io)。

处理全局状态管理问题

React 的设计围绕组件的概念。在组件模型中,一切都被期望是可重用的。全局状态是存在于组件之外的东西。通常情况下,我们应该尽量避免使用全局状态,因为它需要一个额外的组件依赖。然而,全局状态有时非常方便,并允许我们更高效地工作。对于某些应用程序需求,全局状态非常适合。

设计全局状态时有两个挑战:

  • 第一个挑战是如何读取全局状态。

    全局状态通常包含多个值。通常情况下,使用全局状态的组件不需要其中的所有值。如果一个组件在全局状态改变时重新渲染,但改变后的值与组件无关,这将是一个额外的渲染。额外的渲染是不希望的,全局状态库应该提供解决方案。避免额外渲染有几种方法,我们将在优化重新渲染部分更详细地讨论它们。

  • 第二个挑战是如何编写或更新全局状态。

    再次强调,全局状态可能包含多个值,其中一些可能是嵌套对象。拥有一个单独的全局变量并接受任意的突变可能不是一个好主意。下面的代码块展示了全局变量和一个任意的突变示例:

    let globalVariable = {
      a: 1,
      b: {
        c: 2,
        d: 3,
      },
      e: [4, 5, 6],
    };
    globalVariable.b.d = 9;
    

    示例中的突变globalVariable.b.d = 9可能对全局状态不起作用,因为没有方法可以检测变化并触发 React 组件重新渲染。

要更好地控制全局状态如何编写,我们通常会提供更新全局状态的函数。通常还需要在闭包中隐藏一个变量,以便不能直接修改该变量。下面的代码块展示了在闭包中创建用于读取和写入变量的两个函数的示例:

const createContainer = () => {
  let state = { a: 1, b: 2 };
  const geState = () => state;
  const setState = (...) => { ...  };
  return { getState, setState };
};
const globalContainer = createContainer();
globalContainer.setState(...);

createContainer 函数创建了 globalContainer,它包含 getStatesetState 函数。getState 是一个读取全局状态的函数,而 setState 是一个更新全局状态的函数。实现如 setState 这样的更新全局状态的函数有几种方式。我们将在接下来的章节中具体探讨。

全局状态管理与通用状态管理

本书专注于 全局 状态管理;通用 状态管理不在此书的范畴之内。在通用状态管理的领域,流行的方法包括像 Redux (redux.js.org) 那样的单向数据流方法,以及像 XState (xstate.js.org) 那样的基于状态机的方法。通用状态管理方法不仅对全局状态有用,对局部状态也很有用。

关于 Redux 和 React Redux 的注意事项

Redux 在全局状态管理领域一直是一个重要角色。Redux 通过全局状态的单向数据流解决了状态管理问题。然而,Redux 本身与 React 没有关系。是 React Redux (react-redux.js.org) 将 React 和 Redux 绑定在一起。虽然 Redux 本身没有避免额外重新渲染的能力或概念,但 React Redux 有这样的能力。

由于 Redux 和 React Redux 非常受欢迎,过去有些人过度使用它们。这主要是因为 React 16.3 之前缺乏 React Context,并且没有其他流行的选择。这样的人(错误地)主要使用 React Redux 来(遗留)Context,而不需要单向数据流。自从 React 16.3 引入 React Context 和 React 16.8 引入的 useContext 钩子以来,我们可以轻松解决避免 prop 传递和额外重新渲染的用例。这使我们转向了微状态管理——本书的重点。

因此,从技术角度讲,React Redux 减去 Redux 就属于本书的范畴。Redux 本身是一个优秀的通用状态管理解决方案,与 React Redux 结合使用,它解决了本节讨论的全局状态问题。

在本节中,我们讨论了全局状态库的一般挑战。接下来,我们将学习状态驻留的位置。

使用以数据为中心和以组件为中心的方法

从技术上来说,全局状态可以分为两种类型:以数据为中心和以组件为中心。

在接下来的章节中,我们将详细讨论这两种方法。然后,我们还将讨论一些例外情况。

理解以数据为中心的方法

当你设计一个应用时,你可能在应用中有一个作为单例的数据模型,并且你可能已经有了处理数据。在这种情况下,你会定义组件并将数据与组件连接起来。数据可以从外部更改,例如由其他库或其他服务器。

对于以数据为中心的方法,模块状态会更为合适,因为模块状态位于 React 之外的 JavaScript 内存中。模块状态可以在 React 开始渲染之前存在,甚至在所有 React 组件卸载之后。

使用数据为中心的方法的全局状态库将提供 API 来创建模块状态并将模块状态连接到 React 组件。模块状态通常被封装在一个store对象中,该对象有访问和更新state变量的方法。

理解以组件为中心的方法

与数据为中心的方法不同,使用以组件为中心的方法,你可以首先设计组件。在某个时刻,某些组件可能需要访问共享信息。正如我们在第二章中的有效使用本地状态部分所讨论的,使用本地和全局状态,我们可以提升状态并通过 props(即属性钻取)向下传递。如果属性钻取不能作为解决方案,那么我们就可以引入全局状态。当然,我们可以先设计数据模型,但在以组件为中心的方法中,数据模型与组件紧密相关。

对于以组件为中心的方法,组件状态,它在组件生命周期中持有全局状态,更为合适。这是因为当所有相应的组件都卸载时,全局状态也随之消失。这种能力使我们能够在 JavaScript 内存中有两个或更多全局状态存在,因为它们位于不同的组件子树(或不同的 portals)中。

使用数据为中心的方法的全局状态库提供了一个工厂函数来创建初始化全局状态以在 React 组件中使用的方法。工厂函数并不直接创建全局状态,但通过使用生成的函数,我们让 React 处理全局状态的生命周期。

探索两种方法的例外情况

我们所描述的是典型的用例,但总会有一些例外。以数据为中心的方法和以组件为中心的方法并不是同一枚硬币的两面。实际上,你可以使用这两种方法中的一种,或者两种方法的混合。

模块状态通常被用作单例模式,但你可以为子树创建多个模块状态。你甚至可以控制它们的生命周期。

组件状态通常用于在子树中提供状态,但如果你在树的根处放置提供者组件,并且 JavaScript 内存中只有一个树,它可以被看作是单例模式。

组件状态通常使用useState钩子实现,但如果我们需要一个可变的变量或store,可以使用useRef钩子实现。这种实现可能比使用useState更复杂,但它仍然属于组件生命周期的一部分。

在本节中,我们学习了两种使用全局状态的方法。模块状态主要用于数据为中心的方法,而组件状态主要用于组件为中心的方法。接下来,我们将学习几种优化重新渲染的模式。

优化重新渲染

避免额外的重新渲染是全局状态时的一个主要挑战。在设计 React 的全局状态库时,这是一个需要考虑的重要点。

通常,全局状态有多个属性,它们可以是嵌套对象。以下是一个例子:

let state = {
  a: 1,
  b: { c: 2, d: 3 },
  e: { f: 4, g: 5 },
};

使用这个state对象,假设有两个组件ComponentAComponentB,分别使用state.b.cstate.e.g。以下是两个组件的伪代码:

const ComponentA = () => {
  return <>value: {state.b.c}</>;
};
const ComponentB = () => {
  return <>value: {state.e.g}</>;
};

现在,让我们假设我们按照以下方式更改state

++state.a;

这会改变statea属性,但不会改变state.b.cstate.e.g。在这种情况下,两个组件不需要重新渲染。

优化重新渲染的目标是指定组件中使用了state的哪个部分。我们有几种方法来指定state的部分。本节描述了三种方法:

  • 使用选择器函数

  • 检测属性访问

  • 使用原子

我们现在将讨论这些内容。

使用选择器函数

一种方法是使用选择器函数。选择器函数接受一个state变量并返回state变量的一部分。

例如,让我们假设我们有一个useSelector钩子,它接受一个选择器函数并返回state的一部分:

const Component = () => {
  const value = useSelector((state) => state.b.c);
  return <>{value}</>;
};

如果state.b.c2,那么Component将显示2。既然我们知道这个组件只关心state.b.c,我们就可以在state.a改变时避免额外的重新渲染。

useSelector将在每次state改变时比较选择器函数的结果。因此,当给定相同的输入时,选择器函数返回的引用相等的结果非常重要。

选择器函数非常灵活,不仅可以返回state的一部分,还可以返回任何派生值。例如,它可以返回一个双倍值,如下所示:

const Component = () => {
  const value = useSelector((state) => state.b.c * 2);
  return <>{value}</>;
};

关于选择器和记忆化的注意事项

如果选择器函数返回的值是原始值,如数字,则没有问题。然而,如果选择器函数返回一个派生对象值,我们需要确保使用所谓的记忆化技术返回一个引用相等的对象。你可以在en.wikipedia.org/wiki/Memoization上了解更多关于记忆化的信息。

由于选择器函数是一种显式指定组件将使用哪一部分的手段,我们将其称为手动优化。

检测属性访问

我们能否在不使用选择器函数显式指定组件中要使用哪个状态部分的情况下自动进行渲染优化?有一种称为状态使用跟踪的技术,用于检测属性访问并使用检测到的信息进行渲染优化。

例如,假设我们有一个具有状态使用跟踪能力的useTrackedState钩子:

const Component = () => {
  const trackedState = useTrackedState();
  return <p>{trackedState.b.c}</p>;
};

这是因为trackedState可以检测到.b.c属性被访问,而useTrackedState只有在.b.c属性值发生变化时才会触发重新渲染。这是自动渲染优化,而useSelector是手动渲染优化。

为了简化,之前的代码块示例是人为设计的。这个例子可以很容易地通过使用useSelector和手动渲染优化来实现。让我们看看另一个使用两个值的例子:

const Component = () => {
  const trackedState = useTrackedState();
  return (
    <>
      <p>{trackedState.b.c}</p>
      <p>{trackedState.e.g}</p>
    </>
  );
};

现在令人惊讶的是,使用单个useSelector钩子来实现这一点非常困难。如果我们编写一个选择器,它将需要记忆化或自定义相等函数,这些是复杂的技术。然而,如果我们使用useTrackedState,它无需这些复杂技术就能工作。

useTrackedState的实现需要使用代理(developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy)来拦截对state对象的属性访问。如果正确实现,它可以替代大多数useSelector的使用场景,并可以进行自动渲染优化。然而,存在一个微妙的情况,自动渲染优化并不完美。让我们在下一节中更详细地探讨。

useSelectoruseTrackedState的区别

有一些使用场景中,useSelectoruseTrackedState表现更好。因为useSelector可以创建任何派生值,它可以派生更简单的状态值。

通过一个简单的例子,我们可以看到useSelectoruseTrackedState的工作方式之间的区别。以下是一个使用useSelector的示例组件:

const Component = () => {
  const isSmall = useSelector((state) => state.a < 10);
  return <>{isSmall ? 'small' : 'big'}</>;
};

如果我们用useTrackedState创建相同的组件,它将是以下内容:

const Component = () => {
  const isSmall = useTrackedState().a < 10;
  return <>{isSmall ? 'small' : 'big'}</>;
};

从功能上讲,这个带有useTrackedState的组件工作得很好,但它会在每次state.a发生变化时触发重新渲染。相反,使用useSelector时,只有在isSmall发生变化时才会触发重新渲染,这意味着它具有更好的渲染优化。

使用原子

另一种方法,我们称之为使用原子。原子是用于触发重新渲染的最小状态单元。与订阅整个全局状态并尝试避免额外重新渲染不同,原子允许你进行粒度更细的订阅。

例如,假设我们有一个只订阅原子的useAtom钩子。一个atom函数会创建这样的单元(即atom)的state对象:

const globalState = {
  a: atom(1),
  b: atom(2),
  e: atom(3),
};
const Component = () => {
  const value = useAtom(globalState.a);
  return <>{value}</>;
};

如果原子完全分离,几乎等同于拥有单独的全局状态。然而,我们可以使用原子创建派生值。例如,假设我们想要对globalState值求和。伪代码如下:

const sum = globalState.a + globalState.b + globalState.c;

要使这可行,我们需要跟踪依赖关系,并在依赖原子更新时重新评估派生值。我们将仔细研究如何在第八章,“用例场景 2 – Jotai”中实现这样的 API。

使用原子的方法可以看作是手动方法和自动方法之间的某种折中。虽然原子和派生值的定义是明确的(手动),但依赖跟踪是自动的。

在本节中,我们学习了优化重新渲染的各种模式。对于全局状态库来说,设计如何优化重新渲染是很重要的。这通常会影响库的 API,理解如何优化重新渲染对于库用户来说也是值得的。

摘要

在本章中,在深入探讨全局状态库的实际实现之前,我们了解了一些与之相关的基本挑战,以及一些用于区分全局状态库的分类。在选择全局状态库时,我们可以看到库如何让我们读取全局状态和写入全局状态,库存储全局状态的位置,以及库如何优化重新渲染。这些是理解哪些库适用于特定用例的重要方面,它们应该有助于你选择适合你需求的库。

在下一章中,我们将学习关于 Zustand 库的内容,这是一个采用数据为中心的方法并使用选择器函数优化重新渲染的库。

第七章:用例场景 1 – Zustand

到目前为止,我们已经探索了一些可以用来在 React 中实现全局状态的基本模式。在本章中,我们将学习一个公开作为包提供的真实实现,称为 Zustand。

Zustand (github.com/pmndrs/zustand) 是一个主要用于为 React 创建模块状态的微型库。它基于不可变更新模型,其中状态对象不能被修改,但必须始终创建新的对象。渲染优化是通过选择器手动完成的。它提供了一个简单而强大的 store 创建接口。

在本章中,我们将探讨模块状态和订阅的使用,并查看库 API 的样子。

在本章中,我们将涵盖以下主题:

  • 理解模块状态和不可变状态

  • 添加 React hooks 以优化重新渲染

  • 处理读取状态和更新状态

  • 处理结构化数据

  • 此方法和库的优缺点

技术要求

预期您对 React 有一定的了解,包括 React hooks。请参考官方网站 reactjs.org 了解更多信息。

在本章的一些代码中,我们将使用 TypeScript (www.typescriptlang.org),因此您应该对其有基本了解。

本章中的代码可在 GitHub 上找到 github.com/PacktPublishing/Micro-State-Management-with-React-Hooks/tree/main/chapter_07

要运行本章中的代码片段,您需要一个 React 环境,例如 Create React App (create-react-app.dev) 或 CodeSandbox (codesandbox.io)。

在撰写本文时,Zustand 的当前版本是 v3。未来的版本可能会提供一些不同的 API。

理解模块状态和不可变状态

Zustand 是一个用于创建包含状态的 store 的库。它主要用于模块状态,这意味着您在模块中定义此 store 并导出它。它基于不可变状态模型,其中不允许修改状态对象属性。更新状态必须通过创建新对象来完成,而未修改的状态对象必须被重用。不可变状态模型的好处是,您只需检查状态对象的引用等价性即可知道是否有任何更新;您不需要深入检查等价性。

以下是一个最小示例,可以用来创建一个 count 状态。它接受一个返回初始状态的 store 创建函数:

// store.ts
import create from "zustand";
export const store = create(() => ({ count: 0 }));

store 提供了一些函数,如 getStatesetStatesubscribe。您可以使用 getState 来获取 store 中的状态,并使用 setState 来设置 store 中的状态:

console.log(store.getState()); // ---> { count: 0 }
store.setState({ count: 1 });
console.log(store.getState()); // ---> { count: 1 }

状态是不可变的,你不能像++state.count那样修改它。以下是一个无效的使用示例,它违反了状态的不可变性:

const state1 = store.getState();
state1.count = 2; // invalid
store.setState(state1);

state1.count = 2是无效的使用,所以它不会按预期工作。使用这种无效的使用方法,新状态与旧状态具有相同的引用,库无法正确检测到更改。

必须使用新对象来更新状态,例如store.setState({ count: 2 })store.setState函数也接受一个用于更新的函数:

store.setState((prev) => ({ count: prev.count + 1 }));

这被称为函数更新,它使得使用前一个状态更新状态变得容易。

到目前为止,状态中只有一个count属性。状态可以有多个属性。以下示例中有一个额外的text属性:

export const store = create(() => ({
  count: 0,
  text: "hello",
}));

再次强调,状态必须不可变地更新,如下所示:

store.setState({
  count: 1,
  text: "hello",
});

然而,store.setState()将合并新状态和旧状态。因此,你只能指定要设置的属性:

console.log(store.getState());
store.setState({
  count: 2,
});
console.log(store.getState());

第一个console.log语句输出{ count: 1, text: 'hello' },而第二个输出{ count: 2, text: 'hello' }

由于这仅更改了counttext属性没有改变。内部,这是通过Object.assign()实现的,如下所示:

Object.assign({}, oldState, newState);

Object.assign函数将通过合并oldStatenewState属性来返回一个新的对象。

store函数的最后一部分是store.subscribestore.subscribe函数允许你注册一个回调函数,每次store中的状态更新时都会调用该函数。它的工作方式如下:

store.subscribe(() => {
  console.log("store state is changed");
});
store.setState({ count: 3 });

使用store.setState语句时,store.subscribe是实现 React 钩子的重要函数。

在本节中,我们学习了 Zustand 的基本知识。你可能注意到,这与我们在第四章中学习的内容非常相似,通过订阅共享模块状态。本质上,Zustand 是一个围绕不可变状态模型和订阅思想的轻量级库。

在下一节中,我们将学习如何在 React 中使用store

使用 React 钩子优化重新渲染

对于全局状态,优化重新渲染很重要,因为并非所有组件都使用全局状态中的所有属性。让我们看看 Zustand 是如何处理这个问题的。

要在 React 中使用store,我们需要一个自定义钩子。Zustand 的create函数创建了一个可以用于钩子的store

为了遵循 React 钩子的命名约定,我们将创建的值命名为useStore而不是store

// store.ts
import create from "zustand";
export const useStore = create(() => ({
  count: 0,
  text: "hello",
}));

接下来,我们必须在 React 组件中使用创建的useStore钩子。如果调用useStore钩子,它将返回整个state对象,包括其所有属性。例如,让我们定义一个组件来显示store中的count值:

import { useStore } from "./store.ts";
const Component = () => {
  const { count, text } = useStore();
  return <div>count: {count}</div>;
};

此组件显示count值,并且每当store状态改变时,它都会重新渲染。虽然这在大多数情况下都很好,但如果只有text值改变而count值没有改变,组件将输出几乎相同的text值,这会导致额外的重新渲染。

当我们需要避免额外的重新渲染时,我们可以指定一个选择器函数;即useStore。之前的组件可以用选择器函数重写,如下所示:

const Component = () => {
  const count = useStore((state) => state.count);
  return <div>count: {count}</div>;
};

通过进行这个更改,但只有在count值改变时,组件才会重新渲染。

这种基于选择器的额外重新渲染控制就是我们所说的手动渲染优化。选择器工作以避免重新渲染的方式是对比选择器函数返回的结果。在定义选择器函数以返回稳定结果时,你需要小心,以避免重新渲染。

例如,以下示例工作得不好,因为选择器函数创建了一个包含新对象的新数组:

const Component = () => {
  const [{ count }] = useStore(
    (state) => [{ count: state.count }]
  );
  return <div>count: {count}</div>;
};

因此,即使count值没有改变,组件也会重新渲染。这是我们使用选择器进行渲染优化时的一个陷阱。

总结来说,基于选择器的渲染优化的好处是行为相对可预测,因为你明确地编写了选择器函数。然而,基于选择器的渲染优化的缺点是它需要理解对象引用。

在本节中,我们学习了如何使用由 Zustand 创建的钩子,以及如何使用选择器优化重新渲染。

接下来,我们将通过一个最小示例学习如何使用 Zustand 与 React。

处理读取状态和更新状态

虽然Zustand是一个可以以多种方式使用的库,但它有一个读取状态和更新状态的模式。让我们通过一个小示例学习如何使用 Zustand。

下面是我们的小型store,包含count1count2属性:

type StoreState = {
  count1: number;
  count2: number;                                        
};                      
const useStore = create<StoreState>(() => ({
  count1: 0,
  count2: 0,
}));

这创建了一个新的store,包含名为count1count2的两个属性。请注意,StoreState是 TypeScript 中的type定义。

接下来,我们必须定义Counter1组件,它显示count1值。我们必须提前定义selectCount1选择器函数并将其传递给useStore以优化重新渲染:

const selectCount1 = (state: StoreState) => state.count1;
const Counter1 = () => {
  const count1 = useStore(selectCount1);
  const inc1 = () => {
    useStore.setState(
      (prev) => ({ count1: prev.count1 + 1 })
    );
  };
  return (
    <div>           
      count1: {count1} <button onClick={inc1}>+1</button>
    </div>
  );
};

注意到内联的inc1函数已被定义。我们在store中调用setState函数。这是一个典型的模式,我们可以在store中定义函数以提高可重用性和可读性。

传递给create函数的store创建函数接受一些参数;第一个参数是store中的setState函数。让我们使用这种能力重新定义我们的store

type StoreState = {
  count1: number;
  count2: number;                                        
  inc1: () => void;
  inc2: () => void;
};                      
const useStore = create<StoreState>((set) => ({
  count1: 0,
  count2: 0,
  inc1: () => set(
    (prev) => ({ count1: prev.count1 + 1 })
  ),
  inc2: () => set(
    (prev) => ({ count2: prev.count2 + 1 })
  ),
}));

现在,我们的store有两个新属性,称为inc1inc2,它们是函数属性。请注意,将第一个参数命名为set是一个好习惯,它是setState的简称。

使用新的 store,我们必须定义 Counter2 组件。你可以将其与之前的 Counter1 组件进行比较,并注意到它可以以相同的方式进行重构:

const selectCount2 = (state: StoreState) => state.count2;
const selectInc2 = (state: StoreState) => state.inc2;
const Counter2 = () => {
  const count2 = useStore(selectCount2);
  const inc2 = useStore(selectInc2);
  return (
    <div>
      count2: {count2} <button onClick={inc2}>+1</button>
    </div>
  );
};

在这个例子中,我们有一个名为 selectInc2 的新选择器函数,而 inc2 函数仅仅是 useStore 的结果。同样,我们还可以向 store 中添加更多函数,这允许一些逻辑存在于组件之外。你可以将状态更新逻辑与状态值紧密地放在一起。这就是为什么 Zustand 的 setState 会合并旧状态和新状态。我们也在 理解模块状态和不可变状态 部分讨论了这一点,那里我们学习了如何使用 Object.assign

如果我们想创建一个派生状态怎么办?我们可以使用一个派生状态的选择器。首先,让我们看看一个简单示例。以下是一个新组件,它显示了 count1count2total 数量:

const Total = () => {
  const count1 = useStore(selectCount1);
  const count2 = useStore(selectCount2);
  return (
    <div>
      total: {count1 + count2}
    </div>
  );
};

这是一个有效的模式,它可以保持原样。存在一个边缘情况,即额外的重新渲染发生,这是当 count1 增加,而 count2 以相同数量减少时。总数不会改变,但它会重新渲染。为了避免这种情况,我们可以使用一个选择器函数来处理派生状态。

以下示例展示了如何使用新的 selectTotal 函数来计算 total 数量:

const selectTotal = 
  (state: StoreState) => state.count1 + state.count2;
const Total = () => {
  const total = useStore(selectTotal);
  return (
    <div>
      total: {total}
    </div>
  );
};

这只会在 total 数量改变时重新渲染。

因此,我们在选择器中计算了 total 数量。虽然这是一个有效的解决方案,但让我们看看另一种方法,我们可以在存储中创建总数。如果我们能在 store 中创建 total 数量,它将记住结果,并且当许多组件使用该值时,我们可以避免不必要的计算。这并不常见,但如果计算非常计算密集,这很重要。一个简单的方法如下:

const useStore = create((set) => ({
  count1: 0,
  count2: 0,
  total: 0,
  inc1: () => set((prev) => ({
    ...prev,
    count1: prev.count1 + 1,
    total: prev.count1 + 1 + prev.count2,
  })),
  inc2: () => set((prev) => ({
    ...prev,
    count2: prev.count2 + 1,
    total: prev.count2 + 1 + prev.count1,
  })),
}));

有一种更复杂的方法来做这件事,但基本思想是同时计算多个属性并保持它们同步。另一个库,Jotai,处理得很好。请参阅 第八章用例场景 2 - Jotai,了解更多信息。

运行示例应用程序的最后一步是定义 App 组件:

const App = () => (
  <>
    <Counter1 />
    <Counter2 />
    <Total />
  </>
);

当你运行这个应用程序时,你会看到以下类似的内容:

图 7.1 – 运行应用程序的截图

图 7.1 – 运行应用程序的截图

如果你点击第一个按钮,你会看到屏幕上的两个数字——在 count1 标签和 total 数量之后——都会增加。如果你点击第二个按钮,你会看到屏幕上的两个数字——在 count2 标签和 total 数量之后——也会增加。

在本节中,我们学习了在 Zustand 中以常用方式读取和更新状态。接下来,我们将学习如何处理结构化数据以及如何使用数组。

处理结构化数据

处理一组数字的示例相当简单。在现实中,我们需要处理对象、数组以及它们的组合。让我们通过另一个示例来学习如何使用 Zustand。这是一个众所周知的 Todo 应用示例。这是一个你可以做以下事情的应用:

  • 创建一个新的 Todo 项。

  • 查看 Todo 项列表。

  • 切换 Todo 项的完成状态。

  • 删除一个 Todo 项。

首先,在创建存储之前,我们必须定义一些类型。以下是一个Todo对象的类型定义。它具有idtitledone属性:

type Todo = {
  id: number;
  title: string;
  done: boolean;
};

现在,可以使用Todo定义StoreState类型。存储的值部分是todos,它是一系列 Todo 项。除此之外,还有三个函数——addTodoremoveTodotoggleTodo——可以用来操作todos属性:

type StoreState = {
  todos: Todo[];
  addTodo: (title: string) => void;
  removeTodo: (id: number) => void;
  toggleTodo: (id: number) => void;
};

todos属性是一个对象数组。在store状态中有一个对象数组是典型的做法,并将是本节的重点。

接下来,我们必须定义store。它也是一个名为useStore的钩子。当它被创建时,store有一个空的todos属性和三个函数,分别称为addTodoremoveTodotoggleTodonextIdcreate函数外部定义为一种原始解决方案,为新的 Todo 项提供唯一的id

let nextId = 0;
const useStore = create<StoreState>((set) => ({
  todos: [],
  addTodo: (title) =>
    set((prev) => ({
      todos: [
        ...prev.todos,
        { id: ++nextId, title, done: false },
      ],
    })),
  removeTodo: (id) =>
    set((prev) => ({
      todos: prev.todos.filter((todo) => todo.id !== id),
    })),
  toggleTodo: (id) =>
    set((prev) => ({
      todos: prev.todos.map((todo) =>
        todo.id === id ? { ...todo, done: !todo.done } :
          todo
      ),
    })),
}));

注意到addTodoremoveTodotoggleTodo函数是以不可变的方式实现的。它们不会修改现有的对象和数组;相反,它们创建新的。

在我们定义主TodoList组件之前,让我们看看一个负责渲染单个项的TodoItem组件:

const selectRemoveTodo = 
  (state: StoreState) => state.removeTodo;
const selectToggleTodo = 
  (state: StoreState) => state.toggleTodo;
const TodoItem = ({ todo }: { todo: Todo }) => {
  const removeTodo = useStore(selectRemoveTodo);
  const toggleTodo = useStore(selectToggleTodo);
  return (
    <div>
      <input
        type="checkbox"
        checked={todo.done}
        onChange={() => toggleTodo(todo.id)}
      />
      <span
        style={{
          textDecoration: 
            todo.done ? "line-through" : "none",
        }}
      >
        {todo.title}
      </span>
      <button
        onClick={() => removeTodo(todo.id)}
      >
        Delete
      </button>
    </div>
  );
};

由于TodoItem组件在props中接受一个todo对象,所以在状态方面它是一个相当简单的组件。TodoItem组件有两个控件:一个由removeTodo处理的按钮和一个由toggleTodo处理的复选框。这些是来自store的每个控件的两个函数。selectRemoveTodoselectToggleTodo函数被传递给useStore函数,分别获取removeTodotoggleTodo函数。

让我们创建一个名为MemoedTodoItemTodoItem组件的记忆化版本:

const MemoedTodoItem = memo(TodoItem);

现在,我们将讨论这将如何帮助我们的应用。我们已经准备好定义主TodoList组件。它使用selectTodos函数,该函数用于从store中选择todos属性。然后,它遍历todos数组,并为每个 todo 项渲染MemoedTodoItem

在这里使用记忆化组件非常重要,以避免额外的重新渲染。因为我们以不可变的方式更新store状态,所以todos数组中的大多数todo对象都没有改变。如果我们传递给MemoedTodoItem属性的todo对象没有改变,组件就不会重新渲染。每当todos数组发生变化时,TodoList组件会重新渲染。然而,其子组件只有在相应的todo项发生变化时才会重新渲染。

以下代码展示了selectTodos函数和TodoList组件:

const selectTodos = (state: StoreState) => state.todos;
const TodoList = () => {
  const todos = useStore(selectTodos);
  return (
    <div>
      {todos.map((todo) => (
        <MemoedTodoItem key={todo.id} todo={todo} />
      ))}
    </div>
  );
};

TodoList组件遍历todos列表,并为每个todo项目渲染MemoedTodoItem组件。

剩下的工作就是添加一个新的todo项目。NewTodo是一个可以用来渲染文本框和按钮的组件,以及当按钮被点击时调用addTodo函数。selectAddTodo是一个可以用来在store中选择addTodo函数的函数:

const selectAddTodo = (state: StoreState) => state.addTodo;
const NewTodo = () => {
  const addTodo = useStore(selectAddTodo);
  const [text, setText] = useState("");
  const onClick = () => {
    addTodo(text);
    setText(""); // [1]
  };
  return (
    <div>
      <input
        value={text}
        onChange={(e) => setText(e.target.value)}
      />
      <button onClick={onClick} disabled={!text}> // [2]
        Add
      </button>
    </div>
  );
};

关于在NewTodo中改进行为,我们应该提到两个小问题:

  • 当按钮被点击时,它会清除文本框[1]

  • 当文本框为空时,它会禁用按钮[2]

最后,为了完成 Todo 应用程序,我们必须定义App组件:

const App = () => (
  <>
    <TodoList />
    <NewTodo />
  </>
);

运行此应用程序最初将只显示一个文本框和一个禁用的添加按钮:

图 7.2 – 运行中的应用程序的第一次截图

图 7.2 – 运行中的应用程序的第一次截图

如果你输入一些文本并点击添加按钮,项目将会出现:

图 7.3 – 运行中的应用程序的第二次截图

图 7.3 – 运行中的应用程序的第二次截图

点击复选框将切换项目的完成状态:

图 7.4 – 运行中的应用程序的第三次截图

图 7.4 – 运行中的应用程序的第三次截图

点击屏幕上的删除按钮将删除项目:

图 7.5 – 运行中的应用程序的第四次截图

图 7.5 – 运行中的应用程序的第四次截图

你可以添加你想要的任何数量的项目。所有这些功能都是通过本节中我们讨论的所有代码实现的。由于store状态和 React 提供的memo函数的不可变更新,重新渲染得到了优化。

在本节中,我们学习了如何通过典型的 Todo 应用程序示例来处理数组。接下来,我们将讨论这个库以及一般方法的优缺点。

这种方法和库的优缺点

让我们讨论使用 Zustand 或其他库来实现此方法的优缺点。

回顾一下,以下是基于 Zustand 的读取和写入状态:

  • 读取状态:这利用选择器函数来优化重新渲染。

  • 写入状态:这是基于不可变状态模型。

关键点是 React 基于对象不可变性进行优化。一个例子是useState。React 通过基于不可变性的对象引用相等性来优化重新渲染。以下示例说明了这种行为:

const countObj = { value: 0 };
const Component = () => {
  const [count, setCount] = useState(countObj);
  const handleClick = () => {
    setCount(countObj);
  };
  useEffect(() => {
    console.log("component updated");
  });
  return (
    <>
      {count.value}
      <button onClick={handleClick}>Update</button>
    </>
  );
};

这里,即使你点击更新按钮,它也不会显示"组件已更新"消息。这是因为 React 假设如果对象引用相同,则countObj的值不会改变。这意味着更改handleClick函数不会产生任何变化:

  const handleClick = () => {
    countObj.value += 1;
    setCount(countObj);
  };

如果你调用 handleClickcountObj 的值将会改变,但 countObj 对象本身不会变。因此,React 假设它没有改变。这就是我们所说的 React 优化基于不可变性的原因。同样的行为也可以在 memouseMemo 等函数中观察到。

Zustand 的状态模型与这种对象不可变性假设(或约定)完全一致。Zustand 使用选择器函数的渲染优化也是基于不可变性的 – 也就是说,如果一个选择器函数返回相同的对象引用(或值),它假设对象没有改变,从而避免重新渲染。

Zustand 与 React 具有相同的模型,这给我们带来了巨大的好处,包括库的简单性和其小巧的体积。

另一方面,Zustand 的一个限制是它使用选择器进行的手动渲染优化。这要求我们理解对象引用相等性,并且选择器的代码往往需要更多的样板代码。

总结来说,Zustand – 或者任何采用这种方法的库 – 是对 React 原则的一个简单补充。如果你需要一个体积小的库,如果你熟悉引用相等性和记忆化,或者你更喜欢手动渲染优化,这是一个很好的推荐。

摘要

在本章中,我们学习了 Zustand 库。这是一个使用 React 模块状态的微型库。我们通过计数示例和待办事项示例来了解如何使用这个库。我们通常使用这个库来理解对象引用相等性。你可以根据自己的需求和在本章中学到的知识选择这个库或类似的方法。

在本章中,我们没有讨论 Zustand 的某些方面,包括中间件,它允许你向 store 创建者提供一些功能,以及非模块状态的使用,这在 React 生命周期中创建一个 store。在选择库时,这些都是其他需要考虑的因素。你应该始终参考库文档以获取更多 – 以及最新的 – 信息。

在下一章中,我们将学习另一个库,Jotai。

第八章:用例场景 2 – Jotai

Jotai (github.com/pmndrs/jotai) 是一个用于全局状态的轻量级库。它模仿了 useState/useReducer,并使用所谓的原子,这些通常是小的状态片段。与 Zustand 不同,它是一个组件状态,并且像 Zustand 一样,它是一个不可变更新模型。其实现基于我们在 第五章 中学到的上下文和订阅模式,使用上下文和订阅共享组件状态

在本章中,我们将学习 Jotai 库的基本用法以及它是如何处理优化重新渲染的。使用原子,库可以跟踪依赖关系并根据依赖关系触发重新渲染。因为 Jotai 内部使用 Context,而原子本身不持有值,所以原子定义是可重用的,与模块状态不同。我们还将讨论一个使用原子的新颖模式,称为 原子中的原子,这是一种使用数组结构优化重新渲染的技术。

在本章中,我们将涵盖以下主题:

  • 理解 Jotai

  • 探索渲染优化

  • 理解 Jotai 如何存储原子值

  • 添加数组结构

  • 使用 Jotai 的不同功能

技术要求

预期你具备适度的 React 知识,包括 React hooks。请参考官方网站 reactjs.org 了解更多。

在某些代码中,我们使用 TypeScript (www.typescriptlang.org),你应该对其有基本了解。

本章中的代码可在 GitHub 上找到 github.com/PacktPublishing/Micro-State-Management-with-React-Hooks/tree/main/chapter_08

要运行本章中的代码片段,你需要一个 React 环境——例如,Create React App (create-react-app.dev) 或 CodeSandbox (codesandbox.io)。

理解 Jotai

要理解 Jotai 的 应用程序编程接口API),让我们回顾一个简单的计数器示例和 Context 的解决方案。

这里有一个包含两个独立计数器的示例:

const Counter1 = () => {
  const [count, setCount] = useState(0); // [1]
  const inc = () => setCount((c) => c + 1);
  return <>{count} <button onClick={inc}>+1</button></>;
};
const Counter2 = () => {
  const [count, setCount] = useState(0);
  const inc = () => setCount((c) => c + 1);
  return <>{count} <button onClick={inc}>+1</button></>;
};
const App = () => (
  <>
    <div><Counter1 /></div>
    <div><Counter2 /></div>
  </>
);

因为这些 Counter1Counter2 组件有自己的局部状态,所以这些组件中显示的数字是隔离的。

如果我们想让这两个组件共享一个单一的计数状态,我们可以将状态提升并使用 Context 来传递,正如我们在 第二章有效使用局部状态 部分所讨论的那样,使用局部和全局状态。让我们看看一个使用 Context 解决的示例。

首先,我们创建一个 Context 变量来保存计数状态,如下所示:

const CountContext = createContext();
const CountProvider = ({ children }) => (
  <CountContext.Provider value={useState(0)}>
    {children}
  </CountContext.Provider>
);

注意 Context 值与我们在上一个示例中使用的相同状态 useState(0)(标记为 [1])。

然后,以下是对修改后的组件的修改,我们将useState(0)替换为useContext(CountContext)

const Counter1 = () => {
  const [count, setCount] = useContext(CountContext);
  const inc = () => setCount((c) => c + 1);
  return <>{count} <button onClick={inc}>+1</button></>;
};
const Counter2 = () => {
  const [count, setCount] = useContext(CountContext);
  const inc = () => setCount((c) => c + 1);
  return <>{count} <button onClick={inc}>+1</button></>;
};

最后,我们用CountProvider包裹这些组件,如下所示:

const App = () => (
  <CountProvider>
    <div><Counter1 /></div>
    <div><Counter2 /></div>
  </CountProvider>
);

这使得拥有一个共享的计数状态成为可能,你将看到Counter1Counter2组件中的两个count数字会同时增加。

现在,让我们看看与 Context 相比,Jotai 是如何有帮助的。使用 Jotai 有两个好处,如下所示:

  • 语法简洁性

  • 动态原子创建

让我们从第一个好处开始——Jotai 如何帮助简化语法。

语法简洁性

为了理解语法的简洁性,让我们看看使用 Jotai 的相同计数示例。首先,我们需要从 Jotai 库中导入一些函数,如下所示:

import { atom, useAtom } from "jotai";

atom函数和useAtom钩子是 Jotai 提供的基本函数。

原子代表状态的一部分。原子通常是一小块状态,它是触发重新渲染的最小单位。atom函数创建原子的定义。atom函数接受一个参数来指定初始值,就像useState一样。以下代码用于定义一个新的原子:

const countAtom = atom(0);

注意与useState(0)的相似性。

现在,我们在计数组件中使用原子。我们不用useState(0),而是使用useAtom(countAtom),如下所示:

const Counter1 = () => {
  const [count, setCount] = useAtom(countAtom);
  const inc = () => setCount((c) => c + 1);
  return <>{count} <button onClick={inc}>+1</button></>;
};
const Counter2 = () => {
  const [count, setCount] = useAtom(countAtom);
  const inc = () => setCount((c) => c + 1);
  return <>{count} <button onClick={inc}>+1</button></>;
};

因为useAtom(countAtom)返回与useState(0)相同的元组[count, setCount],所以其余的代码不需要更改。

最后,我们的App组件与本章的第一个例子相同,即没有使用 Context,如下面的代码片段所示:

const App = () => (
  <>
    <div><Counter1 /></div>
    <div><Counter2 /></div>
  </>
);

与本章的第二个例子不同,该例子使用 Context,我们不需要提供者。这是由于 Context 中的“默认存储”,正如我们在第五章实现 Context 和订阅模式部分所学的,使用 Context 和订阅共享组件状态。当我们需要为不同的子树提供不同的值时,我们可以选择使用提供者。

为了更好地理解 Jotai 中语法的简洁性,假设你想添加另一个全局状态——比如说,text;你最终会添加以下代码:

const TextContext = createContext();
const TextProvider = ({ children }) => (
  <TextContext.Provider value={useState("")}>
    {children}
  </TextContext.Provider>
);
const App = () => (
  <TextProvider>
    ...
  </TextProvider>
);
// When you use it in a component
  const [text, setText] = useContext(TextContext);

这并不太糟糕。我们添加的是一个 Context 定义和一个提供者定义,并且用Provider组件包裹了App。你还可以避免提供者嵌套,正如我们在第三章使用 Context 的最佳实践部分所学的,使用 Context 共享组件状态

然而,相同的例子也可以用 Jotai 原子来完成,如下所示:

const textAtom = atom("");
// When you use it in a component
  const [text, setText] = useAtom(textAtom);

这要简单得多。本质上,我们只添加了一行原子定义。即使我们有更多的原子,我们只需要为每个原子定义一行在 Jotai 中。另一方面,使用 Context 需要为每个状态片段创建一个 Context。虽然可以用 Context 做,但并不简单。Jotai 的语法要简单得多。这是 Jotai 的第一个好处。

虽然语法简洁性很好,但它并没有提供任何新的功能。让我们简要地讨论第二个好处。

动态原子创建

Jotai 的第二个好处是新的功能——即动态原子创建。原子可以在 React 组件的生命周期中创建和销毁。这与多上下文方法不同,因为添加新状态意味着添加一个新的Provider组件。如果你添加了一个新组件,所有其子组件都将重新挂载,丢弃它们的状态。我们将在添加数组结构部分介绍动态原子创建的用例。

Jotai 的实现基于我们在第五章学到的内容,使用上下文和订阅共享组件状态。Jotai 的 store 基本上是一个原子配置对象和原子值的WeakMap对象(developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/WeakMap)。一个atom函数。useAtom钩子返回。Jotai 中的订阅是基于原子的,这意味着useAtom钩子订阅了store中的某个原子。基于原子的订阅提供了避免额外重新渲染的能力。我们将在下一节进一步讨论这一点。

在本节中,我们讨论了 Jotai 库的基本心智模型和 API。接下来,我们将深入了解原子模型是如何解决渲染优化的。

探索渲染优化

让我们回顾一下基于选择器的渲染优化。我们将从一个例子开始,这个例子来自第四章使用订阅共享模块状态,在那里我们创建了createStoreuseStoreSelector

让我们使用createStore定义一个新的store对象person。我们定义三个属性:firstNamelastNameage,如下所示:

const personStore = createStore({
  firstName: "React",
  lastName: "Hooks",
  age: 3,
});

假设我们想要创建一个显示firstNamelastName的组件。一种直接的方法是选择这些属性。以下是一个使用useStoreSelector的例子:

const selectFirstName = (state) => state.firstName;
const selectLastName = (state) => state.lastName;
const PersonComponent = () => {
  const firstName =
    useStoreSelector(store, selectFirstName);
  const lastName = useStoreSelector(store, selectLastName);
  return <>{firstName} {lastName}</>;
};

由于我们只从store中选择了两个属性,当未选择的属性age发生变化时,PersonComponent不会重新渲染。

这种store和选择器方法就是我们所说的store,它包含了一切,并在需要时从store中选择状态片段。

现在,Jotai 原子对于相同的示例会是什么样子呢?首先,我们定义原子,如下所示:

const firstNameAtom = atom("React");
const lastNameAtom = atom("Hooks");
const ageAtom = atom(3);

原子是触发重新渲染的单位。你可以将原子做得尽可能小以控制重新渲染,就像原始值一样。但原子也可以是对象。

PersonComponent可以使用useAtom钩子实现,如下所示:

const PersonComponent = () => {
  const [firstName] = useAtom(firstNameAtom);
  const [lastName] = useAtom(lastNameAtom);
  return <>{firstName} {lastName}</>;
};

因为这与ageAtom没有关系,所以当ageAtom的值发生变化时,PersonComponent不会重新渲染。

原子可以尽可能小,但这意味着我们可能会有太多的原子需要组织。Jotai 有一个关于派生原子的概念,你可以从现有原子中创建另一个原子。让我们创建一个名为personAtom的变量,它包含名字、姓氏和年龄。我们可以使用atom函数,它接受一个read函数来生成派生值。代码在以下代码片段中展示:

const personAtom = atom((get) => ({
  firstName: get(firstNameAtom),
  lastName: get(lastNameAtom),
  age: get(ageAtom),
}));

read函数接受一个名为get的参数,你可以通过它引用其他原子并获取它们的值。personAtom的值是一个具有三个属性的对象——firstNamelastNameage。这个值在任何一个属性发生变化时都会更新,这意味着当firstNameAtomlastNameAtomageAtom更新时。这被称为依赖跟踪,并且由 Jotai 库自动完成。

重要提示

依赖跟踪是动态的,适用于条件评估。例如,假设一个read函数是(get) => get(a) ? get(b) : get(c)。在这种情况下,如果a的值是真实的,则依赖项是ab,而如果a的值是假的,则依赖项是ac

使用personAtom,我们可以重新实现PersonComponent,如下所示:

const PersonComponent = () => {
  const person = useAtom(personAtom);
  return <>{person.firstName} {person.lastName}</>;
};

然而,这并不是我们预期的结果。当ageAtom改变其值时,它会重新渲染,从而引起额外的重新渲染。

为了避免额外的重新渲染,我们应该创建一个只包含我们使用的值的派生原子。这里有一个名为fullNameAtom的另一个原子:

const fullNameAtom = atom((get) => ({
  firstName: get(firstNameAtom),
  lastName: get(lastNameAtom),
}));

使用fullNameAtom,我们可以再次实现PersonComponent,如下所示:

const PersonComponent = () => {
  const person = useAtom(fullNameAtom);
  return <>{person.firstName} {person.lastName}</>;
};

多亏了fullNameAtom,即使ageAtom的值发生变化,它也不会重新渲染。

我们称这为自下而上的方法。我们创建小的原子并将它们组合起来创建更大的原子。我们可以通过仅添加将在组件中使用到的原子来优化重新渲染。优化不是自动的,但在原子模型中更为直接。

我们如何使用存储和选择器方法来完成最后一个示例?以下是一个使用identity选择器的示例:

const identity = (x) => x;
const PersonComponent = () => {
  const person = useStoreSelector(store, identity);
  return <>{person.firstName} {person.lastName}</>;
};

如你所猜,这会导致额外的重新渲染。当store中的age属性发生变化时,组件会重新渲染。

一种可能的修复方法是只选择firstNamelastName。以下示例说明了这一点:

const selectFullName = (state) => ({
  firstName: state.firstName,
  lastName: state.lastName,
});
const PersonComponent = () => {
  const person = useStoreSelector(store, selectFullName);
  return <>{person.firstName} {person.lastName}</>;
};

很遗憾,这不起作用。当 age 发生变化时,selectFullName 函数会被重新评估,并返回一个具有相同属性值的新对象。useStoreSelector 假设新对象可能包含新值并触发重新渲染,这导致额外的重新渲染。这是选择器方法的一个已知问题,典型的解决方案是使用自定义相等函数或记忆化技术。

原子模型的优点是原子组合可以轻松地与组件中将要显示的内容相关联。因此,控制重新渲染非常简单。使用原子的渲染优化不需要自定义相等函数或记忆化技术。

让我们通过一个反例来了解派生原子。首先,我们定义两个 count 原子,如下所示:

const count1Atom = atom(0);
const count2Atom = atom(0);

我们定义一个组件来使用那些 count 原子。我们不是定义两个计数组件,而是定义一个适用于两个原子的单个 Counter 组件。为此,组件接收 countAtom 作为其 props,如下面的代码片段所示:

const Counter = ({ countAtom }) => {
  const [count, setCount] = useAtom(countAtom);
  const inc = () => setCount((c) => c + 1);
  return <>{count} <button onClick={inc}>+1</button></>;
};

这对于任何 countAtom 配置都是可重用的。即使我们定义了一个新的 count3Atom 配置,我们也不需要定义一个新的组件。

接下来,我们定义一个派生原子,用于计算两个计数的总数。我们使用 atom 和一个 read 函数作为第一个参数,如下所示:

const totalAtom = atom(
  (get) => get(count1Atom) + get(count2Atom)
);

使用 read 函数,atom 将创建一个派生原子。派生原子的值是 read 函数的结果。只有当依赖项发生变化时,派生原子才会重新评估其 read 函数并更新其值。在这种情况下,count1Atomcount2Atom 发生变化。

Total 组件是一个用于使用 totalAtom 并显示 total 数值的组件,如下面的代码片段所示:

const Total = () => {
  const [total] = useAtom(totalAtom);
  return <>{total}</>;
};

totalAtom 是一个派生原子,它是只读的,因为它的值是 read 函数的结果。因此,没有设置 totalAtom 值的概念。

最后,我们定义一个 App 组件。它将 count1Atomcount2Atom 传递给 Counter 组件,如下所示:

const App = () => (
  <>
    (<Counter countAtom={count1Atom} />)
    +
    (<Counter countAtom={count2Atom} />)
    =
    <Total />  
  </>
);

原子可以作为 props 传递,例如本例中的 Counter 原子,或者可以通过任何其他方式传递——模块级别的常量、props、上下文,甚至作为其他原子中的值。我们将在 添加数组结构 部分了解将原子放入另一个原子的用例。

当你运行应用程序时,你会看到一个包含第一个计数、第二个计数和总数的等式。通过点击显示在计数之后的按钮,你会看到计数增加以及总数,如下面的截图所示:

图 8.1 – 计数应用程序的截图

图 8.1 – 计数应用程序的截图

在本节中,我们了解了 Jotai 库中的原子模型和渲染优化。接下来,我们将探讨 Jotai 如何存储原子值。

理解 Jotai 如何存储原子值

到目前为止,我们还没有讨论 Jotai 如何使用 Context。在本节中,我们将展示 Jotai 如何存储原子值以及原子是如何可重用的。

首先,让我们回顾一个简单的原子定义,countAtomatom接受一个初始值0并返回一个原子配置,如下所示:

const countAtom = atom(0);

在实现上,countAtom是一个包含一些表示原子行为的属性的对象。在这种情况下,countAtom是一个原始原子,它是一个可以更新为值或更新函数的值的原子。原始原子被设计成像useState一样行为。

重要的是,像countAtom这样的原子配置不持有它们的值。我们有一个store来持有原子值。store有一个WeakMap对象,其键是一个原子配置对象,其值是一个原子值。

当我们使用useAtom时,默认情况下,它使用在模块级别定义的默认store。然而,Jotai 提供了一个名为Provider的组件,它允许你在组件级别创建store。我们可以从 Jotai 库中导入Provider以及atomuseAtom,如下所示:

import { atom, useAtom, Provider } from "jotai";

假设我们已经定义了Counter组件,如下所示:

const Counter = ({ countAtom }) => {
  const [count, setCount] = useAtom(countAtom);
  const inc = () => setCount((c) => c + 1);
  return <>{count} <button onClick={inc}>+1</button></>;
};

这与我们在理解 Jotai部分和探索渲染优化部分中定义的相同组件。

我们然后使用Provider定义一个App组件。我们使用两个Provider组件,并为每个Provider组件放入两个Counter组件,如下所示:

const App = () => (
  <>
    <Provider>
      <h1>First Provider</h1>
      <div><Counter /></div>
      <div><Counter /></div>
    </Provider>
    <Provider>
      <h1>Second Provider</h1>
      <div><Counter /></div>
      <div><Counter /></div>
    </Provider>
  </>
);

App中的两个Provider组件隔离了存储。因此,在Counter组件中使用的countAtom是隔离的。第一个Provider组件下的两个Counter组件共享countAtom的值,但第二个Provider组件下的另外两个Counter组件的countAtom值与第一个Provider组件中的值不同,如上图所示:

图 8.2 – 两个-provider 应用的截图

图 8.2 – 两个-provider 应用的截图

再次强调,重要的是countAtom本身不持有值。因此,countAtom可以用于多个Provider组件。这与模块状态有显著的不同。

我们可以定义一个派生原子。以下是一个用于定义countAtom双倍数值的派生原子:

const doubledCountAtom = atom(
  (get) => get(countAtom) * 2
);

由于countAtom不持有值,doubledCountAtom也不持有值。如果doubledCountAtom在第一个Provider组件中使用,它表示Provider组件中countAtom值的两倍。同样适用于第二个Provider组件,并且第一个Provider组件中的值可以与第二个Provider组件中的值不同。

因为原子配置只是定义而没有持有值,所以原子配置是可重用的。示例显示它可以用于两个Provider组件,但本质上,它可以用于更多Provider组件。此外,Provider组件可以在 React 组件生命周期中动态使用。在实现上,Jotai 完全基于 Context,Jotai 可以做 Context 能做的所有事情。在本节中,我们了解到原子配置不持有值,因此是可重用的。接下来,我们将学习如何使用 Jotai 处理数组。

添加数组结构

在 React 中处理数组结构很棘手。当组件渲染数组结构时,我们需要为数组项传递稳定的key属性。这在删除或重新排序数组项时尤其必要。

在本节中,我们将学习如何在 Jotai 中处理数组结构。我们将从一个传统方法开始,然后介绍一种我们称之为原子中的原子的新模式。

让我们使用在第七章处理结构化数据部分中使用的相同的待办事项应用示例,即用例场景 1 – Zustand

首先,我们定义一个Todo类型。它具有id字符串、title字符串和done布尔属性,如下面的代码片段所示:

type Todo = {
  id: string;
  title: string;
  done: boolean;
};

接下来,我们定义todosAtom,它代表定义的Todo项数组,如下所示:

const todosAtom = atom<Todo[]>([]);

我们用Todo[]类型注解atom()函数。

然后,我们定义一个TodoItem组件。这是一个纯组件,它接收todoremoveTodotoggleTodo作为props。代码如下所示:

const TodoItem = ({
  todo,
  removeTodo,
  toggleTodo,
}: {
  todo: Todo;
  removeTodo: (id: string) => void;
  toggleTodo: (id: string) => void;
}) => {
  return (
    <div>
      <input
        type="checkbox"
        checked={todo.done}
        onChange={() => toggleTodo(todo.id)}
      />
      <span
        style={{
          textDecoration:
            todo.done ? "line-through" : "none",
        }}
      >
        {todo.title}
      </span>
      <button
        onClick={() => removeTodo(todo.id)}
      >Delete</button>
    </div>
  );
};

<input>中的onChange回调调用toggleTodo,而<button>中的onClick回调调用removeTodo。两者都基于id字符串。

我们用memo包装TodoItem以创建一个记忆化的版本,如下所示:

const MemoedTodoItem = memo(TodoItem);

这允许我们避免不必要的重新渲染,除非todoremoveTodotoggleTodo发生变化。

现在,我们准备好创建一个TodoList组件。它使用todosAtom,使用useCallback定义removeTodotoggleTodo,并对todo数组进行映射,如下所示:

const TodoList = () => {
  const [todos, setTodos] = useAtom(todosAtom);
  const removeTodo = useCallback((id: string) => setTodos(
    (prev) => prev.filter((item) => item.id !== id)
  ), [setTodos]);
  const toggleTodo = useCallback((id: string) => setTodos(
    (prev) => prev.map((item) =>
      item.id === id ? { ...item, done: !item.done } : item
    )
  ), [setTodos]);
  return (
    <div>
      {todos.map((todo) => (
        <MemoedTodoItem
          key={todo.id}
          todo={todo}
          removeTodo={removeTodo}
          toggleTodo={toggleTodo}
        />
      ))}
    </div>
  );
};

TodoList组件为每个todos数组项渲染MemoedTodoItem组件。key属性指定为todo.id

下一个组件是NewTodo。它使用todosAtom并在按钮点击时添加一个新项。新原子的id值应该是唯一生成的,在下面的示例中,它使用了nanoid(www.npmjs.com/package/nanoid):

const NewTodo = () => {
  const [, setTodos] = useAtom(todosAtom);
  const [text, setText] = useState("");
  const onClick = () => {
    setTodos((prev) => [
      ...prev,
      { id: nanoid(), title: text, done: false },
    ]);
    setText("");
  };
  return (
    <div>
      <input
        value={text}
        onChange={(e) => setText(e.target.value)}
      />
      <button onClick={onClick} disabled={!text}>
        Add
      </button>
    </div>
  );
};

为了简单起见,我们使用了useAtom来处理todosAtom。然而,这实际上使得NewTodo组件在todosAtom的值改变时重新渲染。我们可以通过一个额外的实用钩子useUpdateAtom轻松避免这种情况。

最后,我们创建一个App组件来渲染TodoListNewTodo,如下所示:

const App = () => (
  <>
    <TodoList />
    <NewTodo />
  </>
);

这工作得非常完美。你可以添加、删除和切换待办事项,没有任何问题,如下所示:

图 8.3 – Todo 应用的截图

图 8.3 – Todo 应用的截图

然而,从开发者的角度来看,有两个问题,如下所示:

  • 第一个问题是我们需要修改整个todos数组来修改单个项目。在toggleTodo函数中,它需要遍历所有项目并修改其中一个项目。在原子模型中,如果能简单地修改一个项目那就很好了。这也与性能有关。当todos数组的项目被修改时,todos数组本身也会改变。因此,TodoList会重新渲染。多亏了MemoedTodoItemMemoedTodoItem组件只有在特定项目改变时才会重新渲染。理想情况下,我们希望触发那些特定的MemoedTodoItem组件重新渲染。

  • 第二个问题是项目的id值。id值主要用于map中的key,如果能避免使用id那就更好了。

使用 Jotai,我们提出了一种新的模式,原子中的原子,我们将原子配置放在另一个原子值中。这个模式解决了两个问题,并且与 Jotai 的心智模型更一致。

让我们看看如何使用新的模式在这个部分重新创建之前创建的相同的 Todo 应用。

我们首先定义Todo类型,如下所示:

type Todo = {
  title: string;
  done: boolean;
};

这次,Todo类型没有id值。

然后,我们使用PrimitiveAtom创建一个TodoAtom类型,这是 Jotai 库导出的一个泛型类型。代码如下所示:

type TodoAtom = PrimitiveAtom<Todo>;

我们使用这个TodoAtom类型来创建一个todoAtomsAtom配置,如下所示:

const todoAtomsAtom = atom<TodoAtom[]>([]);

名称是明确的,表明这是一个代表TodoAtom数组的atom。这种结构就是为什么这个模式被命名为原子中的原子

这里是TodoItem组件。它接收todoAtomremove属性。组件使用todoAtom原子和useAtom

const TodoItem = ({
  todoAtom,
  remove,
}: {
  todoAtom: TodoAtom;
  remove: (todoAtom: TodoAtom) => void;
}) => {
  const [todo, setTodo] = useAtom(todoAtom);
  return (
    <div>
      <input
        type="checkbox"
        checked={todo.done}
        onChange={() => setTodo(
          (prev) => ({ ...prev, done: !prev.done })
        )}
      />
      <span
        style={{
          textDecoration: 
            todo.done ? "line-through" : "none",
        }}
      >
        {todo.title}
      </span>
      <button onClick={() => remove(todoAtom)}>
        Delete
      </button>
    </div>
  );
};
const MemoedTodoItem = memo(TodoItem);

由于TodoItem组件中的useAtom配置,onChange回调非常简单,只关心项目。它不依赖于它是否是数组中的一个项目。

应该仔细查看TodoList组件。它使用todoAtomsAtom,它返回todoAtoms作为其值。todoatoms变量包含一个todoAtom数组。remove函数很有趣,因为它接受todoAtom作为原子配置,并在todoAtomsAtom中过滤todoAtom数组。TodoList的完整代码如下所示:

const TodoList = () => {
  const [todoAtoms, setTodoAtoms] =
    useAtom(todoAtomsAtom);
  const remove = useCallback(
    (todoAtom: TodoAtom) => setTodoAtoms(
      (prev) => prev.filter((item) => item !== todoAtom)
    ),
    [setTodoAtoms]
  );
  return (
    <div>
      {todoAtoms.map((todoAtom) => (
        <MemoedTodoItem
          key={`${todoAtom}`}
          todoAtom={todoAtom}
          remove={remove}
        />
      ))}
    </div>
  );
};

TodoList遍历todoatoms变量,并为每个todoAtom配置渲染MemoedTodoItem。对于map中的key,我们指定了字符串化的todoAtom配置。原子配置返回的TodoList组件与上一个版本略有不同。因为它处理todoatomsAtom,如果其中一个项目使用toggleTodo被切换,它不会改变。因此,它可以自然地减少一些额外的重新渲染。

NewTodo 组件几乎与前一个示例相同。一个例外是,在创建新项目时,它将创建一个新的原子配置并将其推入 todoAtomsAtom。以下代码片段显示了 NewTodo 组件的代码:

const NewTodo = () => {
  const [, setTodoAtoms] = useAtom(todoAtomsAtom);
  const [text, setText] = useState("");
  const onClick = () => {
    setTodoAtoms((prev) => [
      ...prev,
      atom<Todo>({ title: text, done: false }),
    ]);
    setText("");
  };
  return (
    <div>
      <input
        value={text}
        onChange={(e) => setText(e.target.value)}
      />
      <button onClick={onClick} disabled={!text}>
        Add
      </button>
    </div>
  );
};

代码的其余部分和 NewTodo 组件的行为基本上与前一个示例等效。

最后,我们有相同的 App 组件来运行应用程序,如图所示:

const App = () => (
  <>
    <TodoList />
    <NewTodo />
  </>
);

如果你运行应用程序,你将看不到与前一个示例的差异。如描述的那样,这些差异是为了开发者。

让我们总结一下与 原子内原子 模式的区别,如下所示:

  • 数组原子用于存储项目原子的数组。

  • 要在数组中添加新项目,我们创建一个新的原子并将其添加。

  • 原子配置可以作为字符串进行评估,并返回 UIDs。

  • 一个渲染项目的组件在每个组件中使用项目原子。它简化了项目值的修改,并自然地避免了额外的重新渲染。

在本节中,我们学习了如何处理数组结构。我们看到了两种模式——一种天真模式和一种 原子内原子 模式——以及它们的区别。接下来,我们将学习 Jotai 库提供的其他一些功能。

使用 Jotai 的不同功能

到目前为止,我们已经学习了 Jotai 库的一些基础知识。在本节中,我们将介绍一些更基本的功能,这些功能在处理复杂场景时是必要的。我们还将简要介绍一些高级功能,这些功能的用例超出了本书的范围。

在本节中,我们将讨论以下主题:

  • 定义原子的 write 函数

  • 使用动作原子

  • 理解原子的 onMount 选项

  • 介绍 jotai/utils

  • 理解库的使用

  • 更高级功能的介绍

让我们逐一查看。

定义原子的 write 函数

我们已经看到了如何创建派生原子。例如,doubledCountAtomcountAtom理解 Jotai 如何存储原子值 部分中定义,如下所示:

const countAtom = atom(0);
const doubledCountAtom = atom(
  (get) => get(countAtom) * 2
);

countAtom 被称为原始原子,因为它不是从另一个原子派生出来的。原始原子是一个可写的原子,你可以更改其值。

doubledCountAtom 是一个只读的派生原子,因为它的值完全依赖于 countAtomdoubledCountAtom 的值只能通过更改 countAtom 的值来更改,而 countAtom 是一个可写的原子。

要创建一个可写的派生原子,atom 函数除了接受第一个参数 read 函数外,还接受一个可选的第二个参数 write 函数。

例如,让我们重新定义 doubledCountAtom 以使其可写。我们传递一个 write 函数,该函数将改变 countAtom 的值,如下所示:

const doubledCountAtom = atom(
  (get) => get(countAtom) * 2,
  (get, set, arg) => set(countAtom, arg / 2)
);

write 函数接受三个参数,如下所示:

  • get 是一个返回原子值的函数。

  • set 是一个用于设置原子值的函数。

  • arg 是在更新原子时接收的任意值(在这种情况下,doubledCountAtom)。

使用 write 函数,创建的原子可以像原始原子一样写入。实际上,它并不完全像 countAtom,因为 countAtom 接受一个更新函数,例如 setCount((c) => c + 1)

我们可以技术上创建一个与 countAtom 行为完全相同的新的原子。这会有什么用例?例如,你可以添加日志,如下所示:

const anotherCountAtom = atom(
  (get) => get(countAtom),
  (get, set, arg) => {
    const nextCount = typeof arg === 'function' ?
      arg(get(countAtom)) : arg
    set(countAtom, nextCount)
    console.log('set count', nextCount)
  )
);

anotherCountAtomcountAtom 的工作方式相同,并在设置值时显示一条日志消息。

可写派生原子是一个强大的功能,可以在某些复杂场景中提供帮助。在下一小节中,我们将看到使用 write 函数的另一种模式。

使用动作原子

为了组织状态变更代码,我们通常会创建一个或多个函数。我们可以为此目的使用原子,并将它们称为动作原子。

要创建动作原子,我们只使用 atom 函数第二个参数的 write 函数。第一个参数可以是任何东西,但我们通常使用 null 作为惯例。

让我们看看一个例子。我们有 countAtom 如常,以及 incrementCountAtom,它是一个动作原子,如下所示:

const countAtom = count(0);
const incrementCountAtom(
  null,
  (get, set, arg) => set(countAtom, (c) => c + 1)
);

在这种情况下,incrementCountAtomwrite 函数只使用了三个参数中的 set

我们可以使用这个原子像普通原子一样,只需忽略它的值。例如,这里是一个显示增加计数按钮的组件:

const IncrementButton = () => {
  const [, incrementCount] = useAtom(incrementCountAtom);
  return <button onClick={incrementCount}>Click</button>;
};

这是一个没有参数的简单案例。你可以接受一个参数,并且可以创建任意数量的动作原子。

接下来,我们将看到一个不太常用但很重要的特性。

理解原子的 onMount 选项

在某些用例中,我们希望在原子开始使用时运行某些逻辑。一个很好的例子是订阅外部数据源。这可以通过 useEffect 钩子来完成,但为了在原子级别定义逻辑,Jotai 原子有 onMount 选项。

要了解它是如何使用的,让我们创建一个原子,它在挂载和卸载时显示登录消息,如下所示:

const countAtom = atom(0);
countAtom.onMount = (setCount) => {
  console.log("count atom starts to be used");
  const onUnmount = () => {
    console.log("count atom ends to be used");
  };
  return onUnmount;
};

onMount 函数的主体显示有关使用开始的日志消息。它还返回一个 onUnmount 函数,显示有关使用结束的日志消息。onMount 函数接受一个参数,这是一个用于更新 countAtom 的函数。

这是一个虚构的例子,但有许多实际用例可以连接外部数据源。

接下来,我们将讨论实用函数。

介绍 jotai/utils 包

Jotai 库提供了两个基本函数 atomuseAtom,以及主包中的一个额外的 Provider 组件。虽然小 API 很好理解基本功能,但我们希望有一些实用函数来帮助开发。

Jotai 提供了一个名为 jotai/utils 的单独包,其中包含各种实用函数。例如,atomWithStorage 是一个创建具有特定功能的原子的函数——即与持久存储同步。有关更多信息和其他实用函数,请参阅项目网站 github.com/pmndrs/jotai

接下来,我们将讨论如何在其他库中使用 Jotai 库。

理解库的使用

假设有两个库在内部使用 Jotai 库。如果我们开发一个使用这两个库的应用程序,将存在双重提供者的问题。因为 Jotai 原子通过引用来区分,所以第一个库中的原子可能会意外地连接到第二个库中的提供者。结果,它可能无法按库作者的预期工作。Jotai 库提供了一个“作用域”的概念,这是连接到特定提供者的方式。为了使其按预期工作,我们应该将相同的范围变量传递给Provider组件和useAtom钩子。

在实现方面,这是 Context 的工作方式。作用域功能只是用来恢复 Context 功能。如何使用此功能进行其他目的仍在探索中。作为社区的一员,我们将利用此功能进行更多用例的开发。

最后,我们将看到 Jotai 库中的一些高级功能。

高级功能介绍

在这本书中,我们还没有涵盖更多高级功能。

最值得注意的是,Jotai 支持 React Suspense 功能。当一个派生原子的read函数返回一个 promise 时,useAtom钩子将暂停,React 将显示一个回退。这个功能是实验性的,可能会发生变化,但它是一个非常重要的功能值得探索。

另一个需要注意的是关于库的集成。Jotai 是一个使用原子模型解决单个问题的库,即避免额外的重新渲染。通过与其他库集成,使用场景得以扩展。原子模型具有足够的灵活性,可以与其他库集成,特别是对于外部数据源,onMount选项是必要的。

要了解更多关于这些高级功能的信息,请参考项目网站:

github.com/pmndrs/jotai

在本节中,我们讨论了 Jotai 库提供的其他一些功能。Jotai 是一个提供构建块的原始库,但足够灵活,可以覆盖实际使用场景。

摘要

在本章中,我们学习了名为 Jotai 的库。它基于原子模型和 Context,我们通过简单的示例学习了其基础知识,但它们展示了原子模型的灵活性。Context 和订阅的组合是唯一实现面向 React 的全局状态的方法。如果你的需求是 Context 且没有额外的重新渲染,这种方法应该是你的选择。

在下一章中,我们将学习另一个名为 Valtio 的库,这是一个主要用于模块状态的库,具有独特的语法。

第九章:用例场景 3 – Valtio

Valtio (github.com/pmndrs/valtio) 是另一个用于全局状态的库。与 Zustand 和 Jotai 不同,它基于可变更新模型。它主要用于模块状态,如 Zustand。它利用代理获取不可变快照,这是与 React 集成所需的。

API 只是 JavaScript,所有操作都在幕后进行。它还利用代理自动优化重新渲染。它不需要选择器来控制重新渲染。自动渲染优化基于一种称为 状态使用跟踪 的技术。使用状态使用跟踪,它可以检测状态中哪些部分被使用,并且只有当状态的使用部分发生变化时,它才会让组件重新渲染。最终,开发者需要编写的代码更少。

在本章中,我们将了解 Valtio 库的基本用法以及它如何处理可变更新。快照是创建不可变状态的关键特性。我们还将讨论快照和代理如何帮助我们优化重新渲染。

在本章中,我们将涵盖以下主题:

  • 探索 Valtio,另一个模块状态库

  • 利用代理检测突变并创建不可变状态

  • 使用代理优化重新渲染

  • 创建小型应用程序代码

  • 这种方法的优缺点

技术要求

预期你具备一定的 React 知识,包括 React Hooks。请参考官方网站 reactjs.org 了解更多。

在某些代码中,我们使用 TypeScript (www.typescriptlang.org),你应该对其有基本了解。

本章中的代码可在 GitHub 上找到:github.com/PacktPublishing/Micro-State-Management-with-React-Hooks/tree/main/chapter_09

要运行代码片段,你需要一个 React 环境,例如,Create React App (create-react-app.dev) 或 CodeSandbox (codesandbox.io)。

探索 Valtio,另一个模块状态库

Valtio 是一个主要用于模块状态的库,与 Zustand 相同。

正如我们在 第七章 中学到的,用例场景 1 – Zustand,我们在 Zustand 中创建存储的方式如下:

const store = create(() => ({
  count: 0,
  text: "hello",
}));

store 变量有一些属性,其中之一是 setState。使用 setState,我们可以更新状态。例如,以下代码是增加 count 值:

store.setState((prev) => ({
  count: prev.count + 1,
}))

为什么我们需要使用 setState 来更新状态值?因为我们希望以不可变的方式更新状态。内部,之前的 setState 工作方式如下:

moduleState = Object.assign({}, moduleState, {
  count: moduleState.count + 1
});

这是更新对象的不变方式。

让我们想象一个不需要遵循不可变更新规则的情况。在这种情况下,增加 moduleStatecount 值的代码如下:

++moduleState.count;

如果我们能够编写这样的代码并且让它与 React 一起工作,那岂不是很好?实际上,我们可以使用代理来实现这一点。

代理是 JavaScript 中的一个特殊对象(developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy)。我们可以定义一些处理器来捕获对象操作。例如,你可以添加一个 set 处理器来捕获对象变更:

const proxyObject = new Proxy({
  count: 0,
  text: "hello",
}, {
  set: (target, prop, value) => {
    console.log("start setting", prop);
    target[prop] = value;
    console.log("end setting", prop);
  },
});

我们使用 new Proxy 和两个参数创建 proxyObject。第一个参数是一个对象本身。第二个参数是一个包含处理器的集合对象。在这种情况下,我们有一个 set 处理器,它捕获 set 操作并添加 console.log 语句。

proxyObject 是一个特殊对象,当你设置一个值时,它将在设置值前后向控制台记录日志。以下是在 Node.js REPL 中运行代码的屏幕输出(nodejs.dev/learn/how-to-use-the-nodejs-repl):

> ++proxyObject.count
start setting count
end setting count
1

从概念上讲,由于代理可以检测任何变更,我们可以技术上使用与 Zustand 中的 setState 相似的行为。Valtio 是一个利用代理来检测状态变更的库。

在本节中,我们了解到 Valtio 是一个使用变更更新模型的库。接下来,我们将学习 Valtio 如何通过变更创建不可变状态。

利用代理检测变更并创建不可变状态

Valtio 使用代理从可变对象创建不可变对象。我们称这个不可变对象为 快照

要创建一个被代理对象包装的可变对象,我们使用 Valtio 导出的 proxy 函数。

以下示例是创建一个具有 count 属性的对象:

import { proxy } from "valtio";
const state = proxy({ count: 0 });

proxy 函数返回的 state 对象是一个检测变更的代理对象。这允许你创建一个不可变对象。

要创建一个不可变对象,我们使用 Valtio 导出的 snapshot 函数,如下所示:

import { snapshot } from "valtio";
const snap1 = snapshot(state);

虽然变量 state{ count: 0 } 并且 snap1 变量也是 { count: 0 },但 statesnap1 有不同的引用。state 是一个被代理包装的可变对象,而 snap1 是使用 Object.freeze 冻结的不可变对象(developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/freeze)。

让我们看看快照是如何工作的。我们变更 state 对象并创建另一个快照,如下所示:

++state.count;
const snap2 = snapshot(state);

变量 state{ count: 1 } 并且与之前有相同的引用。变量 snap2{ count: 1 } 并且有新的引用。因为 snap1snap2 是不可变的,我们可以使用 snap1 === snap2 来检查它们的相等性,并知道对象中是否有任何差异。

proxysnapshot 函数适用于嵌套对象,并优化了快照的创建。这意味着只有在其属性发生变化时,snapshot 函数才会创建新的快照。让我们看看另一个例子。state2 有两个嵌套的 c 属性:

const state2 = proxy({
  obj1: { c: 0 },
  obj2: { c: 0 },
});
const snap21 = snapshot(state2)
++state2.obj.c;
const snap22 = snapshot(state2)

在这种情况下,snap21 变量是 { obj1: { c: 0 }, obj2: { c: 0 } },而 snap22 变量是 { obj1: { c: 1 }, obj2: { c: 0 } }snap21snap22 有不同的引用,因此 snap21 !== snap22 成立。

关于嵌套对象呢?snap21.obj1snap22.obj1 是不同的,但 snap21.obj2snap22.obj2 是相同的。这是因为 obj2 的内部 c 属性的值没有改变。obj2 不需要改变,因此 snap21.obj2 === snap22.obj2 成立。

这种快照优化是一个重要特性。snap21.obj2snap22.obj2 有相同的引用意味着它们共享内存。Valtio 只在必要时创建快照,优化内存使用。这种优化可以在 Zustand 中完成,但开发者有责任正确创建新的不可变状态。相比之下,Valtio 在幕后进行优化。在 Valtio 中,开发者无需承担创建新不可变状态的责任。

重要提示

Valtio 的优化基于与先前快照的缓存。换句话说,缓存大小为 1。如果我们使用 ++state.count 增加计数,然后使用 --state.count 减少它,将创建一个新的快照。

在本节中,我们学习了 Valtio 如何自动创建不可变状态“快照”。接下来,我们将学习 Valtio 为 React 提供的钩子。

使用代理优化重新渲染

Valtio 使用代理来优化重新渲染,以及检测突变。这是我们学习到的优化重新渲染的模式,在 第六章检测属性访问 部分,介绍全局状态库

让我们通过一个计数器应用程序来了解 Valtio 钩子的使用和行为。这个钩子叫做 useSnapshotuseSnapshot 的实现基于 snapshot 函数和另一个代理来包装它。这个 snapshot 代理与 proxy 函数中使用的代理有不同的目的。snapshot 代理用于检测快照对象的属性访问。我们将看到渲染优化是如何通过 snapshot 代理来实现的。

我们从导入 Valtio 的函数开始创建计数器应用程序:

import { proxy, useSnapshot } from "valtio";

proxyuseSnapshot 是 Valtio 提供的两个主要函数,它们涵盖了大多数用例。

我们然后使用 proxy 创建一个 state 对象。在我们的计数器应用程序中,有两个计数器 - count1count2

const state = proxy({
  count1: 0,
  count2: 0,
});

proxy 函数接受一个初始对象并返回一个新的代理对象。我们可以像喜欢的那样修改 state 对象。

接下来,我们定义 Counter1 组件,它使用 state 对象并显示 count1 属性:

const Counter1 = () => {
  const snap = useSnapshot(state);
  const inc = () => ++state.count1;
  return (
    <>
      {snap.count1} <button onClick={inc}>+1</button>
    </>
  );
};

我们的习惯是将 useSnapshot 的返回值命名为 nameinc 动作是一个用于修改 state 对象的函数。我们修改 state 代理对象;snap 仅用于读取。snap 对象使用 Object.freeze 冻结(developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/freeze)并且技术上不能被修改。没有 Object.freeze,JavaScript 对象始终是可变的,我们只能按照惯例将其视为不可变。snap.count1 是访问 state 对象的 count1 属性。这种访问被 useSnapshot 钩子检测为跟踪信息,并且基于跟踪信息,useSnapshot 钩子仅在必要时触发重新渲染。

我们同样定义 Counter2 组件:

const Counter2 = () => {
  const snap = useSnapshot(state);
  const inc = () => ++state.count2;
  return (
    <>
      {snap.count2} <button onClick={inc}>+1</button>
    </>
  );
};

Counter1 的区别在于它使用 count2 属性而不是 count1 属性。如果我们想定义一个共享组件,我们可以定义一个单独的组件并在 props 中取属性名。

最后,我们定义 App 组件。由于我们不使用 Context,因此没有提供者:

const App = () => (
  <>
    <div><Counter1 /></div>
    <div><Counter2 /></div>
  </>
);

这个应用是如何工作的呢?在初始渲染时,state 对象是 { count1: 0, count2: 0 },其快照对象也是如此。Counter1 组件访问快照对象的 count1 属性,而 Counter2 组件访问快照对象的 count2 属性。每个 useSnapshot 钩子都知道并记住跟踪信息。跟踪信息表示访问了哪个属性。

当我们在 Counter1 组件(图 9.1 中的第一个按钮)中点击按钮时,它增加 state 对象的 count1 属性:

图 9.1 – 计数器应用的第一张截图

图 9.1 – 计数器应用的第一张截图

因此,state 对象变为 { count1: 1, count2: 0 }Counter1 组件使用新的数字 1 重新渲染。然而,Counter2 组件不会重新渲染,因为 count2 仍然是 0 并且没有变化(图 9.2):

图 9.2 – 计数器应用的第二张截图

图 9.2 – 计数器应用的第二张截图

使用跟踪信息优化重新渲染。

在我们的计数器应用中,state 对象很简单,有两个具有数值属性的属性。Valtio 支持嵌套对象和数组。一个虚构的例子如下:

const contrivedState = proxy({
  num: 123,
  str: "hello",
  arr: [1, 2, 3],
  nestedObject: { foo: "bar" },
  objectArray: [{ a: 1 }, { b: 2 }],
});

基本上,任何包含普通对象和数组的对象都完全支持,即使它们嵌套得很深。更多信息,请参阅项目网站:github.com/pmndrs/valtio

在本节中,我们学习了 Valtio 如何通过快照和代理优化重新渲染。在下一节中,我们将通过示例学习如何构建一个应用。

创建小型应用程序代码

我们将学习如何创建一个小型应用。我们的示例应用是一个待办事项应用。Valtio 对应用的结构没有特定意见。这是其中一种典型模式。

让我们看看待办应用可以如何构建。首先,我们定义Todo类型:

type Todo = {
  id: string;
  title: string;
  done: boolean;
};

一个Todo项目有一个字符串类型的id值,一个字符串类型的title值,以及一个布尔类型的done值。

然后我们使用定义的Todo类型定义一个state对象:

const state = proxy<{ todos: Todo[] }>({
  todos: [],
});

state对象是通过用proxy包装一个初始对象来创建的。

为了操作state对象,我们定义了一些辅助函数 – addTodo用于添加一个新的待办事项,removeTodo用于删除它,以及toggleTodo用于切换完成状态:

const createTodo = (title: string) => {
  state.todos.push({
    id: nanoid(),
    title,
    done: false,
  });
};
const removeTodo = (id: string) => {
  const index = state.todos.findIndex(
    (item) => item.id === id
  );
  state.todos.splice(index, 1);
};
const toggleTodo = (id: string) => {
  const index = state.todos.findIndex(
    (item) => item.id === id
  );
  state.todos[index].done = !state.todos[index].done;
};

nanoid是一个用于生成唯一 ID 的小函数(www.npmjs.com/package/nanoid)。注意这三个函数都是基于正常的 JavaScript 语法。它们将state当作一个正常的 JavaScript 对象来处理。这是通过代理实现的。

以下是一个TodoItem组件,它具有与完成状态相关的复选框切换、具有不同样式的文本,以及一个用于删除项目的按钮:

const TodoItem = ({
  id,
  title,
  done,
}: {
  id: string;
  title: string;
  done: boolean;
}) => {
  return (
    <div>
      <input
        type="checkbox"
        checked={done}
        onChange={() => toggleTodo(id)}
      />
      <span
        style={{
          textDecoration: done ? "line-through" : "none",
        }}
      >
        {title}
      </span>
      <button onClick={() => removeTodo(id)}>
        Delete
      </button>
    </div>
  );
};
const MemoedTodoItem = memo(TodoItem);

注意这个组件分别接收idtitledone属性,而不是接收todo对象。这是因为我们使用了memo函数并创建了MemoedTodoItem组件。我们的状态使用跟踪检测属性访问,如果我们向 memoed 组件传递一个对象,属性访问将被省略。

要使用MemoedTodoItem组件,TodoList组件使用useSnapshot定义,如下所示:

const TodoList = () => {
  const { todos } = useSnapshot(state);
  return (
    <div>
      {todos.map((todo) => (
        <MemoedTodoItem
          key={todo.id}
          id={todo.id}
          title={todo.title}
          done={todo.done}
        />
      ))}
    </div>
  );
};

这个组件从useSnapshot的结果中获取todos,并访问todos数组中对象的全部属性。因此,如果todos的任何部分发生变化,useSnapshot将触发重新渲染。这不是一个大问题,这是一个有效的模式,因为MemoedTodoItem组件只有在idtitledone发生变化时才会重新渲染。我们将在本节稍后学习另一种模式。

要创建一个新的待办事项,以下是一个小的组件,它具有输入字段的本地状态,并在点击添加按钮时调用createTodo

const NewTodo = () => {
  const [text, setText] = useState("");
  const onClick = () => {
    createTodo(text);
    setText("");
  };
  return (
    <div>
      <input
        value={text}
        onChange={(e) => setText(e.target.value)}
      />
      <button onClick={onClick} disabled={!text}>
        Add
      </button>
    </div>
  );
};

最后,我们在App组件中组合定义的组件:

const App = () => (
  <>
    <TodoList />
    <NewTodo />
  </>
);

让我们看看这个应用是如何工作的:

  1. 起初,它只有一个文本字段和一个添加按钮(图 9.3):图 9.3 – todos 应用的第一次截图

    图 9.3 – todos 应用的第一次截图

  2. 如果我们点击添加按钮,将添加一个新的项目(图 9.4):图 9.4 – todos 应用的第二次截图

    图 9.4 – todos 应用的第二次截图

  3. 我们可以添加尽可能多的项目(图 9.5):图 9.5 – todos 应用的第三张截图

    图 9.5 – todos 应用的第三张截图

  4. 点击复选框将切换完成状态(图 9.6):图 9.6 – todos 应用的第四次截图

    图 9.6 – todos 应用的第四张截图

  5. 点击删除按钮将删除项目(图 9.7):

图 9.7 – todos 应用的第五张截图

图 9.7 – todos 应用的第五张截图

我们迄今为止创建的应用程序运行得相当好。但在额外重新渲染方面仍有改进的空间。当我们切换现有项目的 done 状态时,不仅相应的 TodoItem 组件,而且 TodoList 组件也会重新渲染。正如所提到的,只要 TodoList 组件本身相对较轻量,这并不是一个大问题。

我们还有一个模式来消除 TodoList 组件中的额外重新渲染。这并不意味着整体性能总能得到提升。我们应该采取哪种方法取决于具体的应用程序。

在新的方法中,我们在每个 TodoItem 组件中使用 useSnapshotTodoItem 组件只接收 id 属性。以下是被修改的 TodoItem 组件:

const TodoItem = ({ id }: { id: string }) => {
  const todoState = state.todos.find(
    (todo) => todo.id === id
  );
  if (!todoState) {
    throw new Error("invalid todo id");
  }
  const { title, done } = useSnapshot(todoState);
  return (
    <div>
      <input
        type="checkbox"
        checked={done}
        onChange={() => toggleTodo(id)}
      />
      <span
        style={{
          textDecoration: done ? "line-through" : "none",
        }}
      >
        {title}
      </span>
      <button onClick={() => removeTodo(id)}>
        Delete
      </button>
    </div>
  );
};
const MemoedTodoItem = memo(TodoItem);

根据 id 属性,它找到 todoState,使用 useSnapshottodoState,并获取 titledone 属性。只有当 idtitledone 属性发生变化时,此组件才会重新渲染。

现在,让我们看看修改后的 TodoList 组件。与之前的版本不同,它只需要传递 id 属性:

const TodoList = () => {
  const { todos } = useSnapshot(state);
  const todoIds = todos.map((todo) => todo.id);
  return (
    <div>
      {todoIds.map((todoId) => (
        <MemoedTodoItem key={todoId} id={todoId} />
      ))}
    </div>
  );
};

因此,todoIds 是从每个 todo 对象的 id 属性创建的。只有当 id 的顺序发生变化,或者添加或删除某些 id 时,此组件才会重新渲染。如果只是更改现有项目的 done 状态,此组件不会重新渲染。因此,消除了额外的重新渲染。

在中等大小的应用程序中,两种方法在性能方面的变化是微妙的。这两种方法对不同的编码模式更有意义。开发者可以选择他们更熟悉的那个。

在本节中,我们通过一个小型应用学习了 useSnapshot 的使用场景。接下来,我们将讨论这个库以及一般方法的优缺点。

这种方法的优缺点

我们已经看到了 Valtio 的工作原理,一个问题是我们在什么时候应该使用它,在什么时候不应该使用它。

一个很大的方面是心理模型。我们有两种状态更新模型。一个是不可变更新,另一个是可变更新。虽然 JavaScript 本身允许可变更新,但 React 是围绕不可变状态构建的。因此,如果我们混合这两种模型,我们应该小心不要让自己困惑。一个可能的解决方案是将 Valtio 状态和 React 状态明确分开,以便心理模型切换是合理的。如果它有效,Valtio 就可以适应。否则,也许我们应该坚持不可变更新。

可变更新的主要好处是我们可以使用原生 JavaScript 函数。

例如,可以使用以下方式从数组中删除具有 index 值的项目:

array.splice(index, 1)

在不可变更新中,这并不那么容易。例如,可以使用 slice 来编写,如下所示:

[...array.slice(0, index), ...array.slice(index + 1)]

另一个例子是更改深层嵌套对象中的值。可以在可变更新中这样做:

state.a.b.c.text = "hello";

在不可变更新中,它必须类似于以下内容:

{
  ...state,
  a: {
    ...state.a,
    b: {
      ...state.a.b,
      c: {
        ...state.a.b.c,
        text: "hello",
      },
    },
  },
}

这样写并不愉快。Valtio 帮助减少具有可变更新的应用程序代码。

Valtio 还帮助减少基于代理的渲染优化的应用程序代码。

假设我们有一个具有counttext属性的状态,如下所示:

const state = proxy({ count: 0, text: "hello" });

如果我们只在一个组件中使用count,我们可以在 Valtio 中写出以下内容:

const Component = () => {
  const { count } = useSnapshot(state);
  return <>{count}</>;
};

相比之下,使用 Zustand,它将类似于以下内容:

const Component = () => {
  const count = useStore((state) => state.count);
  return <>{count}</>;
};

差异微不足道,但我们有两个地方都有count

让我们看看一个假设的场景。假设我们想在showText属性为真时显示text值。使用useSnapshot,可以这样做:

const Component = ({ showText }) => {
  const snap = useSnapshot(state);
  return <>{snap.count} {showText ? snap.text : ""}</>;
};

使用基于选择器的钩子实现相同的行为很困难。一个解决方案是使用钩子两次。使用 Zustand,它将类似于以下内容:

const Component = ({ showText }) => {
  const count = useStore((state) => state.count);
  const text = useStore(
    (state) => showText ? state.text : ""
  );
  return <>{count} {text}</>;
};

这意味着如果我们有更多条件,我们需要更多钩子。

另一方面,基于代理的渲染优化的一个缺点可能是可预测性较低。代理在幕后处理渲染优化,有时很难调试行为。有些人可能更喜欢显式的基于选择器的钩子。

总结来说,没有一种适合所有情况的解决方案。选择适合他们需求的解决方案取决于开发者。

在本节中,我们讨论了 Valtio 库采用的方法。

摘要

在本章中,我们了解了一个名为 Valtio 的库。它广泛使用代理。我们已经看到了示例,并学习了如何使用它。它允许修改状态,感觉就像使用正常的 JavaScript 对象一样,并且基于代理的渲染优化有助于减少应用程序代码。这种方法是否是一个好的选择取决于开发者的需求。

在下一章中,我们将了解另一个名为 React Tracked 的库,这是一个基于 Context 的库,类似于 Valtio,具有基于代理的渲染优化。

第十章:用例场景 4 – React Tracked

React Tracked (react-tracked.js.org) 是一个用于状态使用跟踪的库,它根据属性访问自动优化重新渲染。它提供了与 Valtio 相同的功能,我们在 第九章用例场景 3 – Valtio 中讨论过,以消除额外的重新渲染。

React Tracked 可以与其他状态管理库一起使用。主要用例是 useStateuseReducer,但它也可以与 Redux (redux.js.org)、Zustand(在第 第七章用例场景 1 – Zustand)和其他类似库一起使用。

在本章中,我们将再次讨论使用状态使用跟踪优化重新渲染,并比较相关库。我们将学习 React Tracked 的两种用法,一种与 useState 一起使用,另一种与 React Redux (react-redux.js.org) 一起使用。我们将以查看 React Tracked 将如何与 React 的未来版本一起工作来结束。

在本章中,我们将涵盖以下主题:

  • 理解 React Tracked

  • 使用 React Tracked 和 useState 以及 useReducer

  • 使用 React Tracked 和 React Redux

  • 未来展望

技术要求

预期你有一定的 React 知识,包括 React Hooks。请参考官方网站 reactjs.org 了解更多。

在某些代码中,我们使用了 TypeScript (www.typescriptlang.org),你应该对其有基本了解。

本章的代码可在 GitHub 上找到:github.com/PacktPublishing/Micro-State-Management-with-React-Hooks/tree/main/chapter_10

要运行代码片段,你需要一个 React 环境 - 例如,Create React App (create-react-app.dev) 或 CodeSandbox (codesandbox.io)。

理解 React Tracked

我们已经学习了几种全局状态库,但 React Tracked 与我们之前学过的略有不同。React Tracked 不提供状态功能,但它提供的是渲染优化功能。我们称这种功能为 状态使用跟踪

让我们回顾一下 React Context 的行为,因为 React Tracked 中状态使用跟踪的一个用例是针对 React Context。

假设我们使用 createContext 定义了一个 Context,如下所示:

const NameContext = createContext([
  { firstName: 'react', lastName: 'hooks' },
  () => {},
]);

createContext 接收一个初始值,在这个例子中是一个数组。数组中的第一个元素是一个初始状态对象。数组中的第二个元素,() => {},是一个占位符更新函数。

我们之所以将这样的数组作为初始值,是为了匹配 useState 的返回值。我们经常使用 useState 定义 NameProvider 以实现全局状态:

const NameProvider = ({ children }) => (
  <NameContext.Provider
    value={
      useState({ firstName: 'react', lastName: 'hooks' })
    }
  >
    {children}
  </NameContext.Provider>
};

你通常应该在根组件或靠近根组件的某个组件中使用 NameProvider 组件。

现在我们有了 NameProvider 组件,我们可以在其树结构下使用它。为了使用 Context 值,我们使用 useContext。假设我们只需要 firstName,并定义一个 useFirstName 钩子:

const useFirstName = () => {
  const [{ firstName }] = useContext(NameContext);
  return firstName;
};

这没问题。然而,存在额外的重新渲染的可能性。如果我们只更新 lastName 而不改变 firstName,新的 Context 值将被传播,并且 useContext(NameContext) 触发重新渲染。useFirstName 钩子只从 Context 值中读取 firstName。因此,这成为了一个额外的重新渲染。

从实现的角度来看,这种行为是明显的。但从开发者的角度来看,这并不理想,因为它只使用了 Context 值中的 firstName。从开发者的角度来看,期望它不依赖于其他属性——在这种情况下,lastName

状态使用跟踪是实现这种预期行为的功能。如果我们只在状态对象中使用 firstName,我们期望钩子只在 firstName 变化时触发重新渲染。这可以通过代理实现。

React Tracked 允许我们定义一个名为 useTracked 的钩子,它可以替代 useContext(NameContext)useTracked 使用代理包装状态并跟踪其使用情况。useTracked 的预期用法如下:

const useFirstName = () => {
  const [{ firstName }] = useTracked();
  return firstName;
};

这种用法与 useContext(NameContext) 的用法没有区别。这正是状态使用跟踪的全部要点。我们的代码看起来和平时一样,但幕后它跟踪状态使用并自动优化渲染。

自动渲染优化在 第九章用例场景 3 – Valtio 中进行了讨论。React Tracked 和 Valtio 使用相同的状态使用跟踪功能。实际上,它们使用相同的内部库,称为 proxy-comparegithub.com/dai-shi/proxy-compare

在本节中,我们回顾了状态使用跟踪,并学习了它如何优化重新渲染。在下一节中,我们将学习如何使用 useStateuseReducer 与 React Tracked 一起使用。

使用 useStateuseReducer 与 React Tracked

React Tracked 的主要用例是替换 React Context 的一个用例。React Tracked 中的 API 专门为此用例设计。

我们将探索使用 useStateuseReducer 的两种用法。首先,让我们了解使用 useState 的用法。

使用 useState 和 React Tracked

在探索使用 useState 的 React Tracked 用法之前,让我们回顾一下如何使用 React Context 创建全局状态。

我们首先创建一个自定义钩子,它使用初始状态值调用 useState

const useValue = () =>
  useState({ count: 0, text: "hello" });

定义自定义钩子对于 TypeScript 来说是好的,因为你可以使用 typeof 操作符来获取类型。

以下是我们 Context 的定义:

const StateContext = createContext<
  ReturnType<typeof useValue> | null
>(null);

它在 TypeScript 中有一个类型注解。默认值是 null

要使用 Context,我们需要一个 Provider 组件。以下是一个使用 useValue 作为 Context 值的自定义 Provider

const Provider = ({ children }: { children: ReactNode }) => (
  <StateContext.Provider value={useValue()}>
    {children}
  </StateContext.Provider>
);

这是一个注入 StateContext.Provider 组件的组件。由于我们单独定义了 useValueProvider 的实现可以使用它,在 JavaScript 语法扩展JSX)中。

要消费 Context 的值,我们使用 useContext。我们如下定义一个自定义钩子:

const useStateContext = () => {
  const contextValue = useContext(StateContext);
  if (contextValue === null) {
    throw new Error("Please use Provider");
  }
  return contextValue;
};

这个自定义钩子通过比较 contextValuenull 来检查 Provider 的存在。如果是 null,它将抛出一个错误,开发者将注意到 Provider 缺失。

现在,是时候为应用定义一些组件了。第一个组件是 Counter,它显示状态中的 count 属性以及一个用于增加 count 值的按钮:

const Counter = () => {
  const [state, setState] = useStateContext();
  const inc = () => {
    setState((prev) => ({
      ...prev,
      count: prev.count + 1,
    }));
  };
  return (
    <div>
      count: {state.count}
      <button onClick={inc}>+1</button>
    </div>
  );
};

注意,useStateContext 返回一个包含 state 值和更新函数的元组。这与 useValue 返回的内容完全相同。

接下来,我们定义第二个组件,TextBox,它显示状态中 text 属性的输入字段:

const TextBox = () => {
  const [state, setState] = useStateContext();
  const setText = (text: string) => {
    setState((prev) => ({ ...prev, text }));
  };
  return (
    <div>
      <input
        value={state.text}
        onChange={(e) => setText(e.target.value)}
      />
    </div>
  );
};

我们再次使用 useStateContext 并获取 state 值和 setState 函数。setText 函数接受一个字符串参数并调用 setState 函数。

最后,我们定义 App 组件,它包含 ProviderCounterTextBox 组件:

const App = () => (
  <Provider>
    <div>
      <Counter />
      <Counter />
      <TextBox />
      <TextBox />
    </div>
  </Provider>
);

这个应用是如何工作的呢?Context 将状态对象作为一个整体处理,当状态对象发生变化时,useContext 将触发重新渲染。即使状态对象中只有一个属性发生变化,所有的 useContext 钩子都会触发重新渲染。这意味着如果我们点击 Counter 组件中的按钮,它将增加状态对象的 count 属性,并导致 CounterTextBox 组件重新渲染。当 Counter 组件重新渲染时,会显示新的 count 值,而 TextBox 组件会重新渲染相同的 text 值。这是一个额外的重新渲染。

Context 的额外重新渲染行为是预期的,如果我们想避免它,我们应该将其拆分成更小的部分。请参阅 第三章使用 Context 共享组件状态,以了解更多关于 React Context 的最佳实践。

现在,React Tracked 看起来是什么样子呢?让我们将之前的示例转换为一个新的示例,使用 React Tracked。首先,我们从 React Tracked 库中导入 createContainer

import { createContainer } from "react-tracked";

我们随后使用在 const useValue = () => useState({ count: 0, text: "hello" }); 中定义的 useValue 钩子并调用 createContainer 函数:

const { Provider, useTracked } = 
  createContainer(useValue);

从结果中提取了 ProvideruseTrackedProvider 组件可以像本节前一个示例中那样使用。useTracked 钩子可以像本节前一个示例中定义的 useStateContext 钩子那样使用。

使用新的 useTracked 钩子,Counter 组件如下所示:

const Counter = () => {
  const [state, setState] = useTracked();
  const inc = () => {
    setState(
      (prev) => ({ ...prev, count: prev.count + 1 })
    );
  };
  return (
    <div>
      count: {state.count}
      <button onClick={inc}>+1</button>
    </div>
  );
};

我们只是将 useStateContext 替换为 useTracked。其余的代码保持不变。

同样,以下是新 TextBox 组件:

const TextBox = () => {
  const [state, setState] = useTracked();
  const setText = (text: string) => {
    setState((prev) => ({ ...prev, text }));
  };
  return (
    <div>
      <input
        value={state.text}
        onChange={(e) => setText(e.target.value)}
      />
    </div>
  );
};

唯一的改变是将 useStateContext 替换为 useTracked

App 组件与该节前一个示例中的完全相同,使用新的 Provider 组件:

const App = () => (
  <Provider>
    <div>
      <Counter />
      <Counter />
      <TextBox />
      <TextBox />
    </div>
  </Provider>
);

这个新应用程序是如何表现的?useTracked 返回的 state 对象是受跟踪的,这意味着 useTracked 钩子会记住 state 的哪些属性被访问。只有当访问的属性发生变化时,useTracked 钩子才会触发重新渲染。因此,如果你在 Counter 组件中点击按钮,只有 Counter 组件会重新渲染,而 TextBox 组件不会重新渲染,如下所示:

图 10.1 – 使用 React Tracked 和 useState 的应用程序截图

图 10.1 – 使用 React Tracked 和 useState 的应用程序截图

实质上,我们改变的是 createContainer 而不是 createContext,以及 useTracked 而不是 useStateContext。这个结果给我们带来了优化的重新渲染。这是状态使用跟踪功能。

我们传递给 createContainer 函数的 useValue 自定义钩子可以是任何东西,只要它返回一个类似于 useState 的元组即可。让我们看看另一个使用 useReducer 的示例。

使用 React Tracked 和 useReducer

在这个示例中,我们使用 useReducer 而不是 useStateuseReducer 钩子是一个具有更多功能的先进钩子,但它主要是语法上的差异。请参阅 第一章Exploring the similarity and difference between useState and useReducer 部分,What Is Micro State Management with React Hooks?,以获取更详细的讨论。

关于 useReducer 的重要注意事项

useReducer 钩子是官方的 React 钩子。它接受一个用于更新状态的 reducer 函数。reducer 函数是一种编程模式,与 React 或 JavaScript 无关。useReducer 钩子将此模式应用于状态。在 React 中,reducer 函数因 Redux 而流行。useReducer 在 reduce 模式方面涵盖了 Redux 的使用案例。然而,它并没有涵盖 Redux 的其他使用案例,例如 React Redux 和 store enhancer 或 middleware。与 Redux 不同,useReducer 钩子接受任何类型的动作。

新的 useValue 钩子使用 useReduceruseEffectuseReducer 使用一个还原函数和一个初始状态来定义。useEffect 有一个将状态值记录到控制台的功能。以下是在 TypeScript 中的 useValue 代码:

const useValue = () => {
  type State = { count: number; text: string };
  type Action =
    | { type: "INC" }
    | { type: "SET_TEXT"; text: string };
  const [state, dispatch] = useReducer(
    (state: State, action: Action) => {
      if (action.type === "INC") {
        return { ...state, count: state.count + 1 };
      }
      if (action.type === "SET_TEXT") {
        return { ...state, text: action.text };
      }
      throw new Error("unknown action type");
    },
    { count: 0, text: "hello" }
  );
  useEffect(() => {
    console.log("latest state", state);
  }, [state]);
  return [state, dispatch] as const;
};

Reducer 函数接受 INCSET_TEXT 的动作类型。useEffect 钩子在控制台日志中使用,但不仅限于它。例如,它可以与远程资源交互。useValue 钩子返回一个包含 statedispatch 的元组。只要返回的元组遵循这种形状,我们就可以按我们的喜好实现钩子。例如,我们可以使用多个 useState 钩子。

使用新的 useValue 钩子,我们运行 createContainer

const { Provider, useTracked } = createContainer(useValue);

我们使用 createContainer 的方式不会改变,即使我们改变了 useValue

使用新的 useTracked 钩子,我们实现 Counter 组件:

const Counter = () => {
  const [state, dispatch] = useTracked();
  const inc = () => dispatch({ type: "INC" });
  return (
    <div>
      count: {state.count}
      <button onClick={inc}>+1</button>
    </div>
  );
};

因为 useTracked 返回的元组形状与 useValue 返回的形状相同,所以我们把元组的第二个项目命名为 dispatch,这是一个分发动作的函数。Counter 组件分发一个 INC 动作。

接下来是 TextBox 组件:

const TextBox = () => {
  const [state, dispatch] = useTracked();
  const setText = (text: string) => {
    dispatch({ type: "SET_TEXT", text });
  };
  return (
    <div>
      <input
        value={state.text}
        onChange={(e) => setText(e.target.value)}
      />
    </div>
  );
};

同样,dispatch 函数用于 SET_TEXT 动作。

最后,我们有 App 组件:

const App = () => (
  <Provider>
    <div>
      <Counter />
      <Counter />
      <TextBox />
      <TextBox />
    </div>
  </Provider>
);

新的 App 组件的行为与上一个完全相同。与 useStateuseReducer 的示例相比,区别在于 useValue 返回一个包含 statedispatch 的元组;因此 useTracked 也返回一个包含 statedispatch 的元组。

React Tracked 可以优化重新渲染的原因不仅在于状态使用跟踪,还在于其内部库 use-context-selector (github.com/dai-shi/use-context-selector)。它允许我们使用 selector 函数订阅 Context 值。这种订阅绕过了 React Context 的限制。

在本节中,我们看到了一个基本的与裸 React Context 的示例,以及两个与 React Tracked 和 useStateuseReducer 一起的示例。在下一节中,我们将学习使用 React Redux 的 React Tracked 的一个用法,它使用状态使用跟踪功能而不使用 use-context-selector

使用 React Tracked 和 React Redux

React Tracked 的主要用途是替换 React Context 的一个用例。这是通过内部使用 use-context-selector 来实现的。

React Tracked 提供了一个名为 createTrackedSelector 的低级函数,用于覆盖非 React Context 用例。它接受一个名为 useSelector 的钩子,并返回一个名为 useTrackedState 的钩子:

const useTrackedState = createTrackedSelector(useSelector);

useSelector 是一个钩子,它接受一个选择器函数并返回选择器函数的结果。当结果改变时,它将触发重新渲染。useTrackedState 是一个钩子,它返回一个包裹在代理中的整个 state 以跟踪 state 的使用。

让我们看看一个具体的 React Redux 示例。这提供了一个 useSelector 钩子,应用 createTrackedSelector 非常简单。

关于 React Redux 的重要说明

React Redux 在内部使用 React Context,但它不使用 Context 来传播状态值。它使用 React Context 进行依赖注入,而状态传播是通过订阅完成的。React Redux 的 useSelector 被优化为仅在选择器结果改变时重新渲染。在撰写本文时,使用 Context 传播是不可能的。还有许多其他库采用相同的方法,实际上,use-context-selector UserLand 解决方案也是如此。

首先,我们从库中导入一些函数,即 reduxreact-reduxreact-tracked

import { createStore } from "redux";
import {
  Provider,
  useDispatch,
  useSelector,
} from "react-redux";
import { createTrackedSelector } from "react-tracked";

前两行导入是传统的 React Redux 设置。第三行是我们的补充。

接下来,我们使用 initialStatereducer 定义一个 Redux 存储:

type State = { count: number; text: string };
type Action =
  | { type: "INC" }
  | { type: "SET_TEXT"; text: string };
const initialState: State = { count: 0, text: "hello" };
const reducer = (state = initialState, action: Action) => {
  if (action.type === "INC") {
    return { ...state, count: state.count + 1 };
  }
  if (action.type === "SET_TEXT") {
    return { ...state, text: action.text };
  }
  return state;
};
const store = createStore(reducer);

这是一种创建 Redux 存储的传统方式。请注意,它与 React Tracked 没有关系,任何创建 Redux 存储的方式都会工作。

createTrackedSelector 允许我们通过从 react-redux 直接导入的 useSelector 钩子创建 useTrackedState 钩子:

const useTrackedState = 
  createTrackedSelector<State>(useSelector);

我们需要显式地使用 <State> 类型化钩子。

使用 useTrackedStateCounter 组件的定义如下:

const Counter = () => {
  const dispatch = useDispatch();
  const { count } = useTrackedState();
  const inc = () => dispatch({ type: "INC" });
  return (
    <div>
      count: {count} <button onClick={inc}>+1</button>
    </div>
  );
};

这应该与正常的 React Redux 模式大致相同,除了 useTrackedState 行。在 React Redux 中,它将是以下这样:

  const count = useSelector((state) => state.count);

这种变化可能看起来微不足道,但使用 useSelector,开发者对重新渲染有更多的控制和责任,而使用 useTrackedState,钩子会自动控制重新渲染。

同样,TextBox 组件的实现如下:

const TextBox = () => {
  const dispatch = useDispatch();
  const state = useTrackedState();
  const setText = (text: string) => {
    dispatch({ type: "SET_TEXT", text });
  };
  return (
    <div>
      <input
        value={state.text}
        onChange={(e) => setText(e.target.value)}
      />
    </div>
  );
};

再次强调,我们使用 useTrackedState 而不是 useSelector 来实现自动渲染优化。为了解释自动渲染优化如何有用,让我们想象一下 TextBox 组件接受一个 showCount 属性,它是一个布尔值,用于在 state 中显示 count 值。我们可以按如下方式修改 TextBox 组件:

const TextBox = ({ showCount }: { showCount: boolean }) => {
  const dispatch = useDispatch();
  const state = useTrackedState();
  const setText = (text: string) => {
    dispatch({ type: "SET_TEXT", text });
  };
  return (
    <div>
      <input
        value={state.text}
        onChange={(e) => setText(e.target.value)}
      />
      {showCount && <span>{state.count}</span>}
    </div>
  );
};

注意,我们没有对 useTrackedState 行进行任何更改。使用单个 useSelector 实现相同的行为将是困难的。

最后,以下是将所有组件组合在一起的 App 组件:

const App = () => (
  <Provider store={store}>
    <div>
      <Counter />
      <Counter />
      <TextBox />
      <TextBox />
    </div>
  </Provider>
);

这与使用不带 React Tracked 的正常 React Redux 完全相同。在这个应用程序中,重新渲染被优化了,这意味着点击按钮只会触发 Counter 组件的重新渲染,而 TextBox 组件不会重新渲染,如下面的图所示:

图 10.2 – 使用 React Tracked 和 React Redux 的应用程序截图

图 10.2 – 使用 React Tracked 和 React Redux 的应用程序截图

在本节中,我们学习了如何使用 React Tracked 与非 React Context 用例。接下来,我们将讨论 React Tracked 在未来版本的 React 中可能的样子。

未来展望

React Tracked 的实现依赖于两个内部库:

正如我们在使用 React Tracked 与 useState 和 useReducer部分以及使用 React Tracked 与 React Redux部分所学的,使用 React Tracked 有两种方式。第一种是通过 React Context 使用createContainer,第二种是通过 React Redux 使用createTrackedSelector。基本函数是createTrackedSelector,它使用proxy-compare库实现。createContainer函数是一个高级抽象,它使用createTrackedSelectoruse-context-selector库实现。

在 React Tracked 中关于 Context 的使用方面,use-context-selector库非常重要。use-context-selector的作用是什么?它提供了一个useContextSelector钩子。正如我们在第三章理解 Context部分所学的,通过 Context 共享组件状态,React Context 被设计成当 Context 值发生变化时,所有 Context 消费者组件都会重新渲染。有一个提议旨在改进 Context 的行为——useContextSelectoruse-context-selector库是一个 Userland 库,尽可能地模拟了提议的useContextSelector钩子。

在撰写本文时,情况非常不确定,但 React 的未来版本可能会实现useContextSelector,或者类似的形式。在这种情况下,React Tracked 可以轻松地从use-context-selector库迁移到本地的useContextSelector。希望这应该能够提供与 React 特性完全兼容。

在 React Tracked 的实现中将use-context-selector抽象出来有助于迁移。如果 React 在未来有一个官方的useContextSelector钩子,React Tracked 可以迁移而不改变其公共 API。在这个实现设计中,createTrackedSelector是 React Tracked 中的一个构建块函数,而createContainer是一个粘合函数。导出这两个函数允许我们使用这两种方式。

在本节中,我们讨论了 React Tracked 的实现设计和它如何迁移到可能的未来版本。

摘要

在本章中,我们了解了一个库——React Tracked。这个库有两个目的。一个目的是替代 React Context 的使用场景。另一个目的是增强一些其他库(如 React Redux)提供的选择器钩子。

从技术上讲,React Tracked 库不是一个全局状态库。它是与状态函数一起使用的,例如useStateuseReducer,或者 Redux。React Tracked 提供的是优化重新渲染的功能。

在下一章中,我们将比较三个全局状态库,即 Zustand、Jotai 和 Valtio,并讨论全局状态模式,以结束本书。

第十一章:三个全局状态库的相似之处和不同之处

在这本书中,我们介绍了三个全局状态库:Zustand、Jotai 和 Valtio。让我们讨论一下它们之间的相似之处和不同之处。这三个库有一些可比较的功能。

Zustand 在用法和存储模型方面与 Redux(以及 React Redux)相似,但与 Redux 不同,它不是基于 reducers 的。

Jotai 在 API 方面与 Recoil (recoiljs.org) 相似,但其目标更多的是提供一个针对非选择器基础的渲染优化的最小 API。

Valtio 在突变更新模型方面与 MobX 相似,但相似程度仅是微小的,并且渲染优化实现非常不同。

这三个库都提供了适合微状态管理的原始功能。它们在编码风格和渲染优化方法上有所不同。

在本章中,我们将通过将其与其可比较的库配对来讨论每个库,然后讨论这三个库之间的相似之处和不同之处。我们将涵盖以下主题:

  • Zustand 与 Redux 的区别

  • 理解何时使用 Jotai 和 Recoil

  • 使用 Valtio 和 MobX

  • 比较 Zustand、Jotai 和 Valtio

技术要求

预期你对 React 有一定的了解,包括 React hooks。参考官方网站,reactjs.org,以了解更多信息。

在某些代码中,我们使用 TypeScript (www.typescriptlang.org),你应该对其有基本了解。

本章中的代码可在 GitHub 上找到:github.com/PacktPublishing/Micro-State-Management-with-React-Hooks/tree/main/chapter_11

要运行代码片段,你需要一个 React 环境,例如,Create React App (create-react-app.dev) 或 CodeSandbox (codesandbox.io)。

Zustand 与 Redux 的区别

在某些用例中,开发者的体验在 Zustand 和 Redux 中可能是相似的。它们都基于单向数据流。在单向数据流中,我们派发action,它代表一个更新状态的命令,在状态通过action更新后,新的状态被传播到需要它的地方。这种派发和传播的分离简化了数据流,并使整个系统更具可预测性。

另一方面,它们在更新状态的方式上有所不同。Redux 基于 reducers。Reducer 是一个纯函数,它接受一个先前的状态和一个action对象,并返回一个新的状态。使用 reducers 更新状态是一种严格的方法,但它带来了更高的可预测性。Zustand 采取了一种灵活的方法,并且不一定使用 reducers 来更新状态。

在本节中,我们将通过将一个 Redux 示例转换为 Zustand 来展示比较。然后我们将看到两者之间的差异。

Redux 和 Zustand 的示例

让我们看看一个官方的 Redux 教程。这是所谓的现代 Redux,使用 Redux Toolkit:redux-toolkit.js.org/tutorials/quick-start

要创建一个 Redux 存储,我们可以使用 Redux Toolkit 库中的configureStore

// src/app/store.js
import { configureStore } from "@reduxjs/toolkit";
import counterReducer from "../features/counter/counterSlice";
export const store = configureStore({
  reducer: {
    counter: counterReducer,
  },
});

configureStore函数接受减少器并返回一个store变量。在这种情况下,它使用一个减少器——counterReducer

counterReducer是在一个单独的文件中定义的,使用 Redux Toolkit 库中的createSlice。首先,我们导入createSlice并定义initialState

// features/counter/counterSlice.js
import { createSlice } from "@reduxjs/toolkit";
const initialState = {
  value: 0,
};

我们然后使用createSliceinitialState定义counterSlice

export const counterSlice = createSlice({
  name: "counter",
  initialState,
  reducers: {
    increment: (state) => {
      state.value += 1;
    },
    decrement: (state) => {
      state.value -= 1;
    },
    incrementByAmount: (
      state,
      action: PayloadAction<number>
     ) => {
      state.value += action.payload;
    },
  },
});

使用createSlice函数创建的counterSlice变量包含一个减少器和动作。为了使它们易于导入,我们提取减少器和动作属性并分别导出:

export const {
  increment,
  decrement,
  incrementByAmount
} = counterSlice.actions;
export default counterSlice.reducer;

接下来是使用创建的存储的Counter组件。首先,我们从react-redux库中导入两个钩子,从counterSlice文件中导入两个动作:

// features/counter/Counter.jsx
import { useSelector, useDispatch } from "react-redux";
import { decrement, increment } from "./counterSlice";

我们然后定义Counter组件:

export function Counter() {
  const count = useSelector((
    state: { counter: { value: number; }; }
  ) => state.counter.value);
  const dispatch = useDispatch();
  return (
    <div>
      <button onClick={() => dispatch(increment())}>
        Increment
      </button>
      <span>{count}</span>
      <button onClick={() => dispatch(decrement())}>
        Decrement
      </button>
    </div>
  );
}

此组件使用来自 React Redux 库的useSelectoruseDispatch钩子。我们使用一个选择器函数从存储状态中获取计数值。注意,此组件没有直接使用创建的存储。useSelector钩子从 Context 中获取存储。

最后,App组件看起来如下:

// App.jsx
import { Provider } from "react-redux";
import { store } from "./app/store";
import { Counter } from "./features/counter/Counter";
const App = () => (
  <Provider store={store}>
    <div>
      <Counter />
      <Counter />
    </div>
  </Provider>
);
export default App;

我们通过Provider组件传递我们创建的store变量。这允许Counter组件中的useSelector钩子访问store变量。

图 11.1所示,这按预期工作。我们在App组件中有两个Counter组件,它们共享相同的count值。

图 11.1 – 使用 Redux 的应用程序截图

图 11.1 – 使用 Redux 的应用程序截图

现在,让我们看看这如何在 Zustand 中实现。

首先,我们使用 Zustand 库中的create函数创建一个存储。我们首先导入 Zustand 库:

// store.js
import create from "zustand";

然后我们为 TypeScript 定义State类型:

type State = {
  counter: {
    value: number;
  };
  counterActions: {
    increment: () => void;
    decrement: () => void;
    incrementByAmount: (amount: number) => void;
  };
};

以下是一个store定义。在 Zustand 中,一个钩子useStore代表一个store

export const useStore = create<State>((set) => ({
  counter: { value: 0 },
  counterActions: {
    increment: () =>
      set((state) => ({
        counter: { value: state.counter.value + 1 },
      })),
    decrement: () =>
      set((state) => ({
        counter: { value: state.counter.value - 1 },
      })),
    incrementByAmount: (amount: number) =>
      set((state) => ({
        counter: { value: state.counter.value + amount },
      })),
  },
}));

这定义了store中的计数状态和计数操作。减少器逻辑是在动作函数体中实现的。

接下来是Counter组件,它使用了创建的存储:

// Counter.jsx
import { useStore } from "./store";
export function Counter() {
  const count = useStore((state) => state.counter.value);
  const { increment, decrement } = useStore(
    (state) => state.counterActions
  );
  return (
    <div>
      <div>
        <button onClick={increment}>Increment</button>
        <span>{count}</span>
        <button onClick={decrement}>Decrement</button>
      </div>
    </div>
  );
}

我们使用useStore钩子来获取count值和更新count值的动作。注意,useStore钩子直接从存储文件导入。

最后,App组件看起来如下:

// App.jsx
import { Counter } from "./Counter";
const App = () => (
  <div>
    <Counter />
    <Counter />
  </div>
);
export default App;

由于我们不使用 Context,因此不需要提供者组件。

现在,让我们讨论两者的比较。

比较 Redux 和 Zustand 的示例

虽然示例与 Redux 和 Zustand部分的两个实现共享一些共同的概念,但也有一些显著的不同:

  • Redux 和 Zustand 的示例之间最大的区别之一是目录结构。现代 Redux 建议使用features目录结构,createSlice函数被设计成遵循功能目录模式。这对于大型应用来说是一个有用的模式。另一方面,Zustand 对结构没有意见。开发者如何组织文件和目录取决于他们。虽然使用 Zustand 可以遵循features目录结构,但库没有提供具体支持。我们的 Zustand 示例展示了counterActions的模式,但这只是其中一种可能。

  • 在创建存储代码中的另一个区别是使用了 Immer (immerjs.github.io/immer/)。Immer 允许使用类似state.value += 1;的突变风格。现代 Redux 默认使用 Immer。Zustand 默认不使用它,我们的例子也是如此。在 Zustand 中使用 Immer 是可选的。

  • 在存储传播方面,Redux 使用 Context,而 Zustand 使用模块导入。Context 允许在运行时注入存储,这在某些用例中效果更好。Zustand 可选地支持 Context 的使用。

  • 最重要的是,Redux Toolkit 基于 Redux,而 Redux 基于单向数据流。因此,在 Redux 中更新状态需要分发动作。这种限制有时对可维护性和可扩展性有益。Zustand 对数据流没有意见,虽然它可以用于单向数据流,但没有库支持,开发者需要处理所有事情。

总结来说,现代 Redux 对如何管理状态有更多的意见,而 Zustand 则较少。最终,Zustand 是一个简约的库,而 Redux 及其家族则是一套功能齐全的库。现代 Redux 和 Zustand 的使用看起来很相似,但它们背后的哲学是不同的。

在本节中,我们看到了现代 Redux 和 Zustand 之间的比较。接下来,我们将比较 Recoil 和 Jotai。

理解何时使用 Jotai 和 Recoil

Jotai 的 API 高度受到 Recoil 的启发。最初,它是故意设计来帮助从 Recoil 迁移到 Jotai 的。在本节中,我们将通过将 Recoil 的示例转换为 Jotai 来进行比较。然后,我们将讨论两者之间的差异。

Recoil 和 Jotai 的示例

让我们查看 Recoil 教程recoiljs.org/docs/introduction/getting-started,看看 Recoil 教程中的示例是如何转换为 Jotai 的。

首先,以回弹(Recoil)为例,我们需要从 Recoil 库中导入一些函数:

import {
  RecoilRoot,
  atom,
  selector,
  useRecoilState,
  useRecoilValue,
} from "recoil";

在这个例子中使用了五个。

文本字符串的第一个状态是通过atom函数创建的:

const textState = atom({
  key: "textState",
  default: "",
});

它接受两个属性——key 字符串和 default 值。

要使用定义的状态,我们使用 useRecoilState 钩子:

const TextInput = () => {
  const [text, setText] = useRecoilState(textState);
  return (
    <div>
      <input
        type="text"
        value={text}
        onChange={(event) => {
          setText(event.target.value);
        }}
      />
      <br />
      Echo: {text}
    </div>
  );
};

useRecoilState 返回的值与 useState 相同。因此,其余的代码应该很熟悉。

第二个状态是一个派生状态。我们使用 selector 函数定义派生状态:

const charCountState = selector({
  key: "charCountState",
  get: ({ get }) => get(textState).length,
});

它接受两个属性——一个 key 字符串和一个 get 函数。get 属性是一个返回派生值的函数。get 属性内的另一个 get 函数返回由其他 atomselector 函数创建的其他状态值。

要使用第二个状态,我们使用 useRecoilValue 钩子,它只返回状态的部分值:

const CharacterCount = () => {
  const count = useRecoilValue(charCountState);
  return <>Character Count: {count}</>;
};

textState 发生变化时,此组件将重新渲染,因为 charCountState 是从它派生出来的。

CharacterCounter 组件如下定义,以组合已定义的两个组件:

const CharacterCounter = () => (
  <div>
    <TextInput />
    <CharacterCount />
  </div>
);

最后,我们定义 App 组件:

const App = () => (
  <RecoilRoot>
    <CharacterCounter />
  </RecoilRoot>
);

App 组件中,我们使用 RecoilRoot 组件,它包含状态值。

图 11.2 所示,此应用程序的工作方式如下:如果你在文本字段中输入某些内容,文本将显示在文本字段下方,并且字符数如下所示:

图 11.2

图 11.2 – 使用 Recoil 的应用程序截图

现在,让我们将此示例代码转换为 Jotai。

我们首先从 Jotai 库中导入两个函数:

import { atom, useAtom } from "jotai";

Jotai 的 API 尝试保持最小化,最小使用需要两个函数。

文本字符串的第一个原子使用 atom 函数创建:

const textAtom = atom("");

这几乎与 Recoil 相同,只是它只有 default 值,因为 Jotai 不需要 key 字符串。在变量名后缀 Atom 而不是 State 是一个技术上不重要的约定。

要使用定义的原子,我们使用 useAtom 函数:

const TextInput = () => {
  const [text, setText] = useAtom(textAtom);
  return (
    <div>
      <input
        type="text"
        value={text}
        onChange={(event) => {
          setText(event.target.value);
        }}
      />
      <br />
      Echo: {text}
    </div>
  );
};

useAtom 函数的工作方式类似于 useState,其余的代码应该对习惯使用 useState 的人来说很熟悉。

第二个原子是一个派生原子,它使用 atom 函数定义:

const charCountAtom = atom((get) => get(textAtom).length);

在这种情况下,我们将一个函数传递给 atom 函数。内部函数计算派生值。

要使用第二个原子,我们再次使用 useAtom 函数:

const CharacterCount = () => {
  const [count] = useAtom(charCountAtom);
  return <>Character Count: {count}</>;
};

需要用 [count] 获取返回值的第一个部分。除此之外,代码和行为应该与 Recoil 相似。

CharacterCounter 组件如下定义,以组合已定义的两个组件:

const CharacterCounter = () => (
  <div>
    <TextInput />
    <CharacterCount />
  </div>
);

最后,我们定义 App 组件:

const App = () => (
  <>
    <CharacterCounter />
  </>
);

Jotai 的最小使用案例不需要 Provider 组件。

从 Recoil 示例到 Jotai 示例的转换主要是语法上的,行为是相同的。

让我们讨论一些差异。

比较 Recoil 和 Jotai 的示例

尽管在示例中没有使用到许多功能方面存在许多差异,但我们将讨论范围限制在我们展示的示例中,如下所示:

  • 最大的差异是key字符串的存在。开发 Jotai 的一个主要动机是省略key字符串。多亏了这个特性,Recoil 中的atom ({ key: "textState", default: "" })原子定义在 Jotai 中可以写成atom("")。从技术上讲,这看起来很简单,但这给开发者体验带来了巨大的差异。在编码中命名是一个困难的工作,尤其是因为key属性必须是唯一的。在实现上,Jotai 利用WeakMap并依赖于原子对象的引用。另一方面,Recoil 基于key字符串,不依赖于对象引用。key字符串的好处是它们是可序列化的。这应该有助于实现持久化,这需要序列化。Jotai 将需要一些技术来克服序列化问题。

  • key字符串相关的另一个差异是统一的atom函数。Jotai 中的atom函数在 Recoil 中的atomselector上都适用。然而,也存在一个缺点。它不能完全表达,可能需要 Jotai 中的其他函数来支持其他用例。

  • 最后但同样重要的是,Jotai 的无提供者模式,允许省略Provider组件,在技术上很简单,但对开发者非常友好,有助于降低使用库的心理障碍。

Recoil 和 Jotai 的基本功能相同,开发者需要根据其他需求或仅仅基于对 API 的偏好来做出选择。Jotai 的 API 是极简的,与 Zustand 相同。

在本节中,我们看到了 Recoil 和 Jotai 之间的比较。接下来,我们将看到 MobX 和 Valtio 之间的比较。

使用 Valtio 和 MobX

虽然动机相当不同,但 Valtio 经常与 MobX (mobx.js.org) 相比较。在用法上,Valtio 和 MobX 在它们的 React 绑定方面有一些相似之处。两者都基于可变状态,开发者可以直接修改状态,这导致了相似的用法。JavaScript 基于可变对象,因此修改对象的语法非常自然和紧凑。与不可变状态相比,这是一个很大的优势。

另一方面,它们在渲染优化方面的差异。对于渲染优化,Valtio 使用一个钩子,而 MobX React 使用高阶组件HoC): reactjs.org/docs/higher-order-components.html

在本节中,我们将把一个简单的 MobX 示例转换为 Valtio。然后我们将看到两者之间的差异。

重要提示

从概念上讲,Valtio 与 Immer (immerjs.github.io/immer/) 相当。两者都试图连接不可变状态和可变状态。Valtio 基于可变状态并将状态转换为不可变状态,而 Immer 基于不可变状态并暂时使用可变状态(草稿)。

涉及 MobX 和 Valtio 的示例

让我们从 MobX 文档中举一个例子:mobx.js.org/README.html#a-quick-example

我们首先从 MobX 库中导入一些函数:

import { makeAutoObservable } from "mobx";
import { observer } from "mobx-react";

由于 MobX 库是框架无关的,因此相关的 React 函数是从 MobX React 库导入的。

下一步是定义业务逻辑,即计时器。我们创建一个类然后实例化它:

class Timer {
  secondsPassed = 0;
  constructor() {
    makeAutoObservable(this);
  }
  increase() {
    this.secondsPassed += 1;
  }
  reset() {
    this.secondsPassed = 0;
  }
}
const myTimer = new Timer();

它有一个属性和两个用于修改属性的函数。makeAutoObservable用于将myTimer实例变为可观察对象。

我们可以在代码的任何地方调用这个可变函数。例如,让我们设置一个间隔:

setInterval(() => {
  myTimer.increase();
}, 1000);

这将每秒增加secondsPassed属性。

现在,使用timer的组件如下:

const TimerView = observer(({ timer }: { timer: Timer }) => (
  <button onClick={() => timer.reset()}>
    Seconds passed: {timer.secondsPassed}
  </button>
));

observer函数是一个高阶组件。它理解timer.secondsPassed在渲染函数中被使用,并且当timer.secondsPassed变化时将触发重新渲染。

最后,App组件包含TimerView组件和myTimer实例:

const App = () => (
  <>
    <TimerView timer={myTimer} />
  </>
);

图 11.3所示,如果你运行这个应用,它将显示一个带有标签显示已过去秒数的按钮。标签每秒变化一次。点击此按钮将重置数值。

图 11.3 – 使用 MobX 的应用截图

现在,让我们看看使用 Valtio 会是什么样子?让我们用 Valtio 查看相同的示例。

我们首先从 Valtio 库中导入两个函数:

import { proxy, useSnapshot } from "valtio";

虽然 Valtio 是一个用于 React 的库,但它有一个用于非 React 用例的原生包。

我们使用proxy函数来定义一个myTimer实例:

const myTimer = proxy({
  secondsPassed: 0,
  increase: () => {
    myTimer.secondsPassed += 1;
  },
  reset: () => {
    myTimer.secondsPassed = 0;
  },
});

它有一个secondsPassed属性用于数值和一个用于更新数值的两个函数属性。

我们使用其中一个函数属性定期增加secondsPassed属性:

setInterval(() => {
  myTimer.increase();
}, 1000);

这个setInterval的使用与 MobX 完全相同。

接下来是使用useSnapshotTimerView组件:

const TimerView = ({ timer }: { timer: typeof myTimer }) => {
  const snap = useSnapshot(timer);
  return (
    <button onClick={() => timer.reset()}>
      Seconds passed: {snap.secondsPassed}
    </button>
  );
};

在 Valtio 中,useSnapshot是一个钩子,用于理解状态在渲染函数中的使用情况,并且当状态中被使用部分发生变化时将触发重新渲染。

最后,App组件与 MobX 相同:

const App = () => (
  <>
    <TimerView timer={myTimer} />
  </>
);

最后,我们应该有与 MobX 相同的行为。它显示一个带有标签的按钮。标签显示已过去的秒数,点击按钮将重置值。

现在,让我们讨论一些区别。

比较 MobX 和 Valtio 的示例

MobX 和 Valtio 中的两个示例看起来很相似,但有两个主要区别:

  • 第一个区别是更新方法。尽管两者都使用可变操作,但 MobX 示例是类基础的,而 Valtio 示例是对象基础的。这主要是风格上的,Valtio 对风格并不强加。

    Valtio 允许的一种样式是将函数与状态对象分离。同样的示例可以用以下方法实现:

    // timer.js
    const timer = proxy({ secondsPassed: 0 })
    export const increase = () => {
      timer.secondsPassed += 1;
    };
    export const reset = () => {
      timer.secondsPassed = 0;
    };
    export const useSecondsPasses = () =>
      useSnapshot(timer).secondsPassed;
    

    我们在由proxy函数定义的状态对象外部定义更新函数。这种方法的优点是它允许代码拆分、压缩和删除死代码。最终,我们可以期待一个优化的包大小。

  • 第二个不同点是渲染优化方法。虽然 MobX 采用观察者方法,Valtio 采用钩子方法。每种方法都有其优缺点。观察者方法更可预测。钩子方法更“并发渲染”友好。实现这种方法可能非常不同。还有风格上的差异;一些开发者更喜欢 HoC 风格,而其他开发者更喜欢钩子风格。

    重要提示

    到目前为止,关于并发渲染,我们只有有限的信息。这是我们的最佳观察,但并不能保证这个说法在未来是否成立。

在本节中,我们比较了 MobX 和 Valtio。接下来,我们将讨论 Zustand、Jotai 和 Valtio 之间的比较。

比较 Zustand、Jotai 和 Valtio

到目前为止,在本章中,我们已经比较了以下对:

  • Zustand 与 Redux 的区别部分比较了 Zustand 和 Redux

  • 理解何时使用 Jotai 和 Recoil部分比较了 Jotai 和 Recoil

  • 使用 Valtio 和 MobX部分比较了 Valtio 和 MobX

我们比较这些对是因为它们有一些相似之处。在本节中,我们将比较 Zustand、Jotai 和 Valtio。

首先,这三个库都是由 Poimandres GitHub 组织提供的(github.com/pmndrs)。这是一个开发者集体,提供许多库。来自单个 GitHub 组织的三个微状态管理库听起来可能有些反直觉,但它们具有不同的风格。这三个库中也有一个共同的哲学:它们的 API 表面较小。所有三个库都尽力提供小的 API 表面,让开发者按需组合 API。

但那么,这三个库之间的区别是什么?

有两个方面:

  • countAtom

    const countAtom = atom(0);
    

    这个countAtom变量持有配置对象,并不存储值。原子值存储在Provider组件中。因此,countAtom可以被多个组件复用。使用模块状态实现相同的行为比较棘手。使用 Zustand 和 Valtio,我们最终会使用 React Context。另一方面,从 React 外部访问组件状态在技术上是不可能的。我们可能需要某种模块状态来连接到组件状态。

    我们是否使用模块状态或组件状态取决于应用需求。通常,使用模块状态或组件状态作为全局状态可以满足应用需求,但在一些罕见情况下,同时使用这两种类型的状态可能是有意义的。

  • state = { count: 0 }。如果您想在不可变状态模型中更新 count,您需要创建一个新的对象。因此,将计数增加1应该是state = { count: state.count + 1 }。在可变状态模式中,它可以是++state.count。这是因为 JavaScript 对象本质上是可变的。不可变模型的好处是您可以比较对象引用以了解是否发生了任何变化。这有助于提高大型嵌套对象的表现。因为 React 主要基于不可变模型,所以与具有相同模型的 Zustand 具有兼容性。因此,Zustand 是一个非常薄的库。另一方面,具有可变状态模型的 Valtio 需要填补两个模型之间的差距。最终,Zustand 和 Valtio 采用了不同的状态更新风格。可变更新风格非常方便,尤其是在对象深度嵌套时。回顾第九章这种方法的优势和劣势部分的例子,用例场景 3 – Valtio

    关于 Immer 使用的注意事项

    使用 Immer 允许在 Zustand 和 Jotai 中更新状态,与 Zustand 和 Immer 的组合相比,Valtio 在可变状态模型上进行了更多优化。它具有更小的 API 表面,并且还优化了重新渲染。Jotai 和 Immer 的组合对于大对象很有用,并且 Jotai 库提供了一种特定功能来集成 Immer。然而,Jotai 原子通常很小,在这种情况下,不可变更新风格并不是一个大问题。

这三个库之间有一些细微的差别,但重要的是它们基于不同的原则。如果我们必须选择其中一个,我们需要看看哪个原则与我们的应用程序需求和我们的心智模型很好地匹配。

摘要

在本章中,我们总结了本书中解释的全局状态三个库之间的差异。它们之所以不同,是因为它们基于不同的模型。

实质上,微状态管理涉及为特定问题选择正确的解决方案和正确的库。微状态管理要求您了解您的问题是什么以及您的问题有哪些解决方案。我们希望这本书涵盖了一些可以帮助开发者找到正确解决方案的主题。

嗨!

我是 Daishi Kato,Micro State Management with React Hooks 的作者。我真心希望您喜欢阅读这本书,并发现它对提高您在 React Hooks 中的生产力和效率很有用。

如果您能在亚马逊上留下对 Micro State Management with React Hooks 的评论,分享您的想法,这将对我(以及其他潜在读者!)真的非常有帮助。

点击下面的链接或扫描二维码留下您的评论:

packt.link/r/1801812373

二维码  描述自动生成

您的评论将帮助我了解这本书哪些地方做得好,以及未来版本可以如何改进,所以这真的非常感谢。

祝好,

作者照片

大地加藤

Packt.com

订阅我们的在线数字图书馆,全面访问超过 7,000 本书和视频,以及领先的行业工具,帮助你规划个人发展并推进职业生涯。更多信息,请访问我们的网站。

第十二章:为什么订阅?

  • 使用来自 4,000 多位行业专业人士的实用电子书和视频,节省学习时间,增加编码时间

  • 通过为你量身定制的技能计划提高你的学习效果

  • 每月免费获得一本电子书或视频

  • 完全可搜索,便于快速访问关键信息

  • 复制粘贴、打印和收藏内容

你知道 Packt 为每本书都提供电子书版本,包括 PDF 和 ePub 文件吗?你可以在 packt.com 升级到电子书版本,并且作为印刷书客户,你有权获得电子书副本的折扣。如需更多信息,请联系我们 customercare@packtpub.com。

www.packt.com,你还可以阅读一系列免费的技术文章,订阅各种免费通讯,并享受 Packt 书籍和电子书的独家折扣和优惠。

你可能还喜欢的其他书籍

如果你喜欢这本书,你可能对 Packt 出版的以下其他书籍感兴趣:

使用 React Testing Library 简化测试

Scottie Crump

ISBN: 978-1-80056-445-9

  • 探索 React Testing Library 及其用例

  • 掌握 RTL 生态系统

  • 使用 RTL 将 jest-dom 应用于增强你的测试

  • 使用 RTL 获得创建不因更改而中断的测试所需的信心

  • 将 Cucumber 和 Cypress 集成到你的测试套件中

  • 使用 TDD 驱动编写测试的过程

  • 应用现有的 React 知识来使用 RTL

正确设计 React Hooks

Fang Jin

ISBN: 978-1-80323-595-0

  • 创建你自己的 hooks 来满足你的状态管理需求

  • 使用 useEffect 检测你网站的当前窗口大小

  • 使用 useMemo 来去抖动一个动作,以改善用户界面(UI)性能

  • 使用 useContext 建立全局站点配置

  • 使用 useRef 避免难以找到的应用程序内存泄漏

  • 使用自定义 Hooks 设计一个简单有效的 API 数据层

Packt 正在寻找像你这样的作者

如果你有兴趣成为 Packt 的作者,请访问 authors.packtpub.com 并今天申请。我们已经与成千上万的开发人员和科技专业人士合作,就像你一样,帮助他们将见解与全球科技社区分享。你可以提交一般申请,申请我们正在招募作者的特定热门话题,或者提交你自己的想法。

posted @ 2025-09-07 09:18  绝不原创的飞龙  阅读(16)  评论(0)    收藏  举报