JS 浅拷贝实现:Object.assign 与展开运算符的局限

作者:代码拾荒时间:2026-11-03 14:46:35浏览:568

深入解析 JavaScript 中 Object.assign 与展开运算符实现浅拷贝的核心原理、内存表现及五大局限性,帮助开发者避开数据共享污染隐患并合理选择拷贝策略。

JavaScript 核心原理
浅拷贝只复制对象第一层的键值对;当属性值为引用类型时,拷贝的仅仅是堆内存指针。Object.assign() 与对象展开运算符(...)是现代前端开发中最常用的两种原生浅拷贝实现,但它们在深层数据隔离、原型继承、属性描述符维护等方面存在本质局限。本文将通过代码实例与底层内存模型,拆解两者的运作方式与隐藏风险,并提供安全合理的拷贝选型方案。
JS 浅拷贝实现:Object.assign 与展开运算符的局限
图 0:浅拷贝运作机制概览(第一层独立,嵌套层共享内存指针)

一、浅拷贝的核心定义与内存模型

在 JavaScript 中,对象的属性按数据类型分为两类:原始类型(如 number、string、boolean、null、undefined、symbol、bigint)与引用类型(如 Object、Array、Function、Date)。

浅拷贝(Shallow Copy)的核心特征可以概括为两句话:

  • 对于第一层的原始类型属性:直接在栈内存中开辟新空间并复制具体数值,原对象与新对象的该属性完全独立,互不影响;
  • 对于第一层的引用类型属性:仅复制保存在对象中的堆内存地址(Pointer)。因此,原对象与新对象的嵌套属性指向同一个实际内存块,任意一方对其进行深层修改,都会同步影响另一方。

我们可以通过下面的代码直观验证这一行为:

// 构造包含原始值和嵌套引用对象的源数据
const original = {
  id: 101,
  title: "JavaScript 浅拷贝实战",
  author: {
    name: "Alice",
    level: "Expert"
  }
};

// 使用展开运算符实现浅拷贝
const copyObj = { ...original };

// 1. 修改第一层的原始类型属性
copyObj.id = 202;
console.log(original.id); // 输出: 101 (互不影响,各自独立)

// 2. 修改第二层嵌套对象的属性
copyObj.author.name = "Bob";
console.log(original.author.name); // 输出: "Bob" (原对象被同时串改!)

为什么修改 copyObj.id 互不干扰,而修改 copyObj.author.name 却导致原对象也被改变?下图展示了这段代码在执行时底层的栈堆内存分布:

JS 浅拷贝实现:Object.assign 与展开运算符的局限
图 1:JavaScript 浅拷贝的栈内存与堆内存分配模型

从图 1 可以清楚地看到,original 和 copyObj 在栈内存中各自拥有独立的堆地址(0x1001 与 0x1002)。但这两个堆对象内部的 author 属性,存储的均是同一个嵌套对象地址 0x3005。这就是浅拷贝导致数据意外串改的根本原因。

二、Object.assign 与展开运算符的操作步骤与细微差异

在实际编码中,实现浅拷贝最常用的语法是 Object.assign() 与 ES6 对象展开运算符 ...。虽然二者的最终效果在绝大多数日常场景中一致,但在底层规范和执行步骤上仍有明确差异。

1. Object.assign 的使用与规范行为

Object.assign(target, ...sources) 方法将所有可枚举的自有属性从一个或多个源对象复制到目标对象,并返回修改后的目标对象:

const target = { a: 1 };
const source = { b: 2, c: 3 };

const result = Object.assign(target, source);

console.log(result); // { a: 1, b: 2, c: 3 }
console.log(result === target); // true (target 本身被原地修改)

注意:如果希望获得全新独立的对象,必须将第一个参数显式传入空对象 {},即 Object.assign({}, source)。

2. 对象展开运算符(...)的简洁表达

对象字面量展开运算符不仅写法更加简洁,而且在语义上始终创建全新的字面量对象:

const source = { b: 2, c: 3 };
const clone = { ...source }; // 总是创建一个全新的字面量对象

3. 内部执行步骤与 Setter 触发差异

尽管两者表面相似,但它们将属性写入目标对象时调用的内部方法不同:

  • Object.assign() 在写入属性时使用的是内部的 [[Set]] 操作符。这意味着如果目标对象或其原型链上存在同名属性的 setter,该 setter 将被触发执行。
  • 展开运算符 { ...source } 使用的是内部的 [[DefineOwnProperty]] 操作符。它直接在新建对象上定义自身属性,完全忽略原型上的 setter。
JS 浅拷贝实现:Object.assign 与展开运算符的局限
图 2:Object.assign 与展开运算符的五步流水线执行机制与差异
差异验证示例:Setter 的触发行为

通过为目标对象定义 setter,可以清晰观察到两者的分歧:

// 目标对象原型上包含 setter
const proto = {
  set val(v) {
    console.log("Setter 被调用,值为:", v);
  }
};

const target = Object.create(proto);

// 方式 A: Object.assign 触发了原型上的 setter
Object.assign(target, { val: 42 }); 
// 控制台输出: "Setter 被调用,值为: 42"
// target 自身并没有生成 val 属性,它被 setter 拦截消费了

// 方式 B: 展开运算符直接定义自有属性
const clone = { ...target, val: 42 };
// 控制台无 setter 输出,clone 自身成功拥有 val 数据属性

三、深度剖析:浅拷贝的五大核心局限

在面对复杂业务实体或状态树时,依赖浅拷贝会带来诸多边界隐患。以下系统梳理 Object.assign 与展开运算符的五大核心局限。

JS 浅拷贝实现:Object.assign 与展开运算符的局限
图 3:浅拷贝在复杂场景下的五大局限与风险矩阵

局限 1:无法实现多层嵌套对象与数组的真实隔离

这是浅拷贝最常引发线上故障的缺陷。在 Redux、Pinia 或 React 的状态流转中,如果直接对含有嵌套对象的状态进行展开拷贝,看似生成了新对象,但深层数据依然是同一份引用:

const state = {
  user: { name: "Carol", roles: ["admin", "editor"] },
  theme: "dark"
};

// 仅在顶层展开
const nextState = { ...state };
nextState.user.roles.push("guest");

// 导致原 state 内部数据被同步破坏,违背了状态不可变性(Immutability)原则
console.log(state.user.roles); // ["admin", "editor", "guest"]

局限 2:丢失原型链与继承方法

Object.assign 与展开运算符只会抓取源对象自身的属性,而完全不会保留源对象由构造函数或类实例继承来的原型链:

class UserAccount {
  constructor(name) {
    this.name = name;
  }
  sayHello() {
    return `Hello, ${this.name}`;
  }
}

const alice = new UserAccount("Alice");
const copyAlice = { ...alice };

console.log(copyAlice.name); // "Alice"
console.log(copyAlice instanceof UserAccount); // false (原型链已断裂)
console.log(typeof copyAlice.sayHello); // "undefined" (类方法未被继承)

局限 3:无法复制属性描述符(Getter/Setter 与元属性)

源对象如果通过 Object.defineProperty 定义了 getter 访问器属性,浅拷贝在读取该属性时会执行 [[Get]],求得当前运行时的计算值,然后将该静态值赋给新对象。原本的动态 getter/setter 逻辑就此遗失:

const pricing = {
  price: 100,
  taxRate: 0.1,
  get total() {
    return this.price * (1 + this.taxRate);
  }
};

const copyPricing = { ...pricing };
console.log(copyPricing.total); // 110 (此处已被固化为静态数值)

copyPricing.price = 200;
console.log(copyPricing.total); // 依然是 110!getter 已经不存在了,不会再动态计算

局限 4:无法复制不可枚举属性

规范明确要求浅拷贝操作仅提取源对象的自有可枚举属性(enumerable: true)。如果某些核心库或数据结构通过设置 enumerable: false 来隐藏属性,这些属性在浅拷贝过程中将被完全忽略:

const secureData = { id: 1 };
Object.defineProperty(secureData, "secretKey", {
  value: "SECRET_999",
  enumerable: false, // 不可枚举
  writable: true
});

const shallowCopy = { ...secureData };
console.log(shallowCopy.secretKey); // undefined (不可枚举属性被丢弃)

局限 5:对循环引用的处理与后续崩溃隐患

若对象内部存在自引用(即 obj.self = obj),直接执行 { ...obj } 虽然在顶层浅拷贝一步不会死循环,但新对象的 self 属性指向的仍旧是原对象,循环链并未被正确闭合为新对象的自引用;并且一旦后续开发者尝试对该对象执行 JSON.stringify(),将立刻抛出 TypeError: Converting circular structure to JSON 异常。

四、对象拷贝的技术选型与安全替代方案

了解了浅拷贝的局限之后,在日常开发中如何根据具体的数据场景选择最合适的复制手段?

JS 浅拷贝实现:Object.assign 与展开运算符的局限
图 4:JavaScript 对象拷贝方案技术选型决策树

1. 什么时候应该使用浅拷贝?

浅拷贝并非一无是处,相反,它是高频轻量操作的首选方案:

  • 扁平数据配置合并:函数入参与默认配置项对象的合并(如 options = { ...defaultOptions, ...customOptions });
  • 单层不可变状态更新:在 React 状态管理中,若数据仅有一层(如 setState(prev => ({ ...prev, count: prev.count + 1 }))),浅拷贝不仅简单直观,而且运行性能最高,无需深层递归的开销。

2. 遇到嵌套数据时:优先选择原生 structuredClone()

在现代浏览器和 Node.js 17+ 环境中,ECMAScript 宿主环境正式标准化了 structuredClone() 原生深拷贝 API。它基于 HTML 规范的结构化克隆算法(Structured Clone Algorithm),相比老旧的 JSON.parse(JSON.stringify(obj)) 拥有压倒性优势:

  • 支持循环引用:天然追踪已克隆对象的引用拓扑,不会产生栈溢出;
  • 原生支持常见内置类型:完美保留 Date、RegExp、Map、Set、ArrayBuffer 等;
  • 高性能:由底层 C++ 引擎直接执行,性能远胜手写递归。
// structuredClone 原生深拷贝使用示例
const deepSource = {
  name: "Project",
  tags: new Set(["frontend", "javascript"]),
  created: new Date("2026-01-01"),
  nested: { count: 10 }
};
// 创建自循环引用
deepSource.self = deepSource;

// 执行原生深拷贝
const deepClone = structuredClone(deepSource);

deepClone.nested.count = 99;
console.log(deepSource.nested.count); // 10 (完全深度隔离!)
console.log(deepClone.tags instanceof Set); // true (保留 Set 实例)
console.log(deepClone.self === deepClone); // true (自引用正确封闭在新对象自身)
structuredClone 的边界限制

structuredClone() 不支持克隆包含函数(Function)、DOM 节点以及 Symbol 键的对象,若传入这些类型会直接抛出 DataCloneError。如果业务对象中不可避免地挂载了方法或自定义类原型,应选用 lodash.cloneDeep 或手写带原型保持的深拷贝函数。

3. 方案对比总结表

拷贝方式 拷贝深度 循环引用支持 特殊类型(Date/Map等) 原型链保留 执行开销
展开运算符 / Object.assign 仅浅层(1 层) 无深层处理 直接复用指针 否(Object.prototype) 极低(纳秒级)
JSON.parse / stringify 深层 报错崩溃 丢失(Date变字符串,Set丢失) 否 中等
structuredClone() 深层 原生完美支持 原生完美支持(函数除外) 否 较低(原生实现)
Lodash cloneDeep 深层 支持 全面支持 可通过扩展支持 高(需载入库文件)

五、结语

Object.assign 与展开运算符是 JavaScript 语法中优雅而高频的特性,其本质是对对象自身可枚举属性的浅层取值与赋值。理解它们在深层引用、原型链及属性描述符上的局限,是写出健壮前端代码的关键基本功。

在面对单层扁平对象与简单配置合并时,坚决使用展开运算符保持代码简洁;当对象涉及复杂嵌套、状态传递或数据树持久化时,明确意识到浅拷贝的边界,果断选用 structuredClone() 或专业深拷贝工具,方能从根本上消除数据引用共享带来的副作用隐患。

作者最新文章
相关文章
更多