ARTICLE DETAIL

资讯详情

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

Vue3响应式三兄弟:ref、shallowRef、markRaw的区别与实战

Vue3响应式三兄弟:ref、shallowRef、markRaw的区别与实战 在vue3项目中ref、shallowRef、markRaw这三个API我几乎每周都会在代码里遇到尤其是刚把项目从vue2迁移过来的时候很容易把ref当成万能药结果页面性能崩了又或者数据明明改了视图就是不动。后来花了一段时间把它们的底层逻辑和适用场景彻底捋清楚才发现这三个API虽然都跟响应式有关但各自管的是不同层级的事情ref管深层shallowRef管浅层markRaw则干脆告诉Vue“这个对象你别碰”。这篇就把三者的区别、原理和实战里的组合用法讲清楚帮大家少踩几个坑。如果你正在做vue3后台管理系统、数据大屏、表格或者图表类项目这几个API几乎绕不开它们直接决定了页面卡不卡、数据更新正不正常。不管你是刚入门vue3还是已经写了一段时间但总觉得自己“会用但说不清原理”这篇都值得花几分钟看完。内容会涉及一些源码层面的简化分析但我会用比较直白的方式解释保证看得懂、用得上。1. 三者各管哪一层从“响应式深度”说起要理解这三者的区别先要明白一个核心问题Vue3的响应式到底是在哪一层起作用的ref、shallowRef、markRaw其实就是在“响应式深度”这个维度上做了不同的选择。1.1 一张表看懂差异先把结论放在前面后面再展开讲。我用一个表格来对比大家在写代码之前可以对照着选API追踪粒度基本类型对象类型典型使用场景ref深层响应式赋值替换触发递归代理所有嵌套属性表单、全局状态、常规复杂数据shallowRef浅层响应式赋值替换触发只追踪.value整体替换不代理内部属性大列表、高频更新的数据流、图表配置markRaw不响应不适用作用于对象给对象打上“永不代理”标记第三方实例、不可变常量、性能敏感对象需要注意的是markRaw和其他两个API不是同一种东西。ref和shallowRef是创建响应式数据的而markRaw是“阻止响应式转换”的。你可以把markRaw理解为给对象贴了一张“免疫贴纸”当这个对象被ref或reactive处理时Vue看到贴纸就会直接放行不碰它。1.2 最小示例一眼看出行为差异光看表格还是有点抽象直接上一个最小代码示例。拿一个嵌套对象分别用ref、shallowRef、ref(markRaw(...))包一下看看修改内部属性和整体替换时的表现import { ref, shallowRef, markRaw } from vue const deepRef ref({ user: { name: 张三, age: 18 } }) const shallow shallowRef({ user: { name: 李四, age: 20 } }) const rawData markRaw({ user: { name: 王五, age: 22 } }) const rawInRef ref(rawData) // 修改内部属性 deepRef.value.user.age 19 // 视图会更新 shallow.value.user.age 21 // 视图不会更新 rawInRef.value.user.age 23 // 视图不会更新 // 整体替换 shallow.value { user: { name: 新的李四, age: 24 } } // 视图会更新 rawInRef.value { user: { name: 新的王五, age: 25 } } // 视图会更新这里最容易被坑的是为什么ref(markRaw({...}))修改内部属性不触发但整体替换又触发了因为markRaw标记的是“那个对象本身”而不是ref实例。ref的value被整体替换成新对象时新对象没有被标记所以又恢复了响应式。换句话说markRaw管的是对象个体ref管的是value引用的赋值。2. ref默认的“深水炸弹”为什么这么重ref是vue3里最常用的响应式API因为它既能包装基本类型又能包装对象。很多人把它当成了“全局万能状态容器”但没意识到它默认做的是深层响应式这在数据量大的时候会成为性能瓶颈。2.1 底层机制Proxy代理加递归依赖收集Vue3的响应式核心是Proxy和Vue2的Object.defineProperty不同Proxy可以直接拦截对象的属性访问、赋值、删除等操作。当ref接收一个对象时内部会调用reactive把这个对象变成代理对象。而reactive在处理一个对象的get时如果返回的值还是一个对象会继续对这个子对象调用reactive一层套一层直到所有嵌套属性都被代理。这就是“深层响应式”的本质。你可以把它想象成一套覆盖整个对象的监控系统不仅大门装了摄像头每个房间、每个抽屉甚至每本书的每一页都有感应器。只要有任何一处变化系统都能感知到。这种机制的好处是省心不管数据嵌套多深直接改就行。坏处是“重”尤其是对象很大、访问很频繁的时候每一次属性读取都可能触发依赖收集每一次属性修改都会触发派发更新逻辑。2.2 ref的value转换流程toReactive到底做了什么ref源码简化后大概是这样的function toReactive(value) { return isObject(value) ? reactive(value) : value } class RefImpl { constructor(value, isShallow false) { this._value isShallow ? value : toReactive(value) } get value() { track(this, value) return this._value } set value(newVal) { newVal this.__v_isShallow ? newVal : toReactive(newVal) if (hasChanged(newVal, this._value)) { this._value newVal trigger(this, value) } } }关键点就是toReactive。当传入的值是对象时它一定会被reactive处理变成代理对象。所以ref的value拿到手之后其实已经不是原始对象了。这一点和shallowRef完全不同后者根本不调用toReactive。另外注意ref对基本类型的处理基本类型没有“内部属性”可言所以toReactive会直接返回原始值依赖追踪发生在.value的get/set上。这也是为什么ref既能包对象又能包基本类型。2.3 性能消耗的根源在哪里深层响应式的消耗不是凭空出现的。假设你有一个2000行的表格数据每行有10个字段用ref存起来。在渲染过程中模板会访问row.name、row.age这些属性每一次访问都会触发代理的get拦截然后做依赖收集。如果数据每秒更新几十次这些拦截操作就会被无限放大页面很容易卡顿。更麻烦的是如果某次更新只改了其中一行的一个字段Vue依然要递归处理所有嵌套结构的相关逻辑。虽然Vue3的Proxy已经比Vue2的defineProperty高效很多但“全量代理”在极端场景下依然是多余的。这时候就需要shallowRef出场。3. shallowRef只认“整换”不认“拆改”shallowRef的名字已经点明了它的特点“浅”。它只关心.value这个整体引用是否被替换至于.value内部怎么改它一概不管。3.1 从源码看shallowRef的精简实现shallowRef的源码比ref简单一截class ShallowRefImpl { constructor(value) { this._value value this.__v_isShallow true } get value() { track(this, value) return this._value // 注意没有 toReactive } set value(newVal) { if (hasChanged(newVal, this._value)) { this._value newVal trigger(this, value) } } }注意构造函数和set value里都没有调用toReactive。这意味着shallowRef保存的对象始终是原始对象不会被Proxy包一层。所以当你修改shallow.value.xxx时没有任何依赖追踪也就不会触发视图更新。只有当你执行shallow.value somethingElse时set value会检测新旧值是否不同然后触发依赖更新。3.2 哪些场景适合用shallowRef我自己的项目里shallowRef主要用在三个地方大列表整体更新比如实时行情表格、日志列表、消息流数据源是一大坨数组每次更新都是“全部替换”不需要关心数组里某个元素的某个属性单独变化。用ref会导致每个元素都变成响应式代理纯属浪费。图表配置对象ECharts的option通常是一个几百行的大对象初始化之后几乎不会再动内部字段每次更新都是整体换一份配置。这种对象用shallowRef存刚刚好。异步数据流WebSocket或轮询接口推送过来的批量数据每次到达都是一个新的完整数据快照。用shallowRef保存赋值一次渲染一次干净利落。这里要注意一个隐藏点shallowRef保存的数组如果直接push新元素是不会触发更新的因为push操作的是.value内部的方法不是.value整体替换。必须这样写const list shallowRef([]) // 错误写法不会触发视图更新 list.value.push(item) // 正确写法整体替换 list.value [...list.value, item]这个坑我见很多同事踩过一上来就把ref换成shallowRef然后发现数据不更新了其实是更新方式没跟着变。3.3 triggerRef手动触发更新的补充手段如果你的数据模型确实需要在shallowRef内部修改属性但又想触发视图更新可以用triggerRef。比如import { shallowRef, triggerRef } from vue const state shallowRef({ count: 0 }) state.value.count triggerRef(state) // 手动强制触发视图才会更新triggerRef的作用是手动触发shallowRef自己的依赖但它不会递归触发嵌套的响应式对象。如果代码里频繁出现“修改内部属性之后手动triggerRef”那很可能意味着这个数据本来就应该用ref而不是shallowRef。triggerRef是补救手段不是常规操作。4. markRaw让对象永远保持“野性”如果说shallowRef是“浅跟踪”那markRaw就是“不跟踪”。它是一个专门用来阻止响应式转换的工具。4.1 markRaw背后到底做了什么markRaw的实现非常轻量核心代码只有一行function markRaw(value) { def(value, __v_skip, true) return value }它是在对象上通过Object.defineProperty打了一个__v_skip: true的标记。当Vue的reactive或者ref准备处理某个对象时会先检查这个标记发现存在就直接返回原始对象不做任何代理。你可以把markRaw想象成给快递包裹贴了一张“本人拒收”的标签。快递员看一眼标签就知道这个包裹不需要派送不代理直接放行。这个标签是永久性的除非手动删除只要对象带着它无论被放到ref、reactive还是其他响应式结构中都不会再变成响应式对象。4.2 常见误用markRaw之后为什么数据不更新很多人知道markRaw可以优化性能但容易忽略它的副作用。比如下面这个例子const rawObj markRaw({ count: 0 }) const state ref(rawObj) state.value.count // 不会触发视图更新这是因为ref发现rawObj带有__v_skip标记直接把它当作普通值存进去了。之后修改state.value.count因为没有代理对象拦截自然不会有依赖更新。更隐蔽的是如果把markRaw过的对象塞进reactive数组里之后修改这个数组元素也不会触发const rawObj markRaw({ count: 0 }) const list reactive([rawObj]) list[0].count // 不会触发视图更新这是因为list[0]本身还是那个原始对象没有被代理。很多人看到这种代码会一脸懵以为reactive失效了其实问题出在markRaw身上。所以使用markRaw之前一定要想清楚这个对象是不是真的永远不需要响应式如果是第三方实例、常量字典、静态配置那没问题如果是业务数据就要三思了。4.3 应用组合shallowRef markRaw的正确姿势shallowRef和markRaw组合起来是vue3性能优化里一个很经典的套路。shallowRef保证了“整体替换”会触发更新markRaw保证了列表里的每个子对象永远不会被代理。举个例子一个包含大量条目的列表每条数据只做展示不需要单独响应式const list shallowRef([ markRaw({ id: 1, name: A, price: 10 }), markRaw({ id: 2, name: B, price: 20 }) ]) // 整体替换触发视图更新 list.value [ ...list.value, markRaw({ id: 3, name: C, price: 30 }) ] // 直接修改某一条的内部属性不会触发更新 list.value[0].name A1这样做的收益很明显列表项本身不需要响应式也不会被代理省掉了大量Proxy开销列表整体更新时因为shallowRef追踪的是.value引用所以新数组赋值一定能触发渲染。如果你想让某一项也能响应式那就不要对它调用markRaw让它保持普通对象。这样同一数组里可以混用“响应式项”和“非响应式项”。5. 实战避坑三者在同一项目中的真实协作案例理论讲完了最后分享几个我实际开发中遇到的案例。这些场景能帮你把ref、shallowRef、markRaw放到具体上下文里理解会更直观。5.1 场景一高频更新的大型表格数据我之前做一个实时监控页面表格里差不多2000行数据每秒从后端推20条更新。一开始图省事直接用ref存表格数据页面卡到怀疑人生。后来把这块状态改成shallowRef每次推送数据时不是去改某一行而是构造一个新数组整体赋值const tableData shallowRef([]) function onPush(rows) { const newMap new Map(tableData.value.map(item [item.id, item])) rows.forEach(row newMap.set(row.id, row)) tableData.value Array.from(newMap.values()) }改完之后页面流畅度提升非常明显。原因很简单ref会把每一行数据都变成代理对象每秒几千次属性访问全被拦截而shallowRef只关心整体替换所有行都是原始对象更新一次渲染一次成本低很多。记住一个原则如果你的业务场景是“整体数据替换”优先用shallowRef而不是扛着ref的深层响应式硬撑。5.2 场景二第三方库实例劫持问题之前集成ECharts时我把图表实例直接放进了reactive对象里const state reactive({ chart: echarts.init(document.getElementById(chart)) })结果图表实例内部的一些方法在调用时出现了异常后来定位发现是因为实例本身比较特殊被Proxy代理后会破坏内部状态。解决办法是用markRaw把这个实例标记为不可代理import * as echarts from echarts const chart markRaw(echarts.init(document.getElementById(chart))) const state reactive({ chart })这样state.chart拿到的还是原始实例不会触发响应式代理。第三方SDK实例、Canvas上下文、WebGL对象这类“自己管自己状态”的东西都非常适合用markRaw“隔离”起来。5.3 场景三面试中常见的追问与易错点我梳理了几个和这三个API相关的高频面试问题可以拿来检验自己是否真的理解了ref和shallowRef在整体替换.value时都会触发更新吗是的只要新旧值不同两者都会触发依赖更新。它们的区别只在于内部属性变化时是否触发。markRaw之后还能让对象恢复响应式吗可以直接删除对象上的__v_skip属性但一般不建议这么做因为很容易引发不可控的后果。更合理的做法是在markRaw之前想清楚。shallowRef已经不做深层代理了为什么还要markRawshallowRef不代理它持有的对象但这个对象如果后来被赋给其他ref或放进reactive还是可能被代理。markRaw能给对象加一道“永久保险”确保它无论出现在哪都不会被响应式系统接管。customRef和shallowRef有什么关系customRef允许你完全自定义get和set逻辑shallowRef可以看作是一个没有自定义逻辑的customRef实现。如果你需要更细的依赖控制可以去看customRef。还有一个很容易被忽视的细节markRaw记得要传给一个“对象”如果传基本类型def方法可能会报错或没有意义因为基本类型本身就没有可代理的内部结构。这也是一个考察细节的考点。从我自己的使用经验看这三个API在一起协作的场景比单独出现多得多。ref负责常规状态shallowRef负责性能敏感的大数据markRaw负责保护不该被碰的对象。搞清楚它们各自管哪一层你在vue3里写代码的信心会完全不一样。
返回列表