第八章:案例 2 - Jotai - 下

0 阅读10分钟

理解Jotai如何存储atom的值

目前为止,我们还没讨论 Jotai是如何使用 Context的。在这个部分,我们会学习atom如何存储值,并如何可复用。

首先,我们先复习一下简单的atom定义:

const countAtom = atom(0);

countAtom是一个有着可以代表atom行为的对象。在这个例子中,countAtom是一个原始值,这意味着这个atom可以通过一个值来更新,或者通过更新函数来更新。一个持有原始值的atom,其行为与useState类似。

非常重要的是,像countAtom这样的atom配置本身并没有值。有一个仓库会存储这些atom的值。这个仓库有一个weakMap数据,其键为atom 配置对象,而其值正是atom的值。

我们使用useAtom时,如果不加以指明,这个值是存在模块级别的。然而,Jotai为我们提供了一个名为Provider的组件,让我们把仓库得以建立在组件级别。我们可以这样引入Provider的组件:

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

之后,我们可以定义使用了ProviderApp组件。我们会使用两个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>
    </>
);

这两个Provider组件,各自创建了一个独立的数据仓库。因此,Counter 组件的 countAtom也是独立的。第一个Provider里的两个Counter 组件 共享同一个useCount 而另一个Provider里的两个Counter 组件 所 共享 的useCount 则有不同的默认值:

image.png

要再次强调的是,countAtom本身并没有值。因此,countAtom可以为不同的 Provider 所复用。而这一点,正是 countAtom 与 模块状态的 显著不同。

我们可以定义一个衍生atom。下面是一个衍生atom的例子:

const doubledCountAtom = atom(
    (get) => get(countAtom) * 2
)

因为countAtom本身并没有值,所以doubledCountAtom也有值。如果doubledCountAtom在第一个Provider组件里被使用,doubledCountAtom则是第一个Provider里的countAtom的值的两倍;同理,如果doubledCountAtom在第二个Provider组件里被使用,doubledCountAtom则是第二个Provider里的countAtom的值的两倍。

因为atom本身是不具有值的一个定义,所以atom是可复用的。这个例子为我们展示了atom可以为两个 Provider组件所复用。但本质上来说,它可以为更多的组件所复用。更进一步,Provider组件可以在React 的生命周期中被动态地使用。Jotai是基于Context实现的,所以Context的能力,Jotai都能实现。接下来,我们要讨论Jotai是如何处理数组类型的。

添加一个数组结构

在React里,渲染一个数组是很容易的。我们只要为每一个要渲染的数组成员,添加一个key属性即可。这个key属性在删除成员和排序时,是非常有用的。

在这个部分,我们会学习如何在Jotai中处理数组结构。我们会先用传统方式实行,之后 会介绍 Atoms-in-Atom 模式。

让我们以一个代表清单应用为 例子 切入。

首先,我们会定义 一个 Todo 类型:

type Todo = {
    id: string;
    title: string;
    done: boolean
}

之后,我们会代表 Todo 数组的 todosAtom

const todosAtom = atom<Todo[]>([]);

我们用Todo 类型 来为 atom添加 类型注解。

之后,我们定义 一个 TodoItem组件。这个组件是一个纯组件。它以 todo,removeTodo 和 toggleTodo为 属性。代码如下:

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 MemoTodoItem = memo(TodoItem);

缓存后,可以避免不必要的重新渲染。只会在 todo,removeTodo 和 toggleTodo 变化时 才 重新渲染。

现在,我们要实现 TodoList 组件。这个组件会 使用 todosAtom,用 useCallback 来 定义 removeTodo 和 togggleTodo:

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组件,再以 todo的 id作为 每一个 MemoedTodoItem组件 的 key。

下一个要实现的,是NewTodo组件。它使用 todosAtom ,并在 点击 按钮时 添加一个 新的 todo。每一个新的 atom的 id 需要是 独一无二的。在下面的例子中,我们使用 nanoid 来 生成唯一 id:

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

最后,我们要实现 App 组件:

const App = () => {
    <>
        <TodoList />
        <NewTodo />
    </>
}

如果,你运行这个应用,你会发现,就使用者的角度而言,这个应用和之前的 待办清单是一样的。但是在开发者角度而言,其不同大致如下:

  • 一个数组atom用于保存一个由atom组成的数组
  • 为了在atom数组里添加一个成员,我们要创建一个新的atom并添加进去
  • atom配置可以理解为字符串,它们会返回uid
  • 一个渲染了atom的组件会在每个组件中消费一个atom成员
  • 这样就避免了改动数组成员值,进而减少不必要的重新渲染

在这个部分,我们学习了如何在Jotai中处理数组。接下来,我们要学习Jotai库为我们提供的一些特性。

使用Jotai库的不同特性

目前为止,我们已经学习了Jotai的基本用法。在本部分,我们还会介绍Jotai的其他基础用法。这些特性,在处理复杂场景时是非常有必要的。此外,我们还会介绍一些已经超纲的高级用法。

我们会讨论这些主题:

  • 定义atom的更新函数
  • 使用Action atom
  • atom配置可以理解为字符串,它们会返回uid
  • 理解ation的onMount配置
  • 介绍 jotai/utils 包

定义atom的更新函数

我们已经知道如何定义衍生atom:

const countAtom = atom(0)

const doubledCountAtom = atom(
    (get) => get(countAtom) * 2
)

countAtom 是一个原始atom,因为它并不是由其他atom衍生而来。而原始atom,是可以更新的,只要atom里的值发生了变动,这个atom就会更新。

doubledCountAtom是一个只读的衍生atom,因为它完全是依赖countAtom而变化的。而doubledCountAtom只会在可更新的countAtom的值发生变动时而变动。

如果想让这个衍生atom变成可写的atom,我们在定义这个衍生atom时,需要传入第二个、可选的更新函数:

const countAtom = atom(0)

const doubledCountAtom = atom(
    (get) => get(countAtom) * 2
)

这个更新函数有三个参数,分别是:

  • get 函数用于返回当前 atom 的值
  • set 函数用于设置一个 atom 的值
  • arg 就是 在设置 atom 新值的 参数

有了更新函数,我们可以不但可以更新衍生atom,也可以更新原始atom。

我们来看一个有更新函数的原始atom:

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大体相同。唯一不同的是,anotherCountAtom会在更新值时,会在后台打印新值。

可更新的衍生atom是Jotai的一个强大特性,它可以处理很多复杂场景。在下一个部分,我们会讨论更新函数的另一种模式。

使用Action atom

为了实现对状态的更新,我们需要一些专门用于更新状态的atom。我们称呼这类atom为 Action atom。

为了创建 Action atom,我们会只传入第二个参数的 写入函数。至于第一个参数填什么,我们通常传入null。

让我们以一个例子切入: 一个常规的 countAtom 和 一个 与之对应 的 Action atom, 即 incrementCountAtom:

const countAtom = atom(0)

const incrementCountAtom(
    null,
    (get, set, arg) => set(countAtom, (c) => c + 1 )
)

在这个例子中,这个写函数只使用了set参数,没有使用其他参数。

我们可以把这个Action atom当作普通atom使用,只要不取值即可。下面是一个用该atom实现的递增方法:

const IncrementButton = () => {
    const [,incrementCount] = useAtom(incrementCountAtom);
    return <button onClick={incrementCount}>Click</button>
}

这是一个无参数的例子。你可以创建有参数的Action atom,甚至创建各种Action atom。

接下来,我们要学习一个重要但很少用的 特性。

atom 的 onMount 选项

在特定场景下,我们想在一个atom被使用时,运行特定逻辑。常用的方式是 使用useEffect。但对应Jotai的 atom数据,我们可以使用 onMount 选项。

我们可以通过这个例子来理解:

const countAtom = atom(0);

countAtom.onMount = (setCount) => {
    console.log('count atoms starts to be used')
    const onUnmount = () => {
            console.log('count atoms ends to be used')
    }
    return onUnmount
}

这段代码实现的功能是,在countAtom被使用时打印count atoms starts to be used的提示。而返回的onUnmount会在countAtom不再被使用时打印count atoms ends to be used的提示。

这个例子虽然离实际编程很远,但是这个特性在很多场景都可以发挥作用。

接下来,我们要讨论utility 函数。

介绍 jotai/utils 包

Jotai的主包里,为我们提供了 atom,useAtom 和 Provider这些 特性。这些特性帮助我们理解了Jotai的基本用法。但是在实际开发过程中,我们还需要借助一些工具函数。

Jotai为我们提供了一个独立的 jotai/utils 独立子包。这个子包,为我们提供了丰富的工具函数。比如,atomWithStorage可以帮助我们持续同步 storage。

接下来,我们Jotai库如何可以被其他库使用。

理解库用法

假设两个库内部都使用了 Jotai 库。如果我们开发的应用同时使用这两个库,会出现双重 Provider 的问题。因为 Jotai 的原子(atom)是通过引用来区分的,第一库中的原子可能会意外地连接到第二库的 Provider,导致库作者预期的功能无法正常工作。Jotai 库提供了“作用域”(scope)的概念,用来连接到特定的 Provider。为了使其按预期工作,我们应该将相同的 scope 变量传递给 Provider 组件和 useAtom 钩子。

从实现角度看,这就是 Context 的工作方式。作用域功能实际上是将 Context 功能重新引入。这个功能还在探索阶段,未来社区会一起研究更多该功能的使用场景。

概要

在这一章,我们学习了Jotai库。它是基于 atom模型和 Context的。我们通过一个简单的例子学习了Jotai的基本用法,也看到了其灵活性。只有将 Context和订阅模式组合,才可以创建React适用的全局对象。如果你希望既能使用Context的特性,又能够减少不必要的重新渲染,Jotai可以成为你的备选项。

在下一章,我们要学习Valtio。Valito是一个有独特语法的模块状态库。