先分享一个我自己的体会:Vue 3 都出来这么久了,面试问响应式的时候,很多候选人依然在背“ref 用于基本类型,reactive 用于对象”这种结论,但一问到底层为什么、什么场景会踩坑,就直接卡壳。这篇结合我这几年在后台管理系统和 C 端项目里的实际经验,把 ref 和 reactive 的底层逻辑、使用场景、高频面试追问一次讲透。
标题虽然是“八股”,但我的经验是:真正能把这两个 API 讲明白的人,写代码的质量和排查问题的速度,跟只会背结论的人完全是两个档次。
1. 整体设计与核心思路:为什么 Vue 3 同时留了 ref 和 reactive
1.1 从设计源头理解两个 API
Vue 3 的 Composition API 里,ref 和 reactive 是响应式数据的两个入口,但它们解决的问题在源头就不一样。reactive 用 Proxy 代理一个对象,让整个对象深层属性都变成响应式;ref 则是把一个独立的值(基本类型、对象、数组都能收)包装成带.value的引用对象,通过 getter/setter 做依赖收集和触发更新。
从源码看,ref 的内部实现是RefImpl这个类,核心就是 value 属性的 getter 和 setter。getter 里会触发依赖收集(track),setter 里触发更新(trigger)。如果传入 ref 的是一个对象,Vue 会调用reactive()把它转为响应式对象,也就是说 ref 包对象时,内部本质上还是 reactive。
也就是说,ref 是“外壳”,reactive 是“内核”。ref 解决了 reactive 不能直接处理基本类型的问题,reactive 解决了 ref 在深层嵌套对象里写起来繁琐的问题,两者是互补关系,而不是竞争关系。
1.2 模板解包:一个隐蔽但有规律的行为差异
在模板里,ref 会自动解包,{{ count }}直接显示数值;但 reactive 对象不会自动把属性解包到模板顶层,必须写成{{ obj.count }}。很多人以为“ref 会自动解包,reactive 不会”,这个说法不够准确,准确说是:ref 在模板顶层会自动解包,reactive 在模板里按属性访问。
这里有个反直觉的坑:如果 ref 被放进 reactive 对象里,比如const obj = reactive({ age: ref(0) }),在模板里写{{ obj.age }}显示的是 0,而不是{ value: 0 }这样的对象,因为 reactive 在访问属性时会把嵌套的 ref 自动解包。这个机制在源码里是有意设计的,避免开发者在使用 reactive 包裹数据时还要到处补.value。
1.3 真正的核心区别在哪里
| 维度 | ref | reactive | | --- | --- | --- | | 数据类型 | 基本类型、对象、数组都可以 | 仅对象、数组、Map、Set 等引用类型 | | 访问方式 | 脚本里.value,模板里自动解包 | 直接用obj.xxx,不需要.value| | 底层机制 |RefImpl类 + getter/setter |Proxy代理整个目标对象 | | 嵌套结构 | 包对象时会转 reactive,深层响应式 | 默认深层响应式 | | 解构后行为 | 解构一个 ref 仍然保留响应式 | 解构后属性丢失 getter/setter,不再响应式 | | 典型场景 | 计数、布尔开关、字符串输入、跨组件共享引用 | 表单对象、表格查询参数、列表数据、多层级配置 |
这个表格如果能在面试现场直接画出来,就已经超过很多候选人了,因为这说明你不是背结论,而是真的理解了两者的机制差异。
1.4 一个能让思路变顺的记忆方式
我习惯这样记:值用 ref,对象用 reactive,但到模板里基本可以无脑 ref。ref 的.value在模板里自动解包,写起来省心;reactive 操作深层属性更直接,适合数据结构复杂的情况。
实际项目里我不太喜欢混用太多风格。一个模块里要么大部分用 ref,要么大部分用 reactive,混着写容易让同事在看代码时来回切换心智模式。建议团队在项目里做一个约定:简单值用 ref,复杂对象用 reactive,面试时也可以这样描述自己的实践。
2. 场景拆解与实现方案:ref 与 reactive 的实际代码形态
2.1 表单和图表这类复杂对象,优先交给 reactive
后台管理系统每天都要写表单页。如果我有一个form对象,里面有多个字段甚至还有嵌套的对象,比如地址信息、配送信息,这类数据用 reactive 是最直观的:
const form = reactive({ name: '', age: 0, address: { city: '', district: '' }, tags: [] }) function updateCity(val) { form.address.city = val }用 reactive 时,嵌套属性也是响应式的,form.address.city这层关系直接改就行。这在写复杂的表格弹窗、表单校验、步骤条时非常省事,不需要考虑每一层是不是要额外包 ref。
2.2 计数器、开关、输入框这类独立值,用 ref 最干净
如果是单个布尔值、数字、字符串,比如“是否显示弹窗”“当前页码”“搜索关键字”,用 ref 更符合代码直觉:
const visible = ref(false) const page = ref(1) const keyword = ref('') function openDialog() { visible.value = true } function nextPage() { page.value++ }这样的代码读起来很轻松,每一个变量就是一个独立的响应式状态。模板里用的时候直接{{ visible }}、{{ page }}就行,不需要绕一层对象。
2.3 跨组件共享同一份状态:ref 可以直接脱离组件存在
如果有一个状态需要在多个组件里共用,比如用户登录状态、全局主题色、购物车数量,ref 可以定义在单独的 store 文件里直接导出:
// store/user.js import { ref } from 'vue' export const token = ref('') export const nickname = ref('') export function setUser(info) { token.value = info.token nickname.value = info.nickname }在任意组件里引入这个 token 和 nickname,它们之间共享同一个响应式引用,改一个地方,所有使用它的组件都会自动更新。这种能力本质上就是 Vue 全局状态管理的雏形,Pinia 内部也是基于类似的响应式机制做的。
2.4 响应式数据到底用哪个,我按下面的规则选
| 场景 | 推荐方案 | 说明 | | --- | --- | --- | | 字符串、数字、布尔 |ref| 语义简单,模板自动解包 | | 表单对象(多层嵌套) |reactive| 深层属性访问方便,代码干净 | | 列表数据(从接口拉取) |ref|loading和list是独立状态,组合自然 | | 跨组件共享 |ref| 可以直接 export 共享 | | 查询参数对象 |reactive| 一个对象统一管理多个参数,方便重置 | | computed 依赖项 |ref| 计算属性返回的是 ref,写在一起逻辑顺 | | 大型 tree 类数据 |reactive| 深层递归操作频繁,reactive 访问更直接 |
这样约定之后,团队里的代码风格很统一,review 的时候也省了很多口舌。
3. 响应式原理拆解:track 与 trigger、Proxy、RefImpl
3.1 依赖收集与触发更新的完整机制
响应式的本质是“当数据被读取时记录谁在用,当数据被修改时通知所有使用者”。读取时做的动作叫 track(依赖收集),修改时做的动作叫 trigger(触发更新)。
Vue 3 的响应式系统把依赖关系存成三层结构:WeakMap<目标对象, Map<属性名, Set<副作用函数>>>。WeakMap 的 key 是原始目标对象,Map 的 key 是属性名,Set 里存的是所有依赖这个属性的 effect 函数。
这里用 WeakMap 有个很实际的考虑:WeakMap 的 key 是弱引用,当目标对象没有其他引用时,可以自动被垃圾回收,避免内存泄漏。如果换成一个普通 Map,这个对象就会一直存在依赖表里,内存只增不减。
3.2 reactive 是怎么处理数组和集合的
Proxy 拦截的是整个对象的操作。访问属性走 get 时,effect 里如果读取了obj.a,就会触发 track;修改obj.a时,set 拦截触发 trigger。
但 Proxy 对数组的下标访问和 length 变化默认不触发依赖更新,这是一个大家最容易踩的坑。特别是直接通过下标修改数组元素,比如arr[0] = newVal,页面不会刷新。这是因为 Vue 3 对数组的处理里,索引访问不是响应式的。实际开发时要用splice或push等方式替代:arr.splice(0, 1, newVal),这样才会触发更新。
Map、Set 这类集合类型在走has和add操作时,Vue 做出了深度拦截,所以使用map.set(key, value)这类方法调用时会正常响应,这一点比老版本 Vue 2 的$set要好用太多。
3.3 ref 的 RefImpl 到底做了什么
ref 的类型是Ref<T>,RefImpl内部维护一个私有_value,并提供了 getter 和 setter。读取.value时调用 track 收集依赖,设置.value时调用 trigger 触发更新。
特别留意:当传入 ref 的是对象时,RefImpl的构造函数里会把 value 传给reactive()做一层转换,所以ref({ count: 0 })内部仍然是 Proxy 在起作用。
// 伪代码逻辑,便于理解 class RefImpl { constructor(value) { this.__v_isRef = true this._value = isObject(value) ? reactive(value) : value } get value() { track(this, 'value') return this._value } set value(newVal) { this._value = isObject(newVal) ? reactive(newVal) : newVal trigger(this, 'value') } }这段逻辑解释了为什么 ref 既可以包基本类型,也可以包复杂对象,而且还保持响应性。面试时如果能把这个伪代码随口说出来,会显得你对源码确实有了解。
3.4 为什么 reactive 解构后就不响应了
解构reactive对象时,比如const { name, age } = form,拿到的是当前时刻的普通值快照,不再经过 Proxy 的 getter/setter,也就失去了依赖收集的通道。后续对name赋值时,它只是一个普通变量,自然无法触发视图更新。
解决办法是toRefs,它会把对象的每个属性都转成一个 ref,再组成一个新的普通对象:
const { name, age } = toRefs(form) name.value = '李四'每个属性变成了独立 ref,解构出来之后仍然调用的是 getter/setter,所以响应式还在。面试如果继续追问,还可以补充toRef和toRefs的区别:toRef只转换单个属性,toRefs转换整个对象的所有可枚举属性。
4. 实操复盘:从计数器到列表查询再到 watch 监听
4.1 一个 small 但完整的组合 API 示例
用 ref 和 reactive 结合 computed 做一个最简单的计数器,能说明这套响应式组合的日常用法:
<template> <div> <h1>{{ counter.count }}</h1> <p>双倍值是:{{ doubleCount }}</p> <button @click="add">+1</button> <button @click="reset">重置</button> </div> </template> <script setup> import { reactive, computed } from 'vue' const counter = reactive({ count: 0 }) const doubleCount = computed(() => counter.count * 2) function add() { counter.count++ } function reset() { counter.count = 0 } </script>这里用 computed 派生双倍值,依赖 counter.count。初次接触 Vue 3 的开发者可以动手跑一下,改一改数据,观察视图如何更新,这是理解响应式最直接的方法。
4.2 后台列表页的查询参数与分页
我平时在后台系统里写列表页,基本上是一个固定套路。查询参数用 reactive,列表和 loading 用 ref:
import { reactive, ref, onMounted } from 'vue' import { fetchList } from '@/api/list' const queryParams = reactive({ page: 1, size: 10, keyword: '', status: undefined }) const list = ref([]) const total = ref(0) const loading = ref(false) async function loadList() { loading.value = true try { const { data } = await fetchList({ ...queryParams }) list.value = data.records total.value = data.total } finally { loading.value = false } } function handleSearch() { queryParams.page = 1 loadList() } function handlePageChange(page) { queryParams.page = page loadList() } onMounted(loadList)注意调用接口时我用了{ ...queryParams }展开,而不是把 reactive 对象直接传给接口。原因很实用:直接传代理对象可能被第三方封装库内部修改原始对象;展开传参能避免副作用。这既是好习惯,也能在面试时体现你的工程敏感度。
4.3 watch 监听 ref 和 reactive 的差异
watch 监听一个 ref,直接传引用就行:
watch(keyword, (newVal, oldVal) => { console.log('keyword 变了', newVal, oldVal) })watch 一个 reactive 对象,需要注意:默认是浅监听,直接传入整个对象时,内部会默认开启 deep 吗?实际上,当 watch 的 source 直接传对象(响应式对象)时,Vue 会自动隐式 deep,当 source 是一个 getter 函数返回某个属性时,默认只监听这个属性的变化。
我的建议是不要依赖隐式行为,用 getter 明确指定要监听的字段:
watch(() => queryParams.keyword, (newVal, oldVal) => { console.log('keyword 变了', newVal, oldVal) })只有当你确实需要监听整个对象的所有嵌套变化时,才显式配置deep: true:
watch(() => ({ ...queryParams }), (newVal, oldVal) => { console.log('整个查询条件变了', newVal, oldVal) }, { deep: true })实际开发中,监听具体字段比监听整个对象更容易排查问题,也更利于逻辑拆分。
4.4 Pinia 中使用 ref 的陷阱
Pinia 的 setup store 风格里,内部状态大多定义成 ref,返回给组件使用时要注意解构方法:
import { defineStore } from 'pinia' import { ref } from 'vue' export const useListStore = defineStore('list', () => { const list = ref([]) function addItem(item) { list.value.push(item) } return { list, addItem } })在组件中,如果像普通对象一样做const { list } = store,会丢失响应式,必须使用storeToRefs:
import { storeToRefs } from 'pinia' const store = useListStore() const { list } = storeToRefs(store)这个点也是面试官比较喜欢考的组合题,把 ref 的特性和状态管理库的响应式打通起来考。
5. 常见问题速查与开发避坑指南
5.1 高频问题速查表
| 现象 | 原因 | 解决方案 | | --- | --- | --- | | 模板中显示[object Object]| ref 里存了对象,模板顶层自动解包后得到对象,直接 toString 了 | 模板中访问.value的具体属性,或把对象属性拆成独立 ref | | reactive 解构后修改不生效 | 解构出来的是普通值,不再走 Proxy 的 set 拦截 | 用toRefs(form)再解构 | | watch 整个 reactive 对象内部字段变化拿不到 | 默认没有监听深层属性 | 显式配置{ deep: true },或用 getter 指向具体属性 | | 数组下标改值页面不更新 | 数组索引访问不是响应式 | 用splice、push等变更方法 | | ref 包对象时模板直接输出对象 | 模板自动解包只针对顶层 ref | 模板里访问refObj.name| | 接口字段名就叫value| 解包逻辑对value字段存在歧义 | 后端和前端统一约束字段命名 |
5.2 数组和集合的进阶避坑
有人问,reactive 包数组为什么用push就能触发视图更新,而length = 0不行。这个需要理解数组原型方法在代理下触发的方式。Vue 对数组的原生方法做了拦截,在内部执行方法后触发一次更新;而直接修改length属性不属于这些拦截的范围,所以不会触发视图渲染。
实际业务里清理列表时,不要写list.length = 0,应该写:
list.splice(0, list.length)或者直接替换整个数组:
list.value = []后者更简单,也更容易被 Vue 的触发机制捕获。
5.3 template 中的自动解包边界
模板中 ref 的自动解包只在 ref 是顶层响应式对象时生效。看这个例子:
const obj = { list: ref([]) }如果在模板里写{{ obj.list }},Vue 3 会解包吗?不会,因为obj是一个普通对象,它的list属性虽然是一个 ref,但它并不处于 reactive 响应式对象内,也不会被模板自动解包为数组本身。这种情况需要手动编写obj.list.value才能拿到真正的数组。
这其实是模板解包的一个常见边界:只有顶层 ref 和 reactive 对象内部的 ref 会被自动解包,普通对象内部的 ref 不会被处理。理解这个边界能避免很多玄学渲染问题。
5.4 构建老项目时,为什么建议优先 ref
旧代码适配 Vue 3 时,直接把 Vue 2 的data里的this.msg = xxx改成ref的.value,改动模式最单一、最容易通过 lint 检查找全。如果把整个 data 替换成一个大reactive对象,还得把所有this.xxx改成this.state.xxx,工作量几何级增长。
ESLint 也给了我们很好的强制手段:可以用vue/ref-required和vue/ref-array这样的规则强制团队代码风格统一。我自己的习惯是:默认 ref 优先,特殊情况用 reactive,再配合规则约束,整个项目一致性会非常好。
6. 面试回答架构与实用体会
6.1 一个标准的“什么时候用 ref,什么时候用 reactive”回答模板
面试时不要只丢结论,最好分层次回答,先结论、再原理、后场景:
- 结论层:ref 适合处理独立值,既能基本类型也能对象;reactive 适合处理复杂嵌套对象
- 原理层:ref 的实现是 RefImpl,
.value的 getter/setter 里做依赖收集;reactive 是 Proxy 代理,拦截整个对象操作 - 坑位层:reactive 解构会失去响应式,要用 toRefs 解决;数组下标修改不更新,要用变更方法
- 实践层:模板里 ref 自动解包;放 reactive 里的 ref 也会被解包;跨组件共享状态用 ref 导出
按这套结构答,既显示出基础扎实,又显示出工程经验,在面试里比空背公式更有说服力。
6.2 影响范围与适用人群
这篇文章适合所有准备 Vue 3 面试的前端同学,也适合刚写完 Vue 2 项目正准备升级到 Vue 3 的开发者。如果你已经在项目里用了一段时间 Vue 3,建议重点看原理拆解和常见问题速查表,排查代码里的坑。
如果你还没完全消化,我的建议是:写一个小项目,分别用 ref 和 reactive 实现同一个功能,比如计数器、购物车、搜索列表。动手跑一遍,看看到底哪里自动更新、哪里不更新,比背十篇文章都管用。
6.3 后续可以继续深入的方向
如果这篇文章对你有帮助,后续可以继续聊 ref 相关的高级主题,比如customRef如何实现自定义防抖,shallowRef和triggerRef在性能优化时怎么用,以及 computed 的缓存机制和副作用处理。响应式这个体系学透之后,Vue 3 的其他部分学起来会快很多。
最后再分享一个小习惯:我每接触一个新 API,都会先看一遍源码里核心类的实现,再看官方文档,最后写一个 demo 验证。这套路径虽然慢,但学到的内容非常扎实,面试时心里很有底。