JavaScript 深拷贝:为什么没点保存,列表数据还是被改脏了
“没点保存,列表数据却自己变了” 这类 bug,十有八九都绕不开对象引用。表面看像重置逻辑失效,真正出问题的往往是编辑态和展示态其实指向了同一份对象,弹窗里改字段,外面的列表也就跟着一起变了。
这次现场就很典型:打开编辑弹窗,随便改几个字段,点“重置”,再点“取消”,页面上那行商品数据已经脏了,刷新才恢复。问题不在重置按钮写错,而在它从一开始就没有独立的“原始数据副本”可退。
后面会从这类引用共享的 bug 往下挖,把浅拷贝为什么不够、JSON 深拷贝为什么经常不靠谱、递归拷贝要补哪些类型和边界,一路拆到循环引用、Date、RegExp 和 Symbol。
一行看起来人畜无害的赋值
这个编辑弹窗是我一月份写的,打开弹窗的代码长这样:
1// 打开编辑弹窗 2openEdit: function (row) { 3 this.form = row // 问题就在这一行 4 this.dialogVisible = true 5}, 6 7// 重置按钮 8resetForm: function () { 9 this.form = Object.assign({}, this.emptyForm) 10}
this.form = row。表单拿到的不是列表那行数据的「副本」,就是列表那行对象本身。用户在弹窗里每敲一个字,v-model 改的都是 this.form.price——而 this.form 和列表里那个 row 是同一个对象,所以列表当场就被改脏了。至于「重置」,它只是让 this.form 换了个新对象指过去,列表里那行早就被污染的数据原地不动。不是重置没生效,是从头到尾就没有存在过一份可以被重置保护的副本。
定位到这一行只花了二十分钟,但我在工位上多坐了一会儿,把这件事往下想了想。因为这不是我第一次撞见它了:之前 Vuex 里直接改了从 getter 拿出来的对象,页面状态莫名串掉;还有一次弹窗取消后数据回滚不干净。这些坑背后是同一件事——对象引用。既然这次已经撞上来,干脆顺势把这一块从头到尾挖穿。
先把最底层的事实垫平
基本类型复制的是值:
1var a = 1 2var b = a 3 4b = 2 5 6console.log(a) // 1
对象复制的是引用:
1var a = { name: 'Tom' } 2var b = a 3 4b.name = 'Jerry' 5 6console.log(a.name) // Jerry
a 和 b 指向同一个对象。栈里存的是同一个堆地址,改谁都是改那块堆内存。理解这点就能解释一堆「灵异现象」:函数参数传对象进去,函数里改了属性外面也变了(传的是引用的拷贝,指向同一对象);两个看着一模一样的对象 {a:1} === {a:1} 却是 false(地址不同)。这些都不是 bug,是引用类型的本性。
数组同理,数组也是对象。var b = a 之后 b.push(1),a 也多了一项。我刚工作那会儿还把这种「共享」当过 feature 用,省了来回赋值,结果埋下一堆隐患。这次的事故就是当年那种「图省事」的账,利滚利之后找上门了。
一行修复,和 code review 上被点破的边界
修复本身很快。这个商品表单的字段基本是平铺的字符串和数字,一个浅拷贝就把表单和列表隔离开了:
1openEdit: function (row) { 2 this.form = { ...row } // 展开运算符,浅拷贝一份 3 this.dialogVisible = true 4}
当天下午发了热修复,测试回归通过,运营那边也消停了。我本来觉得这事就算过去了,结果周五 code review 时组长指着这行问了一句:"商品要是带 SKU 呢?row.skuList 拷完还是同一个数组吧?"
我愣了一下。他说得对。浅拷贝只复制第一层:
1var user = { 2 name: 'Tom', 3 address: { 4 city: 'Shanghai' 5 } 6} 7 8var copy = Object.assign({}, user) 9 10copy.name = 'Jerry' 11copy.address.city = 'Beijing' 12 13console.log(user.name) // Tom —— 第一层隔离了 14console.log(user.address.city) // Beijing —— 嵌套对象还是同一个引用
第一层 name 没互相影响,但嵌套的 address 还是共享的。展开运算符也一样:
1var copy = { 2 ...user 3}
Object.assign 和 ... 本质相同,都只复制第一层。数组的 slice()、concat()、[...arr] 同理,全是浅拷贝。也就是说,当前这个平铺表单没问题,但下个迭代商品编辑要加 SKU 列表(嵌套数组套对象),这行修复就会以同样的姿势再炸一次——用户改 SKU 价格,列表里的 SKU 跟着脏。
组长补了一句让我记到现在的话:"浅拷贝不是不够好,是有边界。你要说得出边界在哪。"边界其实就一个判断标准:**你会不会去改嵌套那一层?**会,就得深拷贝;不会(只读,或者只整体替换引用),浅拷贝足矣。项目里九成的「复制」需求浅拷贝就够,别一看到「复制」俩字就条件反射上重家伙。
JSON.parse(JSON.stringify()):最顺手的深拷贝,和它的七个坑
SKU 那个迭代排期在三月中,我先把深拷贝方案备好。最顺手的写法当然是:
1var copy = JSON.parse(JSON.stringify(user))
序列化成字符串再解析回来,嵌套多深都给你断干净。我在测试环境拿商品数据一跑,立刻踩了第一个坑:商品上有个活动时间字段 activityTime,存的是 Date 对象,拷完变成了字符串,后面调 getTime() 直接报错。
顺着这个坑,我把 JSON 法的限制系统性查了一遍。广为流传的是这五条:
- 函数会丢失
undefined会丢失Date会变成字符串RegExp会变成空对象- 循环引用会报错
例如:
1var obj = { 2 time: new Date(), 3 fn: function() {} 4} 5 6var copy = JSON.parse(JSON.stringify(obj)) 7 8console.log(copy.time) // 字符串 9console.log(copy.fn) // undefined
再往下挖还有几个更隐蔽的:
NaN、Infinity会变成null。做数值统计时这个最阴,一个NaN拷完变null,下游计算结果全错还不报错。Map、Set会变成空对象{},里面的数据全没了。Symbol类型的 key 直接丢失。BigInt会直接抛错(stringify不支持,好在现在项目里几乎碰不到它)。- 对象上如果有
toJSON方法会被悄悄调用,结果可能跟你想的不一样——Date变字符串,根子就是它自带toJSON。
我把这些全在 Node 里验证了一遍,下面这段贴进控制台就能复现,每一项都标了确切结果:
1var obj = { 2 a: undefined, // 丢失 3 fn: function () {}, // 丢失 4 d: new Date('2019-02-28T00:00:00.000Z'), // 变字符串 5 n: NaN, // 变 null 6 inf: Infinity, // 变 null 7 arr: [NaN, undefined] // 变 [null, null] 8} 9 10JSON.parse(JSON.stringify(obj)) 11// => { 12// d: '2019-02-28T00:00:00.000Z', 13// n: null, 14// inf: null, 15// arr: [ null, null ] 16// } 17// 注意:a 和 fn 这两个 key 整个消失了
undefined、函数、Symbol 作为对象属性值时会被直接抹掉,连 key 都不剩;但出现在数组里时为了占位会变成 null(上面 arr 里那个 undefined 就变 null 了),这点特别容易踩。循环引用更干脆,直接抛错:
1var c = {} 2c.self = c 3JSON.stringify(c) 4// => TypeError: Converting circular structure to JSON
结论是:接口返回的普通数据(纯 JSON:对象、数组、字符串、数字、布尔、null)可以放心用它,又快又简单,我自己也常用。但只要数据里可能掺了上面这些类型,就别碰——它不报错,只是悄悄把你的数据改了,等下游炸的时候你根本想不到源头在一次拷贝。能确定数据「干净」就用 JSON 法图省事,拿不准就老实写结构化的深拷贝。
周末补课:手写递归,先从最朴素的版本开始
周六上午我把这个当作补基础的作业,从零手写。基础版本不难:
1function deepClone(value) { 2 if (value === null || typeof value !== 'object') { 3 return value 4 } 5 6 if (Array.isArray(value)) { 7 return value.map(item => deepClone(item)) 8 } 9 10 var result = {} 11 12 Object.keys(value).forEach(key => { 13 result[key] = deepClone(value[key]) 14 }) 15 16 return result 17}
基本类型直接返回,数组逐项递归,对象逐 key 递归。拿普通商品数据测没问题,拿带 activityTime 的一测,拷出来一打印:Invalid Date。我盯着屏幕懵了一会儿才想明白——Date 的时间值存在内部槽里,不是普通的可枚举属性,Object.keys 遍历出来是空的,我等于用一个空对象调了不存在的东西。RegExp 同理,source 和 flags 也不在常规遍历里。
特殊类型不能靠遍历属性去拷,要用构造函数重新造一个:
1function deepClone(value) { 2 if (value === null || typeof value !== 'object') { 3 return value 4 } 5 6 // Date 和 RegExp 要用构造函数重建,否则属性拷不全 7 if (value instanceof Date) { 8 return new Date(value.getTime()) 9 } 10 if (value instanceof RegExp) { 11 return new RegExp(value.source, value.flags) 12 } 13 14 if (Array.isArray(value)) { 15 return value.map(item => deepClone(item)) 16 } 17 18 var result = {} 19 Object.keys(value).forEach(key => { 20 result[key] = deepClone(value[key]) 21 }) 22 return result 23}
改完再测,activityTime 拷出来是个活的 Date,getTime() 正常。这一坑要是没提前踩,等 SKU 迭代上线再炸,又是一张运营的截图。
分类树里的循环引用
周日接着测,我把商品的分类数据也喂进去——然后浏览器标签页卡死了。控制台报的是 Maximum call stack size exceeded。
排查发现我们的分类数据是棵树,某个同事为了方便「往上找父节点」,给每个子节点挂了个 parent 字段指回父节点。父引用子、子又引用父,我的递归就在这两个对象之间无限打转:
1var obj = {} 2obj.self = obj 3 4deepClone(obj) // 死循环,直到爆栈
对付循环引用的思路是记账:拷贝过的对象记下来,再遇到同一个就直接返回已经拷好的副本。我先用数组做缓存:
1function deepClone(value, cache) { 2 cache = cache || [] 3 4 if (value === null || typeof value !== 'object') { 5 return value 6 } 7 8 var hit = cache.find(item => item.source === value) 9 10 if (hit) { 11 return hit.copy 12 } 13 14 var copy = Array.isArray(value) ? [] : {} 15 16 cache.push({ 17 source: value, 18 copy: copy 19 }) 20 21 Object.keys(value).forEach(key => { 22 copy[key] = deepClone(value[key], cache) 23 }) 24 25 return copy 26}
能跑,但写完自己就挑出了刺:find 每次都线性扫一遍缓存,对象一多就慢。更地道的写法是 WeakMap,key 直接存原对象,查找 O(1),而且 WeakMap 是弱引用、不阻止垃圾回收,不会因为缓存拽着对象不放造成内存泄漏:
1function deepClone(value, cache = new WeakMap()) { 2 if (value === null || typeof value !== 'object') return value 3 if (cache.has(value)) return cache.get(value) 4 5 var copy = Array.isArray(value) ? [] : {} 6 cache.set(value, copy) 7 8 Object.keys(value).forEach(key => { 9 copy[key] = deepClone(value[key], cache) 10 }) 11 return copy 12}
这段代码里有一处顺序是整个循环引用问题的题眼:**先把空壳 copy 存进缓存,再去递归子属性。**如果反过来,等递归深入到 obj.self 时缓存里还没有这个对象,照样陷进死循环。先登记、后深入,回头撞见自己时才能从缓存里认出来。我特意把 cache.set 挪到 forEach 后面跑了一次验证——果然又爆栈了。有些顺序,亲手弄错一次比看十遍讲解记得牢。
实习生的一问:Symbol 作 key 拷得到吗
周一我把这套笔记整理了一下,中午顺手给组里实习生讲了一遍这次事故的来龙去脉。讲到遍历那段,他冒出来一个问题:"Object.keys 遍历的话,Symbol 当 key 的属性拷得到吗?"
我张了张嘴,发现答不上来。凭直觉应该拷不到,但说不出为什么。俩人当场开控制台验证:
1var s = Symbol('id') 2var obj = { name: 'Tom', [s]: 123 } 3 4Object.keys(obj) // => ['name'],Symbol 不见了 5Object.getOwnPropertySymbols(obj) // => [Symbol(id)] 6Reflect.ownKeys(obj) // => ['name', Symbol(id)],全了
Object.keys、for...in、JSON.stringify 统统枚举不到 Symbol key,这是 Symbol 设计上的特性——它本来就是给「不想被常规遍历撞见」的属性用的。想拷到它们,要么用 Object.getOwnPropertySymbols 单独取出来补一遍,要么直接换 Reflect.ownKeys,它返回字符串 key 加 Symbol key 的全集。
把遍历那行换掉,前面几步的成果合成完整版:
1function deepClone(value, cache = new WeakMap()) { 2 if (value === null || typeof value !== 'object') return value 3 if (cache.has(value)) return cache.get(value) 4 5 if (value instanceof Date) return new Date(value.getTime()) 6 if (value instanceof RegExp) return new RegExp(value.source, value.flags) 7 8 var copy = Array.isArray(value) ? [] : {} 9 cache.set(value, copy) 10 11 Reflect.ownKeys(value).forEach(key => { 12 copy[key] = deepClone(value[key], cache) 13 }) 14 return copy 15}
有个细节要有数:Reflect.ownKeys 连不可枚举属性也会返回,口径比 Object.keys 宽。深拷贝场景一般无所谓,但想严格对齐「只拷可枚举属性」的语义,就得再拿 Object.getOwnPropertyDescriptor 过滤一道。给实习生讲完这段我心里挺感慨:被人一问问住的地方,才是笔记里真正的空白页。
延伸一:嵌套太深会爆栈,递归还能换成迭代
循环引用会爆栈,嵌套太深也会爆栈——递归每深一层就多一层调用栈,对象套个几万层,Maximum call stack size exceeded 照样伺候。正常业务数据到不了这个深度,但既然补课就补全:把递归改成自己维护栈的迭代写法,深度就只受内存限制,不受调用栈限制:
1function deepCloneIterative(value) { 2 if (value === null || typeof value !== 'object') return value 3 4 var root = Array.isArray(value) ? [] : {} 5 var cache = new WeakMap() 6 cache.set(value, root) 7 8 // 栈里存「待处理的 源对象/目标对象 对」 9 var stack = [{ source: value, target: root }] 10 11 while (stack.length) { 12 var task = stack.pop() 13 var source = task.source 14 var target = task.target 15 16 Reflect.ownKeys(source).forEach(function (key) { 17 var item = source[key] 18 19 if (item === null || typeof item !== 'object') { 20 target[key] = item 21 } else if (cache.has(item)) { 22 target[key] = cache.get(item) // 循环引用也顺手解了 23 } else { 24 var child = Array.isArray(item) ? [] : {} 25 cache.set(item, child) 26 target[key] = child 27 stack.push({ source: item, target: child }) 28 } 29 }) 30 } 31 32 return root 33}
思路和递归版完全一致(登记缓存、逐 key 处理),只是把「调用栈」显式换成了自己数组模拟的栈。我如实说:项目里我从没真遇到过需要它的数据,写它纯粹是为了想通「递归和迭代等价」这件事——但想通之后,递归版反而写得更有底气了。
延伸二:让浏览器替你做结构化克隆的邪道
查资料时还捡到一个有意思的偏方。浏览器的 postMessage 内部走的是「结构化克隆算法」,天然支持 Date、Map、Set、循环引用,可以借 MessageChannel 把它薅出来当深拷贝用:
1function deepCloneAsync(value) { 2 return new Promise(function (resolve) { 3 var channel = new MessageChannel() 4 channel.port2.onmessage = function (e) { 5 resolve(e.data) 6 } 7 channel.port1.postMessage(value) 8 }) 9} 10 11var c = {} 12c.self = c 13deepCloneAsync(c).then(function (copy) { 14 console.log(copy.self === copy) // => true,循环引用被正确克隆 15})
两个硬伤:一是它是异步的,同步场景用不了;二是和 JSON 法一样拷不了函数,碰到函数直接抛 DataCloneError。所以它更像一个知识点而不是生产方案。不过顺着它我才知道 history.pushState 的 state、IndexedDB 存对象,底层走的都是这套结构化克隆——浏览器里「对象被复制了一份」的地方,比想象中多。
说句实在话,SKU 迭代真正上线时,我用的既不是手写版也不是邪道版,而是 import cloneDeep from 'lodash/cloneDeep'。它把 Date、RegExp、Map、Set、循环引用、Symbol 这些边界全处理好了,自己手写很难考虑得这么周全。手写是为了理解原理,生产环境没必要重复造这个轮子——但反过来说,没手写过一遍,你连 lodash 帮你挡了哪些子弹都不知道。
什么时候该拷,什么时候别拷
回到工作里,真正需要深拷贝的场景其实数得过来:
- 编辑表单的临时副本(本案)
- 弹窗取消时的回滚
- Vuex 状态快照
- 拖拽排序前的备份
- 复杂配置的复制
但不要到处深拷贝。它有实打实的性能成本——列表渲染时每次都深拷贝大数组,页面能肉眼可见地变卡。我这次拿捏过的两个尺度:Vuex 里我反对「为了不报 mutation 警告就到处 cloneDeep 整个 state」,那性能账很亏,正确做法是只在 mutation 里改、组件拿到的当只读用;表单编辑按数据深浅选——平铺表单 { ...row } 浅拷贝就够,带 SKU 这种嵌套的才上深拷贝,而且只拷要编辑的那部分,不整个对象一锅端。
还有个反直觉的坑:深拷贝会切断所有引用关系。有时候这正是你要的(隔离),但有时候你其实依赖某些共享引用——内部缓存、DOM 节点引用、事件回调,深拷贝一刀切下去全断了,功能反而坏掉。所以深拷贝不是「更安全的拷贝」,它是「语义不同的拷贝」,用之前想清楚你到底要不要断引用。
事后记录
给这次事故写复盘文档时,我把根因那栏写成了一句话:"表单持有了列表数据的引用,不存在可回滚的副本。"组长看完在下面补了一行:"修复方案的边界(浅拷贝仅适用于平铺结构)已同步到 SKU 迭代。"
一个「重置按钮没用」的工单,最后挖出来的是一整条链:引用与值、浅拷贝的边界、JSON 序列化的七个坑、内部槽、缓存与递归顺序、Symbol 的枚举规则。每一层都对应一个概念,而每个概念我以前都「看过」,只是没被现实按着头验证过。被线上事故逼出来的理解,比看十遍文章都牢。
至于运营那边,我后来专门去解释了一句"不是你的操作问题,是代码的问题"。她说:"哦,那没事了。对了列表页翻到后面有点慢,你们看看?"——那又是下一件要查的事了。