先问大家一个问题你写Vue3的时候有没有遇到过reactive定义的数据明明改了页面却死活不更新或者ref套了一层对象之后改属性居然要重新赋值如果你点头了那挺正常的因为Vue3的响应式原理绝对不是背几个API就能搞定的。今天我从源码执行的角度、实际踩坑的角度把reactive、ref、响应式属性这套东西彻底拆开讲清楚让面试聊起来不虚写代码的时候也不慌。这篇文章适合谁刚上手Vue3的新手写了一段时间但遇到更新问题排查不清的进阶者或者正在准备Vue3面试的人。我尽量用大白话解释原理同时提供可以直接抄的代码和排查思路。1. 先说点题外话Vue3响应式到底解决了什么问题1.1 Vue2响应式的痛点在我项目里是怎么暴露的Vue2用Object.defineProperty劫持对象的属性读写这个方案有个天生的短板它只能拦截已经存在的属性。我在维护一个后台管理系统的时候表格数据是接口返回的某个字段一开始不在对象里后面接口给了值直接用this.someObj.newField value赋值页面死活不渲染。当时查了很久才明白Vue2压根监听不到新增属性必须用this.$set。这个问题不仅影响新增属性还直接影响数组。数组的下标赋值和长度更新Vue2默认是不响应的。当时我们封了个列表组件需求是前端控制显示几行直接arr[0] newItem页面毫无反应最后只能splice重写。只能说不愧是老坑王。还有一个很多人忽略的点Object.defineProperty劫持对象属性初始化的时候要递归遍历整个对象一次性做完所有属性的getter/setter改造。一个大对象、深嵌套的表格数据初始化性能真的肉眼看得出慢而且这个递归遍历耗时是没法避免的。1.2 Vue3的翻身仗从劫持属性变成拦截对象Vue3换成Proxy之后整个思路就变了。Proxy直接代理整个对象不再关心具体有哪些属性。访问和赋值都会被拦截新增属性天然就是响应式的数组也是。我把之前的后台项目升级到Vue3后最直观的感受就是新增字段终于不用$set了。这不是某个API的临时修补而是底层机制真正的结构性变化。官方这个选择的核心逻辑概括起来就一句话与其一个个去监听属性不如把整个对象包一层代理所有操作先过代理这一关再说要不要响应。这一改动带来的额外红利就是初始化性能。因为不需要递归遍历所有属性去改写getter/setter只在属性真正被访问时才建立响应式联系这种惰性设计让大对象表格的初始化明显轻快不少。底层拦截方式变了后面所有API的语义也因此不同想要用好Vue3必须先理解这个地基。2. Proxy与Reflect响应式的地基就是这两个配合2.1 Proxy拦截器的核心点get和set不能少你去看reactive的源码最核心的其实是对Proxy的封装。Proxy接收两个参数目标对象和处理器。处理器里最常用的是get和set两个拦截方法。const state new Proxy(original, { get(target, key, receiver) { // 在这里进行依赖收集 track(target, key) return Reflect.get(target, key, receiver) }, set(target, key, value, receiver) { const result Reflect.set(target, key, value, receiver) // 在这里触发更新 trigger(target, key) return result } })上面这段是原理示意图实际跑的时候track和trigger是Vue3内部维护依赖关系的关键函数。get拦截里做的依赖收集意思是当某个组件或计算函数读取了state.name就要记一笔账——这个组件依赖了 name 属性。等到set拦截触发就拿着 key 去找到所有依赖它的组件告诉它们你该更新了。有个细节值得标记一下set要返回布尔值因为Proxy在严格模式下如果赋值操作返回false会直接抛TypeError。这也是为什么要用Reflect.set而不是直接target[key] value。2.2 为什么一定要配合Reflect可能有人问既然能直接操作原对象为什么还要用Reflect关键在于receiver参数。当对象存在继承关系或者使用了getter/setter时this的指向可能会出问题。Reflect.get(target, key, receiver)会确保getter内部的this指向receiver也就是代理对象本身。如果不这样做某些边界情况下会出现getter里的this指向原始对象导致访问原始对象的其他属性时没有经过代理从而丢失响应式。我举个例子我的一个项目里用getter计算了总价const product reactive({ price: 100, count: 2, get total() { return this.price * this.count } })如果内部用this.price这个this是谁直接决定了后续读取价格时能不能被拦截到。Reflect在这里保证的就是让getter里的this指向代理对象这样this.price才能进入依赖收集的流程。这一层如果出了问题往往表现得很隐蔽——属性变了页面没变控制台还不报错。2.3 惰性响应式读取时才精确定位依赖Vue3还有一个容易被忽略的优秀设计就是惰性收集。Vue2初始化时就收集好了所有属性Vue3则是你访问哪个属性才收集哪个属性的依赖。这带来的好处很实际大对象初始化更快不用提前遍历全部属性依赖关系更精确组件只会因为自己真正用到的值更新而更新深层对象在首次被访问时再执行响应式包装而不是初始化时一口气全包完这样设计的代价是如果代码里没有在任何响应式上下文比如computed、watch、组件的渲染函数中读取过某个属性那这个属性就没有被收集后续修改也不会触发更新。听起来好像不合理但实际项目中我们很少会去修改一个从没读取过的属性所以这个权衡是值得的。3. reactive和ref两种响应式属性的正确打开方式3.1 reactive的用法局限我踩过的坑reactive是Vue3最直观的响应式APIimport { reactive } from vue const state reactive({ user: { name: 张三, tags: [前端, Vue3] } })它适合什么场景对象、数组、Map、Set这类引用类型。因为Proxy只能拦截引用类型对于基本类型字符串、数字、布尔值reactive直接无能为力。这里有一个我在项目中踩过的坑把一个用reactive定义的响应式对象直接赋值给另一个普通变量它会丢失响应式属性吗不会因为reactive返回的是代理对象赋值给const other state后other依然指向同一个代理响应式还是生效的。真正会丢响应式的是解构const { user } state user.name 李四 // 这里还是响应式的因为user本身是代理对象里的一个属性值但是如果你把state.user单独解构到基础变量或者解构出字符串const state reactive({ name: 张三, age: 18 }) const { name } state name 李四 // 这里的name只是普通字符串改了没任何响应式效果这个坑很经典。所以在实际项目里我不太建议直接解构reactive对象的基本类型属性更安全的是用toRefs或者统一通过state.xxx访问。还有一个容易遇到的问题直接整体替换reactive对象。如果我写let state reactive({ list: [] })后面想重置整个对象直接用state reactive({ list: [] })是不行的——因为const绑定不允许重新赋值而且旧引用失效。正确做法是Object.assign(state, { list: [] })这样代理对象本身没有被替换只是更新了它内部的属性。3.2 ref包装原理为什么 .value 很重要ref的出现其实是为了弥补reactive对基本类型无能为力的缺口。ref做的事情很简单把基本类型包装成一个带有value属性的对象然后让这个对象具备响应式能力。import { ref } from vue const count ref(0) count.value // 响应式更新实际实现上Vue内部对ref对象的处理是如果传入的是对象底层还是会走reactive去包装如果传入的是基本类型则用一个只有value属性的类对象来存放配合RefImpl类的 getter/setter实现依赖收集和触发。在模板里ref会自动解包不需要写.value。也就是说你在setup里写const count ref(0)返回给模板后直接写{{ count }}就能显示。这个自动解包机制只发生在模板上下文和响应式对象内部不会发生在普通JavaScript逻辑里所以要记住写逻辑时一定要.value。还有一个初学者容易犯的错const arr ref([1, 2, 3]) arr.value.push(4) // 可以触发更新 arr.value [1, 2, 3, 4] // 也可以触发更新因为ref包装了数组之后数组本身是reactive的所以push这种变更方法能触发更新。但不能直接arr.push(4)因为arr是RefImpl实例没有push方法。这个我见过不少人写错过。3.3 toRefs与toRef解构不失响应式的钥匙toRefs是解决解构reactive对象丢失响应式问题的标准方案import { reactive, toRefs } from vue const state reactive({ name: 张三, age: 18 }) const { name, age } toRefs(state)这样解构出来的name和age都是ref对象修改时用.value模板里也能自动解包。原理是toRefs返回一个对象每个属性都是一个ref而且这个ref指向的是原始reactive对象里的对应属性。所以当你改了解构出来的name.value其实改的还是原state.name响应式链路没断。toRef更精准适合只转换单个属性import { reactive, toRef } from vue const state reactive({ name: 张三 }) const nameRef toRef(state, name)另外补充一个使用场景在setup中返回reactive对象给模板用时我一般用toRefs拆出来。但拆的是基本类型属性时要注意是ref类型模板里自动解包没问题但在逻辑里操作要带.value别当成普通属性去改。3.4 ref与reactive互相转换的常见组合实际项目里最舒服的姿势其实是组合使用用reactive管大对象用ref管单个状态。比如我写一个用户列表页面const filters reactive({ keyword: , status: all }) const list ref([]) const loading ref(false) async function loadList() { loading.value true try { const res await fetchList({ ...filters }) list.value res.data } finally { loading.value false } } watch( () ({ ...filters }), loadList )这里filters直接用reactivelist和loading用ref。访问的时候filters.keyword、list.value、loading.value逻辑很清楚。模板里list和loading自动解包直接写v-ifloading和v-foritem in list。4. 依赖收集与触发更新的完整链路4.1 effect函数的角色说响应式原理绕不开effect。effect是Vue3响应式系统的核心执行器computed、watch甚至组件渲染函数的更新底层都是effect。简单理解当组件渲染时渲染函数会被包装成一个effect执行。执行过程中访问了响应式数据就触发了track把当前这个effect记录为对应属性的依赖。当数据变化时trigger找到依赖列表重新执行effect组件就更新了。平时我们几乎不会直接写effect更多是间接使用组件渲染、computed、watchEffect。但在面试或排查问题时理解这条链路非常重要组件渲染 effect 执行 → 读取 state.name → 进入 Proxy get 拦截 → track(state, name) → 记录 currentEffect 为 name 的依赖4.2 track函数到底在收集什么track在代码层面做的事情是维护一个全局的targetMap这个Map的结构可以理解为targetMap以目标对象为key存一个depsMapdepsMap以属性名为key存一个依赖集合依赖集合里面是这个属性对应的所有effect如果在一个effect执行期间同时读取了state.name和state.age那这两个属性各自的depsMap里都会加上这个effect。下次state.age被修改就能精确触发这个effect重新执行。为什么这个结构重要因为它决定了一个属性变化到底哪些东西会更新。如果是粗粒度更新改一个数据全组件刷新性能会很差。Vue3借助这个结构可以做到精细化更新组件里只用到了name那就是改name才触发这个组件的重新渲染改age不影响。4.3 trigger触发更新的顺序与性能细节trigger做的事情就是根据key找到依赖集合然后逐个通知effect重新执行。但执行不是无脑的全部执行Vue内部有调度器可以控制执行时机和去重。默认情况下effect执行是同步的但也有异步调度。比如在组件渲染的场景中Vue会把组件更新放进一个任务队列多个数据变更可能会合并成一次组件重新渲染。这个优化在频繁修改数据时特别明显比如一个方法里连续改了5个响应式属性如果没有批量处理组件可能被触发5次更新。提示如果需要详细了解调度这块可以去看源码里scheduler相关的逻辑。日常使用中我们会通过nextTick来感知这个异步更新的时机所以修改完数据后立即读DOM读到的是旧值。实际项目中的体现是const count ref(0) async function update() { count.value console.log(document.getElementById(count).textContent) // 旧值 await nextTick() console.log(document.getElementById(count).textContent) // 新值 }不少刚入手Vue3的同事在这里就会困惑我的数据变了但DOM还是旧的怎么办其实不是响应式失效而是更新还没冲刷到DOM。用nextTick等待一下就好。5. 项目实战常见坑与排查技巧实录5.1 动态增删form表单一行数据为什么删不掉热搜词里有一条vue3动态添加删除form表单一行数据这个问题非常典型。我用Element Plus的el-form举例const form reactive({ items: [{ name: , age: null }] }) function addRow() { form.items.push({ name: , age: null }) } function removeRow(index: number) { form.items.splice(index, 1) }这个写法在Vue3是原生的完全响应式没有任何问题。真正容易出问题的是那种绕开响应式系统的操作比如function removeRowWrong(index: number) { const newItems form.items.filter((item, i) i ! index) form.items newItems // 理论上也可以触发更新但注意这其实替换了数组引用 }这个写法原则上也能触发更新因为form.items的赋值本身是响应式的。但问题在于如果你在一个子组件里通过props拿到了这个items数组子组件内部操作时可能直接对props.items做 splice那就改了父组件的状态容易造成数据流混乱。推荐的做法是子组件发事件给父组件父组件统一维护。常见的错误还有在模板里对ref类型的数组直接调用方法const list ref([1, 2, 3])模板里写list.splice(0, 1)是不行的因为ref在模板中自动解包成数组了但模板里的表达式不一定能真正引用到list.value上的方法。这种问题排查时先检查是不是忘了.value这是第一高频错误。注意当你在模板里遇到明明数组变了视图没动先看是不是修改的是同一引用再看是不是用了ref没带.value最后看有没有用reactive解构后赋值给普通变量。5.2 tabs标签页切换时内容组件偶尔不更新vue3修改tabs标签页样式这条热搜背后其实很多人真正的问题是切换tab时数据不更新。我之前遇到过两个tab用了两个子组件切换的时候组件能正常显示但里面的数据还是上一次的状态。原因通常有两个一是组件被KeepAlive缓存了组件实例没有重新创建自然不会重新执行setup。如果你在onMounted里请求数据切换回来就不会再触发。解决办法是用onActivated这个钩子在每次缓存组件被激活时都会执行。二是数据传递问题。父组件传给子组件的props是响应式的但子组件在setup里把它解构或赋给了普通变量// 子组件 props: { userId: { type: Number, required: true } }, setup(props) { const uid props.userId // 解构响应式丢失 // 后续使用 uid不会变化 }正确做法是用toRef或者直接用props.userIdsetup(props) { const uid toRef(props, userId) // 在watchEffect或computed中使用 uid.value }这个坑特别隐蔽因为首次运行完全正常切换一次之后才暴露出问题。5.3 若依Vue3 TS报错reactive类型声明与ref类型推导若依vue3 ts报错也是热搜词说明这类项目在升级Vue3TS过程中会碰到大量类型问题。最常见的是reactive的类型声明和ref的泛型推导。// 错误写法 const state reactive({ list: [] }) // 此时 list 被推断为 never[]这样的list你往里面push对象的时候TS大概率直接报错。解决办法是给reactive传泛型或者单独声明接口interface Item { id: number name: string } const state reactive{ list: Item[] }({ list: [] }) // 或者 const list refItem[]([])ref也一样如果你希望ref可能是null或初始为空后面再赋值必须显式声明类型const data refItem | null(null)另一个高频报错是对象类型可能是null在用data.value.name时TS会提示无法确定属性存在。我的习惯是先用if (data.value)收窄或者用data.value?.name比非空断言安全得多。这个坑在做管理系统时天天遇到。5.4 组件props与emit中响应式属性的正确流转如果表单页面拆了子组件数据从父传子、子传父这套流转很容易出问题尤其是响应式属性在props和emit之间的变化。我的建议是子组件不直接修改props的属性所有修改通过emit通知父组件。比如子组件里有一个输入框// 子组件 const props defineProps{ modelValue: string }() const emit defineEmits{ update:modelValue: [value: string] }() function onInput(e: Event) { emit(update:modelValue, (e.target as HTMLInputElement).value) }这样父组件用v-model绑定就能正常工作Child v-modelform.name /这里form.name是reactive对象的属性v-model更新会直接触发父组件的form.name赋值响应式链路完整。如果子组件内部直接复制props.modelValue并修改副本就会出现输入没反应或输入了但父组件不更新。实操心得排查父子组件数据不同步先检查子组件有没有直接在内部改props再检查是不是用了基础类型变量存储props的值最后检查事件名有没有拼写错误。5.5 reactive对象被整体替换的几种场景最后补充一个我在公司项目里见过的真实情况。有人写了个大表单用户在编辑页切了草稿箱的不同记录想直接替换整个表单数据let form reactive({ name: , description: }) function loadRecord(data: any) { form reactive({ ...data }) // form被重新赋值但原有绑定失效 }这段代码的问题很清楚form被重新赋值后模板里引用的还是旧代理对象新对象没有被任何地方引用等于没更新。就算你把let改成const问题还在。正确做法是function loadRecord(data: any) { Object.keys(form).forEach((key) { form[key] data[key] }) }或者用Object.assign(form, data)。如果数据里有数组直接这样操作有时还是不够因为数组引用整体替换了旧的数组如果被别的组件引用可能也出现不一致。对于深度结构最简单的方式是const raw JSON.parse(JSON.stringify(data)) Object.assign(form, raw)严格来说reactive里的嵌套对象在赋值时会被自动包装Object.assign会逐属性触发set深层属性在访问时也会建立代理所以这条路是可行的。但如果嵌套特别深、结构特别复杂我建议干脆把整个状态拆成多个ref组合或者用一个key强制刷新组件来规避这类状态同步问题。6. 响应式相关的面试追问为什么说Vue3响应式属性更灵活6.1 面试常问Map和Set的响应式是怎么实现的reactive不只是代理普通对象和数组Vue3同样支持Map、Set。因为Proxy可以拦截get所以当你读取map.size或map.get(key)时会走拦截逻辑。但这里有个细节Map.prototype.get内部会访问this的内部槽位Vue内部对这类集合类型做了特殊处理把get、set、add、delete、clear等方法都包装了一遍。实际代码里一般用reactive(new Map())。我有个项目用Map存权限点倒是很顺const permissionMap reactive(new Mapstring, boolean()) function setPermission(key: string, value: boolean) { permissionMap.set(key, value) }这样增删改都是响应式的。面试时可以提一嘴虽然reactive对普通对象使用Proxy但针对集合类型会走额外的方法代理确保内部方法调用也能被拦截。这说明Vue3并不是简单地套一层Proxy就完事还要针对不同数据结构做适配。6.2 面试常问为什么ref对对象和基本类型表现不一样这个问题在面试中几乎必问。答案是ref底层对基本类型用RefImpl类的 getter/setter对对象则直接交给reactive处理。所以当你ref({ name: 张三 })时ref.value访问到的其实是一个reactive代理对象。这也是为什么ref对象解构出某个属性后虽然属性本身可能是响应式的但如果解构的是基本类型依然会丢失响应式。真正安全的解构必须用toRefs。弄懂这个之后面试里再聊ref和reactive该怎么选就可以说得比较透彻了单个基本值用ref大对象用reactive需要解构时用toRefs兜底。6.3 面试常问readonly和shallowReactive是怎么回事readonly很好理解把响应式对象变成只读的修改时会收到警告。源码上它就是在get拦截里正常收集依赖但在set拦截里直接返回false并给出警告。shallowReactive则是不做深层代理只代理第一层属性。比如const state shallowReactive({ n: 1, nested: { count: 0 } }) state.nested.count // 不会触发更新因为nested没有被深度代理 state.n 2 // 会触发更新什么时候用shallowReactive性能敏感、深层数据不需要响应式的场景。比如一些图表配置对象深层数据只在初始化时用一次不需要每次变化都触发组件更新。用shallowReactive能省掉深层代理的开销。shallowRef同理只对.value这一层做响应式value内部的变化不处理。适合大对象引用替换、内部数据不需要细粒度响应的场景。7. 我的实操经验一套完整的排查响应式问题的思路7.1 先判断是响应式失效还是渲染时机问题遇到页面不更新第一步不是翻源码而是先在控制台打印。我常用的办法const state reactive({ name: 张三 }) state.name 李四 console.log(state.name) // 能打出李四说明数据本身改了如果数据改了但视图没改排查方向是数据变更和视图更新之间的链路断了。重点怀疑点解构后赋值给普通变量用watch时 immediate/deep 没配组件被KeepAlive缓存走了缓存分支修改了ref没有.value如果数据改了控制台打印出来也是旧值那问题就出在赋值本身。这时要检查是不是重新绑定了reactive对象或者props被直观修改但父组件没有生效。7.2 使用vue-devtools辅助定位Vue3的DevTools面板可以直接看到组件树里的setup状态以及响应式数据的具体值。打开后点组件在Setup标签页找对应字段看值是否更新。如果值更新了说明响应式系统正常问题在渲染层如果值没更新说明响应式绑定已经断了。用这个方式定位reactive解构丢失响应式的问题基本一目了然。我排查过几个新同事提的bug都是这一步就定位到了。7.3 从状态设计上规避坑响应式问题的根源往往不只是技术细节还有状态设计。我总结了几条能大幅降低踩坑概率的经验尽量少用整体替换对象的方式更新状态多用Object.assign或逐属性赋值不要在多处保存同一份reactive对象的普通变量副本子组件不要直接改props所有变更走emit大型表单或者复杂嵌套数据优先用ref管理整个对象配合toRefs解构需要强制刷新子组件时给子组件绑一个动态key值切数据时换key让Vue重新创建组件实例最后一条在上一条编辑记录和新的编辑记录结构完全不同的场景里特别好用。我有个复杂配置页面数据模板切换时很多嵌套结构都不一样直接动态改属性很容易遗漏后来干脆用记录ID做key切换记录时整个子组件重新创建代码简洁而且bug骤降。8. 写在最后一个关于响应式的手感我对Vue3响应式最深的感受是它不再像Vue2那样需要你时刻提防哪些操作不能被监听但也不代表你可以完全不管底层机制。写Vue3项目真正重要的是养出一个数据流手感你知道哪个数据放在ref合适哪个数据放在reactive合适你知道解构会不会断链你知道数据变更后DOM更新是有异步时机的。把这些手感练成习惯很多所谓玄学bug其实根本不会出现。这篇文章里讲的原理和坑全部来自于我在真实后台管理系统、动态表单、复杂表格项目里踩过的坑。如果你正在写Vue3建议把reactive、ref、toRefs这三个API的源码实现读一遍配合这篇文章的理解基本就能在面试里聊得很扎实写代码也能少走弯路。最后分享一个我自己的习惯每次新建一个页面在动笔画UI之前先在脑子里把数据放哪、怎么流动、哪些数据会被解构、哪些数据会被整体替换这件事想清楚。多花这几分钟后面至少能省一半的开发时间。这个建议不是空话是我在多个项目里反复验证过的。
阅读完成 · 觉得有帮助?