【JS】深拷贝、浅拷贝和赋值解析与lodash源码学习

一般而言,我们对于拷贝的理解就是拷贝之后会获得两个初始内容完全一样的个体。这两个个体是完全独立的。

为什么会存在深浅拷贝的差别?


JS当中数据类型分为

  • 基本类型(undefined、null、string、number、boolean、symbol(ES6))
  • 引用类型(Object、Array、RegExp、Date、Function)

基本类型值指的是那些保存在内存中的简单数据段,即这种值是完全保存在内存中的一个位置。包含Number,String,Boolean,Null,Undefined ,Symbol。
基本数据类型是按照数值来引用,且也是按值来比较,所以拷贝一个基本数据类型的话,只需要拷贝它的数值。

var a = 2;
var b = a;
a = 3;
console.log(a,b);//3,2

可以看到,基本数据类型通过赋值就可以被拷贝,拷贝之后,a和b就是两个独立的个体,他们的变化不会互相影响。所以对于基本数据类型来说,没有什么深拷贝和浅拷贝的差别。

引用类型值指的是那些保存在内存中的对象,所以引用类型的值保存的是一个指针,这个指针指向存储在堆中的一个对象。引用类型统称为Object类型。细分的话,有:Object 类型、Array 类型、Date 类型、RegExp 类型、Function 类型 等。

但是对于引用类型,因为引用类型的具体内容数据是存在在堆当中的,堆中的数据不能直接按照数值引用,对象中实际保存的是引用类型数据的地址信息,而不是它具体的内容和数值。
如果我们只是,简单的拷贝下引用类型,那只是拷贝了它的引用地址,所以会出现这种情况。

var a = {
  test:1
};
var b = a;
b.test = 2;
console.log(a,b);
// {test:2},{test:2}

我们会发现,b修改了test的数值之后,连a的test数值也被修改了。
因为a对象中保存的其实只是{test:1}的数据的一个引用地址,所以b=a只是把a保存的引用地址赋值给b了。所以b其实拿到的是一个引用地址和a指向的是同一组数据。

【JS】深拷贝、浅拷贝和赋值解析与lodash源码学习_第1张图片
引用类型变量

所以当b拿着从a拷贝过来的地址,找到了test属性对应的数值,并且修改了它。等a在引用的时候,a保存的也是同一个地址,但是这个地址对应的数据,已经被b修改过了,所以a输出的数值就变成{test:2}
那从严格的角度来说,赋值对于引用类型的拷贝只是一种浅层次的拷贝,仅拷贝了数据引用地址,但是实际的数据内容还是共享的,即我们常见的“浅拷贝”
因此,对于引用类型,就衍生出了一个问题:
能否让引用类型的拷贝版本的数据内容跟原来的对象完全独立开,互相不影响?
也就是我们常说的“深拷贝”
「JavaScript」带你彻底搞清楚深拷贝、浅拷贝和循环引用

JS常见浅拷贝方式


数组浅拷贝
  • for...in
let refArr = [{test:1},{test:2}];
let refCopy = [];
for(let i = 0; i
  • Array.prototype.slice()
let arr = [1,2,3,4];
let copy = arr.slice();
arr[0] = 0;
console.log(arr,copy);
//[0,2,3,4],[1,2,3,4]

let refArr = [{test:1},{test:2}];
let refCopy = refArr.slice();
refArr[0].test = 0;
console.log(refArr,refCopy);
//[{test:0},{test:2}],[{test:0},{test:2}]

slice不改变原数组的内容,如果内容是基本数据类型,可以正常拷贝
如果数组内容是引用类型,则进行浅拷贝
MDN-Array/slice

  • Array.prototype.concat()

和slice类似

let arr = [1,2,3,4];
let copy = arr.concat();
arr[0] = 0;
console.log(arr,copy);
//[0,2,3,4],[1,2,3,4]

let refArr = [{test:1},{test:2}];
let refCopy = refArr.concat();
refArr[0].test = 0;
console.log(refArr,refCopy);
//[{test:0},{test:2}],[{test:0},{test:2}]

MDN-/Array/concat

注意:如果数组的元素都是基本数据类型,这几种方法无所谓深拷贝浅拷贝,因为都是返回一个新的数组,所以得到的都是互不干扰的新对象。
当数组元素包含引用类型的时候,引用类型拷贝后只拷贝了引用地址,所以还是浅拷贝。

Object浅拷贝
  • Object.assign(target,source)

assign方法是把source对象上的key-val属性和数值添加到target对象上
Symbol类型也可以被拷贝

let obj1 = { a: 0 , b: { c: 0}}; 
let obj2 = Object.assign({}, obj1); 
obj1.b.c = 1;
console.log(JSON.stringify(obj2)); // { a: 0, b: { c: 1}} 

MDN-Object/assign

扩展运算符 ...
//数组
let dd = [{test:1},{test:2}];
 let b = [...dd]; 
dd[0].test = 0;
console.log(b,a);
//[{test:0},{test:2}],[{test:0},{test:2}]
//对象
let obj1 = { a: 0 , b: { c: 0}}; 
let obj2 = {...obj1}
obj1.b.c = 1;
console.log(JSON.stringify(obj2)); // { a: 0, b: { c: 1}} 

总结

  • 上述这些拷贝方式,共性都是会把引用类型的每个元素值赋给另外一个对象。
  • 每个元素直接赋值,对于这种一层数据的引用类型数据,如[1,2,3,4]{a:2,b:3}这样的数据类型,可以实现深拷贝
  • 但是如果是引用类型的元素也是引用类型,那直接赋值拷贝的就是引用地址,所以只能是浅拷贝。

深拷贝的实现方法


思路一:JSON.parse(JSON.stringify(obj))
let arr = [
  {
    level_one:{
      level_two:0
    }
  },
  {
    level_one:{
      level_two:1
    }
  },
];
let arrCopy = JSON.parse(JSON.stringify(arr));
arr[0].level_one.level_two = 1;
console.log(arrCopy);
//[{level_one:{level_two: 0}},level_one:{level_two: 1}}]
  • JSON.stringify(obj)会调用obj上的toJSON方法,把对象转换成符合JSON格式的特殊的string
    • 如果obj里面包含循环引用的对象比如a = { test:a},则会报错
  • 局限性
    • Object里面不可枚举的属性会被忽略,无法拷贝
    • Symbol会被直接忽略
    • 不能完美拷贝undefined,function,Symbol,Date,NaN,Infinity
let objNoWork = {
  a:undefined,
  b:function(){
      console.log('I am function');
  },
  c:NaN,
  d:Symbol('d'),
  e:new Date('2019-01-01')
}
objNoWork[Symbol('f')] = 'Symbol value';
let obj = JSON.parse(JSON.stringify(objNoWork));
console.log(objNoWork);
console.log(obj);

【JS】深拷贝、浅拷贝和赋值解析与lodash源码学习_第2张图片
原始对象

拷贝后对象

从拷贝后结果我们可以看出来,undefined,function,Symbol直接被忽略了,Date也转换成字符串,NaN直接变成null了。
所以JSON.parse(JSON.stringify(obj)),使用的时候特别注意这些特殊的数据情况。
ecma262#sec-json.stringify
mdn-JSON/stringify

思路二:按照类型处理—递归拷贝

如果要实现一个完美的深拷贝,可以根据不同数据类型做递归处理。
所以实现的思路其实主要就是:类型判断+具体的拷贝实现+递归处理

这个基础思路看起来没啥大问题,不过如果遇到一种特殊情况的话,就会有问题。

  • 局限性:循环引用
什么是循环引用

简单的说,就是在子对象当中又引用了父对象,这种情况在JS当中是完全允许的。

//循环引用
let obj ={
  a:1,
  b: {
   ref:obj
  }
}

循环引用的问题:子对象保存的是对象的引用,实际上的数值还是指向同一个对象,所以在递归过程当中,一遇到循环引用,就会进入新的递归过程,无限套娃,这个子对象一直没有解除递归的条件,最后会死循环,导致拷贝爆栈。

所以在实现深拷贝的过程当中,我们需要对循环引用的情况进行另外处理。

结合循环引用情况,梳理下这个深拷贝的实现思路可以概括为:

  1. 判断数值的数据类型
  2. 根据特定数据类型进行具体的拷贝
    • 可遍历类型(Object/Array/Map/Set等):遍历每个值递归处理
    • 不可遍历类型: 根据类型进行赋值
    • 根据类型,通过constructor构造初始值然后拷贝内容
  3. 引用类型,记录拷贝情况,出现循环引用且已经拷贝过的对象,不另外拷贝

深拷贝具体实现

具体实现主要参考的是lodash当中cloneDeep的实现。

类型判断
  • typeof
  • Object.prototype.toString.call(value)
    【js类型判断】包装类以及isArray,instanceof,typeof用法及原理
数组
/**
 * Initializes an array clone.
 *
 * @private
 * @param {Array} array The array to clone.
 * @returns {Array} Returns the initialized clone.
 */
function initCloneArray(array) {
  const { length } = array
  const result = new array.constructor(length)

  // Add properties assigned by `RegExp#exec`.
  if (length && typeof array[0] === 'string' && hasOwnProperty.call(array, 'index')) {
    result.index = array.index
    result.input = array.input
  }
  return result
}

Map/Set
//复制Map
  if (tag == mapTag) {
    value.forEach((subValue, key) => {
      result.set(key, baseClone(subValue, bitmask, customizer, key, value, stack))
    })
    return result
  }
// set
  if (tag == setTag) {
    value.forEach((subValue) => {
      result.add(baseClone(subValue, bitmask, customizer, subValue, value, stack))
    })
    return result
  }
深拷贝Symbol

Symbol是ES6当中出现的全新的基本数据类型,它会返回一个唯一的symbol值,而且不能使用new Symbo()来创建新的对象。

let a = Symbol('b');
let b = Symbol('b');
console.log(a === b);//false
let c = a;
console.log( c === a);//true

MDN-Symbol
Symbol代表的是唯一的值,可是它又不能new,那它的拷贝是几个意思?
我一开始纠结了一下Symbol深拷贝是什么意思呢?
直接赋值那不就是原来的那个value吗,他们是不就是同一个value吗?
但是Symbol('b')Symbol('b')不是两个一样的数值呀?
后来想明白了,所谓拷贝,拷贝后的属性和数值跟原来的要一样,而且Symbol本身是基本数据类型,不是引用类型。

/** Used to convert symbols to primitives and strings. */
const symbolValueOf = Symbol.prototype.valueOf

/**
 * Creates a clone of the `symbol` object.
 *
 * @private
 * @param {Object} symbol The symbol object to clone.
 * @returns {Object} Returns the cloned symbol object.
 */
function cloneSymbol(symbol) {
  return Object(symbolValueOf.call(symbol))
}

export default cloneSymbol

深拷贝function
  • 思路:
  • prototype来区分下箭头函数和普通函数,箭头函数是没有prototype的。
  • 使用eval和函数字符串来重新生成一个箭头函数
  • 使用正则来处理普通函数:使用正则取出函数体和函数参数,然后使用new Function ([arg1[, arg2[, ...argN]],] functionBody)
function cloneFunction(func) {
    const bodyReg = /(?<={)(.|\n)+(?=})/m;
    const paramReg = /(?<=\().+(?=\)\s+{)/;
    const funcString = func.toString();
    if (func.prototype) {
        console.log('普通函数');
        const param = paramReg.exec(funcString);
        const body = bodyReg.exec(funcString);
        if (body) {
            console.log('匹配到函数体:', body[0]);
            if (param) {
                const paramArr = param[0].split(',');
                console.log('匹配到参数:', paramArr);
                return new Function(...paramArr, body[0]);
            } else {
                return new Function(body[0]);
            }
        } else {
            return null;
        }
    } else {
        return eval(funcString);
    }
}

如何写出一个惊艳面试官的深拷贝?

循环引用深拷贝

循环引用处理思路:记录引用记录是否已经被拷贝过了,如果拷贝过了,那么就直接把引用地址赋值就可以,没有拷贝过,再进行深拷贝。

  • lodash处理循环引用——用栈来记录引用记录

通过key-value的形式来保存拷贝值和它是否被拷贝过的记录
如果被拷贝过了,就直接返回它当时被拷贝后的记录,结束当前递归调用。

//  循环引用
//Check for circular references and return its corresponding clone.
// result是拷贝过后的值,value是传入的待拷贝的值
  stack || (stack = new Stack)
  const stacked = stack.get(value)
  if (stacked) {
    return stacked
  }
  stack.set(value, result)

如果我们去看Stack源代码,我们会发现在源代码里面设置了size限制为200

  • 使用weekMap来记录引用记录

设想一下,如果我们要拷贝的对象非常庞大时,使用Map会对内存造成非常大的额外消耗,而且我们需要手动清除Map的属性才能释放这块内存,而WeakMap会帮我们巧妙化解这个问题。

WeakMap:对象是一组键/值对的集合,其中的键是弱引用的。其键必须是对象,而值可以是任意的。
相比之下,原生的 WeakMap 持有的是每个键对象的“弱引用”,这意味着在没有其他引用存在时垃圾回收能正确进行。原生 WeakMap 的结构是特殊且有效的,其用于映射的 key 只有在其没有被回收时才是有效的。
MDN-WeakMap

WeakMap的特性就是,保存在其中的对象不会影响垃圾回收,如果WeakMap保存的节点,在其他地方都没有被引用了,那么即使它还在WeakMap中也会被垃圾回收回收掉了。

那么这个特性为什么可以应用到引用记录这来呢?

在深拷贝的过程当中,里面所有的引用对象,都是被引用的,为了解决循环引用的问题,在深拷贝的过程中,我们希望有个数据结构能够记录每个引用对象有没有被使用过,但是深拷贝结束之后这个数据能自动被垃圾回收,避免内存泄漏。
JS垃圾回收机制

深拷贝和浅拷贝
JavaScript对象深拷贝/浅拷贝遇到的坑和解决方法
【进阶4-3期】面试题之如何实现一个深拷贝
JS深浅拷贝探究

性能优化考量

我们可以看到lodash当中遍历使用的是while循环去实现的。

function arrayEach(array, iteratee) {
  let index = -1
  const length = array.length

  while (++index < length) {
    if (iteratee(array[index], index, array) === false) {
      break
    }
  }
  return array
}

因为对比for...infor循环来说,while循环的效率会更高一些。
参考这篇文章中的性能对比:如何写出一个惊艳面试官的深拷贝?

【JS】深拷贝、浅拷贝和赋值解析与lodash源码学习_第3张图片
三种循环的性能比较

lodash深拷贝源码整体逻辑梳理
  • lodash当中类型判断用的也是Object#toString和typeof结合判断数据类型
/** `Object#toString` result references. */
const argsTag = '[object Arguments]'
const arrayTag = '[object Array]'
const boolTag = '[object Boolean]'
const dateTag = '[object Date]'
const errorTag = '[object Error]'
const mapTag = '[object Map]'
const numberTag = '[object Number]'
const objectTag = '[object Object]'
const regexpTag = '[object RegExp]'
const setTag = '[object Set]'
const stringTag = '[object String]'
const symbolTag = '[object Symbol]'
const weakMapTag = '[object WeakMap]'

const arrayBufferTag = '[object ArrayBuffer]'
const dataViewTag = '[object DataView]'
const float32Tag = '[object Float32Array]'
const float64Tag = '[object Float64Array]'
const int8Tag = '[object Int8Array]'
const int16Tag = '[object Int16Array]'
const int32Tag = '[object Int32Array]'
const uint8Tag = '[object Uint8Array]'
const uint8ClampedTag = '[object Uint8ClampedArray]'
const uint16Tag = '[object Uint16Array]'
const uint32Tag = '[object Uint32Array]'
  • 不同类型的数据,根据类型生成新的拷贝初始值
/**
 * Initializes an object clone based on its `toStringTag`.
 *
 * **Note:** This function only supports cloning values with tags of
 * `Boolean`, `Date`, `Error`, `Map`, `Number`, `RegExp`, `Set`, or `String`.
 *
 * @private
 * @param {Object} object The object to clone.
 * @param {string} tag The `toStringTag` of the object to clone.
 * @param {boolean} [isDeep] Specify a deep clone.
 * @returns {Object} Returns the initialized clone.
 */
function initCloneByTag(object, tag, isDeep) {
  const Ctor = object.constructor
  switch (tag) {
    case arrayBufferTag:
      return cloneArrayBuffer(object)

    case boolTag:
    case dateTag:
      return new Ctor(+object)

    case dataViewTag:
      return cloneDataView(object, isDeep)

    case float32Tag: case float64Tag:
    case int8Tag: case int16Tag: case int32Tag:
    case uint8Tag: case uint8ClampedTag: case uint16Tag: case uint32Tag:
      return cloneTypedArray(object, isDeep)

    case mapTag:
      return new Ctor

    case numberTag:
    case stringTag:
      return new Ctor(object)

    case regexpTag:
      return cloneRegExp(object)

    case setTag:
      return new Ctor

    case symbolTag:
      return cloneSymbol(object)
  }
}
  • 特殊的数据类型不支持拷贝
/** Used to identify `toStringTag` values supported by `clone`. */
const cloneableTags = {}
cloneableTags[argsTag] = cloneableTags[arrayTag] =
cloneableTags[arrayBufferTag] = cloneableTags[dataViewTag] =
cloneableTags[boolTag] = cloneableTags[dateTag] =
cloneableTags[float32Tag] = cloneableTags[float64Tag] =
cloneableTags[int8Tag] = cloneableTags[int16Tag] =
cloneableTags[int32Tag] = cloneableTags[mapTag] =
cloneableTags[numberTag] = cloneableTags[objectTag] =
cloneableTags[regexpTag] = cloneableTags[setTag] =
cloneableTags[stringTag] = cloneableTags[symbolTag] =
cloneableTags[uint8Tag] = cloneableTags[uint8ClampedTag] =
cloneableTags[uint16Tag] = cloneableTags[uint32Tag] = true
cloneableTags[errorTag] = cloneableTags[weakMapTag] = false
  • 拷贝主逻辑
/** Used to compose bitmasks for cloning. */
const CLONE_DEEP_FLAG = 1
const CLONE_FLAT_FLAG = 2
const CLONE_SYMBOLS_FLAG = 4
/**
 * The base implementation of `clone` and `cloneDeep` which tracks
 * traversed objects.
 *
 * @private
 * @param {*} value The value to clone.
 * @param {number} bitmask The bitmask flags.
 *  1 - Deep clone
 *  2 - Flatten inherited properties
 *  4 - Clone symbols
 * @param {Function} [customizer] The function to customize cloning.
 * @param {string} [key] The key of `value`.
 * @param {Object} [object] The parent object of `value`.
 * @param {Object} [stack] Tracks traversed objects and their clone counterparts.
 * @returns {*} Returns the cloned value.
 */
function baseClone(value, bitmask, customizer, key, object, stack) {
  let result
  const isDeep = bitmask & CLONE_DEEP_FLAG
  const isFlat = bitmask & CLONE_FLAT_FLAG
  const isFull = bitmask & CLONE_SYMBOLS_FLAG
  //自定义clone函数
  if (customizer) {
    result = object ? customizer(value, key, object, stack) : customizer(value)
  }
  if (result !== undefined) {
    return result
  }
// 非对象类型,直接返回数值
  if (!isObject(value)) {
    return value
  }
//处理数组
  const isArr = Array.isArray(value)
  const tag = getTag(value)
  if (isArr) {
    result = initCloneArray(value)
    if (!isDeep) {
      return copyArray(value, result)
    }
  } else {
    const isFunc = typeof value === 'function'

    if (isBuffer(value)) {
      return cloneBuffer(value, isDeep)
    }
    if (tag == objectTag || tag == argsTag || (isFunc && !object)) {
      result = (isFlat || isFunc) ? {} : initCloneObject(value)
      if (!isDeep) {
        return isFlat
          ? copySymbolsIn(value, copyObject(value, keysIn(value), result))
          : copySymbols(value, Object.assign(result, value))
      }
    } else {
      if (isFunc || !cloneableTags[tag]) {
        return object ? value : {}
      }
      result = initCloneByTag(value, tag, isDeep)
    }
  }
  //  循环引用Check for circular references and return its corresponding clone.
  stack || (stack = new Stack)
  const stacked = stack.get(value)
  if (stacked) {
    return stacked
  }
  stack.set(value, result)
//复制Map
  if (tag == mapTag) {
    value.forEach((subValue, key) => {
      result.set(key, baseClone(subValue, bitmask, customizer, key, value, stack))
    })
    return result
  }
// set
  if (tag == setTag) {
    value.forEach((subValue) => {
      result.add(baseClone(subValue, bitmask, customizer, subValue, value, stack))
    })
    return result
  }
//TypedArray
  if (isTypedArray(value)) {
    return result
  }

  const keysFunc = isFull
    ? (isFlat ? getAllKeysIn : getAllKeys)
    : (isFlat ? keysIn : keys)

  const props = isArr ? undefined : keysFunc(value)
  arrayEach(props || value, (subValue, key) => {
    if (props) {
      key = subValue
      subValue = value[key]
    }
    // Recursively populate clone (susceptible to call stack limits).
    assignValue(result, key, baseClone(subValue, bitmask, customizer, key, value, stack))
  })
  return result
}

export default baseClone

【进阶4-4期】Lodash是如何实现深拷贝的
深入剖析 JavaScript 的深复制

你可能感兴趣的:(【JS】深拷贝、浅拷贝和赋值解析与lodash源码学习)