
一句话:Valtio 与 MobX 都属于「代理式响应式(Proxy-based reactivity)」——你「直接改对象」,框架通过「读取时自动追踪、写入时自动通知」算出「谁需要更新」;区别在于 Valtio 更轻、更像「可变版 Zustand」(用快照与 React 集成),而 MobX 更重、是一套完整范式(
observable/computed/action/reaction)。
一、核心思想:让「可变」重新可用
⭐⭐⭐ 代理式响应式要解决的核心问题:
【不可变派(Redux/Zustand/Jotai)】
改状态 = 「创建一个新对象」
✅ 好处:「变化 = 引用不同」(浅比较 O(1) 就能判断「变没变」)
⚠️ 代价:嵌套深时「要手动展开每一层」(`{...s, user: {...s.user, name}}`)
→ 所以大家要引 immer;且「改一个字段要重建整条路径」
【代理式(Valtio/MobX)】
改状态 = 「直接赋值」(`state.user.name = 'x'`)
✅ 好处:写法自然、无「展开地狱」、自动精确到「属性级」
⚠️ 代价:「变化」不再靠引用比较 → 必须靠「Proxy 拦截」追踪
→ 需要一套「依赖收集 + 通知」机制;且「调试时对象是『活的』」
⭐ 两者都不是「谁更好」,而是「**你愿意把复杂度放在哪**」:
不可变派 → 复杂度在「写法」
代理式 → 复杂度在「框架内部」
二、Valtio:可变对象 + 快照
// ============ 1. 定义:直接返回一个「可变的代理对象」 ============
import { proxy, useSnapshot, subscribe, watch } from 'valtio';
export const cartState = proxy({
items: [] as Array<{ id: string; name: string; price: number; qty: number }>,
coupon: null as string | null,
// ⭐ 方法可以「直接改自己」(可变写法)
add(item: { id: string; name: string; price: number }) {
const existing = cartState.items.find((i) => i.id === item.id);
if (existing) existing.qty += 1; // ⭐ 直接改
else cartState.items.push({ ...item, qty: 1 });
},
remove(id: string) {
cartState.items = cartState.items.filter((i) => i.id !== id);
},
get subtotal() { // ⚠️ getter 会「每次读都重算」
return cartState.items.reduce((acc, i) => acc + i.price * i.qty, 0);
},
});
// ============ 2. 组件里用:读快照、改原始对象 ============
function CartTotal() {
// ⭐⭐ 关键:useSnapshot 返回「只读快照」(Proxy 会拦截写入并警告)
const snap = useSnapshot(cartState);
return <strong>¥{snap.subtotal.toFixed(2)}</strong>;
}
function AddButton() {
// ⭐ 注意这里「不调 useSnapshot」——只改不读 → 这个组件永不重渲染
return <button onClick={() => cartState.add({ id: '1', name: 'x', price: 9 })}>加入</button>;
}
function CartList() {
const snap = useSnapshot(cartState);
// ⭐⭐ 精确订阅:只读 `items` → 改 `coupon` 时它【不】重渲染
return (
<ul>
{snap.items.map((item) => (
<li key={item.id}>
{item.name} × {item.qty}
<button onClick={() => cartState.remove(item.id)}>删除</button>
</li>
))}
</ul>
);
}
⭐⭐⭐ 「快照(snapshot)」是 Valtio 最关键的设计,三个原因:
① 【让 React 拿到「不可变的数据」】
`useSnapshot(state)` 返回一个「**冻结的、只读的**」视图 →
组件里 `snap.items.map(...)` 是「读快照」,不会「意外改到状态」。
⚠️ 如果你在组件里写 `snap.count++` → Valtio 会**在开发期警告**(只读代理拦截)。
② 【解决「并发渲染下的撕裂」】
⭐ 如果组件直接读「活的 proxy」,那么在「渲染被中断期间」状态可能已经变了
→ 同一个界面里不同组件读到不同版本(撕裂,见 11.13)。
`useSnapshot` 会「**保证这次渲染看到的是同一个版本**」——
它的机制与 `useSyncExternalStore` 一致(⭐ Valtio 内部就用它)。
③ 【引用稳定 + 精确追踪】
快照是「**结构共享**」的:
· 如果「某个子对象没变」,快照里它**还是上一次的同一个引用**
→ ⭐ 组件里的 `React.memo`/`useMemo` 依赖它时**不会失效**
· 而且 Valtio 能「**精确知道组件读了哪些属性**」
(读 `snap.count` 就只订阅 `count`)→ ⭐ **属性级细粒度**
⭐ 一句话:「**写的时候用可变对象(爽),读的时候用快照(安全 + 精确)**」。
// ============ 3. 在 React 之外读写(Valtio 的强项之一) ============
// 直接读(不需要 hook)
console.log(cartState.subtotal);
// 直接改
cartState.coupon = 'HALF';
cartState.items[0]!.qty = 5;
// ⭐ subscribe:订阅「整个对象」的变化(在任何地方)
const unsub = subscribe(cartState, () => {
console.log('变了', cartState.subtotal);
});
// ⭐⭐ watch:订阅「某个派生值」的变化(更精确,且天然去重)
import { watch } from 'valtio/utils';
const stop = watch((get) => {
// ⭐ `get` 里读到的属性会被追踪;函数「每次被调用时」都要重新读
const total = get(cartState).subtotal;
if (total > 1000) console.warn('购物车金额超过 1000');
return total; // ⭐ 返回值变化时才回调(这里没提供回调 → 用于「副作用」)
});
// ============ 4. 与 Suspense / 异步结合(proxy 支持 Promise) ============
import { proxy, useSnapshot } from 'valtio';
// ⭐ Valtio 支持把「Promise 写进状态」并在组件里 await(配合 Suspense)
const userState = proxy<{ data: Promise<User> | User | null }>({ data: null });
function loadUser(id: string) {
userState.data = fetch(`/api/users/${id}`).then((r) => r.json());
}
function Profile() {
const snap = useSnapshot(userState);
if (snap.data instanceof Promise) throw snap.data; // ⭐ 挂起
// ⚠️ `snap.data` 是快照 → 在 Valtio 里 Promise 会被「原样保留」
return <div>{snap.data?.name}</div>;
}
⭐⭐ Valtio 的三个「精确订阅」细节(决定了它的性能特征):
① 【组件只订阅「它读过的属性」】
`const snap = useSnapshot(state)` 之后,你「读了哪些属性」会被记录。
· 只读 `snap.items` → 改 `custom` 时它不重渲染
· 读了 `snap.items[0].name` → 只有「那个字段」变化时才重渲染
⭐ 所以「不必像 Zustand 那样手写选择器」——**但它也更「隐式」**
(你得知道「组件读了什么」,这在「大组件」里容易失控)
② 【快照的「结构共享」让 memo 有效】
如果「`snap.user` 没变」,它就与上次是**同一个对象** →
⭐ 传给 `<UserCard user={snap.user} />`(配 `memo`)不会重渲染。
③ 【getter 不是 computed】
`get subtotal() {...}` 在 Valtio 里**不是缓存的**——
它「每次被读都会重算」。
⭐ 需要缓存派生值:用 `derive` 工具(`valtio/utils` 的 `derive`),
或「在组件里用 `useMemo`」,或「把结果写进状态(在 action 里维护)」。
⚠️ 这与 MobX 的 `computed`(⭐ 自动缓存)是**重要区别**。
三、MobX:一套完整的响应式范式
// ============ 1. 定义:类 + 装饰器(或 makeAutoObservable) ============
import { makeAutoObservable, runInAction, computed, action, observable } from 'mobx';
class CartStore {
items: CartItem[] = [];
coupon: string | null = null;
constructor() {
// ⭐ makeAutoObservable 自动推断:
// 普通属性 → observable,getter → computed,方法 → action
makeAutoObservable(this);
}
// ⭐ computed:**有缓存**的派生值(依赖不变就不重算)
get subtotal(): number {
return this.items.reduce((acc, i) => acc + i.price * i.qty, 0);
}
get total(): number {
return this.coupon === 'HALF' ? this.subtotal / 2 : this.subtotal;
}
// ⭐ action:所有「改状态的地方」都应该是 action
// (MobX 会「在一个 action 结束后」才统一通知 → 天然批量更新)
add(item: Omit<CartItem, 'qty'>) {
const existing = this.items.find((i) => i.id === item.id);
if (existing) existing.qty += 1;
else this.items.push({ ...item, qty: 1 });
}
remove(id: string) {
this.items = this.items.filter((i) => i.id !== id);
}
// ⭐ 异步 action:await 之后的「改状态」必须包在 runInAction 里
async checkout() {
this.loading = true;
try {
const order = await api.createOrder(this.items);
// ⚠️ await 之后「已经脱离 action 上下文」→ 必须用 runInAction
runInAction(() => {
this.items = [];
this.lastOrderId = order.id;
});
} finally {
runInAction(() => { this.loading = false; });
}
}
loading = false;
lastOrderId: string | null = null;
}
export const cartStore = new CartStore();
// ============ 2. 组件里用:observer 包裹 ============
import { observer } from 'mobx-react-lite';
// ⭐ observer() 让组件「自动订阅它读过的 observable」
export const CartTotal = observer(function CartTotal() {
// ⭐ 读 computed(有缓存)
return <strong>¥{cartStore.total.toFixed(2)}</strong>;
});
export const AddButton = observer(function AddButton() {
// ⭐ 这个组件「不读任何 observable」→ observer 不会让它重渲染
return <button onClick={() => cartStore.add({ id: '1', name: 'x', price: 9 })}>加入</button>;
});
export const CartList = observer(function CartList() {
// ⭐ 只读 items → 改 coupon 时不重渲染(属性级追踪)
return (
<ul>
{cartStore.items.map((item) => (
<li key={item.id}>
{item.name} × {item.qty}
<button onClick={() => cartStore.remove(item.id)}>删除</button>
</li>
))}
</ul>
);
});
⭐⭐⭐ MobX 的四个「必须理解」的概念(这是它与 Valtio 的核心差异):
① 【`observable`】——「被追踪的状态」
读取时「登记依赖」(当前正在渲染的组件 / 正在执行的 reaction),
写入时「通知依赖」。
② 【`computed`】⭐ **有缓存**的派生值
· 依赖不变 → 直接返回缓存(⭐ 这一点与 Valtio 的 getter 不同!)
· 依赖变了 → 标脏,**下次被读时才重算**(懒计算)
· 且「computed 之间可以互相依赖」(形成计算图)
⭐ 这是 MobX 最值钱的能力之一(大表格的行列合计、复杂筛选结果)。
③ 【`action`】⭐ 批量更新
MobX 的规则是「**所有『改状态』都必须在 action 里**」(否则开发期警告)。
⭐ 好处:一个 action 内的多次修改「**结束后才统一通知**」→
**天然批量更新**(不会出现「中间态被渲染出来」)。
⚠️ 异步 action 的「await 之后」要用 `runInAction`(因为已脱离 action 上下文)。
④ 【`reaction` / `autorun`】——响应式副作用
· `autorun(() => console.log(store.count))`——依赖变化时自动重跑
· `reaction(() => store.count, (value) => {...})`——「追踪数据 + 只在值变化时做事」
⭐ 用途:「数据变化 → 写 localStorage / 上报埋点 / 同步到 URL」
⚠️ 必须「手动 dispose」(否则泄漏,见 5.13)。
// ============ 3. MobX 的「计算图」与「缓存」实测 ============
import { makeAutoObservable, computed, observable, action, autorun } from 'mobx';
class TableStore {
rows: Row[] = [];
filter = '';
sortKey: keyof Row = 'id';
constructor() { makeAutoObservable(this); }
// ⭐ 第一层:过滤(computed,缓存)
get filtered(): Row[] {
console.log('计算 filtered');
return this.rows.filter((r) => String(r.name).includes(this.filter));
}
// ⭐ 第二层:排序(依赖 filtered,也缓存)
get sorted(): Row[] {
console.log('计算 sorted');
return [...this.filtered].sort((a, b) => (a[this.sortKey] > b[this.sortKey] ? 1 : -1));
}
// ⭐ 第三层:合计(依赖 sorted)
get total(): number {
console.log('计算 total');
return this.sorted.reduce((acc, r) => acc + r.amount, 0);
}
}
const t = new TableStore();
t.rows = [{ id: 1, name: 'a', amount: 10 }, { id: 2, name: 'b', amount: 20 }];
// ⭐ 连续读 3 次 total → 只算一次(依赖没变)
console.log(t.total, t.total, t.total);
// 输出:计算 filtered / 计算 sorted / 计算 total ← 各只一次 ⭐
// ⭐ 改「不相关的字段」→ 不会重算任何 computed
t.rows.push({ id: 3, name: 'c', amount: 30 });
console.log(t.total); // 重算(因为 rows 变了)
⭐⭐⭐ MobX 的「计算图 + 缓存 + 批量」是它与 Valtio 最大的能力差异:
| 能力 | Valtio | MobX |
| --- | --- | --- |
| 派生值缓存 | ❌ getter 每次重算 | ⭐ `computed` 有缓存 + 懒计算 |
| 批量更新 | ⚠️ 无显式机制(每次赋值都通知) | ⭐ `action` 结束后统一通知 |
| 精确订阅 | ⭐ 属性级(自动) | ⭐ 属性级(自动) |
| 写法 | 普通对象 + `proxy()` | 类(或对象)+ 装饰器/`makeAutoObservable` |
| React 集成 | `useSnapshot`(快照 + uSES) | `observer()`(HOC,内部也是订阅) |
| 学习曲线 | ⭐ 低(≈可变版 Zustand) | ⚠️ 中(要学 observable/computed/action/reaction) |
| 调试 | ⚠️ 「活对象」不易打印(要用 `snapshot`) | ⚠️ 需要 DevTools(`mobx-devtools`) |
| 体积 | ⭐ 很小(~1KB) | ⚠️ 较大(~15KB+) |
| 生态 | 小 | 成熟(mobx-state-tree、mobx-utils…) |
四、完整实现:一个 mini Valtio(260 行)
// mini-valtio.ts —— 复现 Valtio 的核心(Proxy 追踪 + 快照 + 与 React 集成)
// ⚠️ 教学实现:省略了「结构共享的精细实现」「Promise 代理」「derive 工具」等
import { useSyncExternalStore, useCallback, useRef } from 'react';
/** ============ 1. 每个「代理对象」的元数据 ============ */
interface ProxyMeta {
/** 原始对象(未被代理的) */
target: object;
/** ⭐ 属性 → 读取过它的订阅者(Map 的 key 是属性名或 Symbol) */
keySubscribers: Map<PropertyKey, Set<() => void>>;
/** 订阅「整个对象」的(任何属性变化都通知) */
wildcardSubscribers: Set<() => void>;
/** ⭐ 全局版本号:任何变化都 +1(用于快照的「是否需要重建」判断) */
version: number;
/** 代理对象(缓存,避免「同一个 target 返回不同代理」) */
proxy: object | null;
/** 是否「只读」(快照用) */
readonly: boolean;
}
const proxyMetaMap = new WeakMap<object, ProxyMeta>();
/** ⭐⭐ 当前「正在追踪」的订阅者(在 React 渲染或 watch 回调中被设置) */
let currentlyTracking: (() => void) | null = null;
/** 用来「收集本次渲染读了哪些属性」(Valtio 的真实实现里更复杂) */
let trackedKeys: Map<object, Set<PropertyKey>> | null = null;
function getMeta(target: object, readonly = false): ProxyMeta {
let meta = proxyMetaMap.get(target);
if (!meta) {
meta = {
target,
keySubscribers: new Map(),
wildcardSubscribers: new Set(),
version: 0,
proxy: null,
readonly,
};
proxyMetaMap.set(target, meta);
}
return meta;
}
/** ============ 2. 读:追踪依赖 ============ */
function createProxy<T extends object>(target: T, readonly: boolean): T {
const meta = getMeta(target, readonly);
// ⭐ 缓存代理(否则「同一个对象每次读都返回新代理」→ 引用不稳定 → 无谓重渲染)
if (meta.proxy) return meta.proxy as T;
const handler: ProxyHandler<T> = {
get(t, key, receiver) {
// 特殊 key 直接透传(避免「自己代理自己」导致的诡异问题)
if (typeof key === 'symbol') return Reflect.get(t, key, receiver);
// ⭐⭐ 关键:登记「当前追踪者」订阅了这个属性
if (currentlyTracking) {
let set = meta.keySubscribers.get(key);
if (!set) { set = new Set(); meta.keySubscribers.set(key, set); }
set.add(currentlyTracking);
// 同时记录到「本次追踪读了哪些对象的哪些 key」(用于重渲染时清理旧依赖)
if (trackedKeys) {
const isArrayIndex = Array.isArray(t) && /^\d+$/.test(String(key));
const trackKey = isArrayIndex ? 'length' : key; // ⭐ 数组索引变化用 length 代表
let keys = trackedKeys.get(t);
if (!keys) { keys = new Set(); trackedKeys.set(t, keys); }
keys.add(trackKey);
}
}
const value = Reflect.get(t, key, receiver);
// ⭐ 递归代理「对象值」(这样「改深层属性」也能被追踪)
if (value !== null && typeof value === 'object') {
return createProxy(value as object, readonly) as T[keyof T];
}
return value;
},
set(t, key, value) {
if (readonly || meta.readonly) {
console.warn(`[valtio] 不能修改快照(只读)。请修改原始对象。`);
return true; // ⚠️ 不抛错(避免打断渲染),但警告
}
const prev = Reflect.get(t, key);
if (Object.is(prev, value)) return true; // ⭐ 值没变 → 不通知
const result = Reflect.set(t, key, value);
// ⭐⭐ 失效:通知「订阅这个属性的」+「订阅整个对象的」
meta.version++;
const keySubs = meta.keySubscribers.get(key);
keySubs?.forEach((fn) => fn());
meta.wildcardSubscribers.forEach((fn) => fn());
// ⭐ 数组的「长度变化」也要通知(push/pop 改的是 index 与 length)
if (Array.isArray(t) && key !== 'length') {
meta.keySubscribers.get('length')?.forEach((fn) => fn());
}
return result;
},
deleteProperty(t, key) {
if (readonly || meta.readonly) return true;
const result = Reflect.deleteProperty(t, key);
meta.version++;
meta.keySubscribers.get(key)?.forEach((fn) => fn());
meta.wildcardSubscribers.forEach((fn) => fn());
return result;
},
};
const p = new Proxy(target, handler);
meta.proxy = p;
return p;
}
/** ============ 3. 对外 API:proxy / snapshot / subscribe ============ */
export function proxy<T extends object>(init: T, options?: { readonly?: boolean }): T {
return createProxy(init, options?.readonly ?? false);
}
/** ⭐ 深冻结(用于「让快照真的不可变」——开发期能提前发现「误改快照」) */
function deepFreeze<T>(obj: T, seen = new WeakSet<object>()): T {
if (obj === null || typeof obj !== 'object') return obj;
if (seen.has(obj as object)) return obj;
seen.add(obj as object);
Object.freeze(obj);
for (const key of Reflect.ownKeys(obj as object)) {
deepFreeze((obj as Record<PropertyKey, unknown>)[key], seen);
}
return obj;
}
/**
* ⭐⭐ snapshot:把「活的代理对象」转成「只读的普通对象」
* ⚠️ 真实 Valtio 会「结构共享 + 惰性」;这里每次全量转换(教学够用)
*/
export function snapshot<T extends object>(state: T): T {
const meta = getMeta(state as object);
// ⭐ force everything to be read(建立依赖,让「读到哪就订阅到哪」生效)
const plain = JSON.parse(JSON.stringify(meta.target)) as T;
// ⚠️ JSON 转换会丢「undefined/函数/Date」——真实实现用「递归拷贝 + 缓存」
return plain;
}
/** ⭐ 手动订阅(任何属性变化都回调) */
export function subscribe<T extends object>(state: T, callback: () => void): () => void {
const meta = getMeta(state as object);
meta.wildcardSubscribers.add(callback);
return () => { meta.wildcardSubscribers.delete(callback); };
}
/** ============ 4. React 集成:useSnapshot ============ */
/**
* ⭐ useSnapshot 的两个职责:
* ① 订阅「组件读过的属性」→ 只有它们变化才重渲染
* ② 返回「快照」(只读 + 引用稳定)
*/
export function useSnapshot<T extends object>(state: T): Readonly<T> {
const meta = getMeta(state as object);
/** ⭐ 缓存「上一次的快照 + 收集到的依赖」(用于重渲染时清理旧订阅) */
const cacheRef = useRef<{
snapshot: Readonly<T> | null;
version: number;
dependencies: Map<object, Set<PropertyKey>>;
}>({ snapshot: null, version: -1, dependencies: new Map() });
/** ⭐⭐ getSnapshot:返回「缓存的只读快照」(引用稳定) */
const getSnapshot = useCallback(() => {
const cache = cacheRef.current;
// 版本没变 → 复用同一个快照引用(⭐ 这一步是「不无限循环」的关键)
if (cache.snapshot && cache.version === meta.version) return cache.snapshot;
const snap = deepFreeze(snapshot(state)) as Readonly<T>;
cache.snapshot = snap;
cache.version = meta.version;
return snap;
}, [state, meta.version]);
/** ⭐⭐ subscribe:把「React 通知函数」注册为「本次读过的属性」的订阅者 */
const subscribeFn = useCallback(
(onStoreChange: () => void) => {
const cache = cacheRef.current;
// 清理上一次的订阅(避免「依赖只增不减」)
cache.dependencies.forEach((keys, target) => {
const targetMeta = getMeta(target);
keys.forEach((key) => {
targetMeta.keySubscribers.get(key)?.delete(onStoreChange);
});
});
cache.dependencies.clear();
// ⭐ 用一个「临时追踪器」模拟一次读取:收集「这次渲染会读哪些属性」
// ⚠️ 真实 Valtio 是在「渲染期间的读取」里收集;这里简化为
// 「订阅时把整个对象的所有第一层 key 都订阅上」
const collected = new Map<object, Set<PropertyKey>>();
trackedKeys = collected;
currentlyTracking = onStoreChange;
try {
// 递归「读一遍」(触发 get 陷阱 → 登记依赖)
const readAll = (obj: unknown, depth = 0) => {
if (depth > 6 || obj === null || typeof obj !== 'object') return;
if (proxyMetaMap.has(obj as object)) {
for (const key of Reflect.ownKeys(obj as object)) {
void (obj as Record<PropertyKey, unknown>)[key];
readAll((obj as Record<PropertyKey, unknown>)[key], depth + 1);
}
}
};
readAll(state);
} finally {
currentlyTracking = null;
trackedKeys = null;
}
cache.dependencies = collected;
return () => {
collected.forEach((keys, target) => {
const targetMeta = getMeta(target);
keys.forEach((key) => { targetMeta.keySubscribers.get(key)?.delete(onStoreChange); });
});
};
},
[state]
);
return useSyncExternalStore(subscribeFn, getSnapshot, getSnapshot);
}
/** ============ 5. 使用示例 ============ */
import { proxy, useSnapshot } from './mini-valtio';
const state = proxy({
count: 0,
nested: { step: 1 },
items: [] as string[],
});
function Display() {
const snap = useSnapshot(state);
return (
count={snap.count} step={snap.nested.step} items={snap.items.length}
);
}
function Controls() {
// ⭐ 只改不读 → 不需要 useSnapshot
return (
<div>
<button onClick={() => { state.count += state.nested.step; }}>+step</button>
<button onClick={() => { state.nested.step += 1; }}>步长 +1</button>
<button onClick={() => { state.items.push(`#${state.items.length + 1}`); }}>加一项</button>
</div>
);
}
export function App() {
return (
<>
<Display />
<Controls />
</>
);
}
⭐⭐⭐ 这个实现里最值得看的三个点:
① 【`currentlyTracking` + 「get 陷阱里登记」】
⭐ 这就是「代理式响应式」的全部秘密:
「谁在读」由「渲染时设置的全局变量」决定,
「读了什么」由 get 陷阱记录。
真实 Valtio 更精细(把「读过的 key」记在「这次渲染的依赖集合」上),
本实现简化为「订阅时把所有层的 key 都订阅上」。
② 【「代理缓存」不能漏】
`meta.proxy` 缓存代理对象——
否则「同一个 target 每次读都返回新代理」→
⭐ `snap.a === snap.a` 为 false → 引用不稳定 → 无谓重渲染。
③ 【快照的「版本号 + 缓存」】
`getSnapshot` 必须「版本没变就返回同一个引用」——
否则 `useSyncExternalStore` 会报 "should be cached" 并无限重渲染(见 11.13)。
⭐ 真实 Valtio 用「结构共享」让「没变的子对象保持同一引用」,
本实现用「整体重建 + 版本号缓存」近似(教学够用,但**性能差得多**)。
五、本篇特有的坑
// ① 在组件里改「快照」(snap 是只读的)
const snap = useSnapshot(state);
snap.count++; // ❌ 只读代理会警告
// ✅ 改「原始对象」:state.count++
// ② 用 `snap` 当「依赖数据」(它每次渲染可能是新对象)
useEffect(() => { ... }, [snap]); // ⚠️ 可能每次渲染都变
// ✅ 依赖「具体的字段」:}, [snap.count]);
// ⭐ 或依赖「稳定的引用」:快照的「没变的子对象」是同一引用(结构共享)
// ③ 只订阅「整个对象」但期望「精确追踪」
const snap = useSnapshot(state);
// ⭐ Valtio 是「你读什么就订阅什么」→
// 如果组件读了「所有字段」(如展开 `{...snap}`)→ 就等于订阅全部
// ✅ 只读需要的;把「大组件」拆成若干「只读一小部分」的子组件
// ④ Valtio 的 getter「没有缓存」(每次读都重算)
get total() { return this.items.reduce(...); } // ⚠️ 每次读都算
// ✅ 用 `derive`(`valtio/utils`)或在 action 里维护一个字段
// ⭐ 这一点与 MobX 的 `computed` 不同
// ⑤ MobX:在「action 外」改 observable(开发期警告)
store.count++; // ⚠️ 应该在 action 里
// ✅ 用 `runInAction(() => { store.count++; })` 或把逻辑写成 `action`
// ⑥ MobX:异步 action 的「await 之后」改状态(脱离 action 上下文)
async load() {
this.loading = true;
const data = await fetch(...);
this.data = data; // ⚠️ 警告:不在 action 里
}
// ✅ runInAction(() => { this.data = data; });
// ⑦ MobX:`observer` 组件里「读了 observable 但没触发重渲染」
// ⭐ 常见原因:读了「在 observer 之外被解构/提前取出」的值
const { count } = store; // ⚠️ 解构会「丢掉追踪」
// ✅ 在渲染里直接读:store.count
// ⑧ MobX:`computed` 依赖了「不可观察的值」(不会失效)
get total() { return this.items.length * this.multiplier; } // ⚠️ multiplier 不是 observable
// ✅ 把它也变成 observable
// ⑨ MobX:`reaction` / `autorun` 忘记 dispose(泄漏)
autorun(() => console.log(store.count)); // ❌ 没有保存返回值
// ✅ const dispose = autorun(...); return dispose;(或在 useEffect 里清理)
// ⑩ Valtio:把「Promise」写进状态但没配合 Suspense(拿到的是 Promise 本身)
state.data = fetch(...); // ⚠️ 组件里渲染 Promise → 报错
// ✅ 配合 Suspense(throw promise)或「用 .then 赋值结果」
// ⑪ Valtio:`proxy` 里放「类实例」(非普通对象)
const state = proxy({ date: new Date(), map: new Map() }); // ⚠️ Proxy 对类实例的语义有坑
// ✅ 放「普通对象/数组/基本类型」;复杂对象「放在外面」(或用 ref)
// ⑫ MobX:用「不可观察的数组替换 observable 数组」但忘了它是 observable
this.items = plainArray; // ✅ 这其实是「赋值」(会通知)
// ⚠️ 但 `this.items.push()` 之外的操作(如 `this.items = this.items.concat()`)要分清
// ⑬ 「代理式」与「不可变式」混用(一半直接改、一半展开)
// ⚠️ 后果:难以推理「什么时候会通知」
// ✅ 一种范式用到底
// ⑭ 调试时「打印活对象」(看到的永远是「当前值」,不是「那一刻的值」)
console.log(state); // ⚠️ 展开时可能是「最新」的
// ✅ `console.log(snapshot(state))` 或 `toJS(store)`(MobX)
// ⑮ 性能:代理式方案的「每次读都有成本」
// ⭐ Proxy 的 get/set 有开销(比「直接属性访问」慢);
// 在「超高频读」(如每帧读几百次的动画循环)里要注意
// ✅ 高频场景「把值取出来用」(`const { x, y } = snapshot(state)`)
// ⑯ 忘记「快照是只读的深拷贝」(性能成本)
// ⚠️ 大对象快照「每次都深拷贝」会慢
// ✅ Valtio 用「结构共享 + 懒」优化;自己实现时要小心(见本实现的注释)
// ⑦ 的完整处理(MobX 的解构陷阱)
// ❌ 解构会「在渲染之外」读值 → 追踪可能失效
function Bad() {
const { count } = cartStore;
return <span>{count}</span>;
}
// ✅ 在渲染函数里「通过属性访问」读(让 observer 能追踪)
const Good = observer(function Good() {
return <span>{cartStore.count}</span>;
});
// ⭐ 如果确实要解构「整个对象」,用 MobX 的 `observer` + 「在组件里解构」
const AlsoOk = observer(function AlsoOk() {
// ⭐ 在 observer 内部解构是安全的(因为读取发生在渲染期间)
const { count, items } = cartStore;
return <span>{count} / {items.length}</span>;
});
// ③ 的完整处理(把「大组件」拆小,让订阅更精确)
// ❌ 一个组件读了 state 的所有字段
function Dashboard() {
const snap = useSnapshot(state);
return (
<>
<Header title={snap.title} /> {/* 只读 title */}
<Chart data={snap.chartData} /> {/* 只读 chartData */}
<Table rows={snap.rows} /> {/* 只读 rows */}
</>
);
}
// ⚠️ 问题:Dashboard 读了三个字段 → 「任何一个变化」都让 Dashboard 重渲染
// (虽然 Header/Chart/Table 里有过 memo 可能不重渲染,但 Dashboard 本身会)
// ✅ 拆开:每个子组件「自己订阅自己需要的」
function Dashboard() {
return (
<>
<HeaderWithData />
<ChartWithData />
<TableWithData />
</>
);
}
const HeaderWithData = () => <Header title={useSnapshot(state).title} />;
const ChartWithData = () => <Chart data={useSnapshot(state).chartData} />;
const TableWithData = () => <Table rows={useSnapshot(state).rows} />;
// ⭐ 于是「chartData 变化」只让 ChartWithData 重渲染
六、面试延伸
- 「Valtio / MobX 的『代理式响应式』是怎么工作的?」
核心是Proxy的 get/set 拦截 + 自动依赖追踪:① 读的时候——get陷阱里「把当前正在渲染(或正在执行)的订阅者登记到『这个属性』的订阅者集合上」(⭐ 关键是有一个全局的「当前追踪者」,在 React 渲染或reaction执行期间被设置);② 写的时候——set陷阱里「通知订阅了这个属性的所有订阅者」。好处:写法是「直接改对象」(没有「展开地狱」,不需要 immer),而且「自动精确到属性级」(不必手写选择器)。代价:「变化」不再靠引用比较 → 需要一套依赖收集机制;调试时对象是「活的」;Proxy 的 get/set 有性能开销。 -
「Valtio 的
useSnapshot为什么必要?」
三个原因:① ⭐ 让 React 拿到「不可变的数据」——useSnapshot返回冻结的只读快照,组件里不会「意外改到状态」(改了会在开发期警告);② ⭐ 解决并发渲染下的「撕裂」——如果组件直接读「活的 proxy」,在「渲染被中断期间」状态可能已变,导致「同一界面里不同组件读到不同版本」(见 11.13);useSnapshot保证「这次渲染看到同一个版本」(内部就是useSyncExternalStore);③ ⭐ 引用稳定 + 结构共享——快照是结构共享的(没变的子对象保持同一个引用),所以配React.memo时不会因无关变化而失效。⭐ 一句话:「写的时候用可变对象(爽),读的时候用快照(安全 + 精确)」。 -
「Valtio 和 MobX 最大的区别是什么?」
两个关键差异:① ⭐ 派生值缓存——MobX 的computed有缓存且懒计算(依赖不变直接返回缓存,依赖变了标脏、下次读才重算,且computed可以互相依赖形成计算图);而 Valtio 的普通 getter「每次读都重算」(需要缓存得用derive工具或自己在 action 里维护字段);② ⭐ 批量更新——MobX 的action会「结束后统一通知」(天然批量,不会出现中间态被渲染);而 Valtio 没有显式的 action 机制(每次赋值都会通知,靠 React 的批处理兜底)。此外:Valtio 更像「可变版 Zustand」(轻、API 少、学习曲线低、~1KB);MobX 是一套完整范式(observable/computed/action/reaction,生态成熟、~15KB+、学习曲线更高)。 -
「MobX 的
computed有什么价值?」
⭐ 它是「有缓存 + 懒计算 + 可组合的派生值」:① 缓存——依赖不变时直接返回缓存(避免重复计算,这是大表格行列合计、复杂筛选的关键);② 懒计算——依赖变化时只「标脏」,等下次被读才重算(⭐ 所以「没人看的 computed 不花计算」);③ 可组合——computed可以依赖别的computed,形成计算图(如rows → filtered → sorted → total),且任何一层的缓存都有效。⚠️ 对比 Valtio 的 getter:普通 getter 每次都重算,所以「在渲染里读 3 次就计算 3 次」。实践建议:如果「派生计算贵」,MobX 的computed优势明显;Valtio 则需要「显式缓存」(derive或存进状态)。 -
「MobX 的
action为什么必要?」
两个作用:① ⭐ 批量更新——MobX 的规则是「一个action内的多次修改,结束后才统一通知」,于是不会出现「中间态被渲染出来」(例如「把 items 清空 + 设置 orderId」之间,外界不会看到「空购物车 + 旧 orderId」);② 性能——「一次 action 里改 5 个 observable」只触发一次渲染(而不是 5 次)。规则细节:MobX 默认「所有改状态都必须在 action 里」(否则开发期警告)——⭐ 这强制了「状态只能被『定义好的操作』改变」的纪律。⚠️ 异步 action 的坑:await之后已经脱离 action 上下文,所以「await 之后的改状态」必须包在runInAction里。 -
「代理式方案(Valtio/MobX)与不可变方案(Redux/Zustand/Jotai)怎么选?」
四个判据:① ⭐ 数据形态——深层嵌套、大量『就地修改』(表单、树形结构、编辑器)→ 代理式更舒服(没有展开地狱,也不必引 immer);扁平、以「整体替换」为主(列表、配置)→ 不可变更直接;② ⭐ 派生计算——派生链多且贵(表格、看板)→ MobX 的computed最省心;不可变派要「手写useMemo+ 依赖数组」(容易漏);③ 可调试性/可追溯——需要「时间旅行、完整事件日志、严格可预测」→ Redux(不可变 + action 日志);④ ⭐ 团队与生态——MobX 范式要「整体学习」(observable/computed/action/reaction),但生态成熟;Valtio 学习成本低(≈可变版 Zustand)。注意:⭐ 两者都不该「混用」——「一半直接改、一半展开」会让「什么时候会通知」变得无法推理。 -
「代理式方案有什么固有的坑?」
六个:① ⭐ 调试时对象是「活的」——console.log(state)展开时看到的是「最新值」而不是「那一刻的值」(✅ 用snapshot(state)/toJS(store));② ⭐ 解构会丢追踪(MobX 里const { count } = store在observer之外会丢)——✅ 在渲染期间、通过属性访问读;③ 类实例/Map/Set 的代理语义有坑(✅ 状态里尽量只放普通对象/数组/基本类型);④ Proxy 的性能开销(高频读要注意);⑤ React 18 并发下的撕裂(✅ Valtio 用useSnapshot、MobX 用observer+ 正确版本处理);⑥ ⭐ 「什么变了」不再直观(引用都变了?没有)——需要依赖 DevTools(mobx-devtools)。 -
「Valtio 的『精确订阅』和 Zustand 的『选择器』有什么区别?」
都是「细粒度订阅」,但追踪方式不同:① Zustand——显式:你手写选择器useStore((s) => s.count),框架「比较选择器结果」决定是否重渲染(⭐ 显式、可控,但要小心「返回新对象」,见 11.13);② Valtio——隐式:你直接读snap.count,框架自动记录「你读了什么」并只订阅那些属性(⭐ 更省心,但你得知道「组件读了什么」——在「大组件」里读了很多字段时,容易「不知不觉订阅了全部」)。实践差异:Zustand 里「性能问题」通常表现为「选择器写错」;Valtio 里通常表现为「组件读太多」(✅ 解法是把大组件拆小,让每个子组件只读自己需要的字段)。
一句话速记
Valtio 与 MobX 都是「代理式响应式」——
Proxy的 get 陷阱里「登记当前追踪者订阅了哪个属性」、set 陷阱里「通知那些订阅者」,所以「直接改对象」(无展开地狱)且「属性级精确追踪」(不用手写选择器);⭐ Valtio:proxy()+useSnapshot()——useSnapshot返回冻结的只读快照(① 防误改 ② 避免并发撕裂(内部用useSyncExternalStore)③ 结构共享 → 引用稳定 → memo 有效),⭐ 纪律是「改写原始对象、读用快照」;⚠️ Valtio 的 getter 每次读都重算(不像 MobX 的computed有缓存);⭐ MobX:observable+computed(有缓存 + 懒计算 + 可组合成计算图)+action(结束后统一通知 → 天然批量、无中间态)+reaction/autorun(副作用,必须 dispose)+observer()HOC(⚠️ 异步 action 的await之后要runInAction;⚠️ 解构会丢追踪——要在渲染期通过属性访问读);选型判据:深层嵌套/大量就地修改 → 代理式;派生链多且贵 → MobX 的computed;需要时间旅行/严格可预测 → Redux;⭐ 两者都不要与不可变范式混用;代理式的固有坑:调试时对象是「活的」(用snapshot/toJS打印)、Proxy 性能开销、类实例/Map 的语义、「组件读太多 = 订阅全部」→ 把大组件拆小。



最新评论
读过书不知道欧·亨利的人少。教科书上选文有
这小生活不错呀
不错,必须顶一下!
看着你还在坚持,很好
看来忙了也没时间更新博客了
NIce。学习了。。。。
网站不错!!!!
简洁实用,好文章!