今年年初我把一个接了三个广告平台的报表系统从 React 16 重写成了 Vue 3 + Vite 5。项目里大量用到嵌套表格、联动筛选、实时折扣计算和权限控制,非常适合对比两个框架在状态管理上的真实边界。在动手之前我花了一个周末去读 @vue/reactivity 的源码,又用三个晚上对比了 React 内部 Fiber 和 hook 依赖判定的行为。重写上线之后,我并没有像社区里大多数人一样得出“Vue 的响应式更优雅”的结论,反而觉得这种优雅在复杂异步场景下非常脆弱。
我踩到的第一个坑出现在 watchEffect 的依赖收集。Vue 3 的依赖是运行时通过 Proxy 的 get 自动注册的,这意味着 effect 的执行路径一旦带有分支,依赖就不稳定。举个例子,我的代码里写了一个 watchEffect,它内部先判断接口返回的配置项是否开启了旧版字段映射,如果开启才读取 row[legacyKey],后端在某个新业务里没传这个配置,于是后续该字段被更新时 effect 根本不会重跑。排查了两个小时后我只能改成手动 watch 并加上 deep: true。相较之下,React 的依赖数组可能呆板,但它可以在编译前就被 lint 规则审查,不会出现“这次不读取了所以更新丢失”这种动态断链。
另一个让我难受的对抗发生在 reactive 和 ref 的结合使用中。团队有个同事喜欢写 const store = reactive({ count: ref(0) }),然后在某个函数里用解构 const { count } = store 传给子组件,结果子组件拿到的 count 从来不会更新。原因我们都懂,一旦把 ref 从 reactive 对象上解构出来,它自动解包的行为就失效了,必须用 toRefs。这些知识写在文档里,但只有正在维护一个多人项目并出现间歇性 bug 时才会意识到:Vue 的高级机制对团队的纪律性要求其实非常高。类似的还有 shallowRef、markRaw、effectScope,每一个都像一颗定时炸弹,懂得越多越不敢随便用。
React 那边的体验则完全反过来。React 18 最让我有安全感的不是并发渲染,而是 hook 规则把副作用和渲染严格绑死:你不能在条件里调用 hook,不能循环里调用 hook,所有的更新都通过来自父级的 props 和自己内部的 state 以及 context 三种来源决定。尽管 useMemo 的依赖数组经常被人嘲讽“心智负担”,但你只需要在 useReducer 里把 state 变更都变成纯函数,就能对任意复杂的数据流做推演。我在重写报表期间,把原来 Vue 组件内部互相干扰的多个 watcher 改成了 React 的 useReducer + 选择器之后,发现每一条更新链路都能画在一张纸上,这对大型重构来说太重要了。
这里不是想说 React 比 Vue 好。我更想提出一个不同的观点:Vue 和 React 两者把不确定性放在完全不同的位置,Vue 把它放在“值依赖上的追踪时机”上,React 把它放在“渲染阶段与副作用的标准”上。Vue 的问题是你很难静态推导出某个 effect 到底监听哪些状态,而 React 的问题是它的不可变数据流导向了非常繁琐的布尔与 memo 优化。我的做法是,如果你的应用大多数是包含复杂关联表单和受控组件的中后台页面,并且团队平均年限不到两年,那就选择 Vue 3 和 Element Plus,尽量只使用 ref 和 computed,禁止在业务组件里写 reactive 解构。相反,如果你的场景需要长时间运行的高频交互、Canvas 编辑器或实时协作白板,选择 React 18 并且使用 zustand 管理全局状态,能让你在调试更新循环时保留最后一块遮羞布。