陌上人如玉
公子世无双

3.12 Valtio 与 MobX

3.12 Valtio 与 MobX

一句话: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 重渲染

六、面试延伸

  1. 「Valtio / MobX 的『代理式响应式』是怎么工作的?」
    核心是 Proxy 的 get/set 拦截 + 自动依赖追踪:① 读的时候——get 陷阱里「把当前正在渲染(或正在执行)的订阅者登记到『这个属性』的订阅者集合上」(⭐ 关键是有一个全局的「当前追踪者」,在 React 渲染或 reaction 执行期间被设置);② 写的时候——set 陷阱里「通知订阅了这个属性的所有订阅者」。好处:写法是「直接改对象」(没有「展开地狱」,不需要 immer),而且「自动精确到属性级」(不必手写选择器)。代价:「变化」不再靠引用比较 → 需要一套依赖收集机制;调试时对象是「活的」;Proxy 的 get/set 有性能开销。

  2. 「Valtio 的 useSnapshot 为什么必要?」
    三个原因:① ⭐ 让 React 拿到「不可变的数据」——useSnapshot 返回冻结的只读快照,组件里不会「意外改到状态」(改了会在开发期警告);② ⭐ 解决并发渲染下的「撕裂」——如果组件直接读「活的 proxy」,在「渲染被中断期间」状态可能已变,导致「同一界面里不同组件读到不同版本」(见 11.13);useSnapshot 保证「这次渲染看到同一个版本」(内部就是 useSyncExternalStore);③ ⭐ 引用稳定 + 结构共享——快照是结构共享的(没变的子对象保持同一个引用),所以配 React.memo 时不会因无关变化而失效。⭐ 一句话:「写的时候用可变对象(爽),读的时候用快照(安全 + 精确)」。

  3. 「Valtio 和 MobX 最大的区别是什么?」
    两个关键差异:① ⭐ 派生值缓存——MobX 的 computed 有缓存且懒计算(依赖不变直接返回缓存,依赖变了标脏、下次读才重算,且 computed 可以互相依赖形成计算图);而 Valtio 的普通 getter「每次读都重算」(需要缓存得用 derive 工具或自己在 action 里维护字段);② ⭐ 批量更新——MobX 的 action 会「结束后统一通知」(天然批量,不会出现中间态被渲染);而 Valtio 没有显式的 action 机制(每次赋值都会通知,靠 React 的批处理兜底)。此外:Valtio 更像「可变版 Zustand」(轻、API 少、学习曲线低、~1KB);MobX 是一套完整范式(observable/computed/action/reaction,生态成熟、~15KB+、学习曲线更高)。

  4. 「MobX 的 computed 有什么价值?」
    ⭐ 它是「有缓存 + 懒计算 + 可组合的派生值」:① 缓存——依赖不变时直接返回缓存(避免重复计算,这是大表格行列合计、复杂筛选的关键);② 懒计算——依赖变化时只「标脏」,等下次被读才重算(⭐ 所以「没人看的 computed 不花计算」);③ 可组合——computed 可以依赖别的 computed,形成计算图(如 rows → filtered → sorted → total),且任何一层的缓存都有效。⚠️ 对比 Valtio 的 getter:普通 getter 每次都重算,所以「在渲染里读 3 次就计算 3 次」。实践建议:如果「派生计算贵」,MobX 的 computed 优势明显;Valtio 则需要「显式缓存」(derive 或存进状态)。

  5. 「MobX 的 action 为什么必要?」
    两个作用:① ⭐ 批量更新——MobX 的规则是「一个 action 内的多次修改,结束后才统一通知」,于是不会出现「中间态被渲染出来」(例如「把 items 清空 + 设置 orderId」之间,外界不会看到「空购物车 + 旧 orderId」);② 性能——「一次 action 里改 5 个 observable」只触发一次渲染(而不是 5 次)。规则细节:MobX 默认「所有改状态都必须在 action 里」(否则开发期警告)——⭐ 这强制了「状态只能被『定义好的操作』改变」的纪律。⚠️ 异步 action 的坑:await 之后已经脱离 action 上下文,所以「await 之后的改状态」必须包在 runInAction 里。

  6. 「代理式方案(Valtio/MobX)与不可变方案(Redux/Zustand/Jotai)怎么选?」
    四个判据:① ⭐ 数据形态——深层嵌套、大量『就地修改』(表单、树形结构、编辑器)→ 代理式更舒服(没有展开地狱,也不必引 immer);扁平、以「整体替换」为主(列表、配置)→ 不可变更直接;② ⭐ 派生计算——派生链多且贵(表格、看板)→ MobX 的 computed 最省心;不可变派要「手写 useMemo + 依赖数组」(容易漏);③ 可调试性/可追溯——需要「时间旅行、完整事件日志、严格可预测」→ Redux(不可变 + action 日志);④ ⭐ 团队与生态——MobX 范式要「整体学习」(observable/computed/action/reaction),但生态成熟;Valtio 学习成本低(≈可变版 Zustand)。注意:⭐ 两者都不该「混用」——「一半直接改、一半展开」会让「什么时候会通知」变得无法推理。

  7. 「代理式方案有什么固有的坑?」
    六个:① ⭐ 调试时对象是「活的」——console.log(state) 展开时看到的是「最新值」而不是「那一刻的值」(✅ 用 snapshot(state) / toJS(store));② ⭐ 解构会丢追踪(MobX 里 const { count } = store 在 observer 之外会丢)——✅ 在渲染期间、通过属性访问读;③ 类实例/Map/Set 的代理语义有坑(✅ 状态里尽量只放普通对象/数组/基本类型);④ Proxy 的性能开销(高频读要注意);⑤ React 18 并发下的撕裂(✅ Valtio 用 useSnapshot、MobX 用 observer + 正确版本处理);⑥ ⭐ 「什么变了」不再直观(引用都变了?没有)——需要依赖 DevTools(mobx-devtools)。

  8. 「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 的语义、「组件读太多 = 订阅全部」→ 把大组件拆小。

赞(0) 打赏
未经允许不得转载:陌上寒 » 3.12 Valtio 与 MobX

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫