valtio 是什么
valtio是一个很轻量级的响应式状态管理库,它基于 Proxy 实现,类似于 vue 的数据驱动视图的理念,使用外部状态代理去驱动 React 视图更新,不管在react组件内部还是外面都可以使用。下面提供 valtio 基本用法例子:
valtio 优势在哪里
这里我们选用老牌状态管理 redux 和 valtio 进行对比,下面通过如何实现数字累加功能的例子来探讨它们的用法差异。
Redux 实现数字累加
首先我们需要定义一系列的模版:Action类型、Action创建函数、State类型、Reducer函数。
tsx
// 定义Action类型
enum ActionType {
INCREMENT = 'INCREMENT',
DECREMENT = 'DECREMENT',
}
// 定义Action创建函数
const increment = () => ({ type: ActionType.INCREMENT });
const decrement = () => ({ type: ActionType.DECREMENT });
// 定义State类型
type State = { count: number };
// 定义Reducer函数
const counterReducer = (state: State = { count: 0 }, action: { type: ActionType }): State => {
switch (action.type) {
case ActionType.INCREMENT:
return { count: state.count + 1 };
case ActionType.DECREMENT:
return { count: state.count - 1 };
default:
return state;
}
};
然后通过 Redux 的createStore创建 store。
tsx
import { createStore } from 'redux';
// 创建Redux store
const store = createStore(counterReducer);
根组件 App 通过 Provider 组件 包裹将store传递给所有子组件,在子组件我们使用 connect 高阶组件将 Redux 的状态映射到组件props,这样在子组件里能够访问 Redux 的状态和触发 Action。
tsx
import React from 'react';
import { Provider, connect } from 'react-redux';
// 定义React组件
type CounterProps = {
count: number;
increment: () => void;
decrement: () => void;
};
const Counter: React.FC<CounterProps> = ({ count, increment, decrement }) => (
<div>
<h2>Counter: {count}</h2>
<button onClick={increment}>+</button>
<button onClick={decrement}>-</button>
</div>
);
// 将Redux state映射到React组件的props
const mapStateToProps = (state: State) => ({
count: state.count,
});
// 将Action创建函数映射到React组件的props
const mapDispatchToProps = {
increment,
decrement,
};
// 使用connect高阶组件包裹Counter组件
const ConnectedCounter = connect(mapStateToProps, mapDispatchToProps)(Counter);
// 使用Provider组件将store传递给所有子组件
const App: React.FC = () => (
<Provider store={store}>
<ConnectedCounter />
</Provider>
);
export default App;
Redux包括View、Actions和State三个核心元素。每当 view 需要更新时,会触发一个 action,然后 dispatch 携带 action, 交给 store 的 reducer 处理,更新 state 并反馈到 view。这种设计使得数据改变遵循统一流程,使代码更易于理解和调试。但 Redux 也有缺点,如需要编写大量样板代码,导致项目文件结构复杂和代码冗余,新开发者需要时间去理解这些概念。此外,Redux 强制单一数据源和同步状态更新,可能限制某些应用的开发效率。
valtio 实现数字累加
我们再来看看使用 valtio 是如何实现数字累加功能。
首先,我们使用 valtio 的 proxy 函数创建了一个响应式的状态代理对象 state,该对象包含 count 属性。
tsx
import { proxy } from 'valtio';
// 使用 valtio 定义 state
const state = proxy({ count: 0 });
然后在组件里使用 useSnapshot 钩子获取状态快照(snapshot),并在状态变化时触发组件的重新渲染 (valtio做到了细粒度re-render,下面的源码小节将会解释) 。
tsx
import React from 'react';
import { useSnapshot } from 'valtio';
// 定义 React 组件
const Counter: React.FC = () => {
// 使用 useSnapshot 获取 state 的快照
const snap = useSnapshot(state);
return (
<div>
<h2>Counter: {snap.count}</h2>
<button onClick={()=>{ state.count += 1; }}>+</button>
<button onClick={()=>{ state.count -= 1; }}>-</button>
</div>
);
};
// 根组件
const App: React.FC = () => {
return <Counter />;
};
export default App;
上面只是为了做演示,并不推荐在组件内部直接修改state。相反,我们可以按照官方文档中提到的方式,通过操作 actions 来管理状态(不唯一)。
与 Redux 相比,valtio 提供了一种更简洁、直观的状态管理方式,我们无需定义一系列的模板代码,而是通过直接操作 proxy 对象来改变状态,无需使用 dispatch 函数或连接函数(如connect)。这样做的优点是代码更加精简,学习曲线更低,特别是在小型到中型的项目中,或者在需要快速原型开发时。
小结
对比 Redux 并结合 valtio 官网给出的优点,小结 valtio 以下几点优势:
- 概念和使用简单,无需繁琐的配置,基于 proxy,只有两个核心方法 proxy 和 useSnapshot。proxy 函数创建状态代理对象, useSnapshot 钩子获取状态快照
- 细粒度渲染,valtio 提供细粒度的渲染机制,使用 useSnapshot 可以只在状态变化的部分触发组件的重新渲染
- 官网文档友好,各种应用场景都有举例
- 提供 devtools api,支持使用 Redux DevTools Extension 调试
- 完全支持 TypeScript,无需额外的类型配置
valtio 源码分析
valtio 的实现主要依赖两个核心方法:proxy 和 useSnapshot。
proxy 用于包装原始对象(initialObject),生成一个可监听修改操作的 proxy state。
在组件中,我们使用 useSnapshot来获取这个proxy state,并返回一个不可变的snapshot。这个snapshot用于组件渲染,当状态需要变更时,我们可以操作proxy state 获取新的 snapshot,触发组件 rerender。
这其实就是一种发布-订阅模式的实现:创建代理对象并监听操作,通过内部机制通知所有依赖这些状态的组件进行更新。
创建可观察的状态 ( proxy state )
在 valtio 中封装 proxy state 的方法是proxy(proxyFunction) --- 基于原生 proxy的封装。外界对目标对象 { count: 0 } 的访问,会通过 handler 层拦截,对于 set/deleteProperty 的操作,会触发 notifyUpdate 方法,最终更新视图。
tsx
const state = proxy({ count: 0 })
我们知道js里的proxy只能实现浅层次的代理,对于深层次的代理需要用到递归。
tsx
if (!proxyStateMap.has(value) && canProxy(value)) {
nextValue = proxyFunction(value)
}
同时为了避免循环引用问题,在代理的过程中需要追踪代理过的对象,这里使用到WeakMap。
tsx
proxyCache = new WeakMap<object, ProxyObject>()
proxyFunction = <T extends object>(initialObject: T): T => {
if (!isObject(initialObject)) {
throw new Error('object required')
}
const found = proxyCache.get(initialObject) as T | undefined
if (found) {
return found
}
// ......
}
使用proxyStateMap收集所有的代理对象,key 是代理对象,value 是每个代理对象都会有 [ProxyState] 属性,以数组的形式保存着。
tsx
[
baseObject, // 目标对象 { count: 0 }
ensureVersion, // 获取版本号,每次set/deleteProperty操作,都会使版本号+1,以此来区分新老数据
createSnapshot, // 创建只读的对象,用于组件的渲染
addListener, // 用于添加监听方法,存储着所有和该state相关的组件的forceUpdate函数调用,用于rerender组件
]
为了便于理解,将 proxyFunction 方法的代码进行简化:
tsx
const proxyFunction = (initialObject) => {
const handler: ProxyHandler<T> = {
deleteProperty(target: T, prop: string | symbol) {
const prevValue = Reflect.get(target, prop)
removePropListener(prop)
const deleted = Reflect.deleteProperty(target, prop)
if (deleted) {
notifyUpdate(['delete', [prop], prevValue])
}
return deleted
},
set(target: T, prop: string | symbol, value: any, receiver: object) {
const hasPrevValue = Reflect.has(target, prop)
const prevValue = Reflect.get(target, prop, receiver)
if (
hasPrevValue &&
(objectIs(prevValue, value) ||
(proxyCache.has(value) &&
objectIs(prevValue, proxyCache.get(value))))
) {
return true
}
removePropListener(prop)
if (isObject(value)) {
value = getUntracked(value) || value
}
let nextValue = value
if (value instanceof Promise) {
value
.then((v) => {
value.status = 'fulfilled'
value.value = v
notifyUpdate(['resolve', [prop], v])
})
.catch((e) => {
value.status = 'rejected'
value.reason = e
notifyUpdate(['reject', [prop], e])
})
} else {
if (!proxyStateMap.has(value) && canProxy(value)) {
nextValue = proxyFunction(value)
}
const childProxyState =
!refSet.has(nextValue) && proxyStateMap.get(nextValue)
if (childProxyState) {
addPropListener(prop, childProxyState)
}
}
Reflect.set(target, prop, nextValue, receiver)
notifyUpdate(['set', [prop], value, prevValue])
return true
},
}
return new Proxy(initialObject, handler)
}
订阅 subscribe
该函数有两个重要参数,proxyObject 和 callback
- proxyObject: 实参为上文中定位的的 Proxy 实例 state
- callback: React 组件重新渲染函数 forceUpdate
将 callback 函数加入到 proxyObject 对象的 listeners 集合中,proxyObject 对象属性的增删改操作会触发 listeners 函数执行。简化代码如下:
tsx
function subscribe<T extends object>(
proxyObject: T,
callback: (ops: Op[]) => void,
notifyInSync?: boolean,
) {
const proxyState = proxyStateMap.get(proxyObject as object)
let promise: Promise<void> | undefined
const ops: Op[] = []
const addListener = (proxyState as ProxyState)[3]
let isListenerActive = false
const listener: Listener = (op) => {
ops.push(op)
if (notifyInSync) {
callback(ops.splice(0))
return
}
if (!promise) {
promise = Promise.resolve().then(() => {
promise = undefined
if (isListenerActive) {
callback(ops.splice(0))
}
})
}
}
const removeListener = addListener(listener)
isListenerActive = true
return () => {
isListenerActive = false
removeListener()
}
}
建立关联
React v18.2.0 新增了 useSyncExternalStore hook【用于读取和订阅外部数据源】(在React低版本中,官方提供了一个水合版:use-sync-external-store/shim),通过使用该hook,将定义在 React 外部的可观察的状态 ( observable state ) 和 React Component Render 建立关联,状态上任意属性的变化都会触发组件的重绘。
tsx
const state = useSyncExternalStore(subscribe, getSnapshot[, getServerSnapshot]);
此方法返回存储的值并接受三个参数:
- subscribe:用于注册一个回调函数,当存储值发生更改时被调用。
- getSnapshot: 返回当前存储值的函数。
- getServerSnapshot:返回服务端渲染期间使用的存储值的函数。
基于 useSyncExternalStore 作者封装了 useSnapshot hook,使用方式:
tsx
const snap = useSnapshot(state)
每次使用 useSnapshot 钩子时,都会订阅并添加监听器。当监听器被触发时,会调用 forceUpdate ,重新获取快照。只有在通过 getSnapshot 获取的快照发生变化时,才会触发重新渲染。作者在这里引入了 proxy-compare 库,用于更精确地比较代理对象的差异,以实现只在真正需要重新渲染的组件上触发重新渲染。代码如下:
tsx
import {
createProxy as createProxyToCompare,
isChanged,
} from 'proxy-compare'
export function useSnapshot<T extends object>(
proxyObject: T,
options?: Options,
): Snapshot<T> {
const notifyInSync = options?.sync
const lastSnapshot = useRef<Snapshot<T>>()
const lastAffected = useRef<WeakMap<object, unknown>>()
let inRender = true
const currSnapshot = useSyncExternalStore(
useCallback(
(callback) => {
const unsub = subscribe(proxyObject, callback, notifyInSync)
callback()
return unsub
},
[proxyObject, notifyInSync],
),
() => {
const nextSnapshot = snapshot(proxyObject, use)
try {
if (
!inRender &&
lastSnapshot.current &&
lastAffected.current &&
!isChanged(
lastSnapshot.current,
nextSnapshot,
lastAffected.current,
new WeakMap(),
)
) {
// not changed
return lastSnapshot.current
}
} catch (e) {
// ignore if a promise or something is thrown
}
return nextSnapshot
},
() => snapshot(proxyObject, use),
)
inRender = false
const currAffected = new WeakMap()
useEffect(() => {
lastSnapshot.current = currSnapshot
lastAffected.current = currAffected
})
const proxyCache = useMemo(() => new WeakMap(), []) // per-hook proxyCache
return createProxyToCompare(
currSnapshot,
currAffected,
proxyCache,
targetCache,
)
}
通知 notify
tsx
versionHolder = [1] as [number]
let version = versionHolder[0]
const notifyUpdate = (op: Op, nextVersion = ++versionHolder[0]) => {
if (version !== nextVersion) {
version = nextVersion
listeners.forEach((listener) => listener(op, nextVersion))
}
}
小结
valtio 整个流程描述为:
buildProxy -> onDataChange -> notifyUpdata -> callListeners -> forceUpdate -> recreateSnap -> re-render
除了 proxy 和 useSnapshot 外,还会有比如 ref、watch、proxyWithHistory、proxySet 等等,感兴趣可以查看使用:
项目实践
当 valtio 应用于页面或组件级别时,需要特别注意页面或组件卸载时需要重置state。这里我封装了 useStore
tsx
function useStore<T extends object>(state:T, initialState:T, options?:{
resetStateWhenUnmout?:boolean
}) {
useEffect(()=>{
if(options?.resetStateWhenUnmout !== false) {
const resetObj = _.cloneDeep(initialState)
Object.keys(resetObj).forEach((key) => {
state[key] = resetObj[key]
})
}
},[])
return useSnapshot(state)
}
参考
你了解过 React 的 useSyncExternalStore 吗?
How to avoid rerenders manually --- Valtio, makes proxy-state simple for React and Vanilla