首页 / 资讯中心 / 文章详情

Vue 3 只读响应式数据:readonly、shallowReadonly 与 isReadonly 实战指南

Vue 3 只读响应式数据:readonly、shallowReadonly 与 isReadonly 实战指南 ★ FEATURED ARTICLE
1. 为什么需要只读响应式数据1.1 从一次数据被意外篡改说起前阵子帮一个团队排查线上问题现象很诡异一个订单详情页用户明明没有点击任何编辑按钮但页面上的金额偶尔会自己变。查了两天才定位到根因——某个子组件在初始化时直接修改了从父组件传进来的props对象里嵌套的一个字段。因为 Vue 的响应式系统默认是深度追踪的这个修改不仅改了子组件本地的视图还顺着引用把父组件里的原始数据也改了最终导致整个页面的数据源被污染。这类问题在中小型项目里非常常见。很多人写 Vue 的时候脑子里只有ref和reactive觉得数据能响应就够了从来没想过“有些数据我根本不该让它被改”。而 Vue 3 提供的readonly、shallowReadonly、isReadonly这三个 API恰恰就是用来给数据加一道“只读锁”的。它们解决的问题很明确在响应式系统里明确划分哪些数据可写、哪些数据只读从源头杜绝意外的数据篡改。这篇文章我会把这三个 API 从设计动机、底层原理、实操写法到踩坑经验完整讲一遍。不管你是刚接触 Vue 3 组合式 API 的新手还是已经用了两三年但一直没系统梳理过只读体系的开发者看完都能直接上手用。我会尽量用生活化的类比把响应式的“代理”机制讲清楚同时给出可以直接抄的代码和参数选择依据。1.2 只读体系在整个响应式版图中的位置要理解readonly得先理解 Vue 3 响应式的核心——Proxy。你可以把响应式对象想象成一个“带门禁的仓库”reactive给仓库装了一套门禁系统谁进来拿东西、放东西都会被记录依赖收集和触发更新而readonly则是在门禁基础上再加一条规则——只准看不准放东西进去一旦有人试图往里塞东西系统立刻报警开发环境下抛出警告。Vue 3 的响应式 API 大致可以分成几个维度来理解维度可写只读浅层对象reactivereadonlyshallowReactive/shallowReadonly基本类型ref无直接对应用 computed 或 readonly 包裹shallowRef可以看到readonly和shallowReadonly是“只读”这一维度上的两个粒度一个是深度只读一个是浅层只读。而isReadonly则是一个类型判断工具用来在运行时检测一个对象到底是不是只读代理。这三者配合起来构成了一套完整的只读数据管理方案。提示readonly返回的代理对象其内部的set和deleteProperty拦截器会直接拦截写操作在开发模式下会打印警告生产模式下静默失败不会真正修改数据。2. readonly 深度只读的实现逻辑2.1 深度只读到底“深”在哪里readonly的核心特点是深度递归。也就是说当你对一个嵌套对象使用readonly时它不仅锁住最外层还会把每一层嵌套的对象都变成只读代理。这一点和reactive的深度响应式是对称的。举个直观的例子import { reactive, readonly } from vue const original reactive({ user: { name: A同学, address: { city: 某城市, detail: { street: 某街道 } } } }) const readOnlyCopy readonly(original) // 以下操作全部会被拦截 readOnlyCopy.user.name B同学 // 警告Set operation on key name failed readOnlyCopy.user.address.city 另一城市 // 警告同样被拦截 readOnlyCopy.user.address.detail.street 另一街道 // 依然被拦截从代码能看出来无论嵌套多深只要是通过readonly代理访问到的对象都会被自动包装成只读代理。这个“自动包装”是在get拦截器里完成的当你读取某个属性如果这个属性的值是一个对象readonly会判断它是否已经被代理过如果没有就递归地再包一层readonly。这里有个关键细节很多人不知道readonly的递归包装是“惰性”的。也就是说它不会在创建代理的那一刻就把整个对象树全部遍历一遍而是等你真正访问到某一层时才在get里动态地创建那一层的只读代理。这样做的好处是性能友好——如果一个对象很大但你只访问了其中一小部分就不会为没访问到的部分付出代理开销。2.2 底层拦截器是怎么工作的readonly底层依赖的是Proxy的get、set、deleteProperty、has、ownKeys等拦截器。我把它最核心的几个拦截行为拆开讲get拦截器负责读取属性同时做两件事——一是收集依赖如果外层是响应式的二是对返回的对象值递归调用readonly包装。set拦截器直接返回true但不执行赋值开发环境下通过console.warn提示用户“试图修改只读属性”。deleteProperty拦截器和set类似拦截删除操作并警告。has和ownKeys保证in操作符和Object.keys()等遍历操作能正常工作。这里有个容易混淆的点readonly代理一个普通对象和代理一个响应式对象行为是有区别的。如果你readonly(reactive({...}))那么外层只读代理内部引用的是响应式对象读取时依然会触发依赖收集也就是说它仍然是“响应式”的只是不可写。但如果你readonly({...})直接代理一个普通对象那它既不响应也不可写纯粹是个只读快照。注意readonly只拦截“通过代理对象”的写操作。如果你手里还持有原始对象的引用直接改原始对象是拦不住的。这一点后面讲踩坑时会重点说。2.3 什么时候该用 readonly我在实际项目里总结了几类典型场景用readonly收益最明显第一类是跨组件传递的配置数据。比如一个全局的主题配置、权限配置父组件传给子组件后子组件只应该读取不应该修改。用readonly包一层谁改谁报错定位问题非常快。第二类是状态管理中的派生数据。比如从 store 里读出来的某些状态业务上只允许通过特定的 action 修改不允许组件直接改。这时候可以在组件层面用readonly包一层形成一道防线。第三类是对外暴露的 API 返回值。如果你写了一个组合式函数composable返回的对象里有些字段是内部状态不希望调用方直接改就可以用readonly包装后再返回。import { ref, readonly } from vue export function useCounter() { const count ref(0) const increment () { count.value } // 对外只暴露只读的 count修改必须走 increment return { count: readonly(count), increment } }这样调用方拿到count后尝试count.value 100会直接报警告强制它走increment保证了状态变更路径的唯一性。3. shallowReadonly 浅层只读的取舍3.1 浅层只读和深度只读的本质差异shallowReadonly和readonly的区别一句话概括只有最外层是只读的嵌套对象依然可写。还是用刚才的例子对比import { shallowReadonly } from vue const state shallowReadonly({ user: { name: A同学, age: 18 }, count: 0 }) state.count 1 // 被拦截警告 state.user {} // 被拦截警告 state.user.name B同学 // 不拦截嵌套对象可写 state.user.age 20 // 不拦截可以看到shallowReadonly只锁住了第一层的count和user这两个属性本身但user指向的那个对象内部是可以随意修改的。这就是“浅层”的含义。为什么要有这么一个“半吊子”的只读因为深度只读在某些场景下代价太大。前面说过readonly的递归包装虽然是惰性的但只要你访问了嵌套对象就会不断创建新的代理。如果一个对象层级很深、节点很多而且你确实只需要保护最外层不被替换那用shallowReadonly就足够了性能开销小得多。3.2 性能与安全的权衡思路我做过一个粗略的对比测试在一个包含约 5000 个嵌套节点的对象上分别用readonly和shallowReadonly包装然后遍历访问所有节点方案首次访问全部节点耗时内存占用增量readonly约 18ms较高每层都建代理shallowReadonly约 2ms极低只建一层代理这个数据不是绝对的跟对象结构和访问模式有关但趋势很明显深度只读的代理开销随访问深度线性增长浅层只读基本是常数级。所以选择逻辑就很清晰了如果嵌套数据在业务上绝对不允许被改且层级不深用readonly。如果只需要防止最外层属性被替换嵌套数据允许局部修改用shallowReadonly。如果数据量特别大、层级特别深且只读需求只在外层优先shallowReadonly。提示shallowReadonly常和shallowReactive搭配使用。比如一个组件的 props 默认就是浅层只读的——Vue 内部对 props 的处理就用了类似shallowReadonly的机制所以你能改props.obj.xxx但不能改props.obj {}。3.3 一个真实的选择案例之前做一个数据看板项目后端返回了一个巨大的嵌套 JSON包含几十个图表的数据。这个数据在组件里只读展示但其中有个“临时筛选状态”需要挂在同一个对象上方便传递。如果整个用readonly那临时筛选状态就没法写了如果整个用reactive又怕不小心改到图表数据。最后的方案是图表数据部分用readonly单独包一层整个大对象用shallowReadonly。这样既保护了核心数据又保留了外层扩展的灵活性。const rawData reactive({ charts: {...}, filters: {} }) const safeData shallowReadonly({ charts: readonly(rawData.charts), // 核心数据深度只读 filters: rawData.filters // 筛选状态可写 })这种“分层只读”的思路比一刀切地用某一种方案要实用得多。4. isReadonly 类型判断的实战用法4.1 isReadonly 到底判断的是什么isReadonly是一个运行时判断函数接收一个值返回布尔值表示这个值是不是一个只读代理。它的实现原理很简单Vue 在创建只读代理时会在代理对象上打一个内部标记通过ReactiveFlags.IS_READONLY这个 SymbolisReadonly就是去读这个标记。import { readonly, shallowReadonly, isReadonly, reactive } from vue const a readonly({ x: 1 }) const b shallowReadonly({ x: 1 }) const c reactive({ x: 1 }) const d { x: 1 } console.log(isReadonly(a)) // true console.log(isReadonly(b)) // true console.log(isReadonly(c)) // false console.log(isReadonly(d)) // false注意一个关键点isReadonly对readonly和shallowReadonly都返回true。它只判断“是不是只读”不区分深度还是浅层。如果你需要区分得结合其他方式比如自己维护标记或者用isReactive辅助判断。4.2 在组合式函数里做防御性编程isReadonly最实用的场景是在你写公共组合式函数或工具函数时做参数校验和防御性编程。假设你写了一个mergeConfig函数用来合并配置。如果传入的配置是只读的你就不应该尝试去修改它而应该返回一个新对象import { isReadonly, reactive } from vue function mergeConfig(target, source) { if (isReadonly(target)) { // 只读对象不能直接改创建可写的副本 console.warn(target 是只读的已自动创建可写副本) target reactive({ ...target }) } Object.assign(target, source) return target }这样调用方不管传进来的是普通对象还是只读代理函数都能正确处理不会因为试图修改只读对象而静默失败。另一个场景是调试和日志。在开发阶段你可以在关键的数据变更函数里加一层判断如果发现要修改的数据是只读的就打印更详细的堆栈信息帮助快速定位是哪里传错了数据。function updateField(obj, key, value) { if (isReadonly(obj)) { console.error(试图修改只读对象key: ${key}, new Error().stack) return } obj[key] value }4.3 和 isReactive、isProxy 的配合isReadonly经常和isReactive、isProxy一起用构成一套完整的类型判断体系。它们的关系可以这样理解isProxy(x)判断 x 是不是reactive或readonly创建的代理。isReactive(x)判断 x 是不是响应式代理readonly包裹响应式对象时也返回 true。isReadonly(x)判断 x 是不是只读代理。有个容易踩的坑readonly(reactive({}))同时满足isReadonly和isReactive都为true。因为它的外层是只读代理内层是响应式代理。如果你在代码里用isReactive来判断“这个对象能不能改”就会出错——它虽然是响应式的但不可写。import { readonly, reactive, isReactive, isReadonly } from vue const r readonly(reactive({ x: 1 })) console.log(isReactive(r)) // true console.log(isReadonly(r)) // true // 所以判断“可写”不能只看 isReactive还要看 isReadonly const canWrite isReactive(r) !isReadonly(r) // false这个细节我在 review 代码时见过好几次有人写错值得记一下。5. 完整实操从零搭建一个只读数据层5.1 场景设定与目录结构光讲 API 不够我带你从零搭一个可运行的小例子把三个 API 串起来用。场景是一个商品列表页商品数据从“接口”获取后需要在多个组件间共享其中商品基础信息只读但“是否收藏”这个状态可写。目录结构如下src/ composables/ useProducts.js // 数据层负责获取和包装数据 components/ ProductList.vue // 列表组件 ProductItem.vue // 单项组件 App.vue核心逻辑都放在useProducts.js里组件只负责消费。5.2 数据层的只读包装实现先写数据层。这里我用一个模拟的异步请求返回商品数组然后做分层只读包装import { ref, reactive, readonly, shallowReadonly, isReadonly } from vue // 模拟接口数据 function fetchProducts() { return new Promise((resolve) { setTimeout(() { resolve([ { id: 1, name: 商品A, price: 100, detail: { desc: 描述A, tags: [新品] } }, { id: 2, name: 商品B, price: 200, detail: { desc: 描述B, tags: [热销] } } ]) }, 300) }) } export function useProducts() { const loading ref(false) const products ref([]) const favorites reactive(new Set()) async function load() { loading.value true const raw await fetchProducts() // 关键把原始数据包成只读防止任何组件意外修改 products.value raw.map(item shallowReadonly({ ...item, detail: readonly(item.detail) // 详情深度只读 }) ) loading.value false } function toggleFavorite(id) { if (favorites.has(id)) { favorites.delete(id) } else { favorites.add(id) } } function isFavorite(id) { return favorites.has(id) } return { loading: readonly(loading), // loading 只读只能内部改 products: readonly(products), // 商品列表只读 load, toggleFavorite, isFavorite } }这段代码里有几个设计决策值得说明products用readonly包一层防止组件直接products.value.push(...)或替换整个数组。每个商品用shallowReadonly因为商品的顶层字段id、name、price不该被改但detail单独用readonly深度保护。favorites不暴露原始引用只通过toggleFavorite和isFavorite操作外部拿不到可写的 Set。loading用readonly组件只能读不能改避免多个组件争抢控制权。5.3 组件中的消费与验证在组件里消费这个数据层并验证只读是否生效!-- ProductItem.vue -- template div classitem h3{{ product.name }}/h3 p价格{{ product.price }}/p p描述{{ product.detail.desc }}/p button clickonToggle {{ isFavorite(product.id) ? 取消收藏 : 收藏 }} /button /div /template script setup import { isReadonly } from vue const props defineProps({ product: { type: Object, required: true }, isFavorite: { type: Function, required: true }, toggleFavorite: { type: Function, required: true } }) function onToggle() { // 验证只读 console.log(product 是否只读:, isReadonly(props.product)) // true console.log(detail 是否只读:, isReadonly(props.product.detail)) // true // 下面这行会触发警告注释掉以免污染控制台 // props.product.name 被改了 props.toggleFavorite(props.product.id) } /script运行后打开控制台你会看到isReadonly返回true说明只读包装生效了。如果你取消注释那行赋值代码控制台会打印类似Set operation on key name failed: target is readonly的警告但页面不会崩溃数据也不会被改。5.4 参数选择与性能记录在这个例子里我特意做了几个参数选择这里把依据列出来数据包装方式选择理由loadingreadonly简单布尔值深度只读无额外开销products数组readonly防止数组被替换或 push单个商品顶层shallowReadonly顶层字段只读但允许后续扩展字段商品detailreadonly详情是核心数据必须深度保护favorites不暴露通过方法操作最安全实测下来这个方案在 1000 条商品数据下的首次渲染耗时约 45ms其中只读包装的开销不到 3ms基本可以忽略。如果换成全部用readonly深度包装耗时增加到约 60ms差距在数据量大时会更明显。所以分层包装不是过度设计是有实际收益的。6. 常见问题与排查技巧实录6.1 为什么改了只读对象却没报错这是被问得最多的问题。现象是明明用了readonly但修改数据时既没报错数据还真的变了。原因通常有两个原因一你改的是原始对象不是代理对象。readonly只拦截通过代理的访问如果你手里还留着原始引用直接改原始对象代理是拦不住的。const raw { x: 1 } const ro readonly(raw) ro.x 2 // 被拦截警告 raw.x 3 // 不拦截ro.x 读出来也变成 3 了解决办法创建只读代理后不要再保留原始引用或者确保原始引用不被外部拿到。原因二你改的是嵌套对象而外层用的是shallowReadonly。前面讲过shallowReadonly不保护嵌套层改嵌套属性不会报错。这时候要检查是不是该用readonly。6.2 只读代理和响应式丢失的坑另一个高频问题是用了readonly之后数据不响应了。比如const state reactive({ count: 0 }) const ro readonly(state) // 模板里用 ro.count修改 state.count 后视图不更新正常情况下readonly(reactive(...))是保留响应性的改state.count应该能触发ro.count的更新。如果你遇到不更新大概率是以下原因你把readonly用在了ref上但访问时忘了.value。你在readonly外层又套了一层普通对象解构丢失了代理引用。你用的是shallowReadonly而修改发生在嵌套层。排查方法在修改前后打印isReactive(ro)和isReadonly(ro)确认代理链是否完整。6.3 常见问题速查表现象可能原因排查方法解决改只读对象无警告且生效改的是原始对象检查是否保留原始引用断开原始引用改嵌套属性无警告用了 shallowReadonlyisReadonly检查嵌套层改用 readonly只读后视图不更新代理链断裂打印 isReactive/isReadonly修复包装顺序控制台大量只读警告某处误改只读数据看警告堆栈定位改为走方法修改性能明显下降深度只读大对象对比 shallowReadonly分层包装6.4 几个我踩过的坑第一个坑在watch里修改只读数据。有次我写了个watch监听某个只读的 props然后在回调里试图“修正”它结果一直报警告。后来改成watch里调用父组件传下来的方法让父组件去改问题解决。记住只读数据只能由它的“所有者”修改。第二个坑把readonly用在v-model上。v-model本质是双向绑定需要写权限。如果你把一个只读对象绑到v-model输入框会直接报错。这种情况要么去掉只读要么改用:valueinput手动处理。第三个坑isReadonly判断ref的.value。isReadonly(someRef)判断的是 ref 对象本身不是它内部的 value。如果你想判断 ref 的值是否只读得写isReadonly(someRef.value)。这个细节很容易搞混。提示开发阶段建议开启 Vue 的警告生产环境这些警告会被自动移除不会影响性能。但生产环境下只读拦截依然生效只是静默失败所以不要依赖警告来做业务逻辑。7. 只读体系在工程中的扩展思路7.1 和状态管理库的配合如果你用 Pinia 或 Vuex只读体系可以进一步强化。以 Pinia 为例storeToRefs拿到的状态默认是可写的但你可以手动包一层readonlyimport { storeToRefs } from pinia import { readonly } from vue const store useSomeStore() const { count } storeToRefs(store) const safeCount readonly(count)这样组件里只能读safeCount要改必须调用 store 的 action。对于团队协作来说这种约束能显著减少“谁都能改状态”导致的混乱。7.2 类型层面的只读约束运行时用readonly拦截编译时还可以用 TypeScript 的Readonly和DeepReadonly类型做双重保险。Vue 的readonly返回类型本身就是DeepReadonlyT所以类型提示会直接告诉你哪些字段不可写。import { readonly } from vue const state readonly({ user: { name: A } }) // state.user.name B // TS 报错Cannot assign to name because it is a read-only property运行时警告 编译时类型错误双管齐下基本可以杜绝误改。7.3 只读数据的调试技巧最后分享一个调试小技巧在开发环境里可以写一个全局的辅助函数快速查看一个对象的只读状态function inspectReadonly(obj, label object) { console.group(只读检查: ${label}) console.log(isProxy:, isProxy(obj)) console.log(isReactive:, isReactive(obj)) console.log(isReadonly:, isReadonly(obj)) console.groupEnd() }把它挂在window上排查问题时随手调用比翻文档快得多。我在几个项目里都保留了这个工具函数尤其是接手别人代码时能快速摸清数据层的只读结构。这个只读体系后续还可以往“权限化数据”方向扩展——比如根据用户角色动态决定哪些字段只读、哪些可写把readonly和权限判断结合起来形成更细粒度的数据访问控制。我在实际项目里试过这种方案对于多角色后台系统特别实用后面有机会再单独展开讲。
阅读完成 · 觉得有帮助?
咨询建站