Skip to content

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;
}

关键设计

  1. Proxy 拦截get 收集依赖,set 触发通知
  2. Snapshot 不可变Object.freeze 递归冻结,保证渲染一致性
  3. 属性级追踪:只有组件实际读取的属性变化才触发重渲染(比 selector 更精确)
  4. 递归代理:嵌套对象自动代理,深层修改也能被追踪

五、与 Vue 响应式的对比

维度ValtioVue 3 Reactivity
底层机制ProxyProxy
依赖收集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)