前几天帮朋友调一个 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。冻结之后这个标志位变成了false,Vue 想再对这个对象插入自己的 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返回false,Observer 会直接跳过这个对象,这两部分开销就省下来了。
但要注意,它省的是初始化阶段的开销,而不是渲染阶段的性能。页面卡顿通常是大量 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 不是 React,Object.freeze不是不可变数据,两者背后的数据哲学完全不是一回事。
说回我自己踩这个坑的体会。那次帮朋友改完以后,我给自己定了一条规矩:凡是进入 data 或者 store 的状态对象,默认不冻结;只有那些初始化之后永不改变、且数据量又大到影响首屏初始化速度的静态数据,才考虑在进入 Vue 之前Object.freeze。真要改数据的时候,第一反应不是this.$set,而是“我能不能用整体替换的方式生成一个新对象”。这个思路在 Vue 2 和 Vue 3 里都适用,简单,也几乎不会出错。你如果在项目里也遇到过这个报错,可以先按这个思路排查一遍,大概率能找到那个被“焊死”的对象。