读取和修改 store 中的内容
使用 proxy
创建的 store 可以在任何位置读取和修改,就像是一个普通的对象一样。例如上一节中创建的 store,可以这样为其中的 count
属性增加数值。
store.count++;
Valtio 默认会对代理对象的修改进行优化,例如给一个属性赋予与其原有值相同的值,就不会有任何效果出现,比如在上例中,如果 store.count
的值为 0
,那么 store.count = 0
就不会触发任何订阅、重新渲染的效果。并且对于连续修改一个 store 中的多个属性,Valtio 也会将所有的修改集中在一起,使其形成一次整体修改,避免连续多次触发订阅或者重新渲染。
读取 store 的值
在读取 store 的值之前首先要记得,store 是通过 proxy
函数创建的一个对象的代理,直接访问其中的内容虽然可以达到目的,但并不是推荐行为。要获取一个 store 保存的内容,需要通过 Valtio 提供的快照功能来获取,在 React 组件之外,可以使用 snapshot
函数来获取 store 的快照。在 React 组件内部,可以使用 useSnapshot
Hook 来获取 store 的快照。
snapshot
函数会从一个代理对象,即一个 store 中解包出一个不可变对象,这个返回的对象是通过高效的深度复制和冻结对象来实现的。换句话说,在连续的 snapshot
调用时,如果 store 的内容没有发生变化,那么返回的快照对象也会是相同的,也就是上一次快照对象的引用。
例如以下示例中返回的两个快照就是完全相同的,但是在对 store 中的内容做出一些修改以后,返回的快照就不相同了。
import { proxy, snapshot } from "valtio";
const store = proxy({ count: 0 });
const snap1 = snapshot(store);
const snap2 = snapshot(store);
console.log(snap1 === snap2); // true
store.count++;
const snap3 = snapshot(store);
console.log(snap1 === snap3); // false
在 React 组件中读取 store 的值
在 React 组件中读取 store 的值是通过 Valtio 提供的 Hook useSnapshot
来完成的。这个 Hook 的用法非常简单,例如依旧获取上一节定义的 store 的 count
值。
const Component = () => {
const snap = useSnapshot(store);
return <div>{snap.count}</div>;
};
这里在使用 useSnapshot
的时候需要注意,在 React 中经常被使用的解构赋值,在这里是不能使用的。例如将上面的示例写成下面的形式,可能会导致组件不会激活重新渲染。
const Component = () => {
const { count } = useSnapshot(store);
return <div>{count}</div>;
};
这里需要注意一下,使用 useSnapshot
获取到的 store 的快照是不建议在组件中的回调函数中使用的。在回调函数中使用快照可能会出现所使用的快照值是过期值的问题,在回调函数中使用 store 的值,不论是访问还是修改,都可以直接使用 store 这个被代理对象来完成。在项目中要防止这项使用错误的出现可以搭配 ESLint 使用 eslint-plugin-valtio
插件来检查和提醒。
在父级组件中获取到的快照,是可以直接传递给子组件的,甚至可以将快照拆解以后将其中部分内容传给子组件。这样在父级组件中的快照发生变化的时候,子组件也会被重新渲染。但是要在子组件中对 store 进行修改,就需要将 store 传递过去,或者传递 store 的一部分了,但需要保证所传递的内容是经过了 Valtio 代理的。例如以下示例。
const bookStore = proxy({
books: [
{ id: 1, title: "Book 1" },
{ id: 2, title: "Book 2" },
],
});
const ParentComponent = () => {
const snap = useSnapshot(bookStore);
return (
<div>
{snap.books.map((book, i) => (
<BookComponent key={book.id} book={bookStore.books[i]} />
))}
</div>
);
};
const BookComponent = ({ book }) => {
const snap = useSnapshot(book);
const updateTitle = () => {
book.updateTitle();
};
return <div onClick={updateTitle}>{snap.title}</div>;
};
在上面这个示例中可以看出,被 Valtio 代理的对象,其中嵌套的对象也同样是被代理的。因此在 BookComponent
中,我们可以通过 book.updateTitle()
来更新 books
列表中某一个 book
对象的 title
属性。
但是在 BookComponent
这样的子组件中,需要注意不能替换整个 book
对象,也就是原有被代理的 store 中的一部分。这将会使原有的代理对象无法被正确地跟踪和更新。并且也不能使用一个新的代理对象来替代,因为 useSnapshot
等功能此时依旧订阅着旧的代理对象,它们无法接收到新代理对象的更新。
useSnapshot
的使用限制
useSnapshot
是通过 useSyncExternalStore
实现的,它是推荐用来访问外部存储的方法,但是这种同步行为与并发渲染并不兼容,所以在搭配 useTransition
使用的时候就会出现问题。
要解决这个问题,可以使用第三方提供的 Hook useValtio
来解决。这个 Hook 是由 use-valtio
库提供的。useValtio
在内部没有使用 useSyncExternalStore
来实现,而是使用了 useReduce
和 useEffect
, 所以它可以有效的支持并发渲染。
而且与 useSnapshot
不同的是,useValtio
支持解构赋值,并且不会破坏对于 store 变化的监控。例如对于之前的 counter 示例,const { count } = useSnapshot(store)
是不可以的,因为会破坏对于 store 变化的监控,但是 const { count } = useValtio(store)
是可以的。
定义修改 store 的 action
action 实际上就是用来修改 store 中内容的方法,Valtio 并没有对如何组织 action 函数进行强制规定或者推荐建议。action 函数防止在任何位置或者采用任何组织形式都是可以的。
这里选择几种比较常见的易用的组织方式予以介绍。
与 store 并列定义
Valtio 的 store 通常都是在一个 module 中定义和导出的,action 函数也可以与 store 并列定义在同一个 module 中。这种方式的好处是 action 函数和 store 的内容是紧密相关的,便于理解和维护。
例如可以这样定义 store 和它相关的 action。
export const store = proxy({
count: 0,
});
export const increase = () => {
store.count++;
};
export const decrease = () => {
store.count--;
};
将 action 组织成为一个对象来管理
这种方式与上一小节中的方式类似,但相关的 action 被组织到了一个对象里,这样实际上相比上一种方式更加模块化。例如上面这个示例可以改写成以下形式。
export const store = proxy({
count: 0,
});
export const actions = {
increase: () => {
store.count++;
},
decrease: () => {
store.count--;
},
};
将 action 定义在 store 中
action 还可以作为成员函数定义在 store 内部,这样可以使 store 存储的内容与 action 之间的关联性更加明显。例如上面的示例可以继续修改成以下形式。
export const store = proxy({
count: 0,
increase: () => {
store.count++;
},
decrease: () => {
store.count--;
},
});
既然 action 方法都已经定义到 store 内部了,那么就可以换一种方式定义 action,允许 action 方法使用 this
来访问 store 中的属性。例如上面的示例可以继续修改成以下形式。
export const store = proxy({
count: 0,
increase() {
this.count++;
},
decrease() {
this.count--;
},
});
注意这两种定义方式之间的区别。第一种方式中,action 方法是作为 store 对象的属性定义的,因此它们可以直接访问 store 对象的属性。而第二种方式中,action 方法是作为 store 对象的方法定义的,因此它们需要使用 this
关键字来访问 store 对象的属性。
将 store 定义成一个类
Valtio 可以代理的内容包括类,所以 store 也可以定义成一个类,这个类里就包含了所要存储的属性内容和操作属性的 action 方法。例如以上示例定义成一个类的形式可以如下所示。
class CounterStore {
count = 0;
increase() {
this.count++;
}
decrease() {
this.count--;
}
}
const store = proxy(new CounterStore());