理解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是一个有独特语法的模块状态库。