先说个背景
Vue 3.5 是 2024 年 9 月 3 号发布的,我当天晚上就在一个后台项目上升级了。那个项目有个运单列表页,单页大概 8000 条数据,每行可以展开看物流轨迹。升级之前在 Chrome Performance 面板里录一次快速滚动,Scripting 大概 180ms 上下;升级之后同样的操作掉到 70ms 出头。官方博客里给的数字是响应式系统的内存占用下降 56%,这个我没法精确复现,但笔记本风扇确实不怎么叫了。
不过今天不打算聊版本升级,那个话题已经被写烂了。我想聊一个更老的、但每次带新人都会被问到的问题:ref 和 reactive 到底怎么选。
先把结论撂在这儿,后面慢慢拆:我现在新写的代码里大概 9 成用 ref,1 成用 reactive,而且那一成基本只出现在表单校验这种一堆字段绑在一起、不需要整体替换、也不需要往下传的局部状态里。shallowRef 单独算一类,专门用在图表实例和第三方 SDK 上。
四个我真踩过的坑
坑一:reactive 解构丢响应性,而且是静默的
const form = reactive({ province: '', city: '' })
const { province, city } = form
// 之后 province 永远是空字符串,控制台一声不吭
基础版大家都知道,麻烦的是变体。去年做一个省市区三级联动的筛选项,父组件把 reactive 对象展开塞给子组件:
<FilterPanel v-bind="{ ...query }" />
子组件里改 query.city,父组件的列表死活不刷新。查了两个多小时,最后发现就是那个展开操作,展开的那一刻 query 已经被拆成普通值了,往后改的都是副本。这种 bug 最难受的地方在于它不报错,你只能靠肉眼盯数据流。
坑二:reactive 对象不能整体替换
let state = reactive({ list: [] })
function reset() {
state = { list: [] } // 响应式链路从这里就断了
}
清空表单、重置筛选条件的时候,很多人的第一反应是重新赋一个对象。但 reactive 返回的是个 Proxy,你 reassign 变量只是换了个指向,原来那个 Proxy 还挂在那儿,模板订阅的也是原来那个。正确写法是 Object.assign(state, initial) 或者逐字段赋值。
坑三:ref 塞进 reactive 会自动解包,但只在一层生效
const count = ref(1)
const state = reactive({ count })
state.count // 是 1,不是 { value: 1 }
这个行为 Vue 文档里写过,只是埋得比较深。我不止一次看到有人靠这个特性写代码,review 的时候被问懵。而且它只对 reactive 的顶层属性生效,嵌套一层就不解了:
const state = reactive({ a: { b: ref(1) } })
state.a.b.value // 这里必须写 .value
心智负担就出在这儿:同一份数据,你得多问自己一句「现在这层解不解包」。
坑四:watch 一个 reactive 对象默认是深层的
watch(state, () => {}) // 深层,任何属性变都触发
watch(() => state.count, () => {}) // 只有 count 变才触发
第一种写法放在 8000 条数据的数组上,每次改动都要递归遍历一遍做比对。我见过一个项目这么 watch 列表,然后开着 devtools 卡到没法调试,最后改成 watch 一个版本号计数器才好。
那为什么我最后还是偏向 ref
理由其实很朴素,不带什么哲学。
第一是传递方便。ref 是个对象,怎么传都还是同一个引用,别人拿到手 .value 一下就行,不会因为一次解构就把响应性弄丢。reactive 就不一样,它在你自己手里是好的,一旦出门就可能被拆散,而且拆散的时候没有警告。
第二是 TypeScript 体验。ref 的类型就是 Ref<T>,一眼看得明白。reactive 被解构之后类型还是能推断出来的,但推断出来的已经不是响应式类型了,编译器不会拦你。我宁愿多敲几个 .value 换编译器帮我兜底。
第三是心智模型统一。ref 的规则就一条:除了模板里自动解包,其他地方都写 .value。reactive 的规则得背三条——顶层解包、不能替换、解构丢响应,我记不住。
当然 reactive 也不是没有用武之地。一个表单有十几个字段,全用 ref 写就是十几个 .value,看着烦,这时候用 reactive 是合理的。前提是它别出门,别解构,别整体替换。
shallowRef 该什么时候上
这个是我觉得比 ref / reactive 之争更值得说的。
import * as echarts from 'echarts'
const chart = shallowRef(null)
onMounted(() => {
chart.value = echarts.init(document.getElementById('chart'))
})
用 ref 包 ECharts 实例,Vue 会把整个实例对象做深层代理,里面几百上千个内部对象全被 Proxy 一遍,初始化肉眼可见地变慢。更要命的是 ECharts 内部会做一些 identity 比较,Proxy 之后原对象和代理对象不相等,某些交互会莫名其妙失效。这个坑我在至少三个项目里见过。
除了第三方实例,下面这几种也建议 shallowRef 或者 markRaw:
- 上千条的只读列表,比如从接口一次拉回来的字典数据,你只会整体替换,不会改里面某一项
- 从服务端返回、只在渲染时读一次的树形结构
- 挂载之后就不再变化的静态配置对象
判断标准挺简单:如果你从头到尾只会做整体赋值,那就没必要让 Vue 去递归追踪它的每一个属性。
3.5 里两个值得改掉的旧写法
第一个是 useTemplateRef。以前拿 DOM 引用是这么写的:
const el = ref(null)
// 模板里 ref="el"
现在是:
const el = useTemplateRef('el')
好处不只是少写一行。旧写法里变量名和模板上的 ref 字符串是靠约定对上的,改个名不报错,跑起来才发现是 null。新 API 是编译期能静态分析的,SSR 场景和类型推导都更稳。
第二个是 props 解构。3.5 之前这样写会丢响应性:
const props = defineProps(['count'])
const { count } = props
3.5 编译器会帮你处理,直接解构也能响应。但有个细节要注意,解构出来的变量不能跟 props.count 混着用,混用的时候按 props.count 走。所以要么全解构,要么全 props.xxx,别一半一半。
一张我给自己用的决策表
| 场景 | 用什么 | 原因 |
|---|---|---|
| 单个原始值 | ref | 没得选 |
| 十几个字段的局部表单 | reactive | 少写一堆 .value |
| 大数组、大对象 | shallowRef | 避免深代理的递归开销 |
| ECharts / 地图 / 第三方 SDK 实例 | shallowRef + markRaw | 顺带躲开 identity 比较的问题 |
| 要传给子组件或 composable | ref | reactive 出门就可能被解构 |
| 需要整体替换的对象 | ref | reactive 替换不了 |
以上都是这两年交学费换来的,不一定对所有人都适用。你要是团队里已经有约定俗成的写法,跟着团队走比跟着我这篇走更靠谱。