【Javascript进阶】深拷贝与浅拷贝原理分析及实现

背景

最近开发的小程序项目中,要使用深拷贝,但是不想用Lodash库(太大,小程序受不了)。所以打算自己写,结果没写出来,于是网上找了现成的先应付着。

项目上线后,觉得是时候做个基础知识的回顾了。这是一篇对深浅拷贝探索的文章,借鉴了很多前辈的总结,在此表示感谢(文末会列出参考的文献)。

浅拷贝

浅拷贝指的是:拷贝后的引用类型数据与源对象是同一份数据,修改源对象的值,会把拷贝对象的也一起修改,反之亦然。

图片来自木易杨的博客

Object.assign是浅拷贝

var obj1 = {
    a:111,
    b:{c:222}
}

var obj2 = Object.assign({},obj1) // 浅拷贝
obj2.b.c = 333 // 修改引用类型的值

console.log(obj1) // obj1.b.c的值被修改未333,是浅拷贝
console.log(obj2)

复制代码

修改obj2的值,obj1的值也被修改,因为obj1和obj2的b属性引用的是同一个对象。

image.png

ES6对象解构也是浅拷贝

var obj1 = {
    a:111,
    b:{c:222}
}

var obj2 = {...obj1} // 浅拷贝
obj2.b.c = 666 // 修改引用类型的值

console.log(obj1) // obj1.b.c的值被修改未666,是浅拷贝
console.log(obj2)
复制代码

原因和上面说的一致,引用的是同一个对象,浅拷贝仅仅拷贝的是对象的引用地址,并没有拷贝对象的内容。

图片来自木易杨的博客

手写一个简单的浅拷贝

思考题:你可以手写一个浅拷贝吗?下方的浅拷贝实现存在哪些问题?
/**
 * 一个简单的浅拷贝
 * @param {Object} sourceObj 要复制的对象
 * @returns 返回浅拷贝的对象
 */
function shallowCopy(sourceObj) {
  var obj = {}

  if (typeof sourceObj !== 'object') {
    return sourceObj
  }
  for (var key in sourceObj) {
    if (Object.prototype.hasOwnProperty.call(sourceObj, key)) {
      obj[key] = sourceObj[key]
    }
  }
  return obj
}

var obj1 = {
  name: 'obj1',
  info: {
    sex: 'male',
    age: 18
  },
  undefined: undefined,
  func: function () { console.log('I am a simple function') },
  exp: new RegExp('\\w+'),
  createTime: new Date(),
  [Symbol('foo')]: 'symbol'
}

// 浅拷贝,修改原对象的引用类型的值,拷贝出来的对象也一同被修改
var shObj = shallowCopy(obj1)
obj1.info.age = 16 // 修改源对象
console.log('shObj', shObj) // 浅拷贝出来的对象也被修改了

复制代码

控制台的输出如下,修改obj1的age为16,shObj对象的age也被修改了。

image.png

上述浅拷贝方法存在的问题有很多,包括判断对象不够严谨,没有考虑循环引用等。我们的重点不是浅拷贝,为什么还要讲浅拷贝?

因为了解浅拷贝,才知道为什么要做深拷贝,浅拷贝是基础,深拷贝是进阶!

深拷贝

深拷贝指的是:拷贝后的对象是独立的(包括引用类型),修改源对象的值,不会对拷贝对象产生影响,反之亦然。

image.png

JSON.stringify()的弊端

说到深拷贝,很多人喜欢一把梭(即使用JSON.stringify()方法和JSON.parse()方法)。你知道使用这个两个方法做深拷贝会有哪些问题和隐患吗?

据我所知,使用JSON.stringify()做深拷贝,至少存在以下问题:

1、会忽略 undefined;

2、会忽略 symbol;

3、会忽略函数;

4、不能正确处理new Date();

5、不能处理正则(变成空对象);

6、不能解决循环引用的对象(直接报错)。

举个例子:

/**
 * 深拷贝0(JSON.stringify()一把梭)
 * @param0 sourceObj 要复制的对象
 * @returns {Object} 返回深拷贝的对象
 */
function deepCopy0(sourceObj) {
  try {
    return JSON.parse(JSON.stringify(sourceObj))
  } catch (error) {
    console.log('JSON.stringify报错:', error)
  }
}

var obj0 = {
  name: 'obj0',
  info: {
    sex: 'male',
    age: 22
  },
  undefined: undefined,
  func: function () { console.log('I am a simple function') },
  exp: new RegExp('\\w+'),
  createTime: new Date(),
  [Symbol('foo')]: 'symbol',
}

// 循环引用(已注释,打开后会报错,不信你拷贝到控制台试一下)
// obj0.circularReference = obj0

// 深拷贝,完全独立的两份数据,修改原对象引用类型的值,对拷贝出来的对象没影响
var copyObj0 = deepCopy0(obj0)
console.log('copyObj0--origin', obj0)
obj0.info.age = 100
console.log('copyObj0--copy', copyObj0)

try {
  copyObj0.func()
} catch (error) {
  console.log('这里会报错:', error)
}
复制代码

从结果可以看出,以上问题确实存在!

image.png

手写一个简单的深拷贝

思考题:除了JSON.stringify()方法,你还能想到其他办法实现深拷贝吗?

回顾上文中手写的浅拷贝,之所以是浅拷贝,是因为仅拷贝了引用类型的地址(保存在栈中的指针),没有拷贝引用类型的值(保存在堆中数据)。

我们稍微修改一下,如果是引用类型,就使用递归的方式拷贝属性的值,一个简单的深拷贝就出来了。

/**
 * 深拷贝1
 * @param0 sourceObj 要复制的对象
 * @returns {Object} 返回深拷贝的对象
 */
function deepCopy1(sourceObj) {
  var obj = {}

  if (typeof sourceObj !== 'object') {
    return sourceObj
  }

  Object.keys(sourceObj).forEach(key => {
    // 新增代码,遇到引用类型的属性,递归拷贝后再赋值
    if (typeof sourceObj[key] === 'object') {
      obj[key] = deepCopy1(sourceObj[key])
    } else {
      obj[key] = sourceObj[key]
    }
  })
  return obj
}

var obj1 = {
  name: 'obj1',
  info: {
    sex: 'male',
    age: 18
  },
  undefined: undefined,
  func: function () { console.log('I am a simple function') },
  exp: new RegExp('\\w+'),
  createTime: new Date(),
  [Symbol('foo')]: 'symbol'
}
// 循环引用
// obj1.circularReference = obj1

// 深拷贝,完全独立的两份数据,修改原对象引用类型的值,对拷贝出来的对象没影响
var copyObj1 = deepCopy1(obj1)
console.log('copyObj1-origin', obj1)
obj1.info.age = 16
console.log('copyObj1-copy', copyObj1)
copyObj1.func()
复制代码

拷贝效果比一把梭好一些,起码保留了undefined属性值。

image.png

但是问题还很多,需要继续完善。

deepCopy1方法存在的问题有:

1、会忽略 symbol

2、不能正确处理new Date()(变成空对象)

3、不能处理正则(变成空对象)

4、不能解决循环引用的对象(会导致堆栈溢出)

解决时间对象、正则表拷贝

/**
 * 深拷贝2(解决时间对象、正则表达式拷贝问题)
 * @param0 sourceObj 要复制的对象
 * @returns {Object} 返回深拷贝的对象
 */
function deepCopy2(sourceObj) {
  var obj = {}

  if (typeof sourceObj !== 'object') {
    return sourceObj
  }

  Object.keys(sourceObj).forEach(key => {
    // 引用类型,递归
    if (typeof sourceObj[key] === 'object') {
      if (sourceObj[key].constructor.name === 'Date') {
        obj[key] = sourceObj[key]
      } else if (sourceObj[key].constructor.name === 'RegExp') {
        var regexp = sourceObj[key]
        var reFlags = /\w*$/ // 提取正则的标识位
        // var result = new regexp.constructor(regexp.source, reFlags.exec(regexp)) // lodash的做法(保留原型链上的内容?)
        var result = new RegExp(regexp.source, reFlags.exec(regexp)) // 我的做法
        result.lastIndex = regexp.lastIndex // 上一次匹配执行到的位置,默认为0
        obj[key] = result
      } else {
        obj[key] = deepCopy2(sourceObj[key])
      }
    } else {
      obj[key] = sourceObj[key]
    }
  })
  return obj
}

var obj2 = {
  name: 'obj2',
  info: {
    sex: 'male',
    age: 18
  },
  undefined: undefined,
  func: function () { console.log('I am a simple function') },
  exp: new RegExp('\[0-9\]'),
  createTime: new Date(),
  [Symbol('foo')]: 'symbol'
}
// 循环引用
// obj2.circularReference = obj2

var copyObj2 = deepCopy2(obj2)
console.log('copyObj2-origin', obj2)
console.log('copyObj2-copy', copyObj2)

复制代码

控制台输出如下(成功拷贝时间和正则表达式):

image.png

deepCopy2方法存在的问题有:

1、会忽略 symbol

2、不能解决循环引用的对象(会导致堆栈溢出)

解决循环引用问题

deepCopy0deepCopy1deepCopy2方法,遇到循环引用会直接爆栈。解决循环引用的办法是,利用WeakMap来缓存遍历过的对象,如果存在就直接使用存在的对象,防止进入死循环。当然也可以用数组或者Map缓存。

思考题:为什么是WeakMap答案在这里
/**
 * 深拷贝3(解决循环引用问题)
 * @param0 sourceObj 要复制的对象
 * @param1 hash 哈希表(非必填)
 * @returns {Object} 返回深拷贝的对象
 */
function deepCopy3(sourceObj, hash) {
  if (typeof sourceObj !== 'object') {
    return sourceObj
  }

  var obj = {}
  var hash = hash || new WeakMap() // 使用哈希表存储遍历过的对象

  // 查找hash表中是否存在相同的值
  if (hash.has(sourceObj)) {
    return hash.get(sourceObj)
  }

  // 缓存当前的数据对象
  hash.set(sourceObj, obj)

  Object.keys(sourceObj).forEach(key => {
    // 引用类型,递归
    if (typeof sourceObj[key] === 'object') {
      if (sourceObj[key].constructor.name === 'Date') {
        obj[key] = sourceObj[key]
      } else if (sourceObj[key].constructor.name === 'RegExp') {
        var regexp = sourceObj[key]
        var reFlags = /\w*$/ // 提取正则的标识位
        var result = new RegExp(regexp.source, reFlags.exec(regexp)) // 我的做法
        // var result = new regexp.constructor(regexp.source, reFlags.exec(regexp)) // lodash的做法

        result.lastIndex = regexp.lastIndex // 上一次匹配执行到的位置,默认为0
        obj[key] = result
      } else {
        obj[key] = deepCopy3(sourceObj[key], hash) // 新增代码,传入hash表
      }
    } else {
      obj[key] = sourceObj[key]
    }
  })
  return obj
}

var obj3 = {
  name: 'obj3',
  info: {
    sex: 'male',
    age: 18
  },
  undefined: undefined,
  func: function () { console.log('I am a simple function') },
  exp: new RegExp('\[0-9\]'),
  createTime: new Date(),
  [Symbol('foo')]: 'symbol'
}
// 循环引用
obj3.circularReference = obj3

var copyObj3 = deepCopy3(obj3)
console.log('copyObj3-origin', obj3)
console.log('copyObj3-copy', copyObj3)

复制代码

效果如下,circularReference是循环引用字段,没有进入死循环,且完成了拷贝工作。

思考题:对属性circularReference的拷贝是深拷贝还是浅拷贝?为什么?

image.png

deepCopy3方法存在的问题有:

1、会忽略 symbol

2、不能拷贝数组

解决Symbol和数组拷贝问题

Symbol是ES6新的基本数据类型,任意两个Symbol值都不相等。Symbol的出现是为了解决对象属性覆盖问题(对象合并时,同名属性会被后来者覆盖)。

/**
 * 深拷贝4(Symbol拷贝、数组拷贝)
 * @param0 sourceObj 要复制的对象
 * @param1 hash 哈希表(非必填)
 * @returns {Object} 返回深拷贝的对象
 */
function deepCopy4(sourceObj, hash) {
  if (typeof sourceObj !== 'object') {
    return sourceObj
  }

  var obj = Array.isArray(sourceObj) ? [] : {}
  var hash = hash || new WeakMap() // 使用哈希表存储遍历过的对象

  // 查找hash表中是否存在相同的值
  if (hash.has(sourceObj)) {
    return hash.get(sourceObj)
  }

  // 缓存当前的数据对象
  hash.set(sourceObj, obj)

  // 新增代码,遍历symbols
  var objSymbols = Object.getOwnPropertySymbols(sourceObj)// 获取所有Symbol的key
  if (objSymbols.length) {
    objSymbols.forEach(symKey => {
      if (typeof objSymbols[symKey] === 'object') {
        obj[symKey] = deepCopy4(sourceObj[symKey], hash)
      } else {
        obj[symKey] = sourceObj[symKey]
      }
    })
  }

  for (var key in sourceObj) {
    if (sourceObj.hasOwnProperty(key)) {
      if (typeof sourceObj[key] === 'object') {
        if (sourceObj[key].constructor.name === 'Date') {
          obj[key] = sourceObj[key]
        } else if (sourceObj[key].constructor.name === 'RegExp') {
          var regexp = sourceObj[key]
          var reFlags = /\w*$/ // 提取正则的标识位
          var result = new RegExp(regexp.source, reFlags.exec(regexp)) // 我的做法
          // var result = new regexp.constructor(regexp.source, reFlags.exec(regexp)) // lodash的做法

          result.lastIndex = regexp.lastIndex // 上一次匹配执行到的位置,默认为0
          obj[key] = result
        } else {
          // 引用类型,递归
          obj[key] = deepCopy4(sourceObj[key], hash) // 新增代码,传入hash表
        }
      } else {
        obj[key] = sourceObj[key]
      }
    }
  }
  return obj
}

var obj4 = {
  name: 'obj4',
  info: {
    sex: 'male',
    age: 18
  },
  undefined: undefined,
  func: function () { console.log('I am a simple function') },
  exp: new RegExp('\[0-9\]'),
  createTime: new Date(),
  [Symbol('foo')]: new Date(),
  [Symbol('bar')]: 'symbol bar',
  [Symbol('bar')]: new RegExp('\[a-z\]'),
  arr: [1, 2, 3],
  fetchData: ["2021-08-12", "2021-08-12"]
}
var arr4 = [
  obj4,
  111,
  [222, 888],
  [{ fetchData: ["2021-08-12", "2021-08-12"] }]
]
// 循环引用
obj4.circularReference = obj4

var copyObj4 = deepCopy4(obj4)
var copyArr4 = deepCopy4(arr4)
console.log('copyObj4-copy', copyObj4)
obj4.info.age = 100
obj4.arr[2] = 666
console.log('copyObj4-origin', obj4)

console.log('copyArr4-copy', copyArr4)
arr4[3][0].a = 'changed to bbb'
arr4[2][1] = 'change to 666'
console.log('copyArr4-origin', arr4)

复制代码

结果如下,Symbol类型的属性可以拷贝了:

image.png

数组也可以拷贝了:

image.png

deepCopy4存在的问题:

1、对引用类型的判断不够严谨;

2、采用递归,没有考虑爆栈问题。

解决递归爆栈问题(成品)

大家都知道,浏览器可以使用的内存受到操作系统的限制。如果需要深拷贝的对象层级太深(例如:10000级别的深度),就会导致内存溢出。所以我们要改用循环的方式,循环的好处是,拷贝过程中内存会得到释放,每一次循环结束都会执行出栈操作,这样一来占用的内存就不会一直增加。

思考题:代码中的continue可否修改为break?为什么?

/**
 * 深拷贝5(改用循环,解决爆栈问题,并优化对象的判断方式)
 * @param0 sourceObj 要复制的对象
 * @param1 hash 哈希表(非必填)
 * @returns {Object} 返回深拷贝的对象
 */
function deepCopy5(sourceObj, hash) {
  // 优化代码,优化引用类型判断
  if (!isObject(sourceObj)) {
    return sourceObj
  }

  // 新增代码,兼容数组类型
  var root = Array.isArray(sourceObj) ? [] : {}
  var uniqueList = [] // 使用数组缓存

  var loopList = [{
    parent: root,
    key: undefined,
    data: sourceObj
  }]

  while (loopList.length) {
    var node = loopList.pop() // 出栈
    var key = node.key
    var data = node.data
    var parent = node.parent

    // 初始化赋值目标,key为undefined则拷贝到父元素,否则拷贝到子元素
    let res = parent
    if (typeof key !== 'undefined') {
      res = parent[key] = Array.isArray(data) ? [] : {}
    }
    console.log('uniqueList', uniqueList)
    // debugger
    // 数据已经存在
    let uniqueData = find(uniqueList, data);
    console.log('uniqueData',uniqueData)
    if (uniqueData) {
      parent[key] = uniqueData.target;
      continue; // 中断本次循环,不能用break,否则会无法拷贝其他的对象({})类型呢
    }

    // 数据不存在
    // 保存源数据,在拷贝数据中对应的引用
    uniqueList.push({
      source: data,
      target: res
    });

    // 遍历symbols
    var objSymbols = Object.getOwnPropertySymbols(data)// 获取所有Symbol的key

    if (objSymbols.length) {
      objSymbols.forEach(symKey => {
        if (typeof objSymbols[symKey] === 'object') {
          loopList.push({
            parent: res,
            key: key,
            data: res[symKey]
          })
        } else {
          res[symKey] = data[symKey]
        }
      })
    }

    for (var key in data) {
      if (data.hasOwnProperty(key)) {
        let tempObj = data[key]

        if (isObject(tempObj)) {
          if (tempObj.constructor.name === 'Date') {
            loopList.push({
              parent: res,
              key: key,
              data: new Date(tempObj)
            })
          } else if (tempObj.constructor.name === 'RegExp') {
            loopList.push({
              parent: res,
              key: key,
              data: copyRegExp(tempObj)
            })
          } else {
            loopList.push({
              parent: res,
              key: key,
              data: tempObj
            })
          }
        } else {
          res[key] = tempObj
        }
      }
    }
  }

  return root
}

/**
 * 判断输入是否为对象类型(数组也是对象)
 * @param {any} obj 
 * @returns Boolean 返回true或false
 */
function isObject(obj) {
  return Object.prototype.toString.call(obj) === '[object Object]' || Object.prototype.toString.call(obj) === '[object Array]'
}

/**
 * 正则拷贝
 * @param {RegExp} regexp 
 * @returns 拷贝后的正则
 */
function copyRegExp(regexp) {
  var reFlags = /\w*$/ // 提取正则的标识位
  var result = new RegExp(regexp.source, reFlags.exec(regexp)) // 我的做法
  // var result = new regexp.constructor(regexp.source, reFlags.exec(regexp)) // lodash的做法

  result.lastIndex = regexp.lastIndex // 上一次匹配执行到的位置,默认为0
  return result
}

/**
 * 数组查找
 * @param {原数组} arr 
 * @param {要查找的目标} item 
 * @returns 返回找到的数据,找不到则null
 */
function find(arr, item) {
  for (let i = 0; i < arr.length; i++) {
    if (arr[i].source === item) {
      return arr[i];
    }
  }

  return null;
}

var obj5 = {
  name: 'obj5',
  info: {
    sex: 'male',
    age: 18
  },
  undefined: undefined,
  func: function () { console.log('I am a simple function') },
  exp: new RegExp('\[0-9\]'),
  createTime: new Date(),
  [Symbol('foo')]: new Date(),
  [Symbol('bar')]: 'symbol bar',
  [Symbol('bar')]: new RegExp('\[a-z\]'),
}

// 循环引用
obj5.circularRef = obj5

// 对象拷贝测试
var copyObj5 = deepCopy5(obj5)
console.log('copyObj5-copy', copyObj5)
copyObj5.info.age = 66
copyObj5.createTime = new Date('2021-08-10 18:16:40')
console.log('copyObj5-origin', obj5)

var arr5 = [
  {
    name: 'obj5',
    info: {
      sex: 'male',
      age: 18
    },
    undefined: undefined,
    func: function () { console.log('I am a simple function') },
    exp: new RegExp('\[0-9\]'),
    createTime: new Date(),
    [Symbol('foo')]: new Date(),
    [Symbol('bar')]: 'symbol bar',
    [Symbol('bar')]: new RegExp('\[a-z\]'),
  },
  111,
  [1, 2]
]

// 数组拷贝测试
var copyArr5 = deepCopy5(arr5)
console.log('copyArr5-copy', copyArr5)
copyArr5[0].info.age = 100
copyArr5[2][0] = 666
console.log('copyArr5-origin', arr5)

复制代码

对象深拷贝拷贝测试结果,修改引用类型的值不会互相影响,循环引用也不会有问题:

image.png

数组深拷贝测试结果,修改引用类型的值不会互相影响:

image.png

还存在的问题

目前没有实现的功能有:Map、Set、Weakmap、Weakset等新增数据类型,还有Function的拷贝,原型链的拷贝等等。

当然如果你能确保项目中没有这些数据类型,那么deepCopy5基本可以满足要求。所谓优化无止境,如果发现错漏或者有优化的建议,欢迎在评论区指出,如果觉得文章对你有帮助,就大方的点个赞吧(^_^)

参考文献:

1、面试题之如何实现一个深拷贝

2、深拷贝的终极探索(99%的人都不知道)

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享