理解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></>
}
之后,我们可以定义使用了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>
</>
);
这两个Provider
组件,各自创建了一个独立的数据仓库。因此,Counter
组件的 countAtom
也是独立的。第一个Provider
里的两个Counter
组件 共享同一个useCount
而另一个Provider
里的两个Counter
组件 所 共享 的useCount
则有不同的默认值:
要再次强调的是,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)
)
);
anotherCountAtom
和 countAtom
大体相同。唯一不同的是,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是一个有独特语法的模块状态库。