第八章:案例 2 - Jotai - 上

102 阅读10分钟

Jotai (github.com/pmndrs/jota… useState 和 useReducer 构建了 用于小块状态的 atoms。与Zustand不同的是,它是组件状态;与Zustand相同的是,它是基于 不可变更新模型。这些特性是基于我们在第五章的内容实现的。

在这一章,我们会学习Jotai的基本用法,以及其如何进行渲染优化。有了atom,这个库可以跟踪依赖,并在依赖变化时进行重新渲染。因为Jotai其内部使用了Context,而atom不携带任何值,所以atom定义是可复用的。我们还将讨论一种有关原子的新颖模式,称为 “atoms-in-atom” 模式,这是一种针对数组结构来优化重新渲染的技术。

在本章,我们会讨论下面几个主题:

  • 理解Jotai
  • 探索渲染优化
  • 了解 Jotai 是如何存储atom值的
  • 添加一个数组结构
  • 理解Jotai的不同特性

理解Jotai

为了理解Jotai的API,让我们从一个计数器应用开始。

下面是一个有两个独立计数器的代码:

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来实现这个的代码。

首先,我们要创建一个存储count状态的Context:

const CountContext = createContext();

const CountProvider = ({ children }) => {
    <CountContext.Provider value={useState(0)}>
        {children}
    </CountContext.Provider>
}

注意,Context的值是同样的状态,useState(0)。

之后,便是要调整组件内容,我们要用useContext(CountContext)来代替useState(0)

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>
}

如此一来,两个组件就可以共享一个count状态了,你也可以看到这两个组件共同渲染同一个状态。

现在,我们可以看看Jotai是多大程度上比Context好用的。使用Jotai,有这两个好处:

  • 更加简洁的语法
  • 动态创建atom

让我先看看第一个优点。

更加简洁的语法

要理解Jotai的语法简洁性,我们还是用计数器例子切入。首先,我们需要引入Jotai库的一些函数:

import { atom, useAtom } from "jotai";

atomuseAtoms是Jotai的两个基础钩子。

一个atom代表一个状态。一个atom通常是一个状态,同时也是触发重新渲染的最小单元。atom函数会生成一个atom的定义。atom函数会接收一个表明值的参数,就像useState一样。下面的代码用于定义一个新的atom:

const countAtom = atom(0);

注意其与useState(0)的相似性。

现在,我们可以在计数器组件里使用atom了。我们将用useAtom(countAtom)来代替useState(0),如下:

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)所返回的一样,所以余下的代码不需要改动。

之后,实现App组件:

const App= () => {
    <>
        <div<Counter1 /></div>
        <div<Counter2 /></div>
    </>
}

与本章的例子不同的是,这里并没有使用Context。

为了更好地理解Jotai的语法简洁性,让我们以一个text全局状态来说展开说明:

const TextContent = createContext();

const TextProvider = ({ children }) => {
    <TextContext.Provider value={useState("")}>
        {children}
    </TextContext.Provider>
}

const App = () => {
    <TextProvider>
        ...
    </TextProvider>
}

// when you use it in component
const [text, setText] = useContext(TextContent);

然而,同样的功能用Jotai实现,只要这样:

const textAtom = atom("");

// when you use it in component
const [text, setText] = useAtom(textAtom);

这样的代码就简单多了,我们只要写一行atom的定义即可。即便我们有很多atom,我们只要为每一个atom写下定义即可。如果是通过Context的话,需要为每一个状态写相关的Context,这样的话,代码就冗余很多了。而Jotai的语法,就简单多了。这就是Jotai的第一个优势:语法简洁性。

接下来,让我们看看Jotai的第二个优势

动态创建atom

Jotai 的第二个优点是具备一项新能力,即动态创建原子。在 React 组件的生命周期内,原子可以被创建和销毁。而采用多上下文(multiple-Context)的方式是无法做到这一点的,因为添加一个新的状态就意味着要添加一个新的 Provider 组件。如果添加新组件,其所有子组件都会被重新挂载,从而丢失它们的状态。我们将在 “添加数组结构” 这一部分介绍动态创建原子的一个用例。

Jotai 的实现基于我们在第五章 “通过上下文和订阅共享组件状态” 中学到的知识。Jotai 的存储本质上是一个由原子配置对象和原子值组成的 WeakMap 对象(developer.mozilla.org/en-US/docs/…)。原子配置对象是通过atom函数创建的定义。原子值是useAtom钩子返回的值。Jotai 中的订阅是基于原子的,这意味着useAtom钩子会订阅存储中的某个特定原子。基于原子的订阅能够避免不必要的重新渲染。我们将在下一部分进一步探讨这一点。

在本节中,我们讨论了 Jotai 库的基本思维模型和 API。接下来,我们将深入探讨原子模型是如何解决渲染优化问题的。

探索渲染优化

让我们回顾一下基于选择器的渲染优化。我们会从第四章的一个例子开始,我们会创建一个createStore,并创建useStoreSelector

让我们从一个person 仓库开始。我们定义了三个属性:firstName,lastName和age:

const personStore = createStore({
    firstName: "React",
    lastName: "Hooks",
    ages: 3,
})

假设我们要在一个组件展示firstName 和 lastName,一个简单的方法是使用选择函数:

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}</>
}

因为我们只选择了这个仓库的两个值,所以当没有被选择的age变化时,PersonComponent组件并不会重新渲染。

这个模式,我们称呼为自顶向下。我们创建了一个存来所有状态的仓库。

那么,如果这个功能通过Jotai来实现,会是怎样?首先,我们要定义atom:

const firstNameAtom = atom("React");
const lastNameAtom = atom("Hooks");
const ageAtom = atom(3);

atom是触发重新渲染的单元。你可以让atom尽可能的小,比如原始值。当然,atom的值也可以是对象。

PersonComponent组件可以用useAtom钩子实现,如下:

const PersonComponent = () => {
    const [firstName] = useAtom(firstNameAtom);
    const [lastName] = useAtom(lastNameAtom);
    
    return <>{firstname} {lastName}</>
}

因为这个组件和ageAtom没有关系,所以当ageAtom变化时,PersonComponent组件并不会重新渲染。

虽然我们可以把atom做的尽可能小,但这也意味着我们要维护和组织很多atom。不过好在,Jotai赋予了我们基于现有的atom来创建一个新的atom的能力。让我们一起来创建一个包含了firstName,lastName 和 age的 personAtom变量:

const personAtom = atom((get) => ({
    firstName: get(firstNameAtom),
    lastName: get(lastNameAtom),
    age: get(ageAtom),
}));

read函数接受一个名为get的参数,通过它你可以引用其他原子并获取它们的值。personAtom的值是一个包含三个属性的对象,即firstName(名字)、lastName(姓氏)和age(年龄)。每当其中一个属性发生变化时,也就是firstNameAtomlastNameAtomageAtom被更新时,这个值就会更新。这被称为依赖跟踪,由 Jotai 库自动完成。

有了personAtom,我们可以重新实现PersonComponent

const PersonComponent = () => {
    const person = useAtom(personAtom);
    retrun <>{person.firstName} {person.lastName}</>
}

然而,这并不是我们所期望的。组件会在ageAtom变化时而重新渲染。

为了避免这些不必要的重新渲染,我们可以重新创建一个atom,这个atom只有firstName和 lastName:

const fullNameAtom = atom((get) => ({
    firstName: get(firstNameAtom),
    lastName: get(lastNameAtom)
}))

使用fullNameAtom,我们可以重新实现PersonComponent

const PersonComponent = () => {
    const person = useAtom(fullNameAtom);
    retrun <>{person.firstName} {person.lastName}</>
}

使用了fullNameAtom,组件就不会因为ageAtom的变动而重新渲染了。

我们称呼这为自底向上模式。我们创建了小的atom,再把它们组合起来,去创建更大的atom。我们可以通过只添加atom的方式,来优化重新渲染。这种优化虽然不是自动式的,但是atom模型使这种模式更加直观。

我们如何用存储和选择器的方法来实现上一个示例呢?下面是一个使用恒等选择器的例子:

const identity = (x) => x;

const PersonComponent = () => {
    const person = useStoreSelector(store, identity);
    retrun <>{person.firstName} {person.lastName}</>
}

你已经猜到了,这会触发额外的重新渲染。当age属性变化时,组件会重新渲染。

一个可能的修复方法,便是只选择firstName 会 lastName。代码如下:

const selectFullName = (state) => ({
    firstName: state.firstName,
    lastName: state.lastName,
});

const PersonComponent = () => {
    const person = useStoreSelector(store, selectFullName);
    return <>{person.firstName} {person.lastName}</>;
};

不幸的是,这段代码不会生效。当age变化时,selectFullName函数被触发了,它会返回一个新的对象。而useStoreSelector会认为这个对象是一个新值,所以会触发额外的重新渲染。这是选择器方法中一个广为人知的问题,常见的解决办法是使用自定义的相等性函数或者一种记忆化技术。

原子模型的优点在于,原子的组合能够轻松地与组件中将要显示的内容建立联系。因此,控制重新渲染就变得很简单直接。使用原子进行渲染优化,既不需要自定义的相等性函数,也不需要记忆化技术。

让我们从一个例子展开这个特点。首先,我们定义两个技术atom:

const count1Atom = atom(0);
const count2Atom = atom(0);

我们可以定义一个使用计数atom的组件。我们不需要创建两个计数组件,我们定义一个可以为两个atom工作的组件。为了达到这个目的,我们让这个组件以countAtom为属性即可,代码如下:

const Counter = ({ countAtom }) => {
    const [count, setCount] = useAtom(countAtom);
    const inc = () => setCount((c) => c + 1);
    return <>{count} <button onClick={inc}> +1 </button></>;
}

如此一来,这个组件就可以为类似的技术atom所复用。

之后,我们可以定义一个用于计算总数的衍生atom。我们需要使用read函数:

const totalAtom = atom(
    (get) => get(count1Atom) + get(count2Atom)
);

我们通过read函数创建了一个衍生atom。这个衍生atomd的值是基于read函数的。这个衍生atom会在其依赖发生变化时,重新读取和更新。在这个例子中,count1Atom或count2Atom的变动,都会触发。

Total组件会使用totalAtom来展示总数:

const Total = () => {
    const [total] = useAtom(totalAtom);
    return <>{total}</>
}

totalAtom 是一个派生原子,并且它是只读的,因为它的值是 read 函数的计算结果。因此,不存在设置 totalAtom 值的概念。

最后,我们定义了一个 App 组件。它将 count1Atom 和 count2Atom 传递给 Counter 组件,如下所示:

const App = () => {
    <>
        (<Counter countAtom={count1Atom} />)
        +
        (<Counter countAtom={count2Atom} />)
        =
        <Total />
    </>
}

atom可以作为属性传递,如上面的例子展示。atom也可以通过其他手段传递,我们会在后面的部分学习到。

当你启动这个应用,你会看到基于count1Atom,count2Atom 和 totalAtom 的等式:

image.png

在这个部分,我们学习了atom模型,Jotai的模型的渲染优化技巧。接下来,我们要学习Jotai是如何存储atom的值的。