
前几天帮朋友调一个 Vue 2 后台项目控制台突然冒出一行大红字Cannot update reactivity because Object is frozen。项目是典型的中后台管理系统订单列表数据量不小为了减少卡顿他在请求回调里对接口数据执行了Object.freeze()结果提交编辑的时候数据改了视图不更新控制台还不停报警。这个报错看起来一脸严肃其实把 Vue 的响应式原理和Object.freeze的关系理清楚之后解决起来并不复杂。这篇文就从一个真实踩坑现场聊起把两者的冲突讲透顺便把我用过的修复方案、排查套路以及“到底该不该用 freeze 做性能优化”的判断标准一起整理出来。无论你是刚接触 Vue 的实习生还是在维护老项目的老手只要和这个报错打过照面都能在里面找到能直接用的解法。1. 什么场景最容易踩到这个报错1.1 我踩坑的真实现场当时的情况是订单列表接口一次返回 2000 条数据每条记录有 20 多个字段页面上还要做多级筛选和排序。朋友觉得操作有点卡就从网上看了一些“性能优化技巧”在拿到接口数据后立刻加了一句this.tableData Object.freeze(res.data)列表渲染确实顺了一点因为 Vue 2 初始化data时会对每个对象里的每个属性做Object.defineProperty重定义数据量一大这个初始化的开销是很明显的。Object.freeze之后对象不可扩展Vue 的 observer 会直接跳过这些对象省掉了 defineProperty 的时间。但问题出在一个新的后台功能上运营人员要批量修改订单的备注和状态。代码大致是这样// 提交前改了 data 里的对象属性 this.tableData[index].remark newRemark // 发现视图不更新又加了 this.$set this.$set(this.tableData[index], status, processed)第一条语句在非严格模式下静默失败vue 视图自然不动。第二条语句一执行控制台直接抛出标题里那个报错。这时候他才意识到之前为了性能做的 freeze 已经把这个对象“焊死”了响应式系统失去了对它的控制权。这个场景非常典型先是有人为了性能主动 freeze 了某块数据后来业务迭代又有人要修改这块数据两边没有对齐于是踩坑。1.2 哪些代码最容易“中招”根据我看到的案例最容易触发这个报错的不只是手动 freeze还有下面几类防御式冻结不少开发者喜欢把配置项写成const CONFIG Object.freeze({...})然后直接把CONFIG赋值给data里的字段。后面某个功能要临时扩展配置立刻踩雷。第三方库返回的冻结对象有些库内部为了安全会对返回结果做冻结处理尤其是图表配置、权限配置、表单 schema 这类偏静态的数据。你以为拿到的只是普通对象修改时才发现是 frozen。接口数据被框架或工具函数冻结某些数据层封装、normalize 工具、缓存插件返回的对象可能是不可扩展的。这类问题隐蔽在深处排查成本更高。“浅冻结”造成的误判很多人以为Object.freeze会递归冻结整个对象树其实它只冻结第一层。外层冻结了、内层没冻结修改内层字段时可能不报错但 Vue 也可能不更新视图更容易形成隐性 bug。团队规范不一致一个人为了性能 freeze另一个人完全不知情直接在业务代码里对这块数据做this.$set报错之后还觉得莫名其妙。如果你发现自己正在面对这些情况与其急着改代码不如先把下面两件事弄清楚Vue 的响应式是怎么建立的以及 freeze 到底破坏了什么。2. 为什么 Vue 会拦住你对“冻结对象”动手2.1 Vue 2 的响应式是基于 defineProperty 的属性拦截Vue 2 在初始化 data 时会递归遍历对象的所有属性用Object.defineProperty把每个普通属性重定义为 getter/setter。这样每次读取属性时做依赖收集每次修改属性时触发依赖更新。这就是响应式系统工作的基础。而新增属性为什么不能自动响应因为组件初始化时Vue 只对当时已经存在的 key 做了 defineProperty后面你往对象上挂一个新 key它并没有被拦截也没有对应的 setter。所以 Vue 2 才提供了Vue.set/this.$set用于手动给对象补上响应式逻辑。现在再看Object.freeze。一个对象被冻结之后会同时满足三个特征不可扩展不能添加新属性属性不可写已有属性不能被修改属性不可配置不能删除属性也不能修改属性描述符。关键在于第三点。Object.defineProperty想在一个对象上定义属性或者把已有数据属性改造成访问器属性前提是configurable为true。冻结之后这个标志位变成了falseVue 想再对这个对象插入自己的 setter就没有位置了。Vue 2 内部在遇到这种情况时会用开发环境警告明确告诉开发者这个对象是冻结的我没法帮你更新数据。于是你就看到了那行Cannot update reactivity because Object is frozen。2.2 Vue 3 换成了 Proxy问题依然存在Vue 3 把响应式系统换成了Proxy在对象级别统一拦截 get、set、has、deleteProperty 等操作看起来比 defineProperty 高级很多。但 Proxy 同样无法覆盖“对象本身已经被冻结”的场景。当你对一个冻结对象调用reactive()时目标对象处于不可扩展状态代理上执行写操作会违反底层约束。Vue 3 内部也会对不可扩展对象做检查开发环境下同样会给出警告并且不会真正把一个冻结对象变成响应式对象。ref如果传入了冻结对象同样会遇到限制。因为ref内部会尝试用reactive或类似逻辑处理对象值撞上冻结对象该有的问题一个都不会少。所以别想着升级到 Vue 3 就自动免疫这个报错的根源是 JavaScript 对象被冻结后无法再被“接管”和 Vue 的版本没有本质关系。2.3 直接赋值和 this.$set 到底差在哪很多人分不清“为什么直接赋值不报警this.$set 却报警”。我用一个表格把差异列清楚操作方式对冻结对象的影响Vue 能否检测到变化obj.attr x非严格模式静默失败属性值不变不能obj.attr x严格模式抛出 TypeError不能给不存在的新属性直接赋值静默失败或抛错不能this.$set(obj, key, x)Vue 尝试调用 defineProperty冻结对象上会抛错或报冻结警告不能且控制台报错明显整体替换引用this.obj newObj不修改原冻结对象能所以this.$set不是“万能药”它只对普通的、可扩展的对象有效。如果目标已经被冻结this.$set不仅救不了你反而会让你更早、更清楚地看到问题。3. 手把手修复四个方案按场景选3.1 方案一整体替换对象引用最推荐既然冻结对象本身改不得那就不改它直接“换一个新的”。Vue 的依赖追踪本质上是基于引用和 getter 的当你把一个 data 属性的整体引用替换成另一个对象时Vue 能够感知到引用变化并触发重新渲染。比如批量修改订单状态的场景可以把代码改成这样// 错误写法直接修改冻结对象内部属性 this.tableData[index].remark newRemark // 正确写法生成新对象整体替换 this.tableData this.tableData.map((item, i) { if (i ! index) return item return { ...item, remark: newRemark, status: processed } })这段代码里map返回了一个新数组被修改的那一项是展开运算符生成的全新对象不是原来的冻结对象。数组本身是 data 属性整体重新赋值Vue 能正常感知。更精细的做法是只替换单个元素而不是整体替换整个数组const newItem { ...this.tableData[index], remark: newRemark } this.$set(this.tableData, index, newItem)这个写法只修改数组下标位置上的元素this.$set在这里是安全的因为它操作的是数组本身数组没有被冻结替换下标上的值完全合法。需要注意的是newItem必须是一个全新的、可扩展的对象不能还是原来的冻结对象。如果你的数据不是数组而是嵌套在某个对象里思路一样// 冻结对象this.editForm.frozenData this.editForm { ...this.editForm, frozenData: { ...this.editForm.frozenData, displayName: newName } }原理没变不碰原冻结对象替换外层引用。这个方案几乎能覆盖所有业务场景也是我在生产环境用得最多的修复手段。3.2 方案二数据源解冻或拷贝如果业务上确实需要频繁修改某个对象而它又来自一个被冻结的数据源最稳妥的办法是“拿到手之后就拷贝一份”。常见的拷贝方式有三种// 方式一老式写法有局限 const copy JSON.parse(JSON.stringify(frozenData)) // 方式二现代写法Node 17 / 现代浏览器 const copy structuredClone(frozenData) // 方式三第三方工具处理复杂对象时更稳 import { cloneDeep } from lodash-es const copy cloneDeep(frozenData)JSON.parse(JSON.stringify(...))虽然简单但会丢失undefined、函数、Date类型变成字符串、Map、Set等特殊数据结构。如果是纯 JSON 数据用这个没问题一旦对象里混入了类实例或函数就要用structuredClone或 lodash 的cloneDeep。我个人的习惯是接口返回的数据在进入 data 之前先判断这个数据后续有没有可能被修改有可能被修改就直接拷贝一份再赋值不碰冻结源。这样后续所有代码都可以用常规写法不需要每次操作都小心翼翼。3.3 方案三用 Vue.set 前先确认对象状态有些人确实不想改整体替换的写法还是习惯用this.$set那至少要在调用前做一层保护判断if (Object.isFrozen(item)) { // 冻结对象不能直接 $set改用整体替换 this.$set(this.list, index, { ...item, status: paid }) } else { this.$set(item, status, paid) }这个判断成本很低但能让代码在遇到冻结对象时走正确的分支避免直接报警。不过说句实话整体替换的写法本身已经足够解决问题布尔判断虽然无害但也不宜过度依赖。更好的做法是从源头保证“进入业务编辑状态的对象一定不是冻结对象”判断只是兜底。3.4 方案四从源头调整“冻结策略”前面几个方案都是“见招拆招”真正长效的解法是在设计阶段就清楚哪些数据该冻结哪些数据不该冻结。拿 Vue 2 项目来说如果只是为了防误改不一定非要用Object.freeze。可以把静态常量放到模块顶层不进 data// constants.js export const ORDER_STATUS Object.freeze({ PENDING: 0, PAID: 1, SHIPPED: 2 })组件里只引用ORDER_STATUS不做赋值操作。这样既不进响应式系统也不存在“改了一半视图不更新”的问题。Vue 3 项目更简单可以用markRaw和shallowRef来精确控制哪些数据不参与响应式转换import { shallowRef, markRaw } from vue const staticRows markRaw(bigList) const rows shallowRef(staticRows)markRaw标记的对象不会被 Vue 转换成响应式对象shallowRef只代理.value本身不会对内部对象深追踪。这种组合比Object.freeze更贴合 Vue 3 的设计方式。如果 freeze 的目的是“防止编辑功能误改只读数据”更合理的做法是在开发环境做校验或者写清楚注释和类型标注而不是在运行时把所有扩展入口全部堵死。因为 freeze 堵死的不仅是误改还堵死了正常业务迭代的可能。3.5 方案选型速查表场景推荐方案注意事项冻结对象里某个字段需要改整体替换外层引用不要改原对象内部属性数组元素需要改this.$set(arr, index, newItem)或替换整个数组新元素必须是全新对象数据从外部接口来且后续要编辑进入 data 前先 cloneDeep / structuredClone注意特殊类型数据较小嵌套对象需要改展开运算符逐层替换注意每层都是新对象只是防误改模块常量、readonly、markRaw 等方案根据 Vue 版本选型4. 别把 Object.freeze 当成万能性能优化4.1 Object.freeze 到底省了什么网上很多文章把Object.freeze和 Vue 性能优化绑定在一起好像不 freeze 就是不懂优化。但实际省下的东西比你想象得要少得多。Vue 2 中data 对象进入响应式系统时要做两件比较耗时的事一是递归遍历对象属性二是通过Object.defineProperty把每个属性改成访问器属性。对象字段越多这个初始化成本越高。如果对象是冻结的isExtensible返回falseObserver 会直接跳过这个对象这两部分开销就省下来了。但要注意它省的是初始化阶段的开销而不是渲染阶段的性能。页面卡顿通常是大量 DOM 更新、复杂 diff、频繁重渲染造成的这些和对象是否被冻结没有直接关系。我实测过一个 1000 行 20 列的表单列表freeze 之后初始化时间确实减少了几十毫秒但页面从数据加载到渲染完成的总耗时基本没变因为真正的瓶颈在于 DOM 节点数量和表格组件自身的重渲染逻辑。所以把 freeze 当成万能性能银弹是误区。它适合“大而静态”的数据不适合所有数据。4.2 什么时候值得用 freeze结合我的实践经验下面这几类场景用Object.freeze是合理的静态字典、枚举、常量配置比如订单状态映射、下拉选项、表头配置这些数据初始化后绝不会变。接口返回的大列表且本次会话中确定只读比如某个详情的只读快照、历史归档列表后续不会被修改。进入 data 但不参与交互的静态资源比如地图上的一批固定标注点渲染完就不动了。缓存场景某些计算结果需要缓存供多个组件复用且不需要响应式更新。在这些场景下freeze 可以帮 Vue 减少不必要的属性劫持和依赖追踪换来少许性能收益和更清晰的数据语义。4.3 什么时候千万别用 freeze反过来碰到下面这些场景我会尽量拦住自己和同事表单对象、编辑弹窗、行内可编辑表格这些数据天然需要频繁改动freeze 等于给自己埋雷。需要后续动态添加或删除属性的对象freeze 会让对象完全不可扩展任何新增属性都做不了。会被第三方库修改的对象有的组件库内部会直接去改传入的 options传入冻结对象可能让第三方库运行出错。当前不确定后续改不改的数据拿不准的情况下默认不要 freeze等确定只读再说。一句话Object.freeze是给“确定不改的数据”用的给可变数据用就是给自己找麻烦。5. 排查思路从报错到定位根因的完整流程5.1 先看调用栈报错出现之后不要急着搜解决方案先在控制台把调用栈展开。Vue 的警告会带出一串内部调用帧你要做的是忽略vue.runtime.esm.js那一堆直接找你业务代码所在的文件。比如我之前帮朋友定位时调用栈里出现了handleSubmit和updateOrderRemark一眼就能看出是“批量修改备注”这个方法触发的。有了这个线索再去看方法里改了哪个对象问题范围就缩小了一半。5.2 确认对象是否真的被冻结遇到可疑对象直接在控制台执行Object.isFrozen(obj) // true 表示已冻结 Object.isExtensible(obj) // false 表示不可扩展这两个 API 足够判断对象状态。如果Object.isFrozen返回true但代码里没有搜到Object.freeze那就去调用链上游找看看对象是不是从某个库函数里返回的。把断点打在数据来源处查看返回值往往能找到问题根源。5.3 全项目搜索 freeze确认对象是冻结的以后在编辑器里全局搜Object.freeze( freeze(把node_modules排除掉只看业务代码。如果业务代码里搜不到再去判断数据来源是不是第三方库内部做的冻结。第三方库的问题比较隐蔽我遇到过一次是某个图表库对配置对象做了Object.freeze我尝试给配置项加字段直接报了同一个错最后是拷贝了一份配置才绕过去。5.4 临时“解冻”复现与二分定位如果还是定位不到是谁冻的可以用一个临时方案验证方向把目标对象做一次深拷贝用拷贝后的对象替换原数据再运行同样的操作。如果深拷贝之后问题不再出现说明冻结确实是直接原因如果深拷贝之后问题依然存在那说明问题不在冻结而是业务代码本身有别的逻辑错误。这种“替换变量对比结果”的方式比盯着代码干想要快很多。5.5 预防措施从规范上杜绝排查是一次性的预防才是长期的。我在团队里定的几条规矩这里也分享出来data 或 store 里的状态对象默认不允许直接Object.freeze只有明确标记为“只读静态数据”的常量才能冻结。静态数据统一命名成XXX_CONSTANTS放到constants目录不准进入 data 做可变状态。需要修改只读数据时先拷贝再改绝不直接改原对象。Code Review 时重点检查“对 data 中对象执行 freeze”的代码一旦发现立刻打回。如果用的是 Vue 3优先用markRaw/shallowRef/readonly这些更语义化的 API而不是在业务里手动 freeze。6. 边界问题数组、嵌套对象和跨框架6.1 数组被冻结时的特殊行为数组也可以是冻结对象。Vue 2 中数组的响应式依赖的是重写后的push、pop、splice等方法这些方法在冻结数组上调用时会失败因为冻结后的数组不可扩展、元素不可写。如果你遇到冻结数组需要修改元素最保险的方式仍然是整体替换// 不推荐arr 已冻结时splice 会失败或报警 this.arr.splice(index, 1, newItem) // 推荐整体替换数组引用 this.arr this.arr.map((item, i) (i index ? newItem : item))6.2 Object.freeze 只是浅冻结Object.freeze冻结的是对象本身也就是第一层属性。如果第一层属性的值还是一个对象这个“内层对象”不会被自动冻结。很多人以为 freeze 是深冻结这是一个高频误区。这种浅冻结会造成一个很尴尬的局面外层改不了内层却能改。如果内层对象已经被 Vue 追踪为响应式修改内层属性时视图可能正常更新如果外层被冻结导致整个子树都没有进入响应式系统修改内层属性就会“改了没反应”且不报错排查起来特别费劲。所以做只读保护时要么手动递归冻结整个对象树要么明确告诉自己和团队“只保证第一层只读深层不管”。最好的办法还是不用 freeze 做业务对象保护。6.3 Vue3 中 readonly 和 freeze 别混用Vue 3 提供了readonly它返回一个只读代理在开发环境下尝试修改会发出警告并且不会让修改生效。readonly是“逻辑层面的只读”而Object.freeze是“JavaScript 引擎层面的不可变限制”两者不是一回事。如果你的目的是防止开发时误改数据优先用readonly因为它和 Vue 的响应式系统配合更好。如果你的目的是跳过响应式转换、提升初始化性能那readonly不能帮你达到这个目标这时该用的是markRaw或shallowRef。同一个对象先reactive再readonly和直接 freeze 的效果差别很大别混着用。6.4 跨框架背景下的“错觉”问题从 React 转过来的同事特别容易踩这个坑。React 的 setState 和不可变数据思想让很多人习惯在全局定义常量和配置对象随手就在定义处加Object.freeze。React 里 state 本身也是替换式更新和冻结对象不太冲突。但 Vue 的不同之处在于它默认劫持属性、自动追踪依赖、鼓励直接改响应式对象。你把一个冻结对象丢进 data等于把这个对象从 Vue 的管辖范围里“踢”了出去。这个反差是很多跨框架开发者第一次见到这个报错时最懵的地方。我的建议是转技术栈时不要把旧习惯直接带过来。先理解新框架的数据流和响应式边界再决定哪些“防御性”写法还有必要保留。Vue 不是 ReactObject.freeze不是不可变数据两者背后的数据哲学完全不是一回事。说回我自己踩这个坑的体会。那次帮朋友改完以后我给自己定了一条规矩凡是进入 data 或者 store 的状态对象默认不冻结只有那些初始化之后永不改变、且数据量又大到影响首屏初始化速度的静态数据才考虑在进入 Vue 之前Object.freeze。真要改数据的时候第一反应不是this.$set而是“我能不能用整体替换的方式生成一个新对象”。这个思路在 Vue 2 和 Vue 3 里都适用简单也几乎不会出错。你如果在项目里也遇到过这个报错可以先按这个思路排查一遍大概率能找到那个被“焊死”的对象。