React 为什么不能像 Vue 那样 state.count++

React 为什么不能像 Vue 那样 state.count++?------ 从一道 Valtio 复刻题说起

一道刷题挖出来的疑问:React 为什么要靠第三方库才能做到 Vue 那种 state.count++ 直接响应?挖了一圈才发现这不是"想不到",而是 React 在并发渲染、时间旅行、UI = f(state) 这三件事上做的取舍 ------ 跟"允许 mutation"是互斥的。这篇文章从一道 BFE 复刻题切入,把 React 和 Vue 响应式哲学的差别、Valtio 用来填这个口子的两个核心机制(照相师 + 调度员)讲清楚。

起因:从一道刷题说起

最近在 BFE.dev 刷到一道题,让你手写一个简化版的 Valtio ------ 实现 proxyuseSnapshot 这两个 API,让 React 的 state 可以这样写:

jsx 复制代码
const state = proxy({ count: 0 });

function App() {
  const snap = useSnapshot(state);
  return <div onClick={() => state.count++}>{snap.count}</div>;
  //                       ↑ 直接 ++ 就能触发重渲染
}

题写完了,但有个问题在脑子里转不掉:这不就是 Vue 一直在做的事吗?

为什么 React 要靠一个第三方库才能做到 Vue 那种直观写法?React 团队明显知道 Vue 的方案,那他们为什么没选?

顺着这个问题挖了一圈,越挖越有意思。这篇文章想搞清楚的是:

  • React 为什么不直接做成 Vue 那样?
  • 现在有哪些库在试图弥补这个差距?
  • 我刚刚刷的那道题差在哪?

一、React 为什么选择"麻烦"?

不是 React 团队没想到 Vue 的方案,是不能选。

先把最根本的差异讲清楚 ------ 响应是谁触发的?

  • Vue :用 Proxy 把你的 state 包了一层。你写 state.count++,Proxy 在赋值那一刻偷偷拦截,知道"该重渲染了"。框架监听你。
  • React :没有 Proxy,根本不知道你改没改 state。只有你调用 setState,它才知道。 你上报框架。

那为什么 React 不也搞 Proxy?因为一旦允许 mutation(原地修改),三件事会塌房。

1. UI = f(state) 这个公式直接失效

React 判断要不要重渲染,靠的是 prevState === nextState 的引用比较。如果允许 mutation,会出现一个新手超经典的坑:

js 复制代码
const [arr, setArr] = useState([1, 2, 3]);
arr.push(4);
setArr(arr);   // ← 没用!界面不更新

arr.push 之后新旧 arr 还是同一个引用,React 一比"没变啊",跳过渲染。这就是为什么 React 教程里反复念"state 必须不可变"。

Vue 没这个问题,因为它不靠"前后对比",它在 Proxy 拦截到赋值的那一刻就知道要更新了。React 要走 Vue 这条路,得把整套渲染调度推翻重写。

2. 时间旅行调试需要"历史快照"

Redux DevTools 有个撤销/重放功能,原理是每次更新都把完整的 state 存一份:

ini 复制代码
t=0  { count: 0 }
t=1  { count: 1 }   ← 用户点了一下
t=2  { count: 2 }   ← 又点了一下

你能"回到 t=1",是因为 { count: 1 } 这个对象还在内存里。

Vue 的 mutation 模式做不到 ------ 上一刻的 state 已经被你 state.count++ 原地改掉了,无从回放。React 团队把可调试性看得很重,这一条不肯让。

3. 并发(Concurrent)模式下 mutation 会"撕裂"

这个最难。React 18 的渲染是可以中途暂停 的(来了高优先级任务,先放下当前渲染去处理它,回头再继续)。如果允许 mutation,渲染期间外部代码可能把 state 偷偷改掉 ------ 等 React 回头继续渲染时,同一棵组件树里不同组件读到的 state 值会不一样。这就是撕裂(tearing):屏幕上 A 显示旧值、B 显示新值,组件之间公然对不上账。

React 用 setState 就避开了 ------ setState 是排队的,本次渲染期间 state 是冻结的。Vue 那种允许任意时刻 mutation 的模型在并发模式下根本走不通,所以 Vue 的调度必须保守,不能像 React 那样激进打断。(具体怎么发生、Valtio 怎么解决,第三节会用一个购物车例子细讲。)

一句话总结 :React 用啰嗦的 setState 换来了三样东西 ------ 不会被 mutation 偷偷坑、能时间旅行调试、能在并发模式下激进打断重排。Vue 用 Proxy 换来了爽快的写法,代价是渲染调度只能保守。这不是谁对谁错,是同一道题的两种解法。


二、不服气的人怎么办?------ React 状态管理江湖速览

不是所有人都买 React 这套哲学。于是出现了一批"在 React 里复刻 Vue 体验"的库。

React 生态的状态管理可以分四个流派:

流派 代表库 心智模型 像不像 Vue
Flux Redux, Zustand 单 store + dispatch / setState
Context React.createContext 跨组件传值,不算真正状态管理
Proxy MobX, Valtio 包成代理 + 自动追踪 ✅✅✅
Atomic Recoil, Jotai 原子 + 依赖图 半像(自动追踪像,写法不像)

只有 Proxy 派真正在做"Vue 体验",但两个库激进程度不同。MobX 要你显式声明哪些字段要响应(makeObservable 加装饰器一个个标),Valtio 干脆把整个对象包成 Proxy,啥都不用标 ------ 写法跟 Vue 几乎一模一样。

顺带一个挺有意思的冷知识:Zustand、Jotai、Valtio 是同一个作者写的(Daishi Kato)。一个人针对不同人群写了三个状态管理库 ------ 想要单 store 用 Zustand,想要原子化用 Jotai,想要 Vue 体验用 Valtio。没有"哪个最好",只有"哪个适合你"。


三、那个精简实现,到底缺了什么?

回到 BFE.dev 那道题。开头你看到的那段代码,其实就是真实 Valtio 的 API ------ 区别只是 import 来自真正的库:

js 复制代码
import { proxy, useSnapshot } from 'valtio';

题目要你手写的,就是复刻这个 API 的最小可用形态。参考实现大致长这样:

ts 复制代码
export function proxy<T extends object>(initialValue: T): T {
  const keys = new Set<string>();
  let setState: any;
  return new Proxy(initialValue, {
    get(target, key) {
      keys.add(key as string);
      return Reflect.get(target, key);
    },
    set(target, key, value) {
      if (key === 'setState') { setState = value; return false; }
      const status = Reflect.set(target, key, value);
      if (status && keys.has(key as string)) {
        keys.clear();
        setState((prev: any) => ({ ...prev, [key]: value }));
      }
      return status;
    },
  });
}

export function useSnapshot<T extends object>(proxyObj: T): T {
  const [, setState] = useState();
  Reflect.set(proxyObj, 'setState', setState);
  return proxyObj;
}

它确实让 state.count++ 能触发组件重渲染。但跟真实 Valtio 一比,缺了四大块 ------ 每一项单看都"看着像吹毛求疵",但只要你做一个稍微正经一点的应用就会全部踩到。下面一个个看:

1. 订阅模型:第二个组件来了,第一个就失声

我这版用一个闭包变量 setState 存 React 的 setter,谁后注册谁覆盖前一个:

jsx 复制代码
function CompA() { useSnapshot(state); /* 注册 setStateA */ }
function CompB() { useSnapshot(state); /* 注册 setStateB,把 A 的覆盖了 */ }

state.count++;
// 只有 B 重渲染,A 永远拿老值

关键是 set 拦截器是绑在 Proxy 上的,跟"谁触发"无关 ------ 不管谁改 state,里面调的都是当前槽位里那个 setter(也就是 B 的)。所以:

谁触发 state.count++ A 重渲染? B 重渲染?
A 触发
B 触发
外部代码触发(如 setTimeout)

A 一旦被覆盖就彻底失声 ------ 任何人的改动都通知不到它。

真实 Valtio 给每个 proxy 内部都挂一个 Set<Listener>:每个组件挂载时,useSnapshot 内部会创建一个属于自己的 notify 函数加进去,卸载时自动清理。当 set 拦截器触发时,遍历整个 Set 调用所有 notify,A、B 各自收到自己的通知去重渲染。Set 在这里的好处是 ------ 多个组件订阅互不覆盖、重复添加自动去重、组件卸载干净清理,跟我这版的"一个槽位轮流被覆盖"完全是两个量级。

2. 通信通道的 key:业务字段叫 setState 直接撞车

我这版用字符串 'setState' 当内部通信信道:

jsx 复制代码
const state = proxy({ count: 0, setState: 'idle' }); // setState 是业务里的合法字段名

function App() {
  useSnapshot(state);          // 把 React 的 setter 塞进 state.setState
  console.log(state.setState); // 期望 'idle',实际拿到一个函数
}

真实 Valtio 用内部 Symbol,业务永远不可能撞上。

3. 渲染触发器:并发模式下会撕裂

我这版的 useSnapshot 内部就一句 return proxyObj,每次读都是读"此刻的 Proxy"。在传统模式下问题不大,但 React 18 并发模式可以中途暂停渲染,这就出事了。

假设购物车有两个组件 CartCount(A,显示数量)和 CartTotal(B,显示总价):

jsx 复制代码
const state = proxy({ count: 1, price: 100 });

function CartCount() {
  const snap = useSnapshot(state);
  return <div>数量:{snap.count}</div>;
}

function CartTotal() {
  const snap = useSnapshot(state);
  return <div>总价:{snap.count * snap.price}</div>; // 也用 count
}

并发模式下可能发生这种事:

下面把 React 并发调度做了简化 ------ 真实机制更复杂,但出问题的本质相同。

ini 复制代码
T1:React 开始渲染(低优先级),先渲染 A
    读 state.count = 1 → A 渲染成 "数量:1"

T2:用户点了"加一"按钮(高优先级事件插队)
    React 暂停当前渲染去处理点击
    handler 执行 state.count++ → state.count 变成 2

T3:React 回头继续渲染 B
    读 state.count = 2 → B 渲染成 "总价:200"

最终:屏幕上 "数量:1" + "总价:200"

同一棵组件树里,A 和 B 对"现在 count 是多少"答案不一样 ------ 数量是 1 但总价按 2 算。这就是撕裂。

为什么会撕裂?因为 mutation 和"通知"不是一回事

把 set 拦截器再看一眼:

ts 复制代码
set(target, key, value) {
  const status = Reflect.set(target, key, value);  // ← ① 立即同步改 Proxy
  if (status && keys.has(key as string)) {
    setState((prev) => ({ ...prev, [key]: value }));  // ← ② 排队通知 React 重渲染
  }
  return status;
}

state.count++ 执行的瞬间发生两件事,但只有第 ② 步是排队的

  • 第 ① 步 Reflect.set 立即把 Proxy 上的 count 改成新值,没有任何排队
  • 第 ② 步 setState 才把"重渲染信号"排进 React 的更新队列

所以问题来了:React 当前那次渲染跑到一半被打断,handler 里 state.count++ 在第 ① 步已经把 Proxy 改了。等 React 恢复渲染继续渲染 B 时,B 去 Proxy 读 count ------ 读到的是新值。

对比一下原生 setState 为什么没事:

jsx 复制代码
const [count, setCount] = useState(1);
setCount(2);  // 把"新值 2"打包进队列,本次渲染读到的还是 1

原生 setState 把新值本身排队,本次渲染从头到尾用的都是开始时的值(1),中途谁也改不了。

而你的封装只把"信号"排队,新值已经在 Proxy 上落地了。React 渲染中途去外面读,读到的是被污染过的值。一句话总结:

原生 setState 排的是"新值",本次渲染读到的永远是老值。

自封装排的只是"信号",新值已经在 Proxy 上落地,本次渲染中途读会读到。

撕裂是过渡态,但仍不能接受

你可能会想:撕裂也不会持续太久,下一次 setState 触发的重渲染就会修好。没错 ------ 在没有任何防护的实现下 (也就是 BFE 那版),撕裂是过渡态不是稳态:

arduino 复制代码
第一次提交:屏幕短暂显示 "数量1 + 总价200"   ← 撕裂帧
第二次渲染:A 和 B 都重新读到 count=2
第二次提交:屏幕变成 "数量2 + 总价200"        ← 一致了

整个窗口可能就几十毫秒。但仍然不能接受,有两层原因:

1. 用户看得到那一帧。 60fps 一帧 16ms,撕裂窗口足够让用户看到"数量 1 但总价 200"这种数学上不可能的画面。在金融、电商这种场景下,一帧的错位都可能让用户截图发客诉。

2. 更严重 ------ 撕裂帧会触发错误副作用。 比如:

jsx 复制代码
function CartTotal() {
  const snap = useSnapshot(state);
  const total = snap.count * snap.price;

  useEffect(() => {
    if (total > 150) sendAnalytics('big_order', total);
    //                  ↑ 用错位的 total=200 上报了!
  }, [total]);
}

修正渲染来不及撤销已经发出去的网络请求、埋点、跳转。所以问题不是"撕一下也无所谓反正会修复",而是"从一开始就不能让撕裂被看到、被处理"。

Valtio 怎么解决:照相师 + 调度员

防撕裂的活拆给两个角色干:

角色 是谁 干什么
照相师 Valtio 的 snapshot() 拍冻结副本;没变就返回同一张(结构共享)
调度员 React 的 useSyncExternalStore 决定什么时候找照相师要快照、要不要丢弃整次渲染重来

下面分别看。

照相师:拍冻结副本,没变就返回同一张

撕裂的根因是 BFE 版每次读 snap.count 都去活 Proxy 实时查。Valtio 提供了一个 snapshot() 方法,按当前 state 返回一份深度冻结的副本(Object.freeze),冻住之后任何代码都改不了:

ts 复制代码
state.user.name = 'Bob';
const snap1 = snapshot(state);
const snap2 = snapshot(state);    // state 没变
snap2 === snap1                   // true,返回同一个引用

state.items.push('apple');        // 改了 items
const snap3 = snapshot(state);
snap3 !== snap1                   // true,顶层是新对象
snap3.user === snap1.user         // true,user 没动,复用旧引用
snap3.items !== snap1.items       // true,items 变了,是新的

没变就给同一个引用 这个细节很关键 ------ 调度员后面要靠它做 === 校验。

调度员:通过 useSyncExternalStore 接管整个流程

Valtio 把 useSnapshot 接进 React 18 的 useSyncExternalStore

ts 复制代码
function useSnapshot(proxyObj) {
  return useSyncExternalStore(
    subscribe,                     // Valtio 告诉 React:怎么订阅我的变化
    () => snapshot(proxyObj)       // Valtio 告诉 React:怎么拿当前快照
  );
}

之后 Valtio 自己变得很被动 ------ 谁来调 snapshot() 就按当前 state 给一张,仅此而已。所有调度都搬到 React 那边,做两件事:

① 同一次渲染里,所有组件共享同一张快照

css 复制代码
React 开始一次渲染
  │
  ├─ 渲染 A:A 调 useSnapshot
  │  口袋空 → 调 getSnapshot() 问照相师要 → snap_v1
  │  snap_v1 装进口袋
  │  → A 拿到 snap_v1 = {count:1}
  │
  └─ 渲染 B:B 调 useSnapshot
     口袋里有 snap_v1 → 不再问照相师,直接给 B
     → B 也拿到 snap_v1 = {count:1}    ← 和 A 同一张!

② 提交前再问一次,对不上就整次丢弃重来

scss 复制代码
[渲染期间] 外部执行 state.count++
              │
              ├─ Proxy 立即变成 {count:2}
              └─ Valtio 通知 React:"外面变了"
                          │
                          ▼
              React 收到通知,但:
              ① 不打断当前渲染
              ② 口袋里 snap_v1 也不动(冻结的,谁都改不了)

  ... 渲染继续跑完,A、B 都用 snap_v1 ...

┌──────────────────────────────────────────────┐
│  ★ 准备提交到屏幕前,React 做最后一次校验    │
│                                              │
│   再调 getSnapshot() → 拿到 snap_v2          │
│   比较 snap_v1 === snap_v2 ?                 │
│                                              │
│        ┌─────────┴─────────┐                 │
│        ▼                   ▼                 │
│      相等                 不等                │
│   (期间没变化)         (期间外面变了)        │
│        │                   │                 │
│        ▼                   ▼                 │
│      提交!             丢弃整次渲染         │
│   屏幕显示              用 snap_v2 从头再来  │
└──────────────────────────────────────────────┘

校验失败那次渲染虽然跑完了,但因为被整个丢弃,撕裂帧根本没机会上屏

这里能成立的前提就是照相师那条"没变就给同一个引用"------ 否则即使 state 没变,每次 snapshot() 都返回新对象,校验永远 !==,会陷入无限重渲染。

4. 代理层级:嵌套对象赋值根本不经过 Proxy

new Proxy 只代理传进去的那一层。嵌套对象拿出来还是原始对象,在它上面赋值不会触发外层的 set 拦截:

jsx 复制代码
const state = proxy({ user: { name: 'Alice' } });

state.user.name = 'Bob';
// 步骤拆解:
// 1. state.user      → 走外层 get,拿到原始的 { name: 'Alice' }
// 2. .name = 'Bob'   → 在原始对象上赋值,没有 Proxy 拦截
// → 不触发任何更新

真实 Valtio 做"懒深层代理":get 到对象类型时,把它也包成 Proxy 再返回,这样赋值才会被拦下来。


这四个 delta 加起来,基本就是真实 Valtio 几千行代码的核心 ------ 这套实现能跑,是因为它默默做了四个"反正用例简单"的假设。一旦用例不简单,每个假设都要付出真实代码量去赎回。


写在最后

回到开头那个问题:为什么 React 要靠第三方库才能做到 Vue 那种直观写法?

走完一圈才发现,这不是 React 团队"懒得做"或"想不到"。UI = f(state)、时间旅行、并发渲染 ------ 这三件事和"允许 mutation"在设计层面是互斥的。Vue 选了爽快写法,代价是调度只能保守;React 选了显式上报,代价是写法啰嗦。 没有谁对谁错。

至于 Valtio ------ 它在 React 这套规则里硬塞了一个"Vue 体验"的口子。但它能跑,是因为背后用照相师 + 调度员两个角色配合,把"防撕裂"这件事做到了用户看不见。我们手写的那版能跑,但跑不远。

写代码的时候 state.count++ 一行就够了,背后的几千行代码,刚好藏起来的就是这篇文章里那些"为什么不能这么简单"的答案。


参考资料

相关推荐
ricardo19731 小时前
防抖节流进阶 + requestAnimationFrame:滚动与输入场景的性能优化
前端·面试
wjj不想说话1 小时前
你项目里的 Pinia,可能已经成了第二个 localStorage
前端·vue.js
wuhen_n1 小时前
LangChain JS 入门:快速搭建前端 AI 开发环境
前端·langchain·ai编程
天蓝色的鱼鱼2 小时前
画1万个图形就卡成PPT?试试这款国产高性能2D引擎
前端·javascript
云水一下2 小时前
JavaScript 从零基础到精通系列:异步编程与网络请求
前端·javascript
卡卡军2 小时前
🌈 react-sketch-ruler v3 升级之旅:当 React 遇上跨框架标尺引擎
前端·react.js
Asmewill2 小时前
DeepAgents学习笔记三(Backend记忆存储)
前端
Alan Lu Pop2 小时前
前端开发助手
前端·智能体
程序员鱼皮2 小时前
我用 GitHub 仓库养 AI 龙虾,自动开发上线项目!保姆级教程
前端·人工智能·ai·程序员·github·编程·ai编程