JS对象合并时属性覆盖顺序是怎么的
全面讲解 JavaScript 中合并对象时的属性覆盖顺序与执行机制,深入分析展开运算符与 Object.assign 的时序差异、浅拷贝陷阱、原型链与访问器属性处理以及工程最佳实践。
在 JavaScript 中合并对象时,属性覆盖遵循一条根本原则:从左到右依次处理,后出现的同名属性无条件覆盖先出现的同名属性(即“后来者居上”)。无论是使用 ES2015 引入的 Object.assign() 还是 ES2018 的对象展开运算符(Spread Operator ...),合并过程本质上都是按参数书写顺序对自身可枚举属性进行迭代赋值,最后一次写入的值决定了最终结果。
一、核心规则:从左到右与后来者居上
日常开发中最常见的对象合并写法是展开运算符 { ...objA, ...objB } 以及 Object.assign(target, ...sources)。两者的覆盖顺序一致:参数列表中位于右侧的对象,在出现相同键名时,会覆盖左侧对象所对应的属性值。
// 基础示例:对象展开运算符
const defaultOptions = { host: 'localhost', port: 8080, debug: false };
const userOptions = { port: 3000, debug: true };
const finalConfig = { ...defaultOptions, ...userOptions };
console.log(finalConfig);
// 输出: { host: 'localhost', port: 3000, debug: true }
在这个例子中,host 仅存在于 defaultOptions 中,因此被完整保留;而 port 和 debug 两个键在 userOptions 中再次出现。按照从左到右的求值顺序,userOptions 中的 port: 3000 与 debug: true 发生在后,将原先的 8080 和 false 覆盖。
如果在展开式之后再次直接声明同名属性,其本质等同于继续在右侧追加写入:
// 展开后再写单字段:继续覆盖左侧全部结果
const overridePort = { ...defaultOptions, ...userOptions, port: 9000 };
console.log(overridePort.port); // 输出: 9000
二、时序推导:对象键的遍历与写入过程
为了理解为什么后出现的属性会生效,我们需要了解合并运行时的内部工作流。无论是 Object.assign 还是对象展开语法,其底层操作大致分为三步:
- 创建或确认目标容器:展开语法
{ ...a, ...b }会在内存中开辟一个新的纯字面量对象;而Object.assign(target, source)会以传入的第一个参数target作为受体。 - 按序遍历源对象属性:引擎依序取出各源对象,通过内部机制获取其“自身可枚举属性”(Own Enumerable Keys)。
- 顺序赋值到容器:每提取到一个属性键值对,就执行一次写入。如果目标容器中该键已存在,新值直接取代旧值。
覆盖发生时,同名属性的值被更新,但这个属性键在对象中的遍历位置会改变吗?答案是:不会重新移到最末尾。在 ES2015 规范中,普通字符串键维持其在目标对象中首次创建时的次序。新值覆盖了旧值,但原键的排列位置依然遵循规范规定的整数升序与字符串插入顺序。
三、关键陷阱:浅拷贝导致嵌套对象被整体替换
这是对象合并中最容易导致线上故障的误区:原生对象合并仅执行浅拷贝(Shallow Copy),不会自动递归合并嵌套子对象。
如果两个对象中都包含一个同名的子对象属性,合并时不是将两个子对象内部的字段融合,而是用右侧子对象的内存引用地址整体替换掉左侧的子对象,导致左侧子对象特有的属性全部丢失。
// 嵌套属性合并陷阱示例
const defaultSettings = {
theme: 'light',
editor: {
fontSize: 14,
lineHeight: 20,
tabSize: 2
}
};
const userSettings = {
editor: {
fontSize: 16 // 期望仅修改字体大小,保留 lineHeight 和 tabSize
}
};
// 错误合并:以为会递归融合
const merged = { ...defaultSettings, ...userSettings };
console.log(merged.editor);
// 实际输出: { fontSize: 16 }
// 注意: lineHeight 与 tabSize 完全丢失!
造成上述现象的原因在于:JavaScript 引擎在合并 editor 属性时,仅把 userSettings.editor 的引用指针直接赋值给 merged.editor,原先指向 { fontSize: 14, lineHeight: 20, tabSize: 2 } 的连接直接断开。
正确处理嵌套合并的方案:
- 层级较浅时:手动逐层展开
const safeMerged = { ...defaultSettings, ...userSettings, editor: { ...defaultSettings.editor, ...userSettings.editor } }; - 多层嵌套或动态结构时:使用专用深合并工具
对于不确定的复杂嵌套结构,应采用深合并函数(如lodash.merge)或自研递归合并算法,依次判断各层级是否为普通对象,再进行深入合并。
四、展开运算符与 Object.assign 的底层差异
虽然两者在同名属性覆盖的大方向上完全一致,但在遇到访问器属性(Getter / Setter)以及目标对象变更时,行为有着本质差异:
1. 写入协议不同:[[DefineOwnProperty]] vs [[Set]]
展开运算符 { ...a, ...b } 内部使用 [[DefineOwnProperty]],它会在新对象上直接建立全新的数据描述符(Data Descriptor)。即使目标对象原型链上存在同名 Setter,也不会被触发。
而 Object.assign(target, source) 写入时使用的是 [[Set]],即相当于执行 target[key] = value。若 target 原本定义了该属性的 set 访问器,Setter 将被执行,而不会将属性转换为普通属性。
// Setter 触发差异演示
const target = {
set value(v) {
console.log('Setter 被调用:', v);
}
};
const source = { value: 100 };
// 使用 Object.assign 会激活 target 的 setter
Object.assign(target, source); // 打印: "Setter 被调用: 100"
// 使用展开运算符构建新对象,不会执行原有 setter
const newObj = { ...target, ...source };
console.log(newObj.value); // 输出: 100 (已成为纯数据属性)
2. 就地修改与纯函数式区别
Object.assign(target, source) 的第一个参数是被修改的目标对象。如果不希望修改原有对象,必须显式以空对象作为首个参数:Object.assign({}, a, b)。而展开运算符 { ...a, ...b } 永远返回全新对象,更符合不可变数据管理范式。
五、边界条件与特殊值覆盖规则
在工程实践中,合并操作往往伴随着各种边界类型与空值。以下为必须掌握的特殊情况:
| 场景 / 特殊值 | 展开运算符 { ...a, key: val } |
Object.assign(target, source) |
覆盖行为说明 |
|---|---|---|---|
显式传入 undefined |
覆盖并赋值为 undefined |
覆盖并赋值为 undefined |
键名仍会保留在对象中,原默认值被冲刷掉 |
显式传入 null |
覆盖并赋值为 null |
覆盖并赋值为 null |
同上,null 作为具体值完全覆盖旧值 |
源对象为 null 或 undefined |
静默忽略,不报错 | 若在 sources 位置静默忽略;若作为 target 则抛 TypeError | { ...obj, ...null } 安全无副作用 |
| 原型链上的属性 (Prototype) | 不拷贝原型属性 | 不拷贝原型属性 | 仅拷贝“自身且可枚举”(Own Enumerable)属性 |
| 不可枚举属性 (enumerable: false) | 忽略,不复制 | 忽略,不复制 | 两者均使用可枚举键遍历协议 |
| Symbol 类型的键 | 若可枚举则正常拷贝覆盖 | 若可枚举则正常拷贝覆盖 | 同名 Symbol 键同样遵循后者覆盖前者 |
当开发者编写通用函数接收用户配置时,经常写出如下代码:
function init(options = {}) {
// 如果 options 中显式传了 { timeout: undefined }
const config = { timeout: 5000, ...options };
console.log(config.timeout); // 结果是 undefined,而不是 5000!
}
这是因为右侧的 timeout: undefined 作为自身属性存在,依然执行了覆盖。如需避免被 undefined 冲掉,应在合并前过滤掉值为 undefined 的键,或在解构时使用默认值。
六、总结与实践建议
掌握 JavaScript 对象合并属性覆盖顺序的核心要点可归纳为以下三点:
- 顺序决定权:覆盖关系由从左至右的书写顺序严格决定,后出现的属性拥有最高优先级。组织配置项时,务必将默认底表置于最前,定制项置于最后。
- 层级警惕:原生合并仅作用于顶层属性。遇到包含配置嵌套的对象时,切记不要直接展开,应进行针对性的逐层解构或选用成熟的深合并策略。
- 语法首选:除性能极其敏感且需复用既有对象引用的特殊场景外,常规业务逻辑中优先推荐使用对象展开运算符
{ ...a, ...b },其语义清晰、无原型 Setter 风险且天然支持不可变性。
JavaScript闭包与类(Class)的区别及适用场景对比
2026-11-05 15:48:57
JavaScript入门要学ES6吗?let与箭头函数等新特性解析
2026-11-05 14:49:16
JavaScript 8种数据类型详解:基本类型与引用类型的区别
2026-11-05 14:25:15
怎么在JavaScript中使用async/await优化Ajax异步流程
2026-11-05 13:56:15
JavaScript replace回调函数实现动态正则替换逻辑
2026-11-05 13:52:13
相关标签:








![JS输出对象显示[object Object]怎么办?JSON.stringify格式化打印技巧](https://img.javascript.cn/tit/articles/13236/20261105/6eaec9baed78171fe0bc2b74184ba5b7b80ebd52de7d3323ff152268aa2df294.webp)