Valtio 深入
Proxy 响应式状态管理:基础使用、快照机制、派生状态、核心原理与源码解析
概述
Valtio 是由 Pmndrs 团队开发的基于 Proxy 的状态管理库。它的核心理念是:直接修改对象就是更新状态——和 Vue 的响应式系统非常相似。对从 Vue 转到 React 的开发者来说,Valtio 几乎零学习成本。
一、优势与劣势
优势
- 直觉式更新:直接修改对象属性(
state.count++),无需setState/dispatch/set - 自动追踪依赖:
useSnapshot基于 Proxy 追踪组件实际访问了哪些属性,自动最小化重渲染 - 极低学习曲线:对 Vue/MobX 背景开发者几乎零门槛
- React 外可用:状态是普通 JS 对象,可在任何地方读写
- 体积小:gzip 后约 3KB
- 嵌套对象友好:深层嵌套的修改自动追踪,无需展开运算符
劣势
- Proxy 兼容性:不支持 IE(2026 年基本不是问题,但某些嵌入式 WebView 可能受限)
- 读写分离心智负担:写时用
state(proxy),读时用snap(snapshot),混用会出 bug - 调试不如 Redux:没有 action 日志,状态变更没有明确的"谁改了什么"
- TypeScript 体验一般:snapshot 的只读类型推断有时需要手动标注
- 不适合复杂派生:虽然有
derive,但不如 Jotai 的 atom 组合灵活 - 隐式更新风险:任何地方都能直接修改 state,大型团队中可能导致状态变更不可追踪
二、基础使用
安装
bash
npm install valtio创建 Proxy 状态
jsx
import { proxy, useSnapshot } from 'valtio';
// 创建代理状态(可直接修改!)
const state = proxy({
count: 0,
user: null,
todos: [],
filter: 'all',
});
// Action 就是普通函数,直接修改 state
function increment() {
state.count++;
}
function decrement() {
state.count--;
}
async function fetchUser(id) {
const res = await fetch(`/api/users/${id}`);
state.user = await res.json();
}
function addTodo(text) {
state.todos.push({ id: Date.now(), text, done: false });
}
function toggleTodo(index) {
state.todos[index].done = !state.todos[index].done;
}
function removeTodo(index) {
state.todos.splice(index, 1);
}在组件中使用
jsx
function Counter() {
const snap = useSnapshot(state); // 不可变快照(只读)
// snap.count 被访问 → 组件订阅 count 属性
// state.todos 变化时,这个组件不会重渲染
return (
<div>
<p>{snap.count}</p>
<button onClick={increment}>+1</button>
<button onClick={decrement}>-1</button>
</div>
);
}
function TodoList() {
const snap = useSnapshot(state);
return (
<ul>
{snap.todos.map((todo, i) => (
<li key={todo.id}>
<span
onClick={() => toggleTodo(i)}
style={{ textDecoration: todo.done ? 'line-through' : 'none' }}
>
{todo.text}
</span>
<button onClick={() => removeTodo(i)}>删除</button>
</li>
))}
</ul>
);
}
function UserProfile() {
const snap = useSnapshot(state);
useEffect(() => { fetchUser(1); }, []);
if (!snap.user) return <Skeleton />;
return <div>{snap.user.name}</div>;
}读写分离规则
jsx
// ⚠️ 核心规则:读用 snapshot,写用 proxy
// ✅ 正确
const snap = useSnapshot(state);
return <p>{snap.count}</p>; // 读 snapshot
<button onClick={() => { state.count++; }}> // 写 proxy
// ❌ 错误:在渲染中直接读 proxy
return <p>{state.count}</p>; // 不会追踪依赖!
// ❌ 错误:修改 snapshot
snap.count++; // 报错:Cannot assign to read only property三、派生状态与订阅
derive —— 派生计算
jsx
import { derive } from 'valtio/utils';
// 类似 computed / 派生 atom
const derived = derive({
todoStats: (get) => {
const todos = get(state).todos;
return {
total: todos.length,
done: todos.filter(t => t.done).length,
pending: todos.filter(t => !t.done).length,
};
},
filteredTodos: (get) => {
const { todos, filter } = get(state);
switch (filter) {
case 'active': return todos.filter(t => !t.done);
case 'done': return todos.filter(t => t.done);
default: return todos;
}
},
});
// 在组件中使用
function Stats() {
const snap = useSnapshot(derived);
return <p>完成 {snap.todoStats.done} / {snap.todoStats.total}</p>;
}subscribe —— 监听变化
jsx
import { subscribe, subscribeKey } from 'valtio';
// 监听整个 state 的任何变化
const unsub = subscribe(state, () => {
console.log('state changed:', state);
// 注意:回调中读的是 proxy(最新值),不是 snapshot
});
// 只监听某个属性
subscribeKey(state, 'count', (value) => {
console.log('count changed to:', value);
document.title = `计数: ${value}`;
});proxyWithHistory —— 撤销/重做
jsx
import { proxyWithHistory } from 'valtio-history';
const state = proxyWithHistory({
text: '',
});
// 使用
state.value.text = 'hello';
state.value.text = 'hello world';
state.undo(); // text → 'hello'
state.redo(); // text → 'hello world'proxyMap / proxySet
jsx
import { proxyMap, proxySet } from 'valtio/utils';
// 响应式 Map 和 Set
const userMap = proxyMap<string, User>();
const tagSet = proxySet<string>();
userMap.set('1', { name: '张三' });
tagSet.add('react');
function UserList() {
const snap = useSnapshot(userMap);
return <ul>{[...snap].map(([id, user]) => <li key={id}>{user.name}</li>)}</ul>;
}四、核心原理
Proxy 追踪机制
javascript
// proxy() 的简化实现
function proxy(initialObj) {
const listeners = new Set();
const handler = {
get(target, prop) {
// 记录哪些属性被访问(依赖收集)
trackAccess(target, prop);
const value = target[prop];
// 如果值是对象,递归创建 Proxy
if (typeof value === 'object' && value !== null) {
return proxy(value);
}
return value;
},
set(target, prop, value) {
const prev = target[prop];
target[prop] = value;
if (!Object.is(prev, value)) {
// 通知所有监听者
notifyListeners(listeners);
}
return true;
},
deleteProperty(target, prop) {
delete target[prop];
notifyListeners(listeners);
return true;
},
};
return new Proxy(initialObj, handler);
}Snapshot 机制
javascript
// useSnapshot 的简化实现
function useSnapshot(proxyState) {
// 1. 创建不可变快照(深度冻结)
const snapshot = createSnapshot(proxyState);
// → Object.freeze 递归冻结,防止意外修改
// 2. 追踪组件访问了哪些属性
const accessedPaths = new Set();
const trackedSnapshot = new Proxy(snapshot, {
get(target, prop) {
accessedPaths.add(prop); // 记录访问路径
return target[prop];
},
});
// 3. 订阅变化(只有访问的属性变化时才重渲染)
useSyncExternalStore(
(callback) => subscribe(proxyState, () => {
// 检查访问过的属性是否变化
const newSnapshot = createSnapshot(proxyState);
for (const path of accessedPaths) {
if (!Object.is(snapshot[path], newSnapshot[path])) {
callback(); // 触发重渲染
return;
}
}
// 访问的属性都没变 → 不重渲染
}),
() => trackedSnapshot
);
return trackedSnapshot;
}关键设计
- Proxy 拦截:
get收集依赖,set触发通知 - Snapshot 不可变:
Object.freeze递归冻结,保证渲染一致性 - 属性级追踪:只有组件实际读取的属性变化才触发重渲染(比 selector 更精确)
- 递归代理:嵌套对象自动代理,深层修改也能被追踪
五、与 Vue 响应式的对比
| 维度 | Valtio | Vue 3 Reactivity |
|---|---|---|
| 底层机制 | Proxy | Proxy |
| 依赖收集 | useSnapshot 中收集 | 模板/computed 中收集 |
| 触发更新 | 通知 React 重渲染 | 直接更新 DOM(fine-grained) |
| 嵌套追踪 | 递归 Proxy | 递归 Proxy(reactive) |
| 计算属性 | derive(第三方) | computed(内置) |
| 批量更新 | React 自动批处理 | nextTick 批处理 |
面试高频题
Q: Valtio 的 proxy 和 snapshot 分别是什么?
- proxy:原始的 Proxy 对象,用于写入。所有修改操作(赋值、push、splice 等)都在 proxy 上进行
- snapshot:proxy 在某一时刻的冻结快照,用于读取。组件中通过
useSnapshot获取,是只读的(Object.freeze) - 分离原因:React 渲染需要不可变数据来做比较,snapshot 提供一致性保证;而直接修改对象需要可变引用
Q: Valtio 如何实现细粒度重渲染?
useSnapshot 返回的 snapshot 本身是一个追踪 Proxy,记录组件访问了哪些属性。当 state 变化时,Valtio 检查被访问的属性是否变化——只有变化了才触发组件重渲染。例如组件只读了 snap.count,那么 state.todos 变化时该组件不会重渲染。
Q: Valtio 适合什么场景?
- 从 Vue / MobX 转来的开发者(熟悉可变风格)
- 需要大量嵌套对象修改的场景(不用手写展开运算符)
- 快速原型和中小型应用
- 不适合:需要严格 action 追踪的大型团队项目(改用 Redux Toolkit 或 Zustand)