JavaScript深浅拷贝完全指南:从Object.assign()到手写深拷贝

44 阅读4分钟

引言

作为前端工程师,在项目中“拷贝对象/数组”的场景随处可见。本文从 Object.assign() 开场,层层深入到 JSON 序列化、structuredClone、再到手写深拷贝,用贴近业务的案例讲清楚每个方法的边界与取舍。每个知识点都配有代码与简短说明,面试也能现场“表演”。

一、赋值 vs 引用:先把底层说清楚

const a = { n: 1 }
const b = a            // b 与 a 指向同一块内存
b.n = 2
console.log(a.n)       // 2(对象是引用类型)

const x = 1
const y = x            // 基本类型是“值拷贝”
  • 对象/数组/函数等是“引用类型”;基本类型(number/string/boolean/undefined/null/symbol/bigint)是“值类型”。

二、Object.assign():最简单的浅拷贝与合并

  • 语法:Object.assign(target, ...sources) 返回的是 target(“修改后的目标对象”),不是新对象。
  • 只拷贝“可枚举的自有属性”(不拷贝原型链上的)。
  • 浅拷贝:嵌套对象的引用会被共享。
const state = { user: { name: 'Tom' }, age: 18 }

// 1) 浅拷贝
const copy = Object.assign({}, state)
copy.age = 20
copy.user.name = 'Jerry'

console.log(state.age)        // 18(顶层值已拷贝)
console.log(state.user.name)  // 'Jerry'(嵌套对象是同一个引用)

// 2) 返回值是 target 本身
const target = { a: 1 }
const result = Object.assign(target, { b: 2 })
console.log(result === target) // true

// 3) 多源合并 & 覆盖
const merged = Object.assign({}, { a: 1 }, { a: 2, b: 3 })
console.log(merged) // { a: 2, b: 3 }

// 4) getter 在拷贝时会执行(拷贝的是 getter 的返回值)
const src = { get val() { console.log('run'); return 1 } }
const o = Object.assign({}, src) // 控制台打印 run
console.log(o.val) // 1

适用场景

  • 合并配置、顶层属性替换、扁平对象浅拷贝
  • 不适合“深层结构安全隔离”(会共享嵌套引用)

三、数组浅拷贝:slice / concat / 展开符

const arr = [1, { n: 1 }]

// 1) slice
const a1 = arr.slice()
// 2) concat
const a2 = [].concat(arr)
// 3) 展开符
const a3 = [...arr]

a1[1].n = 2
console.log(arr[1].n) // 2(同样是浅拷贝,嵌套对象引用共享)

四、JSON.parse(JSON.stringify()):简单粗暴的“伪深拷贝”

优点:一行代码,简单。 缺点:有丢失、报错、类型退化等问题。

const obj = {
  n: 1,
  d: new Date(),
  r: /hi/gi,
  u: undefined,
  f: () => {},
  s: Symbol('s'),
  m: new Map([['k', 'v']]),
  loop: null,
}
obj.loop = obj // 循环引用

// 1) 基本深拷贝
const deep = JSON.parse(JSON.stringify({ a: 1, b: { c: 2 } }))
deep.b.c = 99 // 原对象不变

// 2) 丢失与报错
// JSON.stringify(obj) 会抛出 TypeError:循环引用
// 即使去掉循环引用:
// - 函数/undefined/Symbol 会被丢弃
// - Date 变成字符串
// - RegExp/Map/Set/TypedArray 等无法保真

适用场景

  • 后端 JSON 场景/纯数据对象/无循环引用/不包含复杂类型
  • 配置快照、简单表单状态复制

五、现代首选:structuredClone(原生深拷贝)

浏览器原生(现代环境),Node 17+ 可用,能拷贝大多数内置类型,支持循环引用。

// 现代环境(浏览器/Node 17+)
const obj = {
  n: 1,
  d: new Date(),
  r: /hi/gi,
  m: new Map([['k', { x: 1 }]]),
  s: new Set([1,2,3]),
  a: new Uint8Array([1,2,3]),
}
const cloned = structuredClone(obj)
console.log(cloned.m.get('k') !== obj.m.get('k')) // true(已深拷贝)

// 注意:函数/DOM 节点不可被 clone
// structuredClone(() => {}) // 会抛错

适用场景

  • 需要可靠深拷贝(包含 Map/Set/Date/RegExp/TypedArray/循环引用)的现代项目
  • 如果要兼容旧环境,可考虑 polyfill 或降级方案

六、手写深拷贝:能“表演”的版本(含循环引用/常见内置类型)

最小可用版本(对象/数组 + 循环引用):

function deepCloneBasic(value, visited = new WeakMap()) {
  if (value === null || typeof value !== 'object') return value

  if (visited.has(value)) return visited.get(value)

  const result = Array.isArray(value) ? [] : {}
  visited.set(value, result)

  for (const key of Object.keys(value)) {
    result[key] = deepCloneBasic(value[key], visited)
  }
  return result
}

可用于“表演”的增强版(支持 Date/RegExp/Map/Set/TypedArray/循环引用):

function isObject(val) {
  return val !== null && typeof val === 'object'
}

function cloneRegExp(re) {
  const flags = re.flags !== undefined ? re.flags : 
    (re.global ? 'g' : '') + (re.ignoreCase ? 'i' : '') + (re.multiline ? 'm' : '')
  return new RegExp(re.source, flags)
}

function deepClone(value, visited = new WeakMap()) {
  // 基本类型
  if (!isObject(value)) return value

  // 已克隆过(循环引用)
  if (visited.has(value)) return visited.get(value)

  // 特例类型
  const type = Object.prototype.toString.call(value)

  if (type === '[object Date]') return new Date(value.getTime())
  if (type === '[object RegExp]') return cloneRegExp(value)

  if (type === '[object Map]') {
    const result = new Map()
    visited.set(value, result)
    value.forEach((v, k) => {
      result.set(deepClone(k, visited), deepClone(v, visited))
    })
    return result
  }

  if (type === '[object Set]') {
    const result = new Set()
    visited.set(value, result)
    value.forEach(v => result.add(deepClone(v, visited)))
    return result
  }

  if (ArrayBuffer.isView(value)) {
    return new value.constructor(value) // TypedArray/DataView
  }

  if (type === '[object ArrayBuffer]') {
    return value.slice(0)
  }

  // 普通对象/数组
  const result = Array.isArray(value) ? [] : Object.create(Object.getPrototypeOf(value))
  visited.set(value, result)

  // 仅处理可枚举属性,必要时可改为 Reflect.ownKeys
  for (const key of Object.keys(value)) {
    result[key] = deepClone(value[key], visited)
  }

  return result
}

// 使用
const source = {
  d: new Date(),
  r: /hi/gi,
  m: new Map([['k', { x: 1 }]]),
  s: new Set([1, 2, 3]),
  a: new Uint16Array([1, 2, 3]),
}
source.self = source

const c = deepClone(source)
console.log(c !== source)                    // true
console.log(c.m.get('k') !== source.m.get('k')) // true
console.log(c.self === c)                    // true(循环引用保留)

说明

  • 用 WeakMap 记录“已克隆对象”解决循环引用
  • 常见内置类型逐个处理
  • 保留原型链(Object.create(Object.getPrototypeOf(value)))
  • 如需拷贝 symbol-key/不可枚举属性/属性描述符,可改用 Reflect.ownKeys + getOwnPropertyDescriptors

七、如何选择:按业务下菜

  • 简单顶层变更/合并配置:Object.assign() / 展开符
  • 数组简单复制:slice/concat/展开符
  • JSON 数据快照、表单回显:JSON.parse(JSON.stringify())
  • 现代项目、类型保真、支持循环引用:structuredClone
  • 框架/库内需要可控行为:手写深拷贝(或 lodash.cloneDeep)
  • 性能敏感:尽量“局部更新”,不要随意大对象深拷贝

八、面试场景“表演要点”

  • 从 Object.assign() 开场,快速示范“浅拷贝 + 可枚举自有属性 + 返回 target + getter 执行”
  • 讲 JSON 序列化的边界(函数/undefined/symbol/循环引用/内置类型)
  • 强推 structuredClone 的现代优势
  • 手写深拷贝时:先基础版再进阶版(WeakMap/内置类型/原型),边写边解释设计取舍
  • 落地到业务:让拷贝“服务于场景”,不是“追求银弹” 、