
1. 为什么响应式系统需要只读这层保险刚接触 Vue 3 响应式 API 那会儿我对readonly这类接口是有点不以为然的。心里想的是数据我自己管好不就行了为什么还要专门搞个 API 把对象包一层只读直到在一个多人协作的中型项目里我亲眼看到一位同事在某个深层组件里直接改了一个本该由父级统一维护的配置对象导致整个页面的状态在某个特定操作后莫名其妙地错乱排查了大半天才定位到那一行赋值。从那以后我对readonly系列 API 的态度就彻底变了。readonly、shallowReadonly、isReadonly这三个 API本质上解决的是同一个问题在响应式系统里划定一条只读边界。它不是一个可有可无的语法糖而是一种运行时层面的约束机制。你可能会说TypeScript 的ReadonlyT不也能做只读约束吗区别在于TS 的只读是编译期的编译完就没了运行时该改还是能改而 Vue 的readonly是运行时的它通过 Proxy 拦截了set、deleteProperty等操作真正在运行时阻止了修改并且在开发环境下会给出明确的警告信息。这篇文章我打算把这三个 API 从是什么到怎么用再到什么时候该用、什么时候别用完整讲一遍。适合已经用过 Vue 3 响应式 API、但对只读这块还停留在知道有这么个东西阶段的开发者。我会结合实际的代码场景把每个 API 的行为边界、嵌套规则、和ref/reactive的配合方式都拆开讲清楚也会分享几个我在实际项目里踩过的坑。先说结论性的认知readonly是深度只读shallowReadonly是浅层只读isReadonly是用来判断一个对象是不是只读代理的工具函数。三者配合使用能在组件通信、状态管理、配置传递等场景里建立起清晰的数据流向约束。下面逐个展开。2. readonly 的深度拦截机制与嵌套行为2.1 它到底拦截了什么readonly接收一个对象可以是普通对象也可以是reactive创建的响应式对象还可以是ref返回一个只读代理。这个代理的核心行为是任何试图修改属性的操作都会被拦截并阻止。import { reactive, readonly } from vue const original reactive({ count: 0, user: { name: A同学, tags: [vue, frontend] } }) const readOnlyCopy readonly(original) // 以下操作全部无效开发环境下会打印警告 readOnlyCopy.count 1 // 警告Set operation on key count failed: target is readonly readOnlyCopy.user.name B同学 // 警告同样是 readonly readOnlyCopy.user.tags.push(js) // 警告数组的 push 也会被拦截 delete readOnlyCopy.count // 警告delete 操作同样被拦截这里有个细节值得注意readonly的拦截是深度的。也就是说它不仅拦截顶层属性的修改还会递归地把嵌套的对象、数组都变成只读代理。上面代码里readOnlyCopy.user.name的修改被拦截就是因为user这个嵌套对象在访问时被自动转换成了只读代理。这个深度拦截的实现原理和reactive的深度响应式是同一套机制。Vue 在get拦截器里当访问到的属性值是一个对象时会根据当前代理的类型只读还是可写决定返回什么样的子代理。对于readonly代理它返回的就是子对象的只读代理。2.2 原始对象仍然可写这件事很关键这是很多人第一次用readonly时容易困惑的点readonly返回的是一个代理原始对象本身并没有被冻结。你通过原始对象的引用去修改是完全生效的。const original reactive({ count: 0 }) const ro readonly(original) ro.count 1 // 无效被拦截 console.log(ro.count) // 0 original.count 1 // 有效 console.log(ro.count) // 1只读代理会同步反映原始对象的变化这个行为的设计意图是合理的readonly的目的是限制通过这个代理引用的修改而不是把数据本身锁死。原始对象该由谁维护还是由谁维护只读代理只是给外部消费者一个你只能看不能改的视图。我在实际项目里就利用这个特性做过一件事父组件持有reactive状态并负责修改通过provide向下传递时用readonly包一层子组件拿到的就是只读视图。子组件想改也改不了但父组件改完之后子组件因为响应式依赖的关系会自动更新。数据流向非常清晰。2.3 和 reactive 嵌套时的代理叠加规则当readonly包裹一个reactive对象时返回的代理同时具备只读和响应式两个特性。但如果你反过来用reactive去包裹一个readonly对象会发生什么const ro readonly({ count: 0 }) const re reactive(ro) re.count 1 // 依然无效因为内层还是只读代理这个结果可能出乎意料reactive包裹readonly之后得到的还是一个只读的代理。原因是 Vue 在创建代理时会检查目标对象是否已经是只读代理如果是reactive会直接返回原对象不会创建一个可写代理来绕过只读限制。这个设计防止了只读约束被意外突破是很重要的一道防线。反过来的情况readonly包裹reactive得到的是只读代理原始reactive对象仍然可写这个上面已经验证过了。注意不要试图通过reactive(readonly(obj))这种方式来解锁只读Vue 在代理创建层面就堵死了这条路。如果你确实需要可写版本应该从原始对象重新创建而不是从只读代理转换。2.4 一个容易忽略的边界集合类型的处理readonly对Map、Set这类集合类型同样有效但拦截的行为和普通对象略有不同。对于Mapset、delete、clear这些修改方法会被拦截对于Setadd、delete、clear会被拦截。但读取方法如get、has、size是正常工作的。const map reactive(new Map([[key, value]])) const roMap readonly(map) roMap.set(key2, value2) // 警告被拦截 roMap.get(key) // value读取正常 roMap.size // 1这里有个实际使用中的小坑如果你在只读的Map上调用get拿到一个对象值这个对象值也会被转换成只读代理。所以后续对这个值的修改同样会被拦截。这个链式只读的行为在排查问题时如果不清楚会觉得很诡异——明明我只是get了一下怎么改不了其实就是深度只读在起作用。3. shallowReadonly 的浅层约束与性能取舍3.1 只锁第一层是什么意思shallowReadonly和readonly的区别就一个字浅。它只把对象的第一层属性变成只读嵌套的对象不会被递归转换。import { shallowReadonly } from vue const state shallowReadonly({ count: 0, user: { name: A同学 } }) state.count 1 // 被拦截第一层只读 state.user.name B同学 // 有效嵌套对象没有被只读化 console.log(state.user.name) // B同学这个行为初看有点反直觉既然叫只读为什么嵌套的还能改但仔细想想它的定位就明白了——shallowReadonly提供的是一种浅层保护它假设你只关心顶层属性的不可变性嵌套结构由其他机制来管理。3.2 什么时候该用浅层只读我在几种场景下会优先考虑shallowReadonly而不是readonly第一种是性能敏感的大对象。readonly的深度转换是惰性的访问到才转换但如果你确实会遍历整个大对象树那每一层都会创建代理这个开销在数据量大的时候是实打实的。如果业务上只需要保护顶层用shallowReadonly能省下不少代理创建的成本。第二种是嵌套结构本身需要可变的场景。比如一个配置对象顶层是各种配置项不允许改但某个配置项的值是一个需要被频繁操作的数组或对象这时候深度只读反而碍事。第三种是和第三方库配合的时候。有些库会往你传入的对象上挂载内部属性如果你传的是深度只读代理库的写入操作会被拦截并报一堆警告。这时候用shallowReadonly只保护你自己的顶层字段把嵌套对象的控制权让出去反而更省心。3.3 和 readonly 的行为对比表为了把两者的差异说清楚我整理了一张对比表覆盖了几个关键维度对比维度readonlyshallowReadonly顶层属性修改拦截拦截嵌套对象属性修改拦截允许嵌套数组操作拦截允许代理创建开销惰性递归访问时创建仅顶层开销小适用场景严格数据流约束性能敏感或嵌套需可变和 reactive 配合只读视图原始可写同上但仅顶层只读这张表里我想特别强调惰性递归这一点。readonly并不是在创建代理的那一刻就把整棵树都转换了而是在你访问某个嵌套属性时才动态地为那个子对象创建只读代理。所以它的性能开销和你的访问深度相关不是一次性的大开销。但如果你确实会深度遍历那累积起来的代理创建成本就不能忽略了。3.4 一个真实场景下的选择过程之前做过一个数据看板项目里面有一个从接口拉取回来的大配置对象结构大概是三层顶层是模块配置第二层是每个模块的图表配置第三层是具体的样式参数。这个对象在初始化后就不应该再被修改但图表库在渲染时会往第三层的样式对象上写一些运行时状态。我一开始用了readonly结果图表库每次写入都触发警告控制台刷屏。换成shallowReadonly之后顶层和第二层的配置被保护住了业务代码改不了第三层的样式对象保持可写图表库能正常工作警告消失了约束也还在。这个选择过程让我意识到只读的粒度选择本质上是在约束强度和兼容性之间找平衡点。4. isReadonly 的判断逻辑与实战用途4.1 它判断的到底是什么isReadonly接收一个值返回一个布尔值表示这个值是不是一个只读代理。它的判断依据是 Vue 内部在代理对象上打的一个标记不是通过检查对象是否可写来推断的。import { readonly, shallowReadonly, reactive, isReadonly } from vue const ro readonly({ a: 1 }) const shallowRo shallowReadonly({ a: 1 }) const re reactive({ a: 1 }) const plain { a: 1 } console.log(isReadonly(ro)) // true console.log(isReadonly(shallowRo)) // true console.log(isReadonly(re)) // false console.log(isReadonly(plain)) // false注意shallowReadonly创建的代理isReadonly同样返回true。也就是说isReadonly不区分深度只读和浅层只读它只关心是不是只读代理。4.2 为什么需要这个判断你可能会问我自己创建的对象我还能不知道它是不是只读的吗在简单场景下确实不需要判断。但在下面这几种情况里isReadonly就很有用了第一种是通用工具函数的入参处理。你写了一个工具函数接收一个对象参数内部可能需要根据对象是否只读来决定行为。比如一个安全合并函数如果目标对象是只读的就不能直接改它而要返回一个新对象。import { isReadonly } from vue function safeAssign(target, source) { if (isReadonly(target)) { // 只读对象不能直接改返回合并后的新对象 return Object.assign({}, target, source) } return Object.assign(target, source) }第二种是组件库或插件开发。你在开发一个通用组件时props 传进来的对象可能是父组件用readonly包过的也可能是普通对象。你需要根据这个来判断是否要给出警告或者采取不同的处理策略。第三种是调试和断言。在开发阶段你可以在关键位置加一个isReadonly检查确保数据流符合预期。比如某个状态本应该是只读的如果检查发现不是说明上游的传递出了问题可以尽早暴露。4.3 和 isReactive、isProxy 的配合Vue 还提供了isReactive和isProxy两个相关的判断函数它们和isReadonly配合使用能覆盖更完整的判断需求。import { isReadonly, isReactive, isProxy } from vue const ro readonly({ a: 1 }) const re reactive({ a: 1 }) console.log(isProxy(ro)) // true只读代理也是代理 console.log(isProxy(re)) // true console.log(isReactive(ro)) // false只读代理不算响应式对象 console.log(isReactive(re)) // true这里有个容易混淆的点readonly包裹reactive对象时返回的代理isReactive是false。因为isReactive判断的是是不是可写的响应式代理只读代理虽然底层依赖响应式系统但它本身不被归类为响应式对象。这个区分在做类型判断时很重要别搞混了。4.4 判断逻辑在跨组件通信中的实际价值在一个组件树里数据从顶层往下传中间可能经过好几层provide/inject或者 props 传递。每一层都可能对数据做包装。到了最底层的消费组件它拿到的对象到底是什么状态有时候并不直观。我习惯在开发环境的某些关键消费点加一段断言import { isReadonly } from vue // 在某个本应只读的消费组件里 if (process.env.NODE_ENV development) { if (!isReadonly(props.config)) { console.warn(config 应该是只读的检查上游传递逻辑) } }这种断言在多人协作的项目里特别有用。它能帮你快速定位到是哪一层没有正确传递只读约束而不是等到运行时出现状态被意外修改才去排查。成本很低收益很高。5. 只读代理和 ref、computed 的配合细节5.1 readonly 包裹 ref 会发生什么readonly可以接收ref返回一个只读的ref。这个只读ref的.value是不可写的。import { ref, readonly } from vue const count ref(0) const roCount readonly(count) roCount.value 1 // 警告被拦截 console.log(roCount.value) // 0 count.value 1 // 原始 ref 可写 console.log(roCount.value) // 1只读 ref 同步更新这里的行为和对象版本是一致的只读ref是原始ref的一个只读视图原始ref的修改会反映到只读ref上。但有个细节要注意如果你用readonly包裹一个值为对象的ref那这个对象的嵌套属性也会被深度只读化。const objRef ref({ nested: { a: 1 } }) const roObjRef readonly(objRef) roObjRef.value.nested.a 2 // 被拦截深度只读5.2 computed 返回值的只读性computed本身返回的就是一个只读的ref。你试图给computed的.value赋值会收到警告。import { ref, computed } from vue const count ref(0) const double computed(() count.value * 2) double.value 10 // 警告Write operation failed: computed value is readonly有意思的是isReadonly(double)返回的是true。也就是说computed返回的 ref 在内部被标记为只读。这个标记和readonly创建的只读代理用的是同一套机制。所以如果你写了一个工具函数用isReadonly来判断computed的返回值也会被判定为只读这是符合预期的。5.3 只读 ref 在模板中的解包行为在模板里使用只读ref时Vue 的自动解包机制照常工作。你不需要写.value直接写变量名就行。script setup import { ref, readonly } from vue const count ref(0) const roCount readonly(count) /script template !-- 正常显示自动解包 -- div{{ roCount }}/div /template但如果你在模板里试图通过事件去修改roCount比如clickroCount 1会被拦截并警告。这个在开发时能帮你快速发现这个数据不该在这里被改的问题。5.4 一个组合使用的实际案例我在一个表单场景里这样组合使用过表单的初始数据用reactive管理通过computed派生出一个只读的展示数据再用readonly把整个表单状态包一层传给只负责展示的子组件。import { reactive, computed, readonly } from vue const formState reactive({ name: , age: 0, tags: [] }) // 派生展示数据 const displayData computed(() ({ label: ${formState.name} (${formState.age}), tagCount: formState.tags.length })) // 只读视图传给子组件 const readOnlyForm readonly(formState)子组件拿到readOnlyForm后只能读取不能修改所有修改都必须通过事件通知父组件来做。displayData作为computed本身就是只读的子组件也改不了。这样整个数据流就是单向的父组件改状态子组件读状态清晰可控。6. 实际项目中的踩坑记录与使用建议6.1 只读代理不能直接用于需要序列化的场景这是我在一个接口请求场景里踩过的坑。当时我把一个readonly包裹的对象直接传给了请求库作为请求体结果请求库内部做序列化时遍历对象属性触发了只读代理的get拦截器虽然读取没问题但某些序列化库会尝试在对象上挂载临时属性结果被拦截报错。解决办法很简单传给外部库之前用toRaw拿到原始对象或者用扩展运算符浅拷贝一份。import { readonly, toRaw } from vue const roData readonly({ a: 1, b: 2 }) // 方式一toRaw const raw toRaw(roData) // 方式二浅拷贝 const copy { ...roData }toRaw返回的是被代理的原始对象对于readonly(reactive(obj))这种情况toRaw会返回最底层的原始对象。这个在处理和第三方库交互时特别有用。6.2 深度只读在大型数据结构上的性能考量前面提过readonly的深度转换是惰性的但惰性不代表没有成本。如果你的数据结构很深而且业务上确实会深度遍历那每一层都会创建代理对象。在数据量达到几千个节点的时候这个开销是能感知到的。我的建议是先问自己是否真的需要深度只读。如果只需要保护顶层用shallowReadonly。如果确实需要深度保护但数据量很大考虑在数据进入只读代理之前先做一次结构精简把不需要保护的深层数据剥离出去。6.3 只读约束和 TypeScript 类型的配合readonly返回的类型在 TypeScript 里是DeepReadonlyT这个类型会递归地把所有属性变成readonly。但要注意DeepReadonly对数组、函数等类型的处理有它自己的规则有时候和你的预期不完全一致。import { readonly } from vue const obj { list: [1, 2, 3], fn: () {} } const ro readonly(obj) // ro.list 的类型是 readonly number[] // ro.fn 的类型是 () void函数本身没有被只读化函数属性不会被只读化因为函数本身没有可写属性的概念。但如果你试图替换ro.fn这个属性本身类型系统会阻止你运行时也会被拦截。6.4 几个我总结的使用原则经过这些项目的积累我形成了几个关于只读 API 的使用原则分享出来供参考原则一只读约束应该加在数据流的边界上而不是内部。比如provide给子组件的数据、暴露给外部的模块接口这些地方加只读。组件内部自己用的状态没必要包只读反而增加心智负担。原则二优先用shallowReadonly除非确实需要深度保护。深度只读的约束更强但也更容易和第三方库或特殊场景冲突。从浅层开始遇到需要深度保护的地方再升级。原则三isReadonly用在工具函数和断言里不要用在业务逻辑分支里。业务逻辑不应该依赖这个对象是不是只读来做判断那说明数据流设计有问题。isReadonly更适合做防御性编程和开发期检查。原则四只读代理传给外部库之前先toRaw或拷贝。这是避免兼容性问题的通用做法养成习惯能省很多排查时间。6.5 一个关于警告信息的实用技巧开发环境下Vue 对只读代理的修改会打印警告但警告信息默认只告诉你target is readonly不告诉你具体是哪个组件、哪一行代码触发的。在大型项目里光看这个警告很难定位。我的做法是在开发环境重写一下console.warn在捕获到只读警告时额外打印调用栈if (process.env.NODE_ENV development) { const originalWarn console.warn console.warn (...args) { if (typeof args[0] string args[0].includes(readonly)) { originalWarn.call(console, ...args) originalWarn.call(console, new Error(只读修改调用栈).stack) } else { originalWarn.apply(console, args) } } }这段代码在排查到底是谁改了只读数据的时候特别管用调用栈一打出来问题位置一目了然。当然这只在开发环境用生产环境记得去掉。只读这套机制说到底是 Vue 响应式系统给开发者提供的一种表达意图的手段。你用readonly包一个对象不只是技术上限制了修改更是在代码层面告诉所有读这段代码的人这个数据的所有权在别处这里只是消费方。这种表达在多人协作的项目里价值往往比技术约束本身还大。