本篇文章同时收录在公众号《泡芙玩编程》,持续更新内容中,欢迎关注~

1.Valtio 是啥玩意

Valtio makes proxy-state simple for React and Vanilla

就是让数据管理在 React 和原生 JS (Vanilla) 中变得更加简单的一个库,它类似于 Vue 的数据驱动视图的理念,使用外部状态代理去驱动 React 视图来更新。总的来说,Valtio(用粤语来念就是“我丢”) 是一个很轻量级的响应式状态管理库。

2.主要作者是谁?

主要作者叫做 Daishi Kato(带师?是你吗?)他是日本东京人,是个全职开源作者。戳多马蝶,这货居然还写了好几个状态管理库,分别是 Jotai 13.5k⭐Zustand 31.2k⭐Valtio 7k⭐ ,这三个状态管理库都是这货主要开发的,而且用的人还挺多的。其中 Jotai 和 Recoil 类似, Zustand 和 Redux 类似,Valtio 和 Mobx 类似,它们的名字分别是日语、 德语、芬兰语 中的 “状态”,这几个库和之前一些老牌的库比上手要更简单,而且使用起来更简洁,并且主打轻量级。

上面提到的几个库本质上代表了3个流派:

dispatch 流派(单向数据流-中心化管理):redux、zustand、dva等

响应式流派(中心化管理):mobx、valtio等

原子状态流派(原子组件化管理):recoil、jotai等

下面我们来举几个关于上面提到的 zustand、jotai 、valtio 的基本使用例子,对这几个库有个整体的感知,以计时器为例:

Zustand

 1import { create } from "zustand";
 2
 3const useStore = create((set) => ({
 4  count: 0,
 5  inc: () => set((state) => ({ count: state.count + 1 })),
 6}));
 7
 8export default function Counter() {
 9  const count = useStore((state) => state.count);
10  const inc = useStore((state) => state.inc);
11
12  return (
13    <div>
14      {count}
15      <button onClick={inc}>+1</button>
16    </div>
17  );
18}

Jotai

每个状态都是原子化,用法和原生的 useState 有点像

 1import { atom, useAtom } from "jotai";
 2
 3const countAtom = atom(0);
 4
 5function Counter() {
 6  const [count, setCount] = useAtom(countAtom);
 7
 8  return (
 9    <div>
10      {count}
11      <button onClick={() => setCount((v) => v + 1)}>+1</button>
12    </div>
13  );
14}

Valtio

和 Vue 的响应式类似,当数据发生变化的时候就驱动视图更新

 1import { proxy, useSnapshot } from "valtio";
 2
 3const state = proxy({ count: 0 });
 4
 5function Counter() {
 6  const snap = useSnapshot(state);
 7
 8  return (
 9    <div>
10      {snap.count}
11      <button onClick={() => ++state.count}>+1</button>
12    </div>
13  );
14}

用三个简单的计时器例子看完了它们三者之间的代码风格差异。

关于如何选择完全是要看个人风格喜好了,我个人的话更喜欢响应式风格的,因为我以前写过一年的Vue,而且 Mobx 我也在之前项目中用过很长的一段时间了,所以 Valtio 就觉得很亲切。但是响应式风格和 React 的单向数据流理念有点违背,所以用户没有 dispatch 流派用的人那么多(从⭐ 的数量就能看出来)。

我们今天这里的主角是 Valtio,下面就讲讲 Valtio 的使用

3.基础:如何使用

从上面的例子中我们可以看到 Valtio 最主要的两个 API 是 proxy 和 useSnapshot,proxy 会为原始对象创建一个 Proxy 代理。使用 useSnapshot 会创建一个组件中的本地快照 snap,并且这个快照是只读的(readonly),当改变 state.count 时,该组件就会被重新渲染,但是改变 state.text 的值时,组件不会重渲染,这里的渲染过程经过优化的。

由于底层和 Vue3 一样使用了 Proxy 来做为数据代理,所以我们先看看它的兼容性,可以看到除了 IE 不支持以外别的浏览器都支持得很好了。

监听数据变化

用于监听数据变化时,valtio 提供了 subscribe 这个 API,下面我们看看效果和代码实现

效果演示

示例代码

 1import { useEffect } from "react";
 2import { proxy, subscribe, useSnapshot } from "valtio";
 3
 4const state = proxy({
 5  count: 0,
 6  test: {
 7    arr: [] as string[],
 8  },
 9});
10
11
12subscribe(state.test.arr, () => {
13  console.log("在外部监听到 state.test.arr 发生变化了", state.test.arr);
14});
15
16export default function Counter() {
17  const snap = useSnapshot(state);
18
19  useEffect(() => {
20    const unSubscribe = subscribe(state, () => {
21      
22      console.log("在组件内监听到 state 发生变化了", state.count);
23    });
24
25    return () => {
26      unSubscribe();
27    };
28  }, []);
29
30  console.log("re-render");
31
32  return (
33    <>
34      <button
35        onClick={() => {
36          // 同时修改多个状态,组件也只会 re-render 一次
37          state.count += 1;
38          state.test.arr.push(String(state.count));
39        }}
40      >
41        do it
42      </button>
43      <div>{snap.count}</div>
44      {snap.test.arr.map((i, k) => (
45        <div key={k}>{i}</div>
46      ))}
47    </>
48  );
49}

如果需要监听多个属性的变化,可以使用从 valtio/utils 里导出的 watch API,和 Vue 的 API 有点类似

异步数据

 1const sleep = (ms = 3000) => new Promise((resolve) => setTimeout(resolve, ms));
 2
 3const state = proxy({
 4  asyncState: sleep().then(() => "异步加载完成"),
 5});
 6
 7function AsyncComponent() {
 8  const snap = useSnapshot(state);
 9  return <div>{snap.asyncState}</div>;
10}
11
12export default function App() {
13  return (
14    <Suspense fallback="加载中...">
15      <AsyncComponent />
16    </Suspense>
17  );
18}

snapshot 取消代理

可以将一个用 proxy 包裹代理过的可变对象还原成一个不可变的对象。

简单地说,在顺序的快照调用中,当代理对象的值没有改变时,将返回一个指向相同的前一个快照对象的指针。这可以在函数组件中进行浅比较来避免重渲染。这个函数对于我们后面理解原理比较重要,下面是个使用例子:

 1import { proxy, snapshot } from 'valtio'
 2
 3const store = proxy({ name: 'Puff' })
 4
 5const snap1 = snapshot(store) 
 6const snap2 = snapshot(store)
 7
 8console.log(snap1 === snap2)
 9
10
11store.name = 'PuffMeow'
12const snap3 = snapshot(store)
13
14console.log(snap1 === snap3)

几个使用时的注意事项

1.啥时候用 snap 啥时候用 state

在 React 函数组件中, snap 应该和 hooks 一样,只在渲染体(render-body)里去用,state 应该在非渲染体里去用。这钟写法看起来有一点点割裂,不过在下面的最佳实践部分,我们会用另一种方式去解决这种割裂的写法。

 1import { proxy, useSnapshot } from "valtio"
 2
 3const state = proxy({
 4  count: 0
 5})
 6
 7const Component = () => {
 8  const snap = useSnapshot(state)
 9  
10
11  const handleClick = () => {
12    
13      
14    state.count++
15    
16    console.log(snap) 
17    
18    console.log(state) 
19  }
20  
21  return <button onClick={handleClick}>+1</button>
22}

2.访问整个对象时任意属性触发都会导致重渲染

假如我们有这么一个对象

 1const state = proxy({
 2  obj: {
 3    count: 0,
 4    text: "hello world"
 5  }
 6})

当我们用 snap 去获取 count 时,组件只会在 count 发生变化时才会重新渲染

 1const snap = useSnapshot(state)
 2snap.obj.count

但是假如我们在组件内获取 obj ,那么当 obj 发生变化时,不管是 count 变化还是 text 变化,都会让组件触发重渲染

 1const snap = useSnapshot(state)
 2snap.obj
 3
 4
 5const snapObj = useSnapshot(state.obj)
 6snapObj

所以我们应该在渲染体内尽量的精确读取某一个对象里的属性,防止不必要的 re-render

3. 传递对象属性给 React.memo 包裹的组件可能会引发问题

useSnapshot 返回的 snap 变量是用来做重渲染优化数据追踪的,如果你把整个 snap 或者 snap 嵌套的对象属性传递给一个 React.memo 包裹着的组件,可能会有问题,因为 memo 只会做浅层比较来决定是否重渲染一个组件,如果是遇到嵌套对象的话,那么 memo 就会失效了

下面是一些开发时的约定:

  • 不要传递一个对象属性给 React.memo 包裹的组件

  • 要传递对象的时候就避免使用 React.memo

  • 如果非要传递对象给 React.memo 包裹的组件的话,可以传递 proxy 代理过的对象,子组件里使用 useSnapshot 去读取

 1const state = proxy({
 2  obj: [
 3    { id: 1, label: 'foo' },
 4    { id: 2, label: 'bar' },
 5  ],
 6})
 7
 8const Parent = React.memo(() => {
 9  const stateSnap = useSnapshot(state)
10
11  return stateSnap.obj.map((item, index) => (
12    <Child key={item.id} objectProxy={state.obj[index]} />
13  ))
14})
15
16const Child = React.memo(({ objectProxy }) => {
17  const objectSnap = useSnapshot(objectProxy)
18
19  return objectSnap.label
20})

最佳代码实践

在代码中一般我会这样去管理一个全局数据,这也是官方推荐的写法,使用 useProxy 来封装一个获取全局 store 数据的自定义 hook useStore 即可,这样获取数据和设置数据的时候都可以使用 store 这个变量名来设置,避免了上面提到的 snap 和 store 割裂的写法。

 1
 2import { useProxy, proxy } from "valtio/utils";
 3
 4const store = proxy({
 5    userInfo: {},
 6    list: []
 7})
 8
 9
10export default useStore = () => useProxy(store);
11
12
13import useStore from "../../store";
14
15export function List() {
16    const store = useStore();
17    
18    useEffect(() => {
19        fetchData.then(res => {
20            store.list = res.data.list;
21            
22        })
23    }, [])
24    
25    return (
26        <div>
27            {store.list.map(item => <div>{item.name}</div>)}
28        </div>
29    )
30}

useProxy 其实就是对取 useSnapshot() 或 store 数据的封装,这个 hook 也很简单,就是判断是渲染期间(渲染体内)就返回 useSnapshot() 的快照数据,非渲染期间(非渲染体内)就返回原始的 store 数据,和我们自己手写的是差不多的,只不过这个 hook 帮我们把这个过程封装了起来。

小结

以上就是关于 Valtio 库的基本使用了,使用起来的感受和 Vue 的响应式比较像,都是收集依赖到触发依赖更新的一个过程,内部都使用了 Proxy 进行代理。目前在公司内部我也有项目在用,总的来说,小项目使用起来还是挺方便的,但是大型项目的话如果稍微用不好那可能就会掉坑里去,不过总的来说,还是很好用的~

本篇文章同时收录在公众号《泡芙玩编程》,持续更新内容中,欢迎关注~

个人笔记记录 2021 ~ 2025