JavaScript 深拷贝:为什么没点保存,列表数据还是被改脏了

“没点保存,列表数据却自己变了” 这类 bug,十有八九都绕不开对象引用。表面看像重置逻辑失效,真正出问题的往往是编辑态和展示态其实指向了同一份对象,弹窗里改字段,外面的列表也就跟着一起变了。

这次现场就很典型:打开编辑弹窗,随便改几个字段,点“重置”,再点“取消”,页面上那行商品数据已经脏了,刷新才恢复。问题不在重置按钮写错,而在它从一开始就没有独立的“原始数据副本”可退。

后面会从这类引用共享的 bug 往下挖,把浅拷贝为什么不够、JSON 深拷贝为什么经常不靠谱、递归拷贝要补哪些类型和边界,一路拆到循环引用、DateRegExpSymbol

一行看起来人畜无害的赋值

这个编辑弹窗是我一月份写的,打开弹窗的代码长这样:

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

ab 指向同一个对象。栈里存的是同一个堆地址,改谁都是改那块堆内存。理解这点就能解释一堆「灵异现象」:函数参数传对象进去,函数里改了属性外面也变了(传的是引用的拷贝,指向同一对象);两个看着一模一样的对象 {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

再往下挖还有几个更隐蔽的:

  • NaNInfinity 会变成 null。做数值统计时这个最阴,一个 NaN 拷完变 null,下游计算结果全错还不报错。
  • MapSet 会变成空对象 {},里面的数据全没了。
  • 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 同理,sourceflags 也不在常规遍历里。

特殊类型不能靠遍历属性去拷,要用构造函数重新造一个:

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 拷出来是个活的 DategetTime() 正常。这一坑要是没提前踩,等 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.keysfor...inJSON.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 内部走的是「结构化克隆算法」,天然支持 DateMapSet、循环引用,可以借 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 的枚举规则。每一层都对应一个概念,而每个概念我以前都「看过」,只是没被现实按着头验证过。被线上事故逼出来的理解,比看十遍文章都牢。

至于运营那边,我后来专门去解释了一句"不是你的操作问题,是代码的问题"。她说:"哦,那没事了。对了列表页翻到后面有点慢,你们看看?"——那又是下一件要查的事了。