Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

手写深拷贝 #2

Open
toFrankie opened this issue Oct 29, 2021 · 0 comments
Open

手写深拷贝 #2

toFrankie opened this issue Oct 29, 2021 · 0 comments
Labels
2021 2021 年撰写 手写系列 与手写实现相关的文章

Comments

@toFrankie
Copy link
Owner

toFrankie commented Oct 29, 2021

配图源自 Feepik

一、为什么需要拷贝(复制)?

在 JavaScript 中,分为基本数据类型引用数据类型

基本数据类型(Primitives,原始类型)
  Undefined
  Null
  Boolean
  String
  Number
  Symbol
  BigInt

引用数据类型(Objects,引用类型)
  Object(包括基于 Object 构造器的派生对象,如 Object、Array、Function、Date、Map 等等一系列的内置对象)

请注意,明确几点!!!

  • 原始类型的值称为“原始值”,比如 Boolean 类型的原始值只有 true 和 false。同理,引用类型的值被称为“引用值”。
  • 所有原始值(Primitive Value)都是不可变的(immutable),而且不含任何的属性和方法。

看示例:

// 情况一:只是将一个新值 true 赋予变量 foo,而不是 20 被修改了。
let foo = 20
foo = true

// 情况二:变量 foo 的值,始终没有改变。
let foo = 'foo'
foo.toUpperCase() // "FOO"
foo // "foo"

// 情况三:可以说明将“原始值” num 传入函数 fn,只是将 num 的值拷贝了一份,然后赋予形参 n。并未影响到全局的变量 num。(注意这里指的是原始值,而非引用值)
let num = 1
function fn(n) {
  n++
  console.log(n)
}
fn(num) // 2
num // 1

// 情况四:只是 JS 内部自动隐式类型转换了,当一个原始值访问属性或方法时,
// 会将其转换为对应的引用数据类型,再访问该引用值的属性或方法。(就是常说的包装类)
let foo = 'foo'
foo.length // 3,相当于 Object(foo).length 或 new String(foo).length
foo.toUpperCase() // "FOO"

以上都发生了“拷贝”,只是原始值的拷贝是另外创建一个副本,而原本的值与副本是完全独立,互不干扰的。但如果是“引用值”,那就麻烦了。

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

bar.name = 'Mandy'
foo.name // "Mandy",修改变量 bar 的时候,foo 的值也发生改变了。

foo === bar // true,由始至终变量 foo 和 bar 都指向同一个地址。

这其实也是拷贝,只不过引用值拷贝的是“地址”(也有人说成“指针”,不重要)。因此,通常我们说的“拷贝”是指引用值的拷贝(或称为复制)。

  • 浅拷贝

    • Array.prototype.slice()
    • Array.prototype.concat()
    • Object.assign()
    • 利用 ... 扩展运算符
    • 自实现
  • 深拷贝

    • JSON.stringify()JSON.parse() 结合
    • $.extend()$.clone()(JQuery 库)
    • _.cloneDeep()(Lodash 库)

区别不说了,你们都知道的。

二、如何选择深拷贝的方式?

优先级应由上至下:

  • JSON.stringify()JSON.parse() 可以处理 99.9% 的业务场景,优先选它们。

  • 内置方法处理不了的情况,应根据实际场景而定:

    • 若要实现很完善的深拷贝,应选择 Lodash 的 _.cloneDeep() 方法,更可靠。
    • 若不需要 Lodash 那么完善的深拷贝,应该自己手写一个,性能可能会更好。
  • 面向面试之深拷贝,看本文。

JSON.stringify() 的缺陷

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

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

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

但不得不说,这里面有“坑”,这些“坑”是 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] 属性

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

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 针对正则表达式的处理

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

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 属性,该属性可读可写,其值为整型,用来指定下一次匹配的起始索引。在设置了  global  或  sticky  标志位的情况下(如  /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)
}

六、参考

The end.

@toFrankie toFrankie added the 手写系列 与手写实现相关的文章 label Mar 26, 2022
@toFrankie toFrankie added the 2021 2021 年撰写 label Apr 26, 2023
@toFrankie toFrankie changed the title 手写系列之超详细的JavaScript深拷贝实现 手写深拷贝 Apr 14, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
2021 2021 年撰写 手写系列 与手写实现相关的文章
Projects
None yet
Development

No branches or pull requests

1 participant