Valtio 是一个用于 React 的轻量级状态管理库,其灵感来源于 Proxy API。它旨在让状态管理变得简单而直观,尤其适用于那些希望在应用中实现响应式更新的开发者。

  • 简单易用: Valtio 使用 Proxy API,使状态管理简单且无需大型的样板代码。状态被包裹在 Proxy 对象中,React 组件会自动跟踪这些状态的变化并重新渲染。
  • 响应式更新: 组件当使用到 Proxy 状态中的数据时,会自动收集依赖。当这些数据发生变化时,相关组件会自动重新渲染。
  • TypeScript 支持: Valtio 拥有出色的 TypeScript 支持,这使得开发者能够在获得静态类型检查的同时,自由地操控状态。
  • 与现有代码库兼容: Valtio 设计简洁,容易与现有的代码库集成,无需大规模重构。

基础用法

最基础的用法就是通过proxy方法导出状态代理,通过useSnapshot方法获取快照,他的优势在于只有组件内访问到的属性发生变更时才会重新渲染(如果用过Vue的watchEffect,会感觉很亲切)。

下面我们来实现一个经典案例TodoList,来学习一下Valtio的使用。首先我们来去确定一下store的结构,使用proxy方法创建一个代理。

import { proxy } from "valtio";
import { v4 as uuidv4 } from 'uuid';

interface ITodo {
  id: string;
  content: string;
}

const todo = {
  todoList: [] as ITodo[],
  create(content: string) {
    todoStore.todoList.unshift({ id: uuidv4(), content })
  },
}

export const todoStore = proxy(todo)

⚠️这里要注意的是,proxy包装的对象会被冻结,所以不能在方法中使用this来设置值,需要使用代理对象进行赋值操作。就像上面👆代码里面一样,通过todoStore来进行值的变更。

数据结构有了,我们来实现一个最简易的UI来适配一下这个store。当我们输入一串文本并提交表单时,获取文本内容进行处理(后续我们要将其添加到store中),下面是List展示区域,会遍历我们保存的TodoItem进行展示。

export default function Todo() {
  const todo = useSnapshot(todoStore)
  
  return <div>
    <form
      onSubmit={(e) => {
        // 阻止默认事件(表单提交会跳转页面)
        e.preventDefault()
        const form = new FormData(e.currentTarget)
        const content = form.get('content') as string
        // 处理content
        todo.create(content)
        // 重置
        e.currentTarget.reset()
      }}
    >
      <input type="text" name="content" />
      <input type="submit" value="添加" />
    </form>

    <ul>
      {
        todo
          .todoList.map(todo =>
            <li key={todo.id}>{todo.content}</li>
          )
      }
    </ul>

  </div>
}

此时我们已经可以进行添加todo内容了

image-20240626230831852

proxy支持异步数据,并且支持React Suspense组件,当Promise未完成时显示Suspense的fallback内容

// todo.ts
async function getTodoList() {
  return new Promise<ITodo[]>(resolve => {
    setTimeout(() => {
      resolve(list)
    }, 2000)
  })
}

const todo = {
  remoteTodo: getTodoList(),
  // ......
}

直接将Promise值设置为原始对象的属性,useSnapshot会在初始化时等待Promise完成

function Remote() {
  const todo = useSnapshot(todoStore)

  return todo.remoteTodo.map(todo => <li key={todo.id}>{todo.content}</li>)
}

export default function Todo() {
  const todo = useSnapshot(todoStore)

  return <div>
    {/* ...... */}
    <Suspense fallback="loading...">
      <Remote />
    </Suspense>
    {/* ...... */}

  </div>
}

此时刷新页面可以看到初始状态显示loading

image-20240626233308186

在使用valtio数据流的时候需要注意以下几点:

  1. 不可以对代理对象进行重新赋值,会导致失效,可以更新代理对象的值,但不能直接赋值(原理参考JS引用类型,重新赋值只是将指针替换到另一个位置);
  2. 不是所有的对象都可以被代理,一般来说可以序列化的内容都可以被代理。

这两个api已经满足大部分场景的使用了,下面来看一下高阶一些不太常用的API。

进阶使用

ref

ref允许在proxy中取消某些属性的代理(如果被ref包装了之后就不会被代理,这点和Vue相反)。

const todo = {
  remoteTodo: getTodoList(),
  ref: ref([]),
}

如果更新了ref,页面不会收到更新通知,但是ref属性的内容会保持最新状态,这在某些场景下会很有用,比如某些大型数据不会在页面渲染,但是需要保存使用。

<form
  onSubmit={(e) => {
    e.preventDefault()
    const form = new FormData(e.currentTarget)
    const content = form.get('content') as string
    todo.ref.push({id: uuidv4(), content})
    console.log(todo.ref) // 可以拿到最新的数据
    e.currentTarget.reset()
  }}
>
  <input type="text" name="content" />
  <input type="submit" value="添加" />
</form>

<ul>
  {
    // 页面内容不会更新
    todo.ref.map(todo =>
        <li key={todo.id}>{todo.content}</li>
      )
  }
</ul>

subscribe

subscribe(target, callback): () => void用于订阅对象的变更,可以用于订阅组件外部的任意状态,会在目标发生改变时执行回调函数,方法返回一个函数用于取消订阅。(和Vue中的watch类似,注意不可用于组件内部,会导致重复订阅)

比如我们在任意一个组件中可以通过订阅来处理一些逻辑,这里我们可以通过message验证(没有实际意义,仅作演示)


const unSubscribe = subscribe(todoStore.todoList, () => {
  message.success(todoStore.todoList[0]?.content)
})

此时我们添加一条新的todoItem就可以看到消息弹框

image-20240627001812765

snapshot

注意这里是snapshot,不要和上面的useSnapshot混淆。snapshot接收一个代理对象,从代理对象中解包并返回一个不可变的对象。在顺序快照调用中,当代理中的值没有改变时,将返回指向相同的先前快照对象的指针,这意味着我们可以通过直接对比两个对象来判断代理是否发生过改变。

tips:snapshot也支持Promise。

const store = proxy({ name: 'feng' })
const snap1 = snapshot(store)
const snap2 = snapshot(store)
console.log(snap1 === snap2) // true

store.name = 'louis'
const snap3 = snapshot(store)
console.log(snap1 === snap3) // false

工具方法

valtio提供了许多工具方法,这些方法在某些场景下会非常有用。

subscribeKey

subscribeKey用于订阅代理状态下的原始值,它和subscribe的区别是——subscribe订阅一个复杂类型,subscribeKey订阅一个基本类型。(同样不可用于组件内)

const state = proxy({ count: 0 })
subscribeKey(state, 'count', (v) =>
  console.log(v),
)

watch

用于手动控制监听的代理对象,watch接受一个函数作为参数,在执行时会穿入一个get函数,所有被get函数调用的代理对象都会被视作依赖项,当依赖对象及其子对象发生变化时会执行函数。watch会在开始时执行一次,用于收集依赖(没记错Vue也有类似机制来收集依赖)。

可以在watch中返回一个函数,该函数会在回调函数重新执行和watch停止运行时执行,用来做一些善后清理工作。

watch((get) => {
  const todoList = get(todoStore).todoList // 将todoStore添加订阅
  if (!todoList.length) return

  message.success(todoList[0].content)

  return () => console.log('clean up')
})

watch也不可用于组件内,会引起重复订阅

devtools

valtio支持接入Redux 的开发者工具,用于调试状态

export const todoStore = proxy(todo)
devtools(todoStore)

此时打开redux插件即可对数据进行可视化操作

image-20240627215628282

derive-valtio

derive-valtio需要额外安装,作用类似于Vue的compute,可以在多个代理对象之间组成一个新的对象,当依赖项发生变更之后重新计算值。比Vue的compute不同的是可以针对某个代理对象进行派生,组成一个新的代理对象。

使用时类似于watch,需要手动通过get函数收集依赖。

import { derive } from 'derive-valtio'

const state = proxy({
  count: 1,
})


const derived1 = derive({
  doubled: (get) => get(state).count * 2,
})


const derived2 = derive(
  {
    tripled: (get) => get(state).count * 3,
  },
  {
    proxy: state,
  },
)

derive在某些场景不需要继续监听时,可以手动清理

const derived1 = derive({
  doubled: (get) => get(state).count * 2,
})

underive(derived1)

但是这种模式有个问题,get收集的是整个代理对象,当对象的任意属性发生变化时都会重新计算,会发生很多无用计算(这点不如Vue),此时我们可以通过get子对象来尽量减小监听范围,规避部分无用计算。

const baseProxy = proxy({
  counter1And2: {
    counter1: 0,
    counter2: 0,
  },
  counter3: 0,
  counter4: 0,
})

const derived = derive({
  sum: (get) =>
      // 将子对象添加为依赖
    get(baseProxy.counter1And2).counter1 + get(baseProxy.counter1And2).counter2,
})

此时如果counter3和counter4发生改变将不会重新计算


前端小白