在 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) // 输出 falseArray.prototype.slice
const sourceArray = [1, 2, 3, { a: 4 }]
const targetArray = sourceArray.slice()
console.log(sourceArray === targetArray) // 输出 falseArray.prototype.concat
const sourceArray = [1, 2, 3, { a: 4 }]
const targetArray = sourceArray.concat()
console.log(sourceArray === targetArray) // 输出 falseSpread 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它确实简单,但有明显限制:
- 不支持特殊对象和属性:
JSON.stringify无法序列化函数、undefined、Symbol、Infinity、Map等对象或属性,反序列化时会丢失或变成null。 - 无法处理循环引用: 如果对象存在循环引用,
JSON.stringify会抛出TypeError。 - 函数和原型链丢失: 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.dataStructured 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总结
浅拷贝胜在轻便,深拷贝追求完整,结构化克隆则是现代浏览器的“硬实力”。选哪一种并没有标准答案,取决于你的数据结构与目标场景。
如果你想继续深入: