摸索 JS 内深拷贝的最佳实践

问题

由于 js 的传参方式有时会遇到这样的场景:

function setTime(data) {
  let result = {};
  result.obj = data.obj || {};
  result.obj.time = Date.now();
  return result
}

let data = {
  title:'loooook!',
  obj: {
    name: 'keo',
    age: '12'
  }
}

let res = setTime(data);

console.log('res',res);
//res { obj: { name: 'keo', age: '12', time: 1533625350183 } }
console.log('data',data);
//data { title: 'loooook!', obj: { name: 'keo', age: '12', time: 1533625350183 } }

我只是想继承参数的部分数据,并在此基础添加一些东西,但是参数 data 的源数据也被我改动了,如果之后有其他人想要从data获取数据,他可能还需要注意是否有像 setTime 这样的函数调用它。

一点修改

function setTime(data) {
  let result = {};
  result.obj =  {};
  Object.assign(result.obj,data.obj)
  result.obj.time = Date.now();
  return result
}

嗯,或者你也可以用 for...in,注意下二者的不同。
我们知道 Object.assign 只是浅拷贝,如果 data.obj 的属性值仍然有引用类型的话,那么还是会遇见同样的问题。
那要怎么办?难道要遍历data下每个属性的值?一个个复制过来?我们看看 lodash 是怎么做的

lodash 的深拷贝

你猜的没错,的确是要深度遍历的。
baseClone方法内,拿到要拷贝的对象 value 后,先检查其类型,然后由对应的 handler 来处理,比如value是数组类型,则使 result 为同样长度的数据,然后对每一项都递归调用 baseClone,直到 value 是非引用类型,返回 value的值;如果是普通对象类型,则使 result 为空数组,然后拿取valuekey,对每个key的赋值也是递归调用baseClone

想要简单点

难道我深拷贝一个变量还要引入 lodash 这么麻烦吗 ?没有简单点的办法吗?

JSON.parse(JSON.stringify(param))

嗯,可能有点不是那么酷炫,但是他确实可以满足要求,而且也无须引入其他的库。但如果它真的这么完美,为什么 lodash 不这么写呢?
的确,它的缺点还挺多的,这里取几个我觉得比较重要的:

  1. Set 类型、Map 类型以及 Buffer 类型会被转换成 {}
  2. undefined、任意的函数以及 symbol 值,在序列化过程中会被忽略(出现在非数组对象的属性值中时)或者被转换成 null(出现在数组中时)
  3. 对包含循环引用的对象(对象之间相互引用,形成无限循环)执行此方法,会抛出错误
  4. 所有以 symbol 为属性键的属性都会被完全忽略掉,即便 replacer 参数中强制指定包含了它们

是啊,毕竟JSON的两个方法本身就只是用来转换 js 内的对象为 JSON 格式的,上述几点甚至都不是缺点,是我们想借用其他方法做深拷贝时遇到的问题。

既然是问题那应该可以解决吧,比如第一条和第二条,在 stringify 时判断类型,转化成 带类型标识符的对象字符串如:Set [1,2,3,4,5],然后在parse的时候对字符串进行解析,特别的类型调用对应的构造函数... 听起来变得更麻烦了,没关系,忍忍把各个类型的处理都写了;针对第三条,抛错了?没关系,我 try catch 包起来...,什么?循环引用?

循环引用?

function parse (param){
  return JSON.parse(JSON.stringify(param))
}

var a = {}
var b = {}
a['b'] = b
b['a'] = a

console.log(parse(a))
//TypeError: Converting circular structure to JSON at JSON.stringify

如上代码, 变量ab 互相引用对方,此时如果借用 JSON 的方法来进行深拷贝的话,会报循环结构转换转换 JSON 错误。这个问题怎么解决呢?我们再翻出 lodash 的源码看看...

      // Check for circular references and return its corresponding clone.
      stack || (stack = new Stack);
      var stacked = stack.get(value);
      if (stacked) {
        return stacked;
      }
      stack.set(value, result);

这里的 valueresult 分别是是一次遍历中 要拷贝的值 和 拷贝的结果。stack 是一个用来储存每次对应的 valueresult 的对象, stack下有一块用于储存的数组结构,该数组的每一项记录了单次遍历中的 valueresult,后二者再次以数组的形式存储,以 value 做为下标 0 的项,result 为下标 1 的项(这里不用对象的 key-value 形式可能是因为循环引用的变量无法使用 JSON.stringify 转换成字符串,只能 toString 转成 object Object);stack 是做为参数贯穿整个遍历过程的,每次遍历时都会以当前的 value 值进行查找(这里的查找直接是判断内存地址相等),如果能在 stack 中查到到对应的结果,则直接返回记录中的result,不再继续递归。
好了,循环引用的问题我们解决了,鼓掌!但是我也放弃使用 JSON 方法了...还有没有其他直接点的方法呢?

其他方法

结构化克隆算法是由HTML5规范定义的用于复制复杂JavaScript对象的算法,它通过递归输入对象来构建克隆,同时保持先前访问过的引用的映射,以避免无限遍历循环。

怎么用?
emmm... 它还不能直接使用,你得依靠一些其他的 API ,间接的使用它。

  • postMessage()
function StructuredClone(param) {
  return new Promise(function (res, rej) {
    const {port1, port2} = new MessageChannel();
    port2.onmessage = ev => res(ev.data);
    port1.postMessage(param);
  })
}

StructuredClone(objects).then(result => console.log(result))

什么??还是异步的... 不,我希望能使用同步的方法使用它。

  • history()
function structuralClone(obj) {
  const oldState = history.state;
  history.replaceState(obj, document.title);
  const copy = history.state;
  history.replaceState(oldState, document.title);
  return copy;
}
const clone = structuralClone(objects);

如你所见,我们要借用一下 history.replaceState 这个方法,但是我们不能改变 history 原有的状态,所以用完就要恢复原状,当无事发生过。
至少,这是个同步的方法...,如果是同步的场景可以考虑一下...

性能展示

这里的测试代码是使用的 [Deep-copying in JavaScript] (https://dassur.ma/things/deep-copy/) 一文中的,并再次基础做了一些修改。

结果! (很懒就不画图表了)

单位 μs (缪斯),计算时间的用的接口是 performance.now()结果精确到5微秒。

  • chrome


    chrome
  • safari
    ...em...Safari浏览器在调用完 postMessage 方法后就...没有然后了...表格都没刷出来...等了 40 s 终于刷出第一栏...
    注释完 postMessage 又发现不能频繁的调用 history 。

    调用 history 的 api 抛异常

safari 结果
  • firefox
    ...em.. 调用 history 相关 api 对 firefox 好像压力很大,以至于循环都有些错乱...于是注释了相关代码
firefox 结果

就结果而言好像看不出什么区别,可能是我的数据不好,大家可以去看看原文,有展示阅读性更好的图表,尽管没有 lodash 就是了。

结果

回到我们最初的问题,我们只是想深拷贝一个 js 对象,如果只是一个比较"普通"的对象,用JSON的方法简单又快捷,但是如果这个对象有些“复杂”,似乎使用 lodash 的方法是比较好的选择,而且 lodash 连 Structured Clone 算法忽视的 symbol 类型 和 Function 也考虑其中,兼容性也没问题,也不会在不同的浏览器发生意外的状况...
lodash 万岁!lol!!

参考阅读:
Deep-copying in JavaScript

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,319评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,801评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,567评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,156评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,019评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,090评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,500评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,192评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,474评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,566评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,338评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,212评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,572评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,890评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,169评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,478评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,661评论 2 335

推荐阅读更多精彩内容