Vue 3.5 之后 shallowRef 还值得用吗?我拿 20 万条数据实测了一遍,结论和教程不太一样

🔑 关键词:Vue 3.5,shallowRef,响应式系统重构,markRaw,性能优化

📖 摘要:Vue 3.5 重写了响应式系统,官方称内存降低 56%。我用 20 万条数据在 3.4.21 和 3.5.13 上分别测了 ref / shallowRef / markRaw 三种写法,记录内存占用、首屏时间和 push 耗时,并给出我个人现在选择这几种写法的实际标准,以及两个踩过的坑。

起因:一个 8000 行的表格把我整不会了

图片

上个月接了个后台系统的活,资产盘点模块,一屏要展示 8000 多行设备数据,每行 12 个字段。本地用 mock 数据开发的时候只有 200 条,跑得飞快,等联调接上真实接口,一打开页面直接白屏 3 秒多,滚动的时候风扇呼呼转。

第一反应是虚拟滚动没做——确实没做,但这不是主因。打开 Chrome Performance 面板录了一段,发现 scripting 时间里有 40% 花在 reactive 的依赖收集和触发上,而不是渲染。也就是说,数据还没上屏呢,光是把接口返回的数组变成响应式的,就已经把主线程堵住了。

这事让我重新去看了一遍 Vue 3.5 的响应式重构。官方的说法是内存占用降低 56%、大型深层数组的部分操作快 10 倍——数字听着漂亮,但落到具体项目里到底还剩多少收益,我干脆自己测了一遍。

实测:3.4.21 和 3.5.13 跑同一份代码

测试环境:MacBook Pro M1 Pro / 16G / Chrome 126 / Vite 5.2,production build 之后跑,不开 devtools,取三次平均值。

图片

数据集:20 万条记录,每条 12 个扁平字段(id、name、status、amount、updatedAt 这些),没有嵌套对象。三种写法对比:ref(bigArray)(默认深响应)、shallowRef(bigArray)(只在整体替换时更新)、markRaw(bigArray) + 手动触发。

结果:

写法 3.4.21 内存 3.5.13 内存 3.4.21 首屏 3.5.13 首屏
ref(深响应) 312MB 138MB 2840ms 1510ms
shallowRef 118MB 96MB 1720ms 1390ms
markRaw 91MB 88MB 1650ms 1360ms

内存数字是从 Chrome DevTools 的 Memory 快照里读的,三次快照取最低值,会有波动,但量级是稳的。

图片

最值得看的是两列的差值:在 3.4 里,深响应比浅响应多吃 194MB 内存、首屏多花 1.1 秒;到了 3.5,这两个差距分别缩到 42MB 和 120ms。我又补测了一轮「往 reactive 数组里连续 push 5 万次」,3.4 用了 1.2 秒,3.5 用了 380 毫秒,大概 3 倍。

说实话,测完这个数字我第一反应是:那以前写的一堆 shallowRef 优化,是不是白写了。

3.5 到底动了哪里

扒了下 @vue/reactivity 3.5 的 dep.ts,能看出来核心改动是把依赖收集的容器整个换掉了。

3.4 里每个响应式对象的每个 key 都挂一个 Dep 实例,Dep 内部是一个 Map<effect, number>,也就是「谁订阅了我」。20 万条记录、每条 12 个字段,就是 240 万个 key,哪怕你只读了其中几个字段,这些 Map 的结构本身也要占内存。而且每次依赖清理都要遍历 Map 做删除,开销散在堆上到处都是。

图片

3.5 换成了双向链表:DepSubscriber(也就是 effect)之间用 Link 节点连起来,再加一个全局版本号 globalVersion 做快速跳过。链表的好处是增删依赖只改指针,不用动哈希表结构;版本号的好处是——如果依赖自上次收集之后没有任何变化,整条链路直接跳过去,连遍历都免了。

用人话说就是:以前是「每个人一本花名册,谁来了谁走了都要划掉」,现在是「一条队伍,谁进谁出一看前后指针就知道」。结构本身省内存,操作本身省时间,两个收益叠在一起,所以官方才能喊出 56% 那个数字。

那现在还要不要用 shallowRef

我的结论可能跟很多教程不太一样:shallowRef 该用还是用,只是它的定位变了——从「性能手段」降级成「语义手段」。

以前你用 shallowRef,是因为 reactive 真的扛不住;现在你用 shallowRef,是因为这块数据你本来就只想在整体替换时更新,这是一种表达意图的写法,而不是为了抠那几十毫秒。这两个理由是两码事,混在一起讲就会得出「shallowRef 没用了」或者「shallowRef 必须用」两种极端结论。

图片

我现在的选择标准大概是这样:数据只在接口回来时整体替换 → shallowRef,理由是清晰;数据要行内编辑、拖拽排序、局部改字段 → ref / reactive,3.5 之后可以放心用,别提前优化;第三方实例(ECharts、Cesium、WebSocket、大 Blob)→ markRaw,这个没变过;数据量超过 5 万条 → 不管用哪种,先上虚拟列表,响应式早就不是瓶颈了。

顺带说个数据:我把上面那个 8000 行的表格从 reactive 换成 shallowRef,首屏只快了 200ms 出头;但加上虚拟滚动之后,从 1.4 秒掉到 340 毫秒左右。量级完全不一样。

两个我踩过的坑,顺手记一下

第一个坑是 shallowRef 里改字段不更新。这个老生常谈了,但我见过更隐蔽的版本:有人在十几个地方手动 triggerRef(state) 兜底,最后没人知道哪次更新是响应式触发的、哪次是手动的,调试起来特别痛苦。如果非要局部更新,老老实实换回 ref,别硬撑。

图片

第二个坑是 markRaw 用在数组元素上。markRaw 是给对象打永久标记,一旦标记就再也不会被代理,即使之后被塞进 ref() 里也一样。有次我把接口返回的对象直接 markRaw 了,后来又想在弹窗里编辑它,发现模板死活不更新,查了半小时才发现身上带着那个标记。它适合第三方实例这种「永远不需要响应式」的东西,不适合业务数据。

另外提醒一句,DevTools 里看到的 Proxy 数量不完全等于实际内存开销,3.5 省下来的大部分是 Map 结构的元数据,快照里不一定直观,最好还是看 Performance 面板的 scripting 时间。

一个可能反直觉的观点

Vue 3.5 之后,响应式系统这块的优化空间已经不太值得普通人去抠了。我测下来,20 万条数据用 ref 和用 shallowRef 的首屏差距只剩 120ms 左右,但把列表换成虚拟滚动,直接省掉 80% 多的渲染时间;再比如把某个大组件用 defineAsyncComponent 拆出去,收益也远大于纠结响应式写法。

所以我觉得现在还在标题里写「Vue 大数组一定要用 shallowRef」的文章,多少有点过时了——结论没错,但理由已经不成立了。真正该花时间的地方是渲染层:虚拟列表、v-memo、组件拆分粒度、以及别在 computed 里做 O(n²) 的事情。响应式那边,Vue 团队已经帮你把活干得差不多了。

🏷️ 标签: