按引用传递
在 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
虽然这样可以实现“深拷贝”,但它也有一些重要的弊端,特别是在处理复杂对象或包含循环引用的对象时:
- 不支持特殊对象和属性:
JSON.stringify
方法只能序列化可转换为 JSON 的对象属性,不能序列化函数、undefined
、Symbol
、Infinity
、Map
等等特殊对象和属性。在反序列化时,这些特殊属性会丢失或转换为 null。 - 无法处理循环引用: 如果对象内部引用了自身或引用了其它对象形成循环链,也就是在循环循环引用的情况下,使用
JSON.stringify
时会抛出TypeError
异常,因为 JSON 不支持循环结构的表示。 - 函数和原型链丢失: 使用
JSON.stringify
和JSON.parse
后,对象的方法(函数)和原型链上的属性都会丢失,因为 JSON 字符串仅包含对象的数据,而不包含其行为。
要解决上述问题,我们需要通过递归来实现真正的深拷贝,处理特殊情况并确保循环引用得到正确处理。这也是像 lodash 这样的库所采用的方法。
顺带一提,有一说 JSON.stringify
和 JSON.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。如果你对这些内容感兴趣,可以继续阅读下面的参考资料。