JS合并对象时怎么避免修改原数据
在JavaScript中合并对象时如何避免修改原数据?本文深入解析对象按引用传递的本质,对比浅合并与深合并的适用边界,提供展开运算符、Object.assign正确写法、structuredClone及自定义递归deepMerge等多种防篡改实用方案。
Object.assign() 的首个目标容器,导致就地写操作;二是浅拷贝只复制了首层属性,而内部嵌套的子对象依然通过指针共享堆内存。要彻底避免原数据被篡改,对于扁平对象,应始终使用对象展开运算符 { ...a, ...b } 或以全新空对象为首参数的 Object.assign({}, a, b);对于含有深层嵌套的对象,则必须采用 现代原生 structuredClone() 或递归深度合并函数(Deep Merge)来切断引用链。
一、为什么合并对象时原数据总被意外改动?
在 JavaScript 的内存模型中,对象(Object)、数组(Array)和函数属于引用类型(Reference Types)。变量本身并不直接存放对象的实际数据,而是存放指向堆内存(Heap)的引用地址(指针)。
当我们执行对象赋值或属性传递时,操作的本质是传递这个内存地址。这也是导致原对象在合并时被无意篡改的根本原因。具体表现为以下两类常见误区:
- 就地改写误区(In-Place Mutation):调用
Object.assign(target, source)时,很多开发者直觉地将待补充的对象传入第一个参数target。规范中Object.assign会直接修改并返回target,这就导致原本作为基准数据的对象被强行植入了新属性。 - 引用共享误区(Shared Reference):即便用
{ ...obj }复制了顶层对象,如果该对象的某个属性值是另一个对象,展开运算符仅将该子对象的内存指针复制给了新对象。一旦对新对象的子属性赋值,原对象的对应子属性也会立刻发生同步变化。
二、扁平单层对象的安全合并方法
如果待合并的对象结构简单,所有属性值均为基本类型(字符串、数值、布尔值、null、undefined 等),没有嵌套对象,那么只要保证合并目标是一个全新开辟的内存对象,就能完全避免修改原数据。
方法 1:对象展开运算符(Spread Operator)—— 推荐写法
ES2018 引入的对象展开运算符 { ...obj } 是目前最简洁、最直观的合并方式。它会在底层创建一个全新的普通对象,并将各个源对象的可枚举自有属性逐个拷贝进去。
// 示例:使用展开运算符合并对象
const defaultOptions = {
theme: 'light',
pageSize: 10,
autoRefresh: true
};
const userOptions = {
pageSize: 20,
autoRefresh: false
};
// 安全合并:创建全新对象,不污染原数据
const finalOptions = { ...defaultOptions, ...userOptions };
// 验证原对象是否被改动
console.log(defaultOptions.pageSize); // 输出: 10 (原对象保持完整)
console.log(finalOptions.pageSize); // 输出: 20 (合并结果正确)
console.log(finalOptions === defaultOptions); // 输出: false (内存引用完全独立)
方法 2:以独立空对象为目标的 Object.assign()
如果项目需要兼容更老旧的环境,或者需要将数组中的多个对象动态合并为一个对象,可以使用 Object.assign()。其核心原则是:第一个参数必须显式声明为一个全新的空对象字面量 {}。
// 正确做法:必须以 {} 作为第一个目标参数
const baseConfig = { env: 'production', debug: false };
const overrideConfig = { debug: true, port: 8080 };
// 将属性复制到新对象 {} 中,不会影响 baseConfig
const mergedConfig = Object.assign({}, baseConfig, overrideConfig);
console.log(baseConfig.debug); // 输出: false (原数据未被修改)
console.log(mergedConfig.debug); // 输出: true (成功合并)
Object.assign(baseConfig, overrideConfig),这样会直接将 overrideConfig 写入 baseConfig 所指向的内存空间,彻底破坏原数据。
三、警惕浅拷贝陷阱:嵌套子对象的引用穿透
展开运算符和 Object.assign({}, ...) 只能保护对象的第一层属性。一旦对象内部包含深层嵌套的子对象或数组,这两个方法都属于“浅拷贝”(Shallow Copy)。
在浅拷贝下,顶层属性虽然被复制进了新对象,但深层子对象的属性值依然存放着指向同一个堆内存块的指针。这就造成了极为隐蔽的“引用穿透”现象。
通过下方真实代码观察这种意外篡改的发生过程:
// 嵌套对象浅合并演示
const userProfile = {
id: 1001,
nickname: 'Alice',
settings: {
notifications: true,
language: 'zh-CN'
}
};
// 表面上生成了一个全新的新对象
const updatedProfile = { ...userProfile, nickname: 'Bob' };
// 修改新对象的嵌套属性
updatedProfile.settings.notifications = false;
// 检查原数据:发现 userProfile.settings 同样被修改了!
console.log(userProfile.settings.notifications);
// 输出: false (原数据遭到篡改!)
console.log(userProfile.settings === updatedProfile.settings);
// 输出: true (两者指向完全相同的内存地址)
在复杂的前端项目(如 React 状态管理、Vue 响应式对象、表单默认值回填)中,这种穿透修改会导致极其难以排查的联动 Bug。要解决这个问题,必须采用真正的深层隔离方案。
四、解决嵌套篡改:深层隔离的两种核心方案
当对象具有两层或更多嵌套结构时,必须采用深层复制(Deep Clone)或深度合并(Deep Merge)手段,确保整棵对象树上的每一个引用节点都在堆中重新分配空间。
方案 1:现代原生标准 structuredClone()
在现代浏览器(Chrome 98+、Firefox 94+、Safari 15.4+)以及 Node.js 17+ 环境中,已经内置了原生的 structuredClone() 函数。它基于 HTML 结构化克隆算法(Structured Clone Algorithm),能够真正脱离引用关系完整克隆对象。
// 方案 1:使用 structuredClone 阻断深层嵌套引用
const defaultSettings = {
theme: 'dark',
editor: {
fontSize: 14,
tabSize: 2
}
};
const userSettings = {
editor: {
fontSize: 18
}
};
// 先对原数据进行完全隔离的深拷贝,再进行安全合并
const safeBase = structuredClone(defaultSettings);
const safeOverride = structuredClone(userSettings);
// 如果只需要浅层覆盖子对象:
const merged = {
...safeBase,
...safeOverride,
editor: {
...safeBase.editor,
...safeOverride.editor
}
};
merged.editor.fontSize = 24;
console.log(defaultSettings.editor.fontSize); // 输出: 14 (完全不受影响!)
- 优势:原生支持
Date、RegExp、Set、Map、ArrayBuffer,并且能自动处理循环引用。 - 边界限制:不能克隆
Function、Symbol或 DOM 节点。如果对象包含函数方法,调用structuredClone()会抛出DataCloneError。
方案 2:纯函数手写递归深度合并(Deep Merge)
在许多实际业务中(例如合并组件默认 Props、系统配置覆盖),我们希望源对象的未声明嵌套属性保留,而仅针对性地合并增量属性,同时保证所有源对象均不被修改。此时,一个纯函数式的递归深合并是通用且健壮的方案:
// 生产级深合并纯函数实现(不修改任何原数据)
function isPlainObject(item) {
return (
item !== null &&
typeof item === 'object' &&
Object.prototype.toString.call(item) === '[object Object]'
);
}
function deepMerge(...objects) {
// 步骤 1:创建全新的顶层结果对象
const result = {};
for (const obj of objects) {
if (!isPlainObject(obj)) continue;
// 步骤 2:遍历当前源对象的所有自有属性
for (const key of Object.keys(obj)) {
const prevVal = result[key];
const nextVal = obj[key];
// 步骤 3:根据类型判定分流处理
if (Array.isArray(nextVal)) {
// 数组做浅克隆或按需深克隆,阻断引用
result[key] = nextVal.map(item => (isPlainObject(item) ? deepMerge(item) : item));
} else if (isPlainObject(prevVal) && isPlainObject(nextVal)) {
// 双方均为纯对象,进入递归合并并赋新对象
result[key] = deepMerge(prevVal, nextVal);
} else if (isPlainObject(nextVal)) {
// 原结果无该对象,克隆新对象后写入
result[key] = deepMerge(nextVal);
} else {
// 基础类型直接安全赋值
result[key] = nextVal;
}
}
}
// 步骤 4:返回完全隔离的深层独立对象
return result;
}
// 验证效果
const sourceA = {
network: { timeout: 3000, retry: 2 },
headers: { 'X-App-Id': '123' }
};
const sourceB = {
network: { timeout: 5000 }, // 只覆盖 timeout,保留 retry
headers: { 'Authorization': 'Bearer token' }
};
const finalResult = deepMerge(sourceA, sourceB);
console.log(finalResult.network);
// 输出: { timeout: 5000, retry: 2 }
// 尝试修改合并结果
finalResult.network.timeout = 9999;
// 验证原对象:完全不受影响
console.log(sourceA.network.timeout); // 输出: 3000 (安全!)
console.log(sourceB.network.timeout); // 输出: 5000 (安全!)
补充提醒:为什么不推荐用 JSON.parse(JSON.stringify()) 做合并?
早年常用的 JSON.parse(JSON.stringify(obj)) 虽然能在没有第三方库时实现深拷贝,但在现代 JavaScript 开发中应尽量避免,原因包括:
- 属性值为
undefined、函数或Symbol时,序列化后会被直接丢失或转为null; Date对象会被转换成 ISO 字符串,不再保留原始日期对象实例;NaN和Infinity会被转为null;- 对象中一旦存在循环引用(Circular Reference),会直接抛出
TypeError: Converting circular structure to JSON。
五、各种方案特性对比与场景选型指南
在实际编码中,没有唯一固定的最佳方法,关键在于根据数据结构的复杂度、性能要求和业务上下文做出准确选择。
常见工程场景推荐选型:
- 前端 UI 状态更新(React / Redux / Zustand):
状态必须严格遵循不可变性(Immutability)。对于第一层状态属性,直接优先使用
{ ...state, ...updates };对于嵌套状态,推荐使用展开运算符分层复制,或在工程中引入专为不可变状态设计的工具库(如 Immer.js),避免手动深拷贝带来的多余性能损耗。 - 系统/插件默认配置深度融合:
例如为 Axios、ECharts 或自定义 SDK 提供默认配置时,用户配置往往只提供部分覆盖项。这种场景必须保留默认配置中未被显式覆盖的深层字段,推荐使用封装好的
deepMerge()工具函数或经过严格测试的成熟库函数(如lodash.merge),确保配置合并既不改动预设模板,又不丢失内部默认项。 - 表单初始快照与数据还原:
在进入编辑弹窗时,需要缓存一份初始表单数据用于“重置”。如果表单中包含树状选择器数据或复杂联动结构,在初始化时使用
structuredClone(formModel)做深层隔离快照,并在重置时重新深拷贝赋回,能有效杜绝表单修改污染源数据的问题。
Object.freeze(config) 将原对象冻结。被冻结的对象在严格模式('use strict')下尝试修改会直接抛出 TypeError 异常,能够帮助开发者在编码阶段第一时间发现潜在的就地修改隐患。
什么时候该用JS构造器:与工厂模式、字面量的选择对比
2026-11-05 16:18:22
推荐几款轻量级JS照片墙开源库及对比
2026-11-05 15:56:19
JavaScript递归写法有哪些常见陷阱及避免方法
2026-11-05 14:53:38
JS数组浅拷贝与深拷贝怎么选?场景与方法总结
2026-11-05 14:41:02
p5.js 中 setup() 和 draw() 函数的执行机制详解
2026-11-05 12:32:38
相关标签:








