Valtio官方文档(中文版)

2,407 阅读10分钟

Guides

Async

Promises

proxy对象中的key也可以是一个Promise,它可能在创建snapshot后,才变成resolve状态。

// vanillajs example
const countDiv: HTMLElement | null = document.getElementById('count')
if (countDiv) countDiv.innerText = '0'

const store = proxy({
  count: new Promise((r) => setTimeout(() => r(1), 1000)),
})

subscribe(store, () => {
  const value = snapshot(store).count
  if (countDiv && typeof value === 'number') {
    countDiv.innerText = String(value)
    store.count = new Promise((r) => setTimeout(() => r(value + 1), 1000))
  }
})

上面的官方例子实现了一个定时器,每隔1s会+1,而且使用的是原生js来实现,而不是react。

Example讲解:

例子proxy了一个对象,该对象的count属性是一个Promise,new Promise就会执行构造函数,开始倒计时,这时已经通过subscribe订阅了proxy的状态,订阅时,Promise的状态还未变成resolve,所以typeof value 判断的是一个object而不是 number;当1s过后,状态变为resolve就会触发订阅,这时typeof value是一个number,就会通过innerText修改文案+1,同时会循环执行setTimeout,实现自动+1

Suspend组件中使用

Valtio 支持 React Suspense,它能在渲染函数中返回一个Promise,这样就不需要异步操作了,子组件可以直接访问数据渲染,而父组件只需要负责回调状态和错误处理

const state = proxy({ post: fetch(url).then((res) => res.json()) })

function Post() {
  const snap = useSnapshot(state)
  return <div>{snap.post.title}</div>
}

function App() {
  return (
    <Suspense fallback="Loading...">
      <Post />
    </Suspense>
  )
}

Component State

为了隔离组件状态以实现可重用性,Valtio 必须存在于 React 生命周期中。您可以将proxy包装在 useRef 中,并通过 props 或Context API传递它。

import { createContext, useContext } from 'react'
import { proxy, useSnapshot } from 'valtio'

const MyContext = createContext()

const MyProvider = ({ children }) => {
  const state = useRef(proxy({ count: 0 })).current
  return <MyContext.Provider value={state}>{children}</MyContext.Provider>
}

const MyCounter = () => {
  const state = useContext(MyContext)
  const snap = useSnapshot(state)
  return (
    <>
      {snap.count} <button onClick={() => ++state.count}>+1</button>
    </>
  )
}

计算属性

官方说 Getter 是js中的一项高级的功能,因此 Valtio 建议谨慎使用它们。

因99.9999%的可能性开发用不上,所以暂不翻译。。。

API

基础

proxy

proxy的对象,以及其内部的子对象,都会被监听;当属性发生变化,所有使用该对象的地方都会响应。

import { proxy } from 'valtio'

const state = proxy({ count: 0, text: 'hello' })
不限制调用地点

各种地方修改属性,对象都会动态响应(不限于react组件内部使用),可以像普通对象一样去修改

setInterval(() => {
  ++state.count
}, 1000)
优化:重复更新和批量更新

用相同的值再设置一遍属性,会被Valtio忽略

const state = proxy({ count: 0 })

state.count = 0 // has no effect

同一个事件循环中的多次修改,只会更新1次而不是多次

const state = proxy({ count: 0, text: 'hello' })
// subscribers will be notified once after both mutations
state.count = 1
state.text = 'world'
嵌套proxy

代理可以嵌套在其他代理对象中并作为一个整体进行更新

import { proxy, useSnapshot } from 'valtio'

const personState = proxy({ name: 'Timo', role: 'admin' })
const authState = proxy({ status: 'loggedIn', user: personState })

authState.user.name = 'Nina'
Promise中的proxy

详情请看同步(async)章节

import { proxy } from 'valtio'

const bombState = proxy({
  explosion: new Promise((resolve) => setTimeout(() => resolve('Boom!'), 3000)),
})
陷阱

如果将proxy重新分配给一个全新的对象,它就不会再响应

let state = proxy({ user: { name: 'Timo' } })

subscribe(state, () => {
  console.log(state.user.name)
})
// will not notify subscribers
state = { user: { name: 'Nina' } }

// instead
let state = proxy({ user: { name: 'Timo' } })

subscribe(state, () => {
  console.log(state.user.name) // logs "Nina"
})
// will notify subscribers
state.user.name = 'Nina'

不是所有的对象都可以被proxy,通常来讲,只有能序列化的数据被proxy才能响应,类也可以被proxy,但是要注意一些特殊对象,如: dom节点react的jsx节点Map类型localStorage

// these won't work - changes to these objects won't cause updates
// to store state that is unproxied see the docs on ref
const state = proxy({
  chart: d3.select('#chart'),
  component: React.createElement('div'),
  map: new Map(), // see proxyMap
  storage: localStorage,
})

// this will work
class User {
  first = null
  last = null
  constructor(first, last) {
    this.first = first
    this.last = last
  }
  greet() {
    return `Hi ${this.first}!`
  }
  get fullName() {
    return `${this.first} ${this.last}`
  }
}
const state = proxy(new User('Timo', 'Kivinen'))

useSnapshot

创建一个snapshot,当每次数据变化时,snapshot就会同步改变。

通常proxy的属性变化时,snapshots就会通过snapshot()方法重新创建,从而实现视图刷新。

但是snapshots的重新创建也是有策略的,不会无脑刷新,只有被使用到的属性改变时,snapshots才会被刷新。

渲染时使用snapshot对象,更新时使用proxy对象

snapshot是只读对象,用于JSX渲染;当需要更新时,操作proxy对象,以便回调函数可以读取和写入数据。

function Counter() {
  const snap = useSnapshot(state)
  return (
    <div>
      {snap.count}
      <button
        onClick={() => {
          // also read from the state proxy in callbacks
          if (state.count < 10) {
            ++state.count
          }
        }}
      >
        +1
      </button>
    </div>
  )
}
父组件和子组件中的使用

如果父组件创建了snapshot,并作为props传给子组件,那么当数据变化时,父子组件都会响应。

const state = proxy({
  books: [
    { id: 1, title: 'b1' },
    { id: 2, title: 'b2' },
  ],
})

function AuthorView() {
  const snap = useSnapshot(state)
  return (
    <div>
      {snap.books.map((book) => (
        <Book key={book.id} book={book} />
      ))}
    </div>
  )
}

function Book({ book }) {
  // book is a snapshot
  return <div>{book.title}</div>
}

当books[1].title被更改时,一个新的snap会被创建,此时AuthorView和Book都会更新。

但如果Book组件用了useMemo,,则第一个Book组件不会更新,因为参数是相同的实例,只有第二个Book组件才会发生更新,因为只有它的数据变化了。

子组件想要更新

如果子组件想要更新状态,则需要父组件去将proxy传递过来

function AuthorView() {
  const snap = useSnapshot(state)
  return (
    <div>
      {snap.books.map((book, i) => (
        <Book key={book.id} book={state.books[i]} />
      ))}
    </div>
  )
}

function BookView({ book }) {
  // book is the proxy, so we can re-snap it + mutate it
  const snap = useSnapshot(book)
  return <div onClick={() => book.updateTitle()}>{snap.title}</div>
}

要不就将snapshot 和 proxy都传递给子组件,这样子组件就不用再调用一次useSnapshot了

function AuthorView() {
  const snap = useSnapshot(state)
  return (
    <div>
      {snap.books.map((book, i) => (
        <Book key={book.id} bookProxy={state.books[i]} bookSnapshot={book} />
      ))}
    </div>
  )
}

这两种方法之间没有性能差异。

只获取你需要的proxy部分

在你的proxy对象里,每一个子属性都时一个proxy(除非使用了ref),所以每一个子属性都能变成一个snapshot

function ProfileName() {
  const snap = useSnapshot(state.profile)
  return <div>{snap.name}</div>
}
陷阱

请注意不要用其他对象替换子代理,这会破坏你的快照。

console.log(state)
{
  profile: {
    name: 'valtio'
  }
}
childState = state.profile
console.log(childState)
{
  name: 'valtio'
}
state.profile.name = 'react'
console.log(childState)
{
  name: 'react'
}
state.profile = { name: 'new name' }
console.log(childState)
{
  name: 'react'
}
console.log(state)
{
  profile: {
    name: 'new name'
  }
}

useSnapshot() 取决于子代理的原始引用,因此如果您将其替换为新代理,订阅旧代理的组件将不会收到新的更新,因为它仍然订阅旧代理。 在这种情况下,我们建议采用以下方法之一。在这两个示例中,您都不需要担心重新渲染,因为它是渲染优化的。

const snap = useSnapshot(state)

return <div>{snap.profile.name}</div>
const { profile } = useSnapshot(state)

return <div>{profile.name}</div>

const { profile } = useSnapshot(state)

return <div>{profile.name}</div>
开发模式的debug值

在开发模式下,useSnapshot 使用 React 的 useDebugValue 输出渲染期间访问的字段列表,即当跟踪代理发生更改时哪些特定字段将触发重新渲染。 对于调试值有两个免责声明:

  1. 由于 useSnapshot 在 useSnapshot 返回后使用代理来记录访问,因此 useDebugValue 中列出的字段在技术上来自之前的渲染。
  2. 对象 getter 和类 getter 调用不包含在 useDebugValue 输出中,但不用担心,它们实际上在内部正确跟踪,并在更改时正确触发重新渲染。

进阶

ref

ref允许state的部分属性不被proxy

const store = proxy({
    users: [
        { id: 1, name: 'Juho', uploads: ref([]) },
    ]
  })
})

一旦对象被包装在ref中,修改该对象时,千万不要重新赋值一个新对象或者用新的ref包裹替换

// do mutate
store.users[0].uploads.push({ id: 1, name: 'Juho' })
// do reset
store.users[0].uploads.splice(0)

// don't
store.users[0].uploads = []

ref 也不应该包裹proxy的整个对象,从而使代理的使用毫无意义。

subscribe

任何地方都可以使用订阅subscribe

您可以访问组件外部的状态并订阅更改。

import { proxy, subscribe } from 'valtio'

const state = proxy({ count: 0 })

// Subscribe to all changes to the state proxy (and its child proxies)
const unsubscribe = subscribe(state, () =>
  console.log('state has changed to', state),
)
// Unsubscribe by calling the result
unsubscribe()

您还可以订阅状态的一部分。

const state = proxy({ obj: { foo: 'bar' }, arr: ['hello'] })

subscribe(state.obj, () => console.log('state.obj has changed to', state.obj))
state.obj.foo = 'baz'
subscribe(state.arr, () => console.log('state.arr has changed to', state.arr))
state.arr.push('world')

snapshot

什么是snapshot?

store对象被proxy代理后,会return一个不可变(immutable)对象,就是snapshot。

不可变性(immutable)是通过object的深度复制和freez object冻结对象来实现的(有关详细信息,请参阅“拷贝和写入”部分)。

简而言之,store被proxy代理,只要store的值不变,返回的snapshot的指针就不会变,这就允许我们可以对snapshot对象进行浅层比较,从而防止重复渲染。

快照也能返回Promise对象,从而能够与 React.Suspense 组件结合使用

import { proxy, snapshot } from 'valtio'

const store = proxy({ name: 'Mika' })
const snap1 = snapshot(store) // an efficient copy of the current store values, unproxied
const snap2 = snapshot(store)
console.log(snap1 === snap2) // true, no need to re-render

store.name = 'Hanna'
const snap3 = snapshot(store)
console.log(snap1 === snap3) // false, should re-render
拷贝和写入

尽管snapshots对整个store进行了深拷贝,但它们的update是惰性写入机制,因此在实践中很容易去维护。

例如,我们有一个被包裹的对象

const author = proxy({
  firstName: 'f',
  lastName: 'l',
  books: [{ title: 't1' }, { title: 't2' }],
})

const s1 = snapshot(author)

这个s1snapshot会创建4个实例:

  • 1个author实例
  • 1个books数组实例
  • books数组里的2个对象,分别对应2个实例

当我们修改books数组里的第二个对象实例,就会创建一个新的snapshot

author.books[1].title = 't2b'
const s2 = snapshot(author)

这个 s2 会给books数组里的第2个对象创建一个新的实例, 但是会重用数组里的第1个对象的实例。

console.log(s1 === s2) // false
console.log(s1.books === s2.books) // false
console.log(s1.books[0] === s2.books[0]) // true
console.log(s1.books[1] === s2.books[1]) // false

尽管此示例只重用了四个snapshot的实例中的一个,但从这个例子可以看出,Valtio维护snapshot的成本是由state tree的深度决定而不是它的广度决定(上面的例子的深度只有3层:作者、书籍、书籍评论,如果书籍评论有1千条,则广度是1千)。

简单说就是,state tree的大小不会影响性能,但是它的层级深度会影响性能,所以把store拆分的层级越小,性能越优

Classes

snapshots保留了原始对象的原型,因此对象的方法和 getter 可以都正常使用,并根据快照的冻结状态正确评估。

import { proxy, snapshot } from 'valtio'

class Author {
  firstName = 'f'
  lastName = 'l'
  fullName() {
    return `${this.firstName} ${this.lastName}`
  }
}

const state = proxy(new Author())
const snap = snapshot(state)

// the snapshot has the Author prototype
console.log(snap instanceof Author) // true

state.firstName = 'f2'

// Invocations use the snapshot's state, e.g. this is still 'f' because
// inside `fullName`, `this` will be the frozen snapshot instance and not
// the mutable state proxy
console.log(snap.fullName()) // 'f l'

请注意,getter 和方法的结果不会被缓存,并且会在每次调用时重新计算。

这样做肯定没问题,因为期望是它们的执行速度要非常快(比缓存它们的开销,这样确实更快)并且也是确定性的,因此返回值仅基于已经冻结的快照状态。

不在react中使用

如果不在react中使用Valtio,也就是在普通js里使用,需要从 'valtio/vanilla'中导入 proxy 和 snapshot。

import { proxy, subscribe, snapshot } from "valtio/vanilla";

在普通js中,无论是在subscribe回调函数里还是外,snapshot都不再被需要,直接使用被proxy代理的对象即可,这是有用的,因为保留了proxy之前的对象序列化key列表,可以检测对象是否被改变,也能resolve 一个 promise

(可以再看一遍subscribe的官方实例理解上面的话)

import { proxy, subscribe } from 'valtio'

const state = proxy({ count: 0 })

// Subscribe to all changes to the state proxy (and its child proxies)
const unsubscribe = subscribe(state, () =>
  console.log('state has changed to', state),
)
// Unsubscribe by calling the result
unsubscribe()

UTILS

subscribeKey

如果要订阅proxy对象的某个key,则使用subscribeKey

import { subscribeKey } from 'valtio/utils'

const state = proxy({ count: 0, text: 'hello' })
subscribeKey(state, 'count', (v) =>
  console.log('state.count has changed to', v),
)

其它utils

watch、devtools、derive、proxyWithHistory、proxySet、proxyMap使用频率较低,请查看原文

最佳实践

如何变成非响应式、有条件的响应,从而精准的控制 reRender ?

先看一下基础使用:

const Component = () => {
  const { count } = useSnapshot(state) // this is reactive
  return <>{count}</>
}

下面这种用法,可以正常读取到变量,但变成了非响应式,所以一般情景下不建议使用

const Component = () => {
  const { count } = state // this is not reactive
  return <>{count}</>
}

下面的用法,就是通过订阅,有条件的改变state从而重新渲染

const Component = () => {
  const [count, setCount] = useState(state.count)
  useEffect(
    () =>
      subscribe(state, () => {
        if (state.count % 2 === 0) {
          // conditionally update local state
          setCount(state.count)
        }
      }),
    [],
  )
  return <>{count}</>
}

上面的写法大多数情况下有效,但若要支持在订阅之前,state也可以变化,就需要如下修复:

const Component = () => {
  const [count, setCount] = useState(state.count)
  useEffect(() => {
    const callback = () => {
      if (state.count % 2 === 0) {
        // conditionally update local state
        setCount(state.count)
      }
    }
    const unsubscribe = subscribe(state, callback)
    callback()
    return unsubscribe
  }), [])
  return <>{count}</>
}

如何在应用程序的任何位置都能容易的访问到state

当在大型项目中组织代码,经常会将有些模块单独放到一个文件或目录中,Valtio的state也是如此,您可能希望将state对象单独放入自己的文件中,这样组织好代码后,还需要在项目中的任何位置能够轻松访问它。

配置Path Aliases的方式访问state

如果通过相对路径,比如import state from '../../../../state;这样访问state是很丑的,也是很不方便的,不如任何路径通过import { state } from '@state';就能访问到state来的简单

使用 JS Config 和 Babel Config

  1. 创建文件 /src/state 并将 Valtio 状态放入其中:
import { proxy, useSnapshot, subscribe } from 'valtio'
const state = proxy({
  foos: [],
  bar: { ... },
  boo: false
})
export { state, useSnapshot, subscribe }
  1. 创建文件 /jsconfig.json:
{
  "compilerOptions": {
    "baseUrl": "src",
    "paths": {
      "@state/*": ["./state/*"],
      "@mypath/*": ["./my/deep/path*"],
      "@anotherpath/*": ["./my/another/deep/path*"]
    }
  },
  "exclude": ["node_modules"]
}
  1. 将 Module Resolver 插件添加到 babel.config.js 中:
module.exports = {
  // ...
  plugins: [
    // The other existing plugins
    [
      'module-resolver',
      {
        root: ['./src'],
        extensions: ['.js', '.jsx', '.json', '.svg', '.png'],
        alias: {
          '@state': './src/state',
        },
      },
    ],
    // ...
  ],
}
  1. 安装 Babel 插件模块解析器
// 通过 npm
npm install babel-plugin-module-resolver

// 通过 yarn
yarn add babel-plugin-module-resolver
  1. 重启服务

就这样,您现在可以在程序的任意位置执行 import { (state, useSnapshot, subscribe) } from '@state';

如何组织actions代码

Valtio不对actions的代码组织持有任何意见,但下面有一些举例,为您遇到的各种各样情景提供建议。

通过函数组织 action

注意这种方式是首选,因为它更适合代码分割。

import { proxy } from 'valtio'

export const state = proxy({
  count: 0,
  name: 'foo',
})

export const inc = () => {
  ++state.count
}

export const setName = (name) => {
  state.name = name
}

通过对象组织action

import { proxy } from 'valtio'

export const state = proxy({
  count: 0,
  name: 'foo',
})

export const actions = {
  inc: () => {
    ++state.count
  },
  setName: (name) => {
    state.name = name
  },
}

action定义到state原始对象中

export const state = proxy({
  count: 0,
  name: 'foo',
  inc: () => {
    ++state.count
  },
  setName: (name) => {
    state.name = name
  },
})

在aiciton中使用this

export const state = proxy({
  count: 0,
  name: 'foo',
  inc() {
    ++this.count
  },
  setName(name) {
    this.name = name
  },
})

在 Class 中使用

class State {
  count = 0
  name = 'foo'
  inc() {
    ++this.count
  }
  setName(name) {
    this.name = name
  }
}

export const state = proxy(new State())

如何持久化 state

用 localstorage 实现持久化

如果您的state是可序列化的 JSON,这样就很简单了

const state = proxy(
  JSON.parse(localStorage.getItem('foo')) || {
    count: 0,
    text: 'hello',
  },
)

subscribe(state, () => {
  localStorage.setItem('foo', JSON.stringify(state))
})

如果您有不可序列化的值,请在反序列化后添加它们,并在序列化时移除它们。

valtio-persist是一个可以帮助解决此问题的库。

如何重置 state

在某些情况下,您可能希望将proxy重置为初始值,这在Valtio中非常简单

const initialObj = {
  text: 'hello',
  arr: [1, 2, 3],
  obj: { a: 'b' },
}

const state = proxy(initialObj)

const reset = () => {
  const resetObj = _.cloneDeep(initialObj)
  Object.keys(resetObj).forEach((key) => {
    state[key] = resetObj[key]
  })
}

请注意,上面使用 lodash 中的 cloneDeep 函数来拷贝对象,这样,当键的value是对象时,Valtio 能正确更新,如果也可以使用其它库。

或者,您可以将该对象存储在另一个对象中,这使得重置逻辑更容易:

const state = proxy({ obj: initialObj })

const reset = () => {
  state.obj = _.cloneDeep(initialObj)
}

如何拆分、组合 state

拆分state

将所有的state创建到一个大对象中

const state = proxy({
  obj1: { a: 1 },
  obj2: { b: 2 },
})

然后您可以将状态分成几部分,他们都是proxy。

const obj1State = state.obj1
const ojb2State = state.obj2

合并state

您可以创建多个proxy,然后合并到一个大的proxy

const obj1State = proxy({ a: 1 })
const obj2State = proxy({ a: 2 })

const state = proxy({
  obj1: obj1State,
  obj2: obj2State,
})

这跟前面的例子等效

创建循环 state

虽然用例较少,但您可以创建圆形结构。

const state = proxy({
  obj: { foo: 3 },
})

state.obj.bar = state.obj // 🤯

在Context API中使用

要使 valtio 状态仅存在于 React 生命周期中,您可以在 useRef 中创建一个状态,并且可以使用 props 或 context 传递它

import { createContext, useContext, useRef } from 'react'
import { proxy, useSnapshot } from 'valtio'

const MyContext = createContext()

const MyProvider = ({ children }) => {
  const state = useRef(proxy({ count: 0 })).current
  return <MyContext.Provider value={state}>{children}</MyContext.Provider>
}

const MyCounter = () => {
  const state = useContext(MyContext)
  const snap = useSnapshot(state)
  return (
    <>
      {snap.count} <button onClick={() => ++state.count}>+1</button>
    </>
  )
}

valtio的原理

请查看原文

踩坑

使用useSnapshot(state)后,没有访问其子属性,那么它总是会 re-render

假设我们有这个state。

const state = proxy({
  obj: {
    count: 0,
    text: 'hello',
  },
})

如果访问了state的子属性count

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

只有count的属性改变时,它才会re-render

如果是访问obj属性

const snap = useSnapshot(state)
snap.obj

那么,只要obj改变,它就会re-render,包括obj的count改变和tex改变,任意一个都会引起re-render

现在,我们只订阅state的一部分

const snapObj = useSnapshot(state.obj)
snapObj

这样写代码,和上面的写法效果相同,只要 obj 发生变化,它都将重新渲染。

总结:

如果没有使用任何属性访问snapshot对象(无论是否嵌套),它就会假设整个对象都被访问,因此对象内部的任何更改都将触发重新渲染。

将 React.memo 与对象 props 一起使用可能会导致意外行为

Valtio给出了一些选择:

  1. 不要使用 React.memo。
  2. 不要使用 React.memo 将对象传递给组件(而是传递原始值)。
  3. 可以传入proxy对象,然后通过useSnapshot使用。

Example of (b)

const ChildComponent = React.memo(
  ({
    title, // 字符串或者其它基本类型是可以正常响应的
    description, // 字符串或者其它基本类型是可以正常响应的
    // obj, // 不能传引用类型的值
  }) => (
    <div>
      {title} - {description}
    </div>
  ),
)

const ParentComponent = () => {
  const snap = useSnapshot(state)
  return (
    <div>
      <ChildComponent
        title={snap.obj.title}
        description={snap.obj.description}
      />
    </div>
  )
}

Example of (c)

const state = proxy({
  objects: [
    { id: 1, label: 'foo' },
    { id: 2, label: 'bar' },
  ],
})

const ObjectList = React.memo(() => {
  const stateSnap = useSnapshot(state)

  return stateSnap.objects.map((object, index) => (
    <Object key={object.id} objectProxy={state.objects[index]} />
  ))
})

const Object = React.memo(({ objectProxy }) => {
  const objectSnap = useSnapshot(objectProxy)

  return objectSnap.bar
})

在函数组件中,state 和 snap 分别什么时候使用?

  • snap应该被用在react的渲染body中
  • state应该被用在非渲染body中,例如修改store状态的callback函数中
const Component = () => {
  // 这里是渲染body
  const handleClick = () => {
    // 这里不是渲染body
  }
  return <button onClick={handleClick}>button</button>
}
  • 应该在 useEffect 中从 snap 解构出要使用的原始值。例如:const { num, string, bool } = snap.watchObj
  • 根据一个state更改另一个state(不涉及到函数组件中的值比如 props ),最好应该在 React 之外完成
subscribe(state.subscribeData, async () => {
  state.results = await load(state.someData)
})
proxy数组的相关问题

没看懂暂不翻译,请查看原文