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

72 阅读1小时+

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

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

译者:飞龙

协议:CC BY-NC-SA 4.0

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

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

在本章中,我们将学习一种新的方法:结合 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,具有基于代理的渲染优化。