超详细的JavaScript深拷贝实现

配图源自 Feepik

此前写过一篇文章:JavaScript深浅拷贝,其实没那么难!,但里面的拷贝处理显然不够理想。

今天再来详细的讲讲...

一、JSON.stringify() 的缺陷

利用 JavaScript 内置的 JSON 处理函数,可以实现简易的深拷贝:

const obj = {
  // ...
}
JSON.parse(JSON.stringify(obj)) // 序列化与反序列化

这个方法,其实能适用于 90% 以上的应用场景。毕竟多数项目下,很少会去拷贝一个函数什么的。

但不得不说,这里面有“坑”,这些“坑”是 JSON.stringify() 方法本身实现逻辑产生的:

JSON.stringify(value[, replacer[, space]])

该方法有以下特点:

  • 布尔值、数值、字符串对应的包装对象,在序列化过程会自动转换成其原始值。
  • undefined任意函数Symbol 值,在序列化过程有两种不同的情况。若出现在非数组对象的属性值中,会被忽略;若出现在数组中,会转换成 null
  • 任意函数undefined 被单独转换时,会返回 undefined
  • 所有以 Symbol 为属性键的属性都会被完全忽略,即便在该方法第二个参数 replacer 中指定了该属性。
  • Date 日期调用了其内置的 toJSON() 方法转换成字符串,因此会被当初字符串处理。
  • NaNInfinity 的数值及 null 都会当做 null
  • 这些对象 MapSetWeakMapWeakSet 仅会序列化可枚举的属性。
  • 被转换值如果含有 toJSON() 方法,该方法定义什么值将被序列化。
  • 对包含 循环引用 的对象进行序列化,会抛出错误。

二、深拷贝的边界

其实,针对以上两个内置的全局方法,还有这么多情况不能处理,是不是很气人。其实不然,我猜测 JSON.parse()JSON.stringify() 只是让我们更方便地操作符合 JSON 格式的 JavaScript 对象或符合 JSON 格式的字符串。

至于上面提到的“坑”,很明显是不符合作为跨平台数据交换的格式要求的。在 JSON 中,它有 null,是没有 undefinedSymbol 类型、函数等。

JSON 是一种数据格式,也可以说是一种规范。JSON 是用于跨平台数据交流的,独立于语言和平台。而 JavaScript 对象是一个实例,存在于内存中。JavaScript 对象是没办法传输的,只有在被序列化为 JSON 字符串后才能传输。

此前写过一篇文章,介绍了 JSON 和 JavaScript 的关系以及上述两个方法的一些细节。可看:详谈 JSON 与 JavaScript。

如果自己实现一个深拷贝的方法,其实是有很多边界问题要处理的,至于这些种种的边界 Case,要不要处理最好从实际情况出发。

常见的边界 Case 有什么呢?

主要有循环引用、包装对象、函数、原型链、不可枚举属性、Map/WeakMap、Set/WeakSet、RegExp、Symbol、Date、ArrayBuffer、原生 DOM/BOM 对象等。

就目前而言,第三方最完善的深拷贝方法是 Lodash 库的 _.cloneDeep() 方法了。在实际项目中,如需处理 JSON.stringify() 无法解决的 Case,我会推荐使用它。否则请使用内置 JSON 方法即可,没必要复杂化。

但如果为了学习深拷贝,那应该要每种情况都要去尝试实现一下,我想这也是你在看这篇文章的原意。这样,无论是实现特殊要求的深拷贝,还是面试,都可以从容应对。

下面一起来学习吧,如有不足,欢迎指出 ~

三、自实现深拷贝方法

主要运用到递归的思路去实现一个深拷贝方法。

PS:完整的深拷贝方法会在文章最后放出。

先写一个简易版本:

const deepCopy = source => {
  // 判断是否为数组
  const isArray = arr => Object.prototype.toString.call(arr) === '[object Array]'

  // 判断是否为引用类型
  const isObject = obj => obj !== null && (typeof obj === 'object' || typeof obj === 'function')

  // 拷贝(递归思路)
  const copy = input => {
    if (typeof input === 'function' || !isObject(input)) return input

    const output = isArray(input) ? [] : {}
    for (let key in input) {
      if (input.hasOwnProperty(key)) {
        const value = input[key]
        output[key] = copy(value)
      }
    }

    return output
  }

  return copy(source)
}

以上简易版本还存在很多情况要特殊处理,接下来针对 JSON.stringify() 的缺陷,一点一点去完善它。

3.1 针对布尔值、数值、字符串的包装对象的处理

需要注意的是,从 ES6 开始围绕原始数据类型创建一个显式包装器对象不再被支持。但由于遗留原因,现有的原始包装器对象(如 new Booleannew Numbernew String)仍可使用。这也是 ES6+ 新增的 SymbolBigInt 数据类型无法通过 new 关键字创建实例对象的原因。

由于 for...in 无法遍历不可枚举的属性。例如,包装对象的 [[PrimitiveValue]] 内部属性,因此需要我们特殊处理一下。

以上结果,显然不是预期结果。包装对象的 [[PrimitiveValue]] 属性可通过 valueOf() 方法获取。

const deepCopy = source => {
  // 获取数据类型(本次新增)
  const getClass = x => Object.prototype.toString.call(x)

  // 判断是否为数组
  const isArray = arr => getClass(arr) === '[object Array]'

  // 判断是否为引用类型
  const isObject = obj => obj !== null && (typeof obj === 'object' || typeof obj === 'function')

  // 判断是否为包装对象(本次新增)
  const isWrapperObject = obj => {
    const theClass = getClass(obj)
    const type = /^\[object (.*)\]$/.exec(theClass)[1]
    return ['Boolean', 'Number', 'String', 'Symbol', 'BigInt'].includes(type)
  }

  // 处理包装对象(本次新增)
  const handleWrapperObject = obj => {
    const type = getClass(obj)
    switch (type) {
      case '[object Boolean]':
        return Object(Boolean.prototype.valueOf.call(obj))
      case '[object Number]':
        return Object(Number.prototype.valueOf.call(obj))
      case '[object String]':
        return Object(String.prototype.valueOf.call(obj))
      case '[object Symbol]':
        return Object(Symbol.prototype.valueOf.call(obj))
      case '[object BigInt]':
        return Object(BigInt.prototype.valueOf.call(obj))
      default:
        return undefined
    }
  }

  // 拷贝(递归思路)
  const copy = input => {
    if (typeof input === 'function' || !isObject(input)) return input

    // 处理包装对象(本次新增)
    if (isWrapperObject(input)) {
      return handleWrapperObject(input)
    }

    // 其余部分没变,为了减少篇幅,省略一万字...
  }

  return copy(source)
}

我们在控制台打印一下结果,可以看到是符合预期结果的。

3.2 针对函数的处理

直接返回就好了,一般不用处理。在实际应用场景需要拷贝函数太少了...

const copy = input => {
  if (typeof input === 'function' || !isObject(input)) return input
}
3.3 针对以 Symbol 值作为属性键的处理

由于以上 for...in 方法无法遍历 Symbol 的属性键,因此:

const sym = Symbol('desc')
const obj = {
  [sym]: 'This is symbol value'
}
console.log(deepCopy(obj)) // {},拷贝结果没有 [sym] 属性

这里,我们需要用到两个方法:

  • Object.getOwnPropertySymbols()
    它返回一个对象自身的所有 Symbol 属性的数组,包括不可枚举的属性。

  • Object.prototype.propertyIsEnumerable()
    它返回一个布尔值,表示指定的属性是否可枚举。

const copy = input => {
  // 其它不变
  for (let key in input) {
    // ...
  }

  // 处理以 Symbol 值作为属性键的属性(本次新增)
  const symbolArr = Object.getOwnPropertySymbols(input)
  if (symbolArr.length) {
    for (let i = 0, len = symbolArr.length; i < len; i++) {
      if (input.propertyIsEnumerable(symbolArr[i])) {
        const value = input[symbolArr[i]]
        output[symbolArr[i]] = copy(value)
      }
    }
  }

  // ...
}

下面我们对 source 对象做拷贝操作:

const source = {}
const sym1 = Symbol('1')
const sym2 = Symbol('2')
Object.defineProperties(source,
  {
    [sym1]: {
      value: 'This is symbol value.',
      enumerable: true
    },
    [sym2]: {
      value: 'This is a non-enumerable property.',
      enumerable: false
    }
  }
)

打印结果,也符合预期结果:

3.4 针对 Date 对象的处理

其实,处理 Date 对象,跟上面提到的包装对象的处理是差不多的。暂时先放到 isWrapperObject()handleWrapperObject() 中处理。

const deepCopy = source => {
  // 其他不变...

  // 判断是否为包装对象(本次更新)
  const isWrapperObject = obj => {
    const theClass = getClass(obj)
    const type = /^\[object (.*)\]$/.exec(theClass)[1]
    return ['Boolean', 'Number', 'String', 'Symbol', 'BigInt', 'Date'].includes(type)
  }

  // 处理包装对象
  const handleWrapperObject = obj => {
    const type = getClass(obj)
    switch (type) {
      // 其他 case 不变
      // ...
      case '[object Date]':
        return new Date(obj.valueOf()) // new Date(+obj)
      default:
        return undefined
    }
  }

  // 其他不变...
}
3.5 针对 Map、Set 对象的处理

同样的,暂时先放到 isWrapperObject()handleWrapperObject() 中处理。

利用 Map、Set 对象的 Iterator 特性和自身的方法,可以快速解决。

const deepCopy = source => {
  // 其他不变...

  // 判断是否为包装对象(本次更新)
  const isWrapperObject = obj => {
    const theClass = getClass(obj)
    const type = /^\[object (.*)\]$/.exec(theClass)[1]
    return ['Boolean', 'Number', 'String', 'Symbol', 'BigInt', 'Date', 'Map', 'Set'].includes(type)
  }

  // 处理包装对象
  const handleWrapperObject = obj => {
    const type = getClass(obj)
    switch (type) {
      // 其他 case 不变
      // ...
      case '[object Map]': {
        const map = new Map()
        obj.forEach((item, key) => {
          // 需要注意的是,这里的 key 不能深拷贝,否则就会失去引用了
          // 具体原因可以思考一下,不难。想不明白再评论区吧
          map.set(key, copy(item))
        })
        return map
      }
      case '[object Set]': {
        const set = new Set()
        obj.forEach(item => {
          set.add(copy(item))
        })
        return set
      }
      default:
        return undefined
    }
  }

  // 其他不变...
}

打印下结果:

3.6 针对循环引用的问题

以下是一个循环引用(circular reference)的对象:

const foo = { name: 'Frankie' }
foo.bar = foo

上面提到 JSON.stringify() 无法处理循环引用的问题,我们在控制台打印一下:

从结果可以看到,当对循环引用的对象进行序列化处理时,会抛出类型错误:Uncaught TypeError: Converting circular structure to JSON

接着,使用自行实现的 deepCopy() 方法,看下结果是什么:

我们看到,在拷贝循环引用的 foo 对象时,发生栈溢出了。

在另一篇文章,我提到过使用 JSON-js 可以处理循环引用的问题,具体用法是,先引入其中的 cycle.js 脚本,然后 JSON.stringify(JSON.decycle(foo)) 就 OK 了。但究其根本,它使用了 WeakMap 去处理。

那我们去实现一下:

const deepCopy = source => {
  // 创建一个 WeakMap 对象,记录已拷贝过的对象(本次新增)
  const weakmap = new WeakMap()

  // 中间这块不变,省略一万字...

  // 拷贝(递归思路)
  const copy = input => {
    if (typeof input === 'function' || !isObject(input)) return input

    // 针对已拷贝过的对象,直接返回(本次新增,以解决循环引用的问题)
    if (weakmap.has(input)) {
      return weakmap.get(input)
    }

    // 处理包装对象
    if (isWrapperObject(input)) {
      return handleWrapperObject(input)
    }

    const output = isArray(input) ? [] : {}

    // 记录每次拷贝的对象
    weakmap.set(input, output)

    for (let key in input) {
      if (input.hasOwnProperty(key)) {
        const value = input[key]
        output[key] = copy(value)
      }
    }

    // 处理以 Symbol 值作为属性键的属性
    const symbolArr = Object.getOwnPropertySymbols(input)
    if (symbolArr.length) {
      for (let i = 0, len = symbolArr.length; i < len; i++) {
        if (input.propertyIsEnumerable(symbolArr[i])) {
          output[symbolArr[i]] = input[symbolArr[i]]
        }
      }
    }

    return output
  }

  return copy(source)
}

先看看打印结果,不会像之前一样溢出了。

需要注意的是,这里不使用 Map 而是 WeakMap 的原因:

首先,Map 的键属于强引用,而 WeakMap 的键则属于弱引用。且 WeakMap 的键必须是对象,WeakMap 的值则是任意的。

由于它们的键与值的引用关系,决定了 Map 不能确保其引用的对象不会被垃圾回收器回收的引用。假设我们使用的 Map,那么图中的 foo 对象和我们深拷贝内部的 const map = new Map() 创建的 map 对象一直都是强引用关系,那么在程序结束之前,foo 不会被回收,其占用的内存空间一直不会被释放。

相比之下,原生的 WeakMap 持有的是每个键对象的“弱引用”,这意味着在没有其他引用存在时垃圾回收能正确进行。原生 WeakMap 的结构是特殊且有效的,其用于映射的 key 只有在其没有被回收时才是有效的。

基本上,如果你要往对象上添加数据,又不想干扰垃圾回收机制,就可以使用 WeakMap。

可看 Why WeakMap?

我们熟知的 Lodash 库的深拷贝方法,自实现了一个类似 WeakMap 特性的构造函数去处理循环引用的。(详看)

这里提供另一个思路,也是可以的。

const deepCopy = source => {
  // 其他一样,省略一万字...

  // 创建一个数组,将每次拷贝的对象放进去
  const copiedArr = []

  // 拷贝(递归思路)
  const copy = input => {
    if (typeof input === 'function' || !isObject(input)) return input

    // 循环遍历,若有已拷贝过的对象,则直接放回,以解决循环引用的问题
    for (let i = 0, len = copiedArr.length; i < len; i++) {
      if (input === copiedArr[i].key) return copiedArr[i].value
    }

    // 处理包装对象
    if (isWrapperObject(input)) {
      return handleWrapperObject(input)
    }

    const output = isArray(input) ? [] : {}

    // 记录每一次的对象
    copiedArr.push({ key: input, value: output })

    // 后面的流程不变...
  }

  return copy(source)
}

此前实现有个 bug,感谢虾虾米指出,现已更正。

请在实现深拷贝之后测试以下示例:

const foo = { name: 'Frankie' }
foo.bar = foo

const cloneObj = deepCopy(foo) // 自实现深拷贝
const lodashObj = _.cloneDeep(foo) // Lodash 深拷贝

// 打印结果如下,说明是正确的
console.log(lodashObj.bar === lodashObj) // true
console.log(lodashObj.bar === foo) // false
console.log(cloneObj.bar === cloneObj) // true
console.log(cloneObj.bar === foo) // false
3.7 针对正则表达式的处理

正则表达式里面,有两个非常重要的属性:

  • RegExp.prototype.source
    返回当前正则表达式对象的模式文本的字符串。注意,这是 ES6 新增的属性。
  • RegExp.prototype.flags
    返回当前正则表达式对象标志。
const { source, flags } = /\d/g
console.log(source) // "\\d"
console.log(flags) // "g"

有了以上两个属性,我们就可以使用 new RegExp(pattern, flags) 构造函数去创建一个正则表达式了。

const { source, flags } = /\d/g
const newRegex = new RegExp(source, flags) // /\d/g

但需要注意的是,正则表达式有一个 lastIndex 属性,该属性可读可写,其值为整型,用来指定下一次匹配的起始索引。在设置了 globalsticky 标志位的情况下(如 /foo/g/foo/y),JavaScript RegExp 对象是有状态的。他们会将上次成功匹配后的位置记录在 lastIndex 属性中。

因此,上述拷贝正则表达式的方式是有缺陷的。看示例:

const re1 = /foo*/g
const str = 'table football, foosball'
let arr

while ((arr = re1.exec(str)) !== null) {
  console.log(`Found ${arr[0]}. Next starts at ${re1.lastIndex}.`)
}

// 以上语句会输出,以下结果:
// "Found foo. Next starts at 9."
// "Found foo. Next starts at 19."


// 当我们修改 re1 的 lastIndex 属性时,输出以下结果:
re1.lastIndex = 9
while ((arr = re1.exec(str)) !== null) {
  console.log(`Found ${arr[0]}. Next starts at ${re1.lastIndex}.`)
}
// "Found foo. Next starts at 19."

// 以上这些相信你们都都懂。

所以,你可以发现以下示例,打印结果是不一致的,原因就是使用 RegExp 构造函数去创建一个正则表达式时,lastIndex 会默认设为 0

const re1 = /foo*/g
const str = 'table football, foosball'
let arr

// 修改 lastIndex 属性
re1.lastIndex = 9

// 基于 re1 拷贝一个正则表达式
const re2 = new RegExp(re1.source, re1.flags)

console.log('re1:')
while ((arr = re1.exec(str)) !== null) {
  console.log(`Found ${arr[0]}. Next starts at ${re1.lastIndex}.`)
}

console.log('re2:')
while ((arr = re2.exec(str)) !== null) {
  console.log(`Found ${arr[0]}. Next starts at ${re2.lastIndex}.`)
}

// re1:
// expected output: "Found foo. Next starts at 19."
// re2:
// expected output: "Found foo. Next starts at 9."
// expected output: "Found foo. Next starts at 19."

因此:

const deepCopy = source => {
  // 其他不变,省略...

  // 处理正则表达式
  const handleRegExp = regex => {
    const { source, flags, lastIndex } = regex
    const re = new RegExp(source, flags)
    re.lastIndex = lastIndex
    return re
  }

  // 拷贝(递归思路)
  const copy = input => {
    if (typeof input === 'function' || !isObject(input)) return input

    // 正则表达式
    if (getClass(input) === '[object RegExp]') {
      return handleRegExp(input)
    }

    // 后面不变,省略...
  }

  return copy(source)
}

打印结果也是符合预期的:

由于 RegExp.prototype.flags 是 ES6 新增属性,我们可以看下 ES5 是如何实现的(源自 Lodash):

/** Used to match `RegExp` flags from their coerced string values. */
var reFlags = /\w*$/;

/**
 * Creates a clone of `regexp`.
 *
 * @private
 * @param {Object} regexp The regexp to clone.
 * @returns {Object} Returns the cloned regexp.
 */
function cloneRegExp(regexp) {
  var result = new regexp.constructor(regexp.source, reFlags.exec(regexp));
  result.lastIndex = regexp.lastIndex;
  return result;
}

但还是那句话,都 2021 年了,兼容 ES5 的问题就放心交给 Babel 吧。

3.8 处理原型

注意,这里只实现类型为 "[object Object]" 的对象的原型拷贝。例如数组等不处理,因为这些情况实际场景太少了。

主要是修改以下这一步骤:

const output = isArray(input) ? [] : {}

主要利用 Object.create() 来创建 output 对象,改成这样:

const initCloneObject = obj => {
  // 处理基于 Object.create(null) 或 Object.create(Object.prototype.__proto__) 的实例对象
  // 其中 Object.prototype.__proto__ 就是站在原型顶端的男人
  // 但我留意到 Lodash 库的 clone 方法对以上两种情况是不处理的
  if (obj.constructor === undefined) {
    return Object.create(null)
  }

  // 处理自定义构造函数的实例对象
  if (typeof obj.constructor === 'function' && (obj !== obj.constructor || obj !== Object.prototype)) {
    const proto = Object.getPrototypeOf(obj)
    return Object.create(proto)
  }

  return {}
}

const output = isArray(input) ? [] : initCloneObject(input)

来看下打印结果,可以看到 source 的原型对象已经拷贝过来了:

再来看下 Object.create(null) 的情况,也是预期结果。

我们可以看到 Lodash 的 _.cloneDeep(Object.create(null)) 深拷贝方法并没有处理这种情况。当然了,要拷贝这种数据结构在实际应用场景,真的少之又少...

关于 Lodash 拷贝方法为什么不实现这种情况,我找到了一个相关的 Issue #588:

A shallow clone won't do that as it's just _.assign({}, object) and a deep clone is loosely based on the structured cloning algorithm and doesn't attempt to clone inheritance or lack thereof.

四、优化

综上所述,完整但未优化的深拷贝方法如下:

const deepCopy = source => {
  // 创建一个 WeakMap 对象,记录已拷贝过的对象
  const weakmap = new WeakMap()

  // 获取数据类型
  const getClass = x => Object.prototype.toString.call(x)

  // 判断是否为数组
  const isArray = arr => getClass(arr) === '[object Array]'

  // 判断是否为引用类型
  const isObject = obj => obj !== null && (typeof obj === 'object' || typeof obj === 'function')

  // 判断是否为包装对象
  const isWrapperObject = obj => {
    const theClass = getClass(obj)
    const type = /^\[object (.*)\]$/.exec(theClass)[1]
    return ['Boolean', 'Number', 'String', 'Symbol', 'BigInt', 'Date', 'Map', 'Set'].includes(type)
  }

  // 处理包装对象
  const handleWrapperObject = obj => {
    const type = getClass(obj)
    switch (type) {
      case '[object Boolean]':
        return Object(Boolean.prototype.valueOf.call(obj))
      case '[object Number]':
        return Object(Number.prototype.valueOf.call(obj))
      case '[object String]':
        return Object(String.prototype.valueOf.call(obj))
      case '[object Symbol]':
        return Object(Symbol.prototype.valueOf.call(obj))
      case '[object BigInt]':
        return Object(BigInt.prototype.valueOf.call(obj))
      case '[object Date]':
        return new Date(obj.valueOf()) // new Date(+obj)
      case '[object Map]': {
        const map = new Map()
        obj.forEach((item, key) => {
          map.set(key, copy(item))
        })
        return map
      }
      case '[object Set]': {
        const set = new Set()
        obj.forEach(item => {
          set.add(copy(item))
        })
        return set
      }
      default:
        return undefined
    }
  }

  // 处理正则表达式
  const handleRegExp = regex => {
    const { source, flags, lastIndex } = regex
    const re = new RegExp(source, flags)
    re.lastIndex = lastIndex
    return re
  }

  const initCloneObject = obj => {
    if (obj.constructor === undefined) {
      return Object.create(null)
    }

    if (typeof obj.constructor === 'function' && (obj !== obj.constructor || obj !== Object.prototype)) {
      const proto = Object.getPrototypeOf(obj)
      return Object.create(proto)
    }

    return {}
  }

  // 拷贝(递归思路)
  const copy = input => {
    if (typeof input === 'function' || !isObject(input)) return input

    // 正则表达式
    if (getClass(input) === '[object RegExp]') {
      return handleRegExp(input)
    }

    // 针对已拷贝过的对象,直接返回(解决循环引用的问题)
    if (weakmap.has(input)) {
      return weakmap.get(input)
    }

    // 处理包装对象
    if (isWrapperObject(input)) {
      return handleWrapperObject(input)
    }

    const output = isArray(input) ? [] : initCloneObject(input)

    // 记录每次拷贝的对象
    weakmap.set(input, output)

    for (let key in input) {
      if (input.hasOwnProperty(key)) {
        const value = input[key]
        output[key] = copy(value)
      }
    }

    // 处理以 Symbol 值作为属性键的属性
    const symbolArr = Object.getOwnPropertySymbols(input)
    if (symbolArr.length) {
      for (let i = 0, len = symbolArr.length; i < len; i++) {
        if (input.propertyIsEnumerable(symbolArr[i])) {
          const value = input[symbolArr[i]]
          output[symbolArr[i]] = copy(value)
        }
      }
    }

    return output
  }

  return copy(source)
}

接下来就是优化工作了...

4.1 优化一

我们上面使用到了 for...inObject.getOwnPropertySymbols() 方法去遍历对象的属性(包括字符串属性和 Symbol 属性),还涉及了可枚举属性和不可枚举属性。

  • for...in:遍历自身继承过来可枚举属性(不包括 Symbol 属性)。
  • Object.keys:返回一个数组,包含对象自身所有可枚举属性(不包括不可枚举属性和 Symbol 属性)
  • Object.getOwnPropertyNames:返回一个数组,包含对象自身的属性(包括不可枚举属性,但不包括 Symbol 属性)
  • Object.getOwnPropertySymbols:返回一个数组,包含对象自身的所有 Symbol 属性(包括可枚举和不可枚举属性)
  • Reflect.ownKeys:返回一个数组,包含自身所有的属性(包括 Symbol 属性,不可枚举属性以及可枚举属性)

由于我们仅拷贝可枚举的字符串属性和可枚举的 Symbol 属性,因此我们将 Reflect.ownKeys()Object.prototype.propertyIsEnumerable() 结合使用即可。

所以,我们将以下这部分:

for (let key in input) {
  if (input.hasOwnProperty(key)) {
    const value = input[key]
    output[key] = copy(value)
  }
}

// 处理以 Symbol 值作为属性键的属性
const symbolArr = Object.getOwnPropertySymbols(input)
if (symbolArr.length) {
  for (let i = 0, len = symbolArr.length; i < len; i++) {
    if (input.propertyIsEnumerable(symbolArr[i])) {
      const value = input[symbolArr[i]]
      output[symbolArr[i]] = copy(value)
    }
  }
}

优化成:

// 仅遍历对象自身可枚举的属性(包括字符串属性和 Symbol 属性)
Reflect.ownKeys(input).forEach(key => {
  if (input.propertyIsEnumerable(key)) {
    output[key] = copy(input[key])
  }
})
4.2 优化二

优化 getClass()isWrapperObject()handleWrapperObject()handleRegExp() 及其相关的类型判断方法。

由于 handleWrapperObject() 原意是处理包装对象,但是随着后面要处理的特殊对象越来越多,为了减少文章篇幅,暂时都写在里面了,稍微有点乱。

因此下面我们来整合一下,部分处理函数可能会修改函数名。

五、最终

其实,上面提到的一些边界 Case、或者其他一些特殊对象(如 ArrayBuffer 等),这里并没有处理,但我认为该完结了,因为这些在实际应用场景真的太少了。

代码已丢到 GitHub toFrankie/Some-JavaScript-File。

还是那句话:

如果生产环境使用 JSON.stringify() 无法解决你的需求,请使用 Lodash 库的 _.cloneDeep() 方法,那个才叫面面俱到。千万别用我这方法,切记!

这篇文章主要面向学习、面试(手动狗头),或许也可以帮助你熟悉一些对象的特性。如有不足,欢迎指出,万分感谢 ~

终于终于终于......要写完了,吐了三斤血...

最终版本如下:

const deepCopy = source => {
  // 创建一个 WeakMap 对象,记录已拷贝过的对象
  const weakmap = new WeakMap()

  // 获取数据类型,返回值如:"Object"、"Array"、"Symbol" 等
  const getClass = x => {
    const type = Object.prototype.toString.call(x)
    return /^\[object (.*)\]$/.exec(type)[1]
  }

  // 判断是否为数组
  const isArray = arr => getClass(arr) === 'Array'

  // 判断是否为引用类型
  const isObject = obj => obj !== null && (typeof obj === 'object' || typeof obj === 'function')

  // 判断是否为“特殊”对象(需要特殊处理)
  const isSepcialObject = obj => {
    const type = getClass(obj)
    return ['Boolean', 'Number', 'String', 'Symbol', 'BigInt', 'Date', 'Map', 'Set', 'RegExp'].includes(type)
  }

  // 处理特殊对象
  const handleSepcialObject = obj => {
    const type = getClass(obj)
    const Ctor = obj.constructor // 对象的构造函数
    const primitiveValue = obj.valueOf() // 获取对象的原始值

    switch (type) {
      case 'Boolean':
      case 'Number':
      case 'String':
      case 'Symbol':
      case 'BigInt':
        // 处理包装对象 Wrapper Object
        return Object(primitiveValue)
      case 'Date':
        return new Ctor(primitiveValue) // new Date(+obj)
      case 'RegExp': {
        const { source, flags, lastIndex } = obj
        const re = new RegExp(source, flags)
        re.lastIndex = lastIndex
        return re
      }
      case 'Map': {
        const map = new Ctor()
        obj.forEach((item, key) => {
          // 注意,即使 Map 对象的 key 为引用类型,这里也不能 copy(key),否则会失去引用,导致该属性无法访问得到。
          map.set(key, copy(item))
        })
        return map
      }
      case 'Set': {
        const set = new Ctor()
        obj.forEach(item => {
          set.add(copy(item))
        })
        return set
      }
      default:
        return undefined
    }
  }

  // 创建输出对象(原型拷贝关键就在这一步)
  const initCloneObject = obj => {
    if (obj.constructor === undefined) {
      return Object.create(null)
    }

    if (typeof obj.constructor === 'function' && (obj !== obj.constructor || obj !== Object.prototype)) {
      const proto = Object.getPrototypeOf(obj)
      return Object.create(proto)
    }

    return {}
  }

  // 拷贝方法(递归思路)
  const copy = input => {
    if (typeof input === 'function' || !isObject(input)) return input

    // 针对已拷贝过的对象,直接返回(解决循环引用的问题)
    if (weakmap.has(input)) {
      return weakmap.get(input)
    }

    // 处理包装对象
    if (isSepcialObject(input)) {
      return handleSepcialObject(input)
    }

    // 创建输出对象
    const output = isArray(input) ? [] : initCloneObject(input)

    // 记录每次拷贝的对象
    weakmap.set(input, output)

    // 仅遍历对象自身可枚举的属性(包括字符串属性和 Symbol 属性)
    Reflect.ownKeys(input).forEach(key => {
      if (input.propertyIsEnumerable(key)) {
        output[key] = copy(input[key])
      }
    })

    return output
  }

  return copy(source)
}

六、参考

  • GitHub/lodash
  • 原生 JS 灵魂之问(中),检验自己是否真的熟悉 JavaScript?

The end.

你可能感兴趣的:(超详细的JavaScript深拷贝实现)