在 JavaScript 中实现拷贝

在 JavaScript 里谈“拷贝”,其实是在讨论“引用”。拷贝不是复制一个值,而是决定谁拥有那份数据的控制权。

按引用传递

在 JavaScript 中,数据总是通过引用传递:当把一个变量传给函数或赋值给另一个变量时,实际上传递的是内存地址,而不是数据本身。这个行为被称为“按引用传递”(pass-by-reference)。

问题也由此产生:当我们修改传入的数据时,原始数据会被改变。

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

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

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

当然,对于字符串、数字、布尔值等基本类型而言,它们是不可变的,修改变量并不会影响原始数据。但对象和数组就不一样了。

所以我们才会需要“拷贝”,来让修改发生在新的副本上。

浅拷贝 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 无法序列化函数、undefinedSymbolInfinityMap 等对象或属性,反序列化时会丢失或变成 null
  2. 无法处理循环引用: 如果对象存在循环引用,JSON.stringify 会抛出 TypeError
  3. 函数和原型链丢失: JSON 字符串只包含数据,不包含方法与原型信息。

对于大对象而言,性能也可能成为隐忧。不过在多数场景下,它已经足够使用。

深拷贝 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 (Object.prototype.hasOwnProperty.call(obj, key)) {
        copy[key] = deepCopy(obj[key])
      }
    }
  }

  return copy
}

但循环引用依旧是难题:

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

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

要处理这些边界条件,需要更复杂的实现,这也是 lodash 等库要做的工作。

结构化克隆 Structured Clone

很多人不知道,其实浏览器早已内置了一种强大的拷贝方式:结构化克隆(structured clone)。History API 的 history.replaceState 就是它的典型应用。

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、函数对象、DOM 对象等仍然无能为力,强行调用会抛出 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

总结

浅拷贝胜在轻便,深拷贝追求完整,结构化克隆则是现代浏览器的“硬实力”。选哪一种并没有标准答案,取决于你的数据结构与目标场景。

如果你想继续深入: