React 为什么不能像 Vue 那样 state.count++?------ 从一道 Valtio 复刻题说起
一道刷题挖出来的疑问:React 为什么要靠第三方库才能做到 Vue 那种
state.count++直接响应?挖了一圈才发现这不是"想不到",而是 React 在并发渲染、时间旅行、UI = f(state)这三件事上做的取舍 ------ 跟"允许 mutation"是互斥的。这篇文章从一道 BFE 复刻题切入,把 React 和 Vue 响应式哲学的差别、Valtio 用来填这个口子的两个核心机制(照相师 + 调度员)讲清楚。
起因:从一道刷题说起
最近在 BFE.dev 刷到一道题,让你手写一个简化版的 Valtio ------ 实现 proxy 和 useSnapshot 这两个 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++ 一行就够了,背后的几千行代码,刚好藏起来的就是这篇文章里那些"为什么不能这么简单"的答案。