Setup()

在 JavaScript 中实现拷贝

本文将简单地介绍一下传统的浅拷贝、深拷贝、以及结构化克隆 Structured Clone API。

#浅拷贝 #深拷贝 #结构化克隆 #JSON.stringify #JSON.parse #JavaScript #structuredClone

按引用传递

在 JavaScript 中,数据总是通过引用传递的。这意味着当将一个变量作为参数传递给函数或赋值给另一个变量时,实际上传递的是该变量在内存中的地址,而不是数据本身的拷贝。这种传递方式被称为“按引用传递”(pass-by-reference)。

这种传递方式存在一个问题,就是当我们想要修改传递的数据时,会影响到原始数据。下面通过一个简单的例子来说明 JavaScript 中按引用传递的行为:

function modifyArray (arr) {
  arr.push(4)
}

const originalArray = [1, 2, 3]
modifyArray(originalArray)

console.log(originalArray) // 输出 [1, 2, 3, 4]

在上面的例子中,originalArray 是一个数组,它被传递给 modifyArray 函数。在函数内部,我们使用 push() 方法向数组中添加了一个新元素。当我们在函数外部输出 originalArray 时,可以看到它已经被修改了,这是因为在函数内部修改的是原始数组本身,而不是数组的拷贝。

虽然 JavaScript 中总是按引用传递数据,但是对于基本数据类型(如字符串、数字、布尔值等),它们是不可变的,即使在函数内部修改它们的值,也不会影响到原始变量。

为了避免出现这种情况,我们往往会考虑创建一个原始数据的拷贝,然后对拷贝进行修改,这样就不会影响到原始数据了。接下来,我们来看一些常见的拷贝方法。

浅拷贝 Shallow copy

浅拷贝只复制一层数据,而不会递归地复制嵌套对象或数组。简单来说,浅拷贝只复制了对象或数组本身,而没有复制对象或数组内部的引用。因此浅拷贝适用于那些不存在多层嵌套的数据结构。

以下是一些常见的浅拷贝方法:

Object.assign

const sourceObject = { a: 1, b: { c: 2 } }
const targetObject = Object.assign({}, sourceObject)

console.log(sourceObject === targetObject) // 输出 false

Array.prototype.slice

const sourceArray = [1, 2, 3, { a: 4 }]
const targetArray = sourceArray.slice()

console.log(sourceArray === targetArray) // 输出 false

Array.prototype.concat

const sourceArray = [1, 2, 3, { a: 4 }]
const targetArray = sourceArray.concat()

console.log(sourceArray === targetArray) // 输出 false

Spread Operator

const sourceArray = [1, 2, 3, { a: 4 }]
const targetArray = [...sourceArray]

console.log(sourceArray === targetArray) // 输出 false

const sourceObject = { a: 1, b: { c: 2 } }
const targetObject = { ...sourceObject }

console.log(sourceObject === targetObject) // 输出 false

浅拷贝的适用场景有限。如果数据结构存在多层嵌套,我们就需要另寻其他方法来实现深拷贝。

JSON.stringify / JSON.parse

JSON.stringify 的序列化和 JSON.parse 的反序列化过程中创建了新的对象副本,可以实现简单的“深拷贝”:

const originalObject = { a: 1, b: { c: 2 } };
const deepCopiedObject = JSON.parse(JSON.stringify(originalObject));

console.log(originalObject === deepCopiedObject); // 输出 false

虽然这样可以实现“深拷贝”,但它也有一些重要的弊端,特别是在处理复杂对象或包含循环引用的对象时:

  1. 不支持特殊对象和属性: JSON.stringify 方法只能序列化可转换为 JSON 的对象属性,不能序列化函数、undefinedSymbolInfinityMap 等等特殊对象和属性。在反序列化时,这些特殊属性会丢失或转换为 null。
  2. 无法处理循环引用: 如果对象内部引用了自身或引用了其它对象形成循环链,也就是在循环循环引用的情况下,使用 JSON.stringify 时会抛出 TypeError 异常,因为 JSON 不支持循环结构的表示。
  3. 函数和原型链丢失: 使用 JSON.stringifyJSON.parse 后,对象的方法(函数)和原型链上的属性都会丢失,因为 JSON 字符串仅包含对象的数据,而不包含其行为。

要解决上述问题,我们需要通过递归来实现真正的深拷贝,处理特殊情况并确保循环引用得到正确处理。这也是像 lodash 这样的库所采用的方法。

顺带一提,有一说 JSON.stringifyJSON.parse 在处理非常大的对象时可能存在性能问题,但目前的测试显示,对于大多数用例,它们的性能已经足够高效,无需过度担忧性能问题。

深拷贝 Deep copy

深拷贝会递归地复制嵌套对象或数组,期间也可以对数据类型进行判断和处理,避免特殊的对象和属性丢失,这样就可以实现对多层嵌套的数据结构的深度拷贝,下面简单地写一个例子:

function deepCopy (obj) {
  if (typeof obj !== 'object' || obj === null) {
    return obj
  }

  let copy
  if (Array.isArray(obj)) {
    copy = []
    for (let i = 0; i < obj.length; i++) {
      copy[i] = deepCopy(obj[i])
    }
  } else {
    copy = {}
    for (const key in obj) {
      if (obj.hasOwnProperty(key)) {
        copy[key] = deepCopy(obj[key])
      }
    }
  }

  return copy
}

该方法会递归复制所有嵌套的对象和数组,确保原始数据与副本数据完全独立,但是循环引用的问题仍然存在:

const objA = {}
objA.b = objA // 循环引用

// 递归深拷贝会陷入无限递归,导致栈溢出
deepCopy(objA)

结构化克隆 Structured Clone

不知道大家有没有使用过 History API 的 history.replaceState 方法,这个方法就能够创建一个与原始对象完全相同的副本,这是因为它使用了 结构化克隆算法(structured clone algorithm)。

History API

const objA = {}
objA.b = objA // 循环引用

history.replaceState(objA, document.title)
const objB = history.state

console.log(objB === objA) // 输出 false

这是个“古老”的方法可以实现对循环引用的对象的深拷贝,但是部分浏览器(比如 Safari)中存在调用限制,它并不是一个完美的方案,我们再来看两个列子。

MessageChannel

const objA = {}
objA.b = objA // 循环引用

const { port1, port2 } = new MessageChannel()
port2.onmessage = event => {
  const objB = event.data
  console.log(objB === objA) // 输出 false
}
port1.postMessage(objA)

MessageChannel 也可以实现对循环引用的对象的深拷贝,但拷贝的结果是异步返回的 🤔

Notification API

const objA = {}
objA.b = objA // 循环引用

const notification = new Notification('', { data: objA })
const objB = notification.data

Structured Clone API

类似的“hack 手段”其实还有,不过他们底层都是基于前文所说的结构化克隆算法实现的,你可以简单地理解为浏览器曾经为了自身对深拷贝复杂对象的需要,而实现了一个内置的深拷贝方法。

现在 structuredClone 作为一个标准的 API,已经被列入了 HTML Standard 中,它的使用方式也很简单:

structuredClone(value)
structuredClone(value, { transfer })

我们可以不费吹灰之力地获得一个对象副本:

const objA = { c: new Map() }
objA.b = objA // 循环引用

const objB = structuredClone(objA)
console.log(objB === objA) // 输出 false

不过对于某些特殊对象(Symbol、Function Object、DOM Object 等),structuredClone 也是无能为力的,执意要运行的话就会获得一个 DOMException 异常 🥲。

transfer 参数用于指定需要转移的可转移对象,这些对象将不会被复制,而是被转移给新的对象:

// 创建一个8MB的“文件”并填充它. 8MB = 1024 * 1024 * 8 B
const uInt8Array = new Uint8Array(1024 * 1024 * 8).map((v, i) => i)
console.log(uInt8Array.byteLength) // 8388608

const clonedArray = structuredClone(uInt8Array)
console.log(uInt8Array.byteLength) // 0
console.log(clonedArray.byteLength) // 8388608

总结

本文简单地介绍了一下传统的浅拷贝、深拷贝、以及结构化克隆 Structured Clone API。如果你对这些内容感兴趣,可以继续阅读下面的参考资料。