news 2026/9/15 8:47:17

Vue 3 响应式详解:ref 与 reactive 的底层原理、使用场景与避坑指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Vue 3 响应式详解:ref 与 reactive 的底层原理、使用场景与避坑指南

先分享一个我自己的体会: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|loadinglist是独立状态,组合自然 | | 跨组件共享 |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 对数组的处理里,索引访问不是响应式的。实际开发时要用splicepush等方式替代:arr.splice(0, 1, newVal),这样才会触发更新。

Map、Set 这类集合类型在走hasadd操作时,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,所以响应式还在。面试如果继续追问,还可以补充toReftoRefs的区别: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 指向具体属性 | | 数组下标改值页面不更新 | 数组索引访问不是响应式 | 用splicepush等变更方法 | | 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-requiredvue/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如何实现自定义防抖,shallowReftriggerRef在性能优化时怎么用,以及 computed 的缓存机制和副作用处理。响应式这个体系学透之后,Vue 3 的其他部分学起来会快很多。

最后再分享一个小习惯:我每接触一个新 API,都会先看一遍源码里核心类的实现,再看官方文档,最后写一个 demo 验证。这套路径虽然慢,但学到的内容非常扎实,面试时心里很有底。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/15 8:46:32

重装系统工具实测对比:9款免费U盘制作工具安全与兼容性分析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/15 8:45:35

VCSA安装深度指南:vCenter Server Appliance部署三阶段精解

1. 这不是“点下一步”的安装&#xff0c;而是给vCenter Server Appliance做一次精准手术你搜“VMware vCenter Server Appliance 安装”&#xff0c;页面上大概率会跳出一堆标题党&#xff1a;“5分钟搞定&#xff01;”、“手把手保姆级教程&#xff01;”、“小白也能装&…

作者头像 李华
网站建设 2026/9/15 8:45:28

Python爬虫处理JWT认证的实战指南

1. 为什么需要处理JWT认证的爬虫场景在当今的Web开发中&#xff0c;JSON Web Token(JWT)已经成为API认证的主流方案之一。根据2023年Postman发布的API状态报告&#xff0c;超过68%的认证API采用了JWT方案。这种趋势给爬虫开发者带来了新的挑战——传统的Cookie/Session认证方式…

作者头像 李华
网站建设 2026/9/15 8:44:35

网站建设ag图解步骤:3步搞定备案避坑指南

网站建设ag图解步骤:3步搞定备案避坑指南 做网站最头疼的不是代码写不出来,而是备案流程一头雾水。很多新手盯着“网站建设ag”这个词,以为是个什么高深的技术架构,其实它更多是指代网站建设的合规性与安全属性,尤其是ICP备案这块硬骨头。 别被复杂的政策文件吓跑。今天把 图解步骤…

作者头像 李华
网站建设 2026/9/15 8:43:31

7K星的开源选股系统真正省掉的,是你每天翻涨幅榜的那半小时

原创不易,转载请标明出处及原作者。 文中示例仅用于技术讨论,不构成任何操作建议。 量化策略开发应以学习和技术交流为目的。 本号不荐股、不卖课、不承诺收益。 市场有风险,请合法合规投资。 本文约 4000 字 | 预计阅读 7-10 分钟,前半是介绍和安装,后半有完整代码,建议…

作者头像 李华
网站建设 2026/9/15 8:42:45

昇腾CANN Runtime深度解析:从原理到实践的AI推理避坑指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华