深入理解 JavaScript 的浅拷贝与深拷贝

在 JavaScript 中执行 b = a 赋值操作时,基本数据类型会生成一份相互独立的值;但对象类型仅仅是新增一个变量指向同一块堆内存。修改 b.foo 的同时 a.foo 的值也会同步变化,这并不是赋值逻辑异常,而是对象引用被多个变量共享。

浅拷贝只会复制对象第一层属性,嵌套对象依旧维持引用共享;深拷贝会递归处理全部嵌套结构,完整生成一套相互独立的数据副本。

能够分清赋值、浅拷贝、深拷贝三者差异,才能在状态修改、函数传参、数据缓存的业务场景中,规避「修改 A 对象却意外改动 B 对象」这类隐蔽 bug。

对象引用共享的底层根源,取决于数据存储位置与变量实际保存的内容,先理清内存模型,才能真正理解拷贝行为。

堆与栈的概念辨析

堆、栈存在两套完全不同的释义:一套属于数据结构范畴,一套是 JavaScript 引擎内存分区的实现概念。ECMAScript 标准没有强制规定数据必须存放于栈或者堆,下文内存模型为主流 JavaScript 引擎的实现方案,用于解释变量存储逻辑。

作为数据结构

  • 栈(Stack):运算受限的线性表,仅允许在栈顶一端完成增删操作。元素入栈称为压栈(Push),栈顶元素移除称为出栈(Pop)。遵循先进后出(First In Last Out,FILO)规则,也常称作后进先出(Last In First Out,LIFO)。
  • 堆(Heap):树形结构,属于特殊完全二叉树,具备堆序性。根节点为最大值称为大顶堆,根节点为最小值称为小顶堆,堆也常被叫做优先队列(Priority Queue)。堆内部左右子节点之间没有强制大小约束。

计算机内存中的堆与栈

这里是引擎管理内存的两种区域,和上面的数据结构概念区分开。

堆(Heap)

在 C、C++ 中,堆内存由开发者手动分配、释放。JavaScript 不提供 mallocfree 接口,对象内存由 JavaScript 引擎分配,依靠垃圾回收器(GC)自动回收。

  • 函数局部变量失去引用后,会被 GC 回收;闭包会延长对象引用,推迟回收时机。
  • 全局变量在页面销毁前持续存在,业务开发尽量减少全局变量;不需要的数据手动置为 null,解除引用帮助 GC 回收内存。
  • 堆存储引用类型对象ObjectArrayDateRegExpFunction 等。栈中变量存储的不是对象本体,而是指向堆内存的指针,读取对象时通过指针寻址拿到堆内实体。对象占用内存大小各不相同,由引擎动态分配。
  • 堆内对象内容是可变的。
  • 对象之间进行比较,对比的是内存引用地址,而不是逐个比对内部属性。

JavaScript 依靠标记-清除算法(搭配分代优化)实现垃圾回收。开发者无法直接调用 GC,只能通过解除对象引用触发回收。

栈(Stack)

栈内存由引擎随执行上下文自动分配、自动释放,存放函数参数、局部绑定,操作逻辑和栈数据结构保持一致,栈内存访问速度更快。

  • 栈保存基本类型的值,教学文档中常将 undefinedbooleannumberstringbigintsymbolnull 视作栈存储。实际引擎实现中,长字符串、部分 BigIntSymbol 会落到堆内存,变量保存的是值句柄,不存在对象那样的共享可变特性。
  • 基本类型占用内存大小固定,按值存储,可以直接访问
  • 基本类型的值不可变,字符串下标赋值不会修改原始字符串。
var str = "abc"
str[1] = 'ddd' // 非严格模式下赋值无效;严格模式抛出 TypeError
console.log(str)  // 输出 abc
  • 基本类型之间做比较,是真实值的对比。

为什么内存要划分堆、栈? 主要出于内存分配开销、垃圾回收效率考量。函数创建执行上下文,上下文内绑定随函数销毁释放(闭包除外),基础值放在栈内存销毁效率更高。

函数内部通过 new 创建的对象本体存放在堆,栈仅保存引用指针。对象不会随着函数执行结束销毁,只要存在变量引用就会常驻内存;全部引用断开之后,GC 才会回收堆内存。

回到拷贝逻辑:b = a,如果 a 是对象,赋值只会复制指针引用。两个变量指向堆中同一个对象,修改属性就会互相影响。想要两份完全独立的数据,就需要手动创建新对象复制属性;只复制第一层就是浅拷贝,递归复制全部嵌套就是深拷贝。

赋值、浅拷贝、深拷贝的区别

赋值操作 =

  • 基本类型赋值:复制原始值,生成两份独立数据,修改其中一个不会影响另一个。
  • 引用类型赋值:复制内存引用地址,并不会创建新对象。新旧变量指向堆中同一个实体,修改任意一方的对象属性,另一方同步变化。
let objA = {
    a: 'abc',
    b: '123'
}
let objB = objA
objB.a = 'xyz'
console.log(objA) // {a: "xyz", b: "123"}

浅拷贝与深拷贝

浅拷贝、深拷贝仅针对引用类型。二者都会生成全新对象,分配独立存储空间,把源对象属性复制到新对象,拷贝完成后外层对象引用相互独立。

  • 浅拷贝:仅复制对象第一层自有属性,不递归嵌套结构。第一层为基本类型,副本互不干扰;第一层如果是对象或数组,依旧共享堆内存引用,修改嵌套属性会互相影响。
  • 深拷贝:复制第一层同时递归遍历所有嵌套对象,完整生成独立副本(函数、DOM 节点等特殊类型除外)。

浅拷贝的各类实现

测试用例,覆盖嵌套对象、Symbol 键、访问器、不可枚举属性,用来校验每种实现的能力边界:

let objA = {
    a: 'abc',
    b: '123',
    c: {
      d: 'ddd',
      e: 'eee',
    },
    [Symbol()]: {
        f: 'fff',
        g: 'ggg'
    },
    get m () { return 1 }
}
Object.defineProperty(objA, 'h', {
    value: 4,
    enumerable: false
})

for...in 循环实现

function shallowCopy1 (obj) {
    if (typeof obj !== 'object') {
        return
    }
    let newObj = obj.constructor === Array ? [] : {}
    for (let key in obj) {
        if (obj.hasOwnProperty(key)) {
            newObj[key] = obj[key]
        }
    }
    return newObj
}
shallowCopy1(objA)
// 结果:a、b、c、m

问题点:

  • typeof null === 'object',传入 null 会读取 null.constructor,直接抛出报错;
  • 函数的 typeof 返回 'function',会在入口被直接 return;数组优先推荐使用 Array.isArray 判断;
  • 无法拷贝 Symbol 属性、不可枚举属性;
  • 遇到访问器属性,会执行 getter 获取返回值,拷贝得到普通值,丢失访问器本身。
方法 自身属性 继承属性 可枚举 不可枚举 Symbol 属性
for...in X X
Object.keys() X X X
Object.getOwnPropertyNames() X X
Object.getOwnPropertySymbols() X
Reflect.ownKeys() X
  • for...in:遍历自身和继承的可枚举属性,排除 Symbol
  • Object.keys():仅自身可枚举字符串键,排除 Symbol
  • Object.getOwnPropertyNames():自身全部字符串键,包含不可枚举,排除 Symbol
  • Object.getOwnPropertySymbols():自身全部 Symbol 键,无视可枚举状态;
  • Reflect.ownKeys():对象自身全部键,字符串、Symbol、可枚举、不可枚举全部获取。

改用 Reflect.ownKeys 遍历全部自身键:

function shallowCopy2 (obj) {
    if (typeof obj !== 'object') {
        return
    }
    let newObj = obj.constructor === Array ? [] : {}
    Reflect.ownKeys(obj).forEach(key => {
        newObj[key] = obj[key]
    })
    return newObj
}
shallowCopy2(objA)
// 结果:a、b、c、h、m、Symbol()

Reflect.ownKeys 返回的本身就只有自身属性,hasOwnProperty 判断可以省略。

该版本可以拷贝 Symbol、不可枚举属性,但属性描述符依旧丢失:访问器会执行 getter 拿到返回值;不可枚举属性复制到新对象后,变成可写、可枚举、可配置。

Object.create 与属性描述符完整拷贝

借助 Object.getPrototypeOf 获取原型,Object.getOwnPropertyDescriptors 获取全部属性描述符,Object.create 创建对象。

function shallowCopy3 (obj) {
    return Object.create(
        Object.getPrototypeOf(obj),
        Object.getOwnPropertyDescriptors(obj)
    )
}
shallowCopy3(objA)

该方案完整保留原型、Symbol 属性、不可枚举属性、访问器描述符。

注意:数组经过该方式创建,Array.isArray() 返回 falseObject.prototype.toString.call 返回 [object Object],Vue 2 的类型校验会出现异常。嵌套对象依旧保留引用共享,属于浅拷贝。

Object.assign 与对象扩展运算符

Object.assign()、对象扩展运算符 { ...obj } 行为保持一致:只会复制源对象自身可枚举属性,支持可枚举 Symbol,忽略继承属性、不可枚举属性;访问器会执行 getter,拷贝得到计算后的普通值。

Object.assign({}, objA)
{ ...objA }

输出会丢失不可枚举属性 h,访问器 m 被解析为数字 1

数组的浅拷贝

数组常用浅拷贝方式,仅复制第一层,数组内部对象元素依旧共享引用:

let arrA = [1, 2, 3]
// concat
let arrB = arrA.concat()
// slice
let arrB = arrA.slice()
// 扩展运算符
let arrB = [...arrA]
// Array.from
let arrB = Array.from(arrA)

深拷贝实现方案

测试用例,覆盖绝大多数 JavaScript 数据类型、Symbol、不可枚举属性、访问器:

let objB = {
    num: 0,
    str: '',
    boolean: true,
    unf: undefined,
    nul: null,
    obj: {
        name: '我是一个对象',
        id: 1
    },
    arr: [0, 1, 2],
    func: function() {
        console.log('我是一个函数')
    },
    date: new Date(),
    reg: new RegExp('我是一个正则', 'ig'),
    err: new Error('我是一个错误'),
    [Symbol()]: {
        f: 'fff',
        g: 'ggg'
    }
}
Object.defineProperties(objB, {
    h: {
        value: 4,
        enumerable: false,
        writable: false,
        configurable: false
    },
    m: {
        get() {
            console.log('调用了get')
            return [1,2,3]
        },
        set(val) {
            console.log('调用了set')
        }
    }
})

JSON.parse(JSON.stringify())

原理:对象序列化 JSON 字符串,再反向解析为 JavaScript 对象,嵌套结构全部重建。

仅支持 JSON 标准可序列化的数据:普通对象、数组、字符串、有限数字、布尔值、null

存在大量缺陷

  • 对象属性为 undefinedfunctionSymbol,直接丢失;数组内部出现以上类型转为 null
  • NaNInfinity 转为 nullBigInt 直接抛出异常;
  • Date 对象调用自身 toJSON,输出 ISO 字符串,解析完得到字符串而非 Date 实例;
  • RegExpError 序列化后变成空对象 {}
  • MapSet 会变成 {}
  • 不可枚举属性全部丢失;
  • 对象循环引用直接抛出报错;
  • 同源多引用对象,拷贝后拆分成多份独立对象。

适用场景:只拷贝接口返回纯净 JSON 数据,业务简单场景快速使用。

structuredClone() 结构化克隆

浏览器、Node.js 17 起原生提供 structuredClone(),使用结构化克隆算法。

const cloned = structuredClone(obj)

支持类型:DateRegExpErrorMapSetArrayBufferTypedArray 视图、BlobFile,原生支持循环引用,同源多引用对象拷贝后维持引用关系。

边界限制:

  • 函数、DOM 节点、WindowWeakMapWeakSet 直接抛出 DataCloneError
  • Symbol 作为属性值会抛错;Symbol 键会被丢弃;
  • 原型链自定义方法丢失,拷贝出来是普通对象;
  • 访问器、属性描述符不保留,读取属性实际值再克隆。

手写递归迭代基础版本

原理:基础类型直接返回;引用类型递归复制每一层属性。

function deepCopy1 (obj) {
    if (!obj || typeof obj !== 'object') {
        return obj
    }
    let newObj = obj.constructor === Array ? [] : {}
    Reflect.ownKeys(obj).forEach(key => {
        newObj[key] = typeof obj[key] === 'object' ? deepCopy1(obj[key]) : obj[key]
    })
    return newObj
}

缺陷:

  • DateRegExpError 实例无法正确还原;
  • 属性描述符、访问器丢失;
  • 对象循环引用直接递归栈溢出;
  • 同一个对象多处引用,拷贝后变成多份独立对象。

函数不会进入递归分支,直接复用原始函数引用。

完善特殊类型拷贝

针对 DateRegExp 做构造器实例重建。

注意:使用描述符创建对象后,直接赋值会触发 getter,覆盖属性描述符。想要完整保留访问器,需要递归处理描述符内部的 value,而不是直接对键赋值。完整逻辑参考 lodash 的 baseClone 内部源码。

function deepCopy2 (obj) {
    if (!obj || typeof obj !== 'object') {
        return obj
    }
    let newObj
    let Constructor = obj.constructor
    switch(Constructor){
        case RegExp:
            newObj = new Constructor(obj)
            break
        case Date:
            newObj = new Constructor(obj.getTime())
            break
        default:
            newObj = Object.create(
                Object.getPrototypeOf(obj),
                Object.getOwnPropertyDescriptors(obj)
            )
            break
    }
    Reflect.ownKeys(obj).forEach(key => {
        newObj[key] = typeof obj[key] === 'object' ? deepCopy2(obj[key]) : obj[key]
    })
    return newObj
}

解决循环引用与重复引用

对象循环引用会无限递归,触发栈溢出。解决方案:使用 WeakMap 缓存已经拷贝完成的对象,每次拷贝优先查询缓存,已经拷贝过直接返回缓存副本。该方案同时可以解决同一对象多处被引用,拷贝后分裂成多个实例的问题。

function deepCopy3 (obj, hash = new WeakMap()) {
    if (!obj || typeof obj !== 'object') {
        return obj
    }
    if (hash.has(obj)) {
        return hash.get(obj)
    }
    let newObj
    let Constructor = obj.constructor
    switch(Constructor){
        case RegExp:
            newObj = new Constructor(obj)
            break
        case Date:
            newObj = new Constructor(obj.getTime())
            break
        default:
            newObj = new Constructor()
            newObj = Object.create(
                Object.getPrototypeOf(newObj),
                Object.getOwnPropertyDescriptors(obj)
            )
            break
    }
    hash.set(obj, newObj)
    Reflect.ownKeys(obj).forEach(key => {
        newObj[key] = typeof obj[key] === 'object' ? deepCopy3(obj[key], hash) : obj[key]
    })
    return newObj
}

deepCopy2 一样,后面的赋值仍会触发 getter、冲掉描述符。

解决递归深度过大栈溢出

递归拷贝对象层级过深,会触发调用栈溢出。广度不会影响栈。 递归无法使用尾递归优化,可以改用迭代配合栈容器手动模拟递归流程,消除函数调用栈。

function deepCopy5(obj, root = {}) {
    const stackList = [
        {
            parent: root,
            key: null,
            data: obj,
        }
    ]
    while(stackList.length) {
        const node = stackList.pop()
        const parent = node.parent
        const key = node.key
        const data = node.data
        let res = parent
        if (key !== null) {
            res = parent[key] = {}
        }
        for(let k in data) {
            if (data.hasOwnProperty(k)) {
                if (typeof data[k] === 'object') {
                    stackList.push({
                        parent: res,
                        key: k,
                        data: data[k],
                    })
                } else {
                    res[k] = data[k]
                }
            }
        }
    }
    return root
}

该示例仅演示消除递归栈思路,没有处理特殊对象、循环引用、null。业务中超深嵌套对象很少见。

Vue 2 适配版深拷贝

Vue 2 响应式数据会挂载不可枚举的 __ob__Observer 实例)。如果直接拷贝属性描述符,会把响应式观察者一并复制,副本依旧和源对象绑定。同时 Object.create 生成的伪数组,Array.isArray 返回 false,触发 props 类型校验失败。Vue 3 改用 Proxy,没有这一套 __ob__

放弃拷贝不可枚举属性、属性描述符:

function deepCopy (obj, hash = new WeakMap()) {
    if (!obj || typeof obj !== 'object') {
        return obj
    }
    if (hash.has(obj)) {
        return hash.get(obj)
    }
    let newObj
    let Constructor = obj.constructor
    switch(Constructor){
        case RegExp:
            newObj = new Constructor(obj)
            break
        case Date:
            newObj = new Constructor(obj.getTime())
            break
        default:
            newObj = new Constructor()
            break
    }
    hash.set(obj, newObj)
    for (let key in obj) {
        if (obj.hasOwnProperty(key)) {
            newObj[key] = typeof obj[key] === 'object' ? deepCopy(obj[key], hash) : obj[key]
        }
    }
    return newObj
}

总结

核心本质:赋值、浅拷贝、深拷贝的区别,就是新对象的创建层级,以及引用断开到哪一层。

开发时需要清楚每种拷贝方法会丢失哪些特性,根据业务数据结构选择最合适的实现。

  • 普通可结构化数据(不含函数、访问器):优先 structuredClone()
  • 需要拷贝函数、复杂自定义实例、完整兼容性:使用 lodash 的 _.cloneDeep
  • Vue 2 项目:使用上面适配版本,规避响应式 __ob__ 与数组类型校验问题;
  • 仅接口纯净 JSON 对象:可以简单使用 JSON.parse(JSON.stringify()),清楚它的全部缺陷。

© lizhao all right reserved,powered by Gitbook文件修订时间: 2026-09-01 22:29:16

results matching ""

    No results matching ""