十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

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

Vue 3 响应式详解:ref 与 reactive 的底层原理、使用场景与避坑指南 先分享一个我自己的体会Vue 3 都出来这么久了面试问响应式的时候很多候选人依然在背“ref 用于基本类型reactive 用于对象”这种结论但一问到底层为什么、什么场景会踩坑就直接卡壳。这篇结合我这几年在后台管理系统和 C 端项目里的实际经验把 ref 和 reactive 的底层逻辑、使用场景、高频面试追问一次讲透。标题虽然是“八股”但我的经验是真正能把这两个 API 讲明白的人写代码的质量和排查问题的速度跟只会背结论的人完全是两个档次。1. 整体设计与核心思路为什么 Vue 3 同时留了 ref 和 reactive1.1 从设计源头理解两个 APIVue 3 的 Composition API 里ref 和 reactive 是响应式数据的两个入口但它们解决的问题在源头就不一样。reactive 用 Proxy 代理一个对象让整个对象深层属性都变成响应式ref 则是把一个独立的值基本类型、对象、数组都能收包装成带.value的引用对象通过 getter/setter 做依赖收集和触发更新。从源码看ref 的内部实现是RefImpl这个类核心就是 value 属性的 getter 和 setter。getter 里会触发依赖收集tracksetter 里触发更新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、RefImpl3.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 的类型是RefTRefImpl内部维护一个私有_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 clickadd1/button button clickreset重置/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 用 refimport { 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: truewatch(() ({ ...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会丢失响应式必须使用storeToRefsimport { 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 验证。这套路径虽然慢但学到的内容非常扎实面试时心里很有底。
返回列表