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 输出渲染期间访问的字段列表,即当跟踪代理发生更改时哪些特定字段将触发重新渲染。 对于调试值有两个免责声明:
- 由于 useSnapshot 在 useSnapshot 返回后使用代理来记录访问,因此 useDebugValue 中列出的字段在技术上来自之前的渲染。
- 对象 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
- 创建文件
/src/state并将 Valtio 状态放入其中:
import { proxy, useSnapshot, subscribe } from 'valtio'
const state = proxy({
foos: [],
bar: { ... },
boo: false
})
export { state, useSnapshot, subscribe }
- 创建文件
/jsconfig.json:
{
"compilerOptions": {
"baseUrl": "src",
"paths": {
"@state/*": ["./state/*"],
"@mypath/*": ["./my/deep/path*"],
"@anotherpath/*": ["./my/another/deep/path*"]
}
},
"exclude": ["node_modules"]
}
- 将 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',
},
},
],
// ...
],
}
- 安装 Babel 插件模块解析器
// 通过 npm
npm install babel-plugin-module-resolver
// 通过 yarn
yarn add babel-plugin-module-resolver
- 重启服务
就这样,您现在可以在程序的任意位置执行 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给出了一些选择:
- 不要使用 React.memo。
- 不要使用 React.memo 将对象传递给组件(而是传递原始值)。
- 可以传入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数组的相关问题
没看懂暂不翻译,请查看原文