JS合并对象时怎么避免修改原数据

作者:闭包旅人

时间:2026-11-03

浏览:275

在JavaScript中合并对象时如何避免修改原数据?本文深入解析对象按引用传递的本质,对比浅合并与深合并的适用边界,提供展开运算符、Object.assign正确写法、structuredClone及自定义递归deepMerge等多种防篡改实用方案。

文章目录
在 JavaScript 中合并对象时,原数据之所以被意外修改,主要原因有两个:一是错误地将原对象作为 Object.assign() 的首个目标容器,导致就地写操作;二是浅拷贝只复制了首层属性,而内部嵌套的子对象依然通过指针共享堆内存。要彻底避免原数据被篡改,对于扁平对象,应始终使用对象展开运算符 { ...a, ...b } 或以全新空对象为首参数的 Object.assign({}, a, b);对于含有深层嵌套的对象,则必须采用 现代原生 structuredClone() 或递归深度合并函数(Deep Merge)来切断引用链。
JS合并对象时怎么避免修改原数据
缩略图概览:不可变对象合并的核心是将所有源数据隔离,通过开辟全新堆空间生成新对象

一、为什么合并对象时原数据总被意外改动?

在 JavaScript 的内存模型中,对象(Object)、数组(Array)和函数属于引用类型(Reference Types)。变量本身并不直接存放对象的实际数据,而是存放指向堆内存(Heap)的引用地址(指针)。

当我们执行对象赋值或属性传递时,操作的本质是传递这个内存地址。这也是导致原对象在合并时被无意篡改的根本原因。具体表现为以下两类常见误区:

  • 就地改写误区(In-Place Mutation):调用 Object.assign(target, source) 时,很多开发者直觉地将待补充的对象传入第一个参数 target。规范中 Object.assign 会直接修改并返回 target,这就导致原本作为基准数据的对象被强行植入了新属性。
  • 引用共享误区(Shared Reference):即便用 { ...obj } 复制了顶层对象,如果该对象的某个属性值是另一个对象,展开运算符仅将该子对象的内存指针复制给了新对象。一旦对新对象的子属性赋值,原对象的对应子属性也会立刻发生同步变化。
JS合并对象时怎么避免修改原数据
图 1:浅层合并机制对比 —— 就地篡改 vs 全新对象分配

二、扁平单层对象的安全合并方法

如果待合并的对象结构简单,所有属性值均为基本类型(字符串、数值、布尔值、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)。

在浅拷贝下,顶层属性虽然被复制进了新对象,但深层子对象的属性值依然存放着指向同一个堆内存块的指针。这就造成了极为隐蔽的“引用穿透”现象。

JS合并对象时怎么避免修改原数据
图 2:浅拷贝的嵌套穿透隐患 —— 为什么深层属性依然被改动?

通过下方真实代码观察这种意外篡改的发生过程:

// 嵌套对象浅合并演示
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)手段,确保整棵对象树上的每一个引用节点都在堆中重新分配空间。

JS合并对象时怎么避免修改原数据
图 3:递归深度合并(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 (完全不受影响!)
structuredClone 的优势与边界:
  • 优势:原生支持 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。

五、各种方案特性对比与场景选型指南

在实际编码中,没有唯一固定的最佳方法,关键在于根据数据结构的复杂度、性能要求和业务上下文做出准确选择。

JS合并对象时怎么避免修改原数据
图 4:JavaScript 对象防篡改合并方案决策指南与特性全景矩阵

常见工程场景推荐选型:

  1. 前端 UI 状态更新(React / Redux / Zustand):

    状态必须严格遵循不可变性(Immutability)。对于第一层状态属性,直接优先使用 { ...state, ...updates };对于嵌套状态,推荐使用展开运算符分层复制,或在工程中引入专为不可变状态设计的工具库(如 Immer.js),避免手动深拷贝带来的多余性能损耗。

  2. 系统/插件默认配置深度融合:

    例如为 Axios、ECharts 或自定义 SDK 提供默认配置时,用户配置往往只提供部分覆盖项。这种场景必须保留默认配置中未被显式覆盖的深层字段,推荐使用封装好的 deepMerge() 工具函数或经过严格测试的成熟库函数(如 lodash.merge),确保配置合并既不改动预设模板,又不丢失内部默认项。

  3. 表单初始快照与数据还原:

    在进入编辑弹窗时,需要缓存一份初始表单数据用于“重置”。如果表单中包含树状选择器数据或复杂联动结构,在初始化时使用 structuredClone(formModel) 做深层隔离快照,并在重置时重新深拷贝赋回,能有效杜绝表单修改污染源数据的问题。

防御性编程小技巧:在团队协作或核心配置文件中,可以使用 Object.freeze(config) 将原对象冻结。被冻结的对象在严格模式('use strict')下尝试修改会直接抛出 TypeError 异常,能够帮助开发者在编码阶段第一时间发现潜在的就地修改隐患。

实用工具推荐

电子包浆

绿化、压缩、噪点和虚拟水印,自由调节做旧程度。

使用 ›
图片压缩

选择画质和输出格式,预览压缩前后的大小。

使用 ›
相关文章