1. 先还原一次翻车现场:为什么原对象被悄悄改掉了
先把话说在前面:Object.assign() 这个 API,我在项目里见过太多人用错。最常见的误解是“它能把一个对象复制出一个新对象”,但实际行为完全不是这样。这里我先说结论,再带你复现一遍。
1.1 最小可复现案例
假设你有两份配置,一份是默认配置,一份是用户传入的覆盖配置,想合并成一份最终配置。第一反应写出来的代码往往是这样的:
javascript复制const defaultConfig = {
theme: 'light',
fontSize: 14,
};
const userConfig = {
theme: 'dark',
};
const finalConfig = Object.assign(defaultConfig, userConfig);
console.log(finalConfig);
// { theme: 'dark', fontSize: 14 }
console.log(defaultConfig);
// { theme: 'dark', fontSize: 14 } ← 原对象也被改了!
如果你觉得 finalConfig 是一个“新对象”,那这个输出会直接打破你的认知:defaultConfig 怎么会变成 { theme: 'dark', fontSize: 14 }?最扎心的是,你检查 finalConfig === defaultConfig,结果会是 true——它们就是同一个对象。
这就是我常说的“第一个坑”。Object.assign() 不是“复制到新对象”,而是“把源对象的属性赋值给目标对象”,目标对象会被原地修改,然后返回它。你可以这么理解:你把水果装进一个篮子里,这个篮子本来就是你自己的,装完之后它还是那个篮子,只是里面的东西变了。你要的是“另一篮子水果”,但代码给的是“你自己篮子换了一批水果”。
1.2 返回值不是“新对象”:如何优雅地不污染原对象
那么问题来了:日常合并配置时,绝大多数情况下我们不想改掉默认配置对象。解决办法很简单——把目标对象指定成一个全新的空对象:
javascript复制const finalConfig = Object.assign({}, defaultConfig, userConfig);
console.log(defaultConfig);
// { theme: 'light', fontSize: 14 } ← 原对象原封不动
console.log(finalConfig);
// { theme: 'dark', fontSize: 14 }
看到没有,第一个参数传 {},defaultConfig 就不会被污染。这也是为什么所有教程里写 Object.assign 时,几乎都会带着一个空对象 {} 当靶子。记住一条经验:只要看到 Object.assign 的第一个参数不是 {},就要停下来想一想,这个目标对象是不是真的允许被修改。
另外,因为返回值就是第一个参数,所以 Object.assign 支持链式调用。比如:
javascript复制const state = Object.assign(
Object.assign({}, baseState, { loading: true }),
{ error: null }
);
这种写法在老代码里偶尔能看到,本质上是把上一次合并的结果继续当作目标对象。理解了“返回值等于目标对象”这件事,就不会对着这种代码发懵。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从命名到合并规则:assign 到底在 assign 什么
很多人把 Object.assign 当“黑盒”用,但我建议把它的名字拆开看:Object 是一个静态方法容器,assign 的意思是“分配、指派”。连起来,就是把一个或多个源对象的属性,一个一个“指派”给目标对象。
2.1 它挂在 Object 上的静态方法,不是对象按钮
有个新手常见疑问:为什么我访问 obj.assign 是 undefined?因为 Object.assign 是定义在 Object 构造函数上的静态方法,不是挂载在每个对象实例上的。你需要写 Object.assign(target, source),而不能直接写 target.assign(source)。这和 obj.toString() 这种通过原型链调用的对象方法是不同的。
你可以这么区分:凡是 Object.xxx() 这种形式,一般是工具函数,处理对象相关的通用逻辑;凡是 obj.xxx() 这种形式,一般是实例方法,操作当前对象本身。Object.assign 属于前者。
2.2 多对一合并:后面的源对象优先级更高
Object.assign 支持一次传入多个源对象,关键规则是:如果几个源对象里有相同的属性名,后面的源对象会覆盖前面的源对象。
javascript复制const result = Object.assign(
{},
{ a: 1, b: 2 },
{ b: 3 },
{ c: 4 }
);
console.log(result);
// { a: 1, b: 3, c: 4 }
看到这里你应该能理解为什么它适合做“默认配置 + 用户配置”的合并:把默认配置放前面,用户配置放后面,用户传了什么就覆盖什么,没传的就保留默认值。这种“后面的赢”的规则,在 JavaScript 里其实很常见,比如 CSS 里后面的样式会覆盖前面的,同一个思路。
2.3 复制属性时的筛选规则
Object.assign 不是“把所有东西都搬过去”,它只复制源对象的可枚举自有属性。什么叫可枚举?你可以简单理解为一个属性在 for...in 或 Object.keys() 的遍历中能不能被看到。什么叫自有?就是这个属性定义在对象自己身上,而不是从原型链上继承下来的。
| 场景 | 是否会被 Object.assign 复制 |
|---|---|
| 可枚举的自有字符串属性 | 是 |
| 可枚举的自有 Symbol 属性 | 是 |
| 不可枚举的自有属性 | 否 |
| 原型链上的属性 | 否 |
| 字符串源的字符索引属性 | 是 |
| 数组的索引属性 | 是 |
| 数组的 length 属性 | 否(不可枚举) |
举个例子,一个通过 Object.create(proto) 创建的对象,它的原型上有 inherited 属性,同时自己用 Object.defineProperty 定义了一个不可枚举的 hidden 属性,最后再手动加一个 own 属性:
javascript复制const proto = { inherited: 'from proto' };
const source = Object.create(proto);
Object.defineProperty(source, 'hidden', {
value: 'secret',
enumerable: false,
});
source.own = 'yes';
const result = Object.assign({}, source);
console.log(result);
// { own: 'yes' }
inherited 没过来,hidden 也没过来,只有 own 过来了。原因就是 Object.assign 内部只遍历“可枚举的自有 key”,Object.keys() 拿到的是什么,它就处理什么。
2.4 值为 undefined 的属性也会覆盖
这点非常容易误导人:你以为“传了 undefined 就相当于没传”,但 Object.assign 不这么认为——只要属性名存在,哪怕值是 undefined,它也会老老实实地覆盖目标对象上的同名字段。
javascript复制const result = Object.assign(
{ a: 1, b: 2 },
{ a: undefined }
);
console.log(result);
// { a: undefined, b: 2 }
这对“默认配置合并”的场景影响很大。比如你的默认配置是 { width: 400 },用户显式传了 { width: undefined },最终结果不会是 400,而是 undefined。这不算 bug,是行为如此,但在实际项目中确实容易造成诡异的展示问题。后面讲坑的时候我会再说怎么处理。
3. Object.assign、对象展开、直接赋值的真实差异
很多文章会把 Object.assign 和对象展开运算符 { ...source } 混为一谈,说“它们差不多”。在大部分简单场景下,它们确实可以互换,但较真起来,差异并不少,而且这些差异在特定场景下会直接影响代码正确性。
3.1 和对象展开运算符 {...}:表面等价,细节不同
大多数人写合并对象时会犹豫:用 Object.assign({}, a, b) 还是 { ...a, ...b }?从结果看两者经常一样:
javascript复制const a = { x: 1 };
const b = { y: 2 };
const result1 = Object.assign({}, a, b);
const result2 = { ...a, ...b };
console.log(result1, result2);
// 都是 { x: 1, y: 2 }
差异主要体现在两个地方。
第一个差异:Object.assign 会把值写入目标对象,因此会触发目标对象上的 setter;对象展开在字面量中复制属性时,不会走“写入已有对象”的流程,自然不会触发任何目标 setter。举个例子:
javascript复制const target = {};
Object.defineProperty(target, 'a', {
set(value) {
console.log('触发 target 的 setter,值:', value);
},
configurable: true,
});
Object.assign(target, { a: 1 });
// 控制台输出:触发 target 的 setter,值:1
如果把同样的源对象展开到一个对象字面量里,就不会有这段输出。平时我们合并的对象基本都是普通对象,感知不到这个差异,但一旦目标对象是某个类的实例、或者被 Proxy 包装过,这个差异就可能变成“隐患”。
第二个差异:Object.assign 的目标可以是任意已经存在的对象,而对象展开只能生成一个新的对象字面量。如果你想往一个已有的对象上批量追加属性,展开运算符帮不上忙,它必须有个 {} 作为“接收器”。
当然,两者也有个共同点:读取源对象的属性值时,如果源对象上有 getter,都会触发 getter。这一点后面讲坑的时候会展开。
3.2 和直接赋值 obj.a = 1:一个负责单点,一个负责批量
你可能觉得 Object.assign(obj, { a: 1 }) 和 obj.a = 1 完全等价。对单个属性来说,效果基本一样,但它们的设计意图不同。
直接赋值适合“明确知道要改哪个字段”的场景,代码直白,性能也更好。而 Object.assign 适合“有一批字段要改,或者要改的字段名是动态拼接出来的”场景。比如你从接口拿到一个表单回填数据,字段可能有十几个,手写 obj.a = ...; obj.b = ... 又长又容易漏,这时候:
javascript复制Object.assign(this.form, payload);
一行搞定。这里要特别小心一点:如果目标对象上的某个属性是只读的(writable: false),在严格模式下,Object.assign 的写入会直接抛 TypeError;在非严格模式下则静默失败。这和直接赋值的行为一致,但正因为 Object.assign 通常批量操作,更容易在某个属性上翻车。
3.3 浅拷贝到底“浅”在哪
Object.assign 是典型的浅拷贝工具:它只拷贝对象的第一层属性。如果某个属性值本身是对象或数组,那么拷贝后的新对象和原对象会共享这个子对象引用。
javascript复制const person = {
name: '张三',
address: {
city: '北京',
},
};
const copy = Object.assign({}, person);
copy.address.city = '上海';
console.log(person.address.city);
// 上海 ← 原对象被影响了
我习惯用“复印合同”来理解浅拷贝:你复印了合同的第一页,但合同里附带的附件,复印版本和原件共用同一张纸。所以对浅拷贝结果做深层修改,会“穿透”到原对象。
如果你需要深拷贝,Object.assign 是做不到的。比较常见的替代方案是 JSON.parse(JSON.stringify(obj)),但它会丢掉 undefined、函数、Symbol 键,碰到 Date 还会变成字符串,局限性很大。现代浏览器里可以用 structuredClone(obj),这才是真正的深拷贝。但如果只是合并配置,大多数时候浅拷贝就够了——前提是你能接受它“只负责第一层”。
| 操作方式 | 是否修改原对象 | 深层对象处理 | 适用场景 |
|---|---|---|---|
直接赋值 obj.a = 1 |
修改原对象 | 引用共享 | 单字段更新 |
Object.assign(target, src) |
修改 target | 浅拷贝 | 批量合并/覆盖 |
{ ...source } |
生成新对象 | 浅拷贝 | 创建新对象 |
JSON.parse(JSON.stringify(obj)) |
生成新对象 | 深拷贝(有局限) | 纯数据备份 |
structuredClone(obj) |
生成新对象 | 深拷贝 | 现代环境通用 |
4. 日常开发里真正值得用 Object.assign 的五个场景
讲完原理和差异,我们落到实际开发。Object.assign 到底在哪些场景里最好用?我按出现频率从高到低给你盘一遍。
4.1 合并默认配置:参数兜底的经典写法
这是 Object.assign 使用频率最高的场景,没有之一。封装组件、写工具函数时,经常要处理“用户没传参数就用默认值”的逻辑:
javascript复制function createModal(options) {
const DEFAULT_OPTIONS = {
width: 400,
height: 300,
animation: true,
closeOnMask: true,
};
const settings = Object.assign({}, DEFAULT_OPTIONS, options);
return settings;
}
createModal({ width: 500 });
// { width: 500, height: 300, animation: true, closeOnMask: true }
为什么推荐用 Object.assign 而不是手动逐个判断?因为它的合并规则天然符合“默认值优先、用户配置覆盖”的需求,而且以后往 DEFAULT_OPTIONS 里加字段,createModal 内部代码一行都不用改,可维护性很好。
这里提醒一下前面提到的 undefined 覆盖问题:如果调用方写 createModal({ width: undefined }),最终 width 会是 undefined,不是默认值。我在实际开发中碰到的处理方式有两种:一种是在调用方约定“不传就不带这个 key”,另一种是在函数开头过滤掉值为 undefined 的字段:
javascript复制function cleanUndefined(obj) {
return Object.fromEntries(
Object.entries(obj).filter(([, value]) => value !== undefined)
);
}
const settings = Object.assign({}, DEFAULT_OPTIONS, cleanUndefined(options));
这个看你项目里的约定,但至少要知道有这个坑。
4.2 生成不可变数据快照:以 setState 为例
在 React 的 Class 组件时代,Object.assign 几乎是管理 state 的标配。因为 React 强调不可变更新,你不能直接修改 this.state,而是要生成一个新对象:
javascript复制this.setState(prevState =>
Object.assign({}, prevState, {
count: prevState.count + 1,
})
);
现在函数组件流行后,大家更习惯用展开运算符:
javascript复制setState(prev => ({ ...prev, count: prev.count + 1 }));
两者效果等价。但如果你在维护老项目,或者是看开源库里 Class 组件的代码,一定会大量碰到 Object.assign({}, prevState, payload) 这种写法。看懂它比吐槽它更重要。
4.3 字符串和数组也能当源对象
Object.assign 的源对象不一定是“普通对象”。字符串的每个字符有可枚举的索引属性,数组的元素也是。所以你可以写出这样的代码:
javascript复制const strObj = Object.assign({}, 'abc');
console.log(strObj);
// { '0': 'a', '1': 'b', '2': 'c' }
const arrObj = Object.assign({}, [10, 20, 30]);
console.log(arrObj);
// { '0': 10, '1': 20, '2': 30 }
为什么 length 没有复制过来?因为 length 不可枚举。这个冷知识在平时开发里不太常用,但当你调试某些“类数组转对象”的问题时,知道这个机制能快速定位问题。
4.4 批量给对象挂方法:mixin 的老牌姿势
在插件、类库、工具代码里,经常需要把一组方法一次性挂到某个对象或原型上。逐个 obj.methodA = ... 很啰嗦,用 Object.assign 更清爽:
javascript复制function Plugin() {}
Object.assign(Plugin.prototype, {
init() {
console.log('init');
},
destroy() {
console.log('destroy');
},
refresh() {
console.log('refresh');
},
});
const plugin = new Plugin();
plugin.init(); // init
这种写法本质上是一种轻量级 mixin,把多个模块的方法“混合”进同一个目标。在方法数量多、模块拆分细的项目里,比逐个赋值可读性强得多。
4.5 基于已有对象构建新对象
当你需要以某个对象为基础,追加或覆盖几个字段,生成一个新对象时,Object.assign 也很有用:
javascript复制const user = {
name: '张三',
role: 'user',
};
const userWithToken = Object.assign({}, user, {
token: 'jwt-token-xxx',
role: 'admin',
});
console.log(user);
// { name: '张三', role: 'user' } ← 原对象没变
console.log(userWithToken);
// { name: '张三', role: 'admin', token: 'jwt-token-xxx' }
这种写法和 { ...user, token, role } 等价,在老代码里很常见。重点是体会“第一个参数传 {},才不会污染原对象”这个核心原则。
5. 最容易踩进去的四个坑:修复过程与验证
这里我想把几个高频问题串成一条完整的排查链路。很多新手遇到这些问题时第一反应是“我代码写错了”,但其实是没搞懂 Object.assign 的边界行为。
5.1 坑一:target 传 null 或 undefined 直接抛 TypeError
有次同事找我排查线上问题,报错是 Cannot convert undefined or null to object,定位到最后发现有一段代码写成了:
javascript复制Object.assign(target, payload);
而 target 在某些分支下是 undefined。为什么源对象为 null 没报错,目标对象为 null 就崩?因为规范里第一步就是把第一个参数做 ToObject 转换,null 和 undefined 没有对应的对象包装类,直接失败。
修复方式很简单:保证第一个参数始终是对象,或者在调用前做兜底:
javascript复制const data = target ?? {};
Object.assign(data, payload);
5.2 坑二:源对象里的 null 和 undefined 被静默跳过
和第一个坑形成鲜明对比的是:如果源对象是 null 或 undefined,Object.assign 不会报错,而是安静地跳过它。
javascript复制const result = Object.assign(
{},
null,
undefined,
{ a: 1 }
);
console.log(result);
// { a: 1 }
这其实是规范里的有意设计,好处是你可以放心地把可能为空的配置对象直接塞进参数列表。坏处是如果某个源对象本应该存在但没拿到数据,代码不会给你任何提示,容易掩盖问题。我的建议是:如果你明确要求某个源必须存在,别依赖这个“静默跳过”,提前加个空值判断,让逻辑更显式。
5.3 坑三:原型链属性和不可枚举属性都复制不了
这个坑前面已经演示过了。很多人会以为“复制对象”就是“复制对象的所有属性”,但 Object.assign 只认“可枚举自有属性”。所以当你复制一个来自类实例或带有内部状态的对象时,很可能丢掉很多预期中的字段。
如果你确实需要把不可枚举属性也复制过去,就不能只用 Object.assign,可以自己用 Object.getOwnPropertyNames 和描述符复制:
javascript复制function copyAllOwnProps(target, source) {
const keys = Object.getOwnPropertyNames(source);
for (const key of keys) {
const descriptor = Object.getOwnPropertyDescriptor(source, key);
Object.defineProperty(target, key, descriptor);
}
return target;
}
这种需求比较少见,一般只出现在工具库底层实现中。日常开发里,知道 Object.assign 的边界就够了,别在不该用的地方硬用。
5.4 坑四:getter 会被提前触发
这是最隐蔽的坑之一。Object.assign 在复制属性值的过程中,会执行一次属性的读取操作。如果源对象的属性是通过 getter 定义的,getter 里的副作用代码都会被触发。
javascript复制let count = 0;
const source = {
get counter() {
count++;
return count;
},
};
const result = Object.assign({}, source);
console.log(count); // 1
console.log(result); // { counter: 1 }
看起来没问题,但如果 getter 里做了接口请求、日志上报、状态修改之类的事情,这些副作用会在你“仅仅是复制对象”时全部执行一遍,很容易造成意外。想判断一个对象有没有 getter,可以用 Object.getOwnPropertyDescriptor(obj, key),看返回的 descriptor.get 是否为函数。
需要说明的是,对象展开运算符 { ...source } 也会触发 getter,所以这并不是 Object.assign 独有的坑。想要“不触发 getter 地复制属性描述符”,也只能用 Object.getOwnPropertyDescriptor + Object.defineProperty 那套底层方案。
6. 手写一个 Object.assign:从实现里看懂所有坑
把坑讲完,我建议你亲手实现一个简化版 Object.assign。这东西一眼看上去复杂,其实核心逻辑就四步:校验目标对象、遍历源对象、过滤可枚举属性、逐一赋值。
6.1 行为等价的手写版本
下面这个版本可以帮你建立直观的心智模型:
javascript复制function myAssign(target, ...sources) {
// 第一步:把 target 转成对象类型,null/undefined 在这里就会抛错
const to = Object(target);
// 第二步:逐个处理源对象
for (const source of sources) {
// null/undefined 源对象直接跳过
if (source === null || source === undefined) {
continue;
}
const sourceObj = Object(source);
// 第三步:取出所有键(包括 Symbol),过滤出可枚举的
const keys = Reflect.ownKeys(sourceObj);
for (const key of keys) {
const descriptor = Object.getOwnPropertyDescriptor(sourceObj, key);
if (descriptor && descriptor.enumerable) {
// 第四步:读取源对象的值,写入目标对象
to[key] = sourceObj[key];
}
}
}
return to;
}
这个版本没有完全照抄规范里的每一步,但行为已经和原生 Object.assign 非常接近了。为什么我要用 Reflect.ownKeys + getOwnPropertyDescriptor?因为 Object.keys() 拿不到 Symbol 键,而 Reflect.ownKeys 能同时拿到字符串键和 Symbol 键,再用 descriptor.enumerable 过滤掉不可枚举属性。
6.2 用前面的手写代码逐一解释行为
你会发现,前面讲的每一个坑,在这个手写实现里都有对应的代码:
- 第一个参数为
null/undefined时,Object(target)直接抛出TypeError,所以原生Object.assign(null, {})会报错。 - 源对象为
null/undefined时,if (source === null || source === undefined) continue;解释了为什么源对象为空会静默跳过。 - 只复制可枚举自有属性,是因为
Reflect.ownKeys只取自有键,且descriptor.enumerable会过滤不可枚举属性。 - getter 被触发,是因为
to[key] = sourceObj[key]这行代码里,sourceObj[key]是一次属性读取操作,getter 必然执行。
有了这个心智模型,你再遇到和 Object.assign 相关的诡异问题,几乎都能在脑子里推演出原因。
6.3 什么情况下你应该换掉 Object.assign
最后给一个决策建议。Object.assign 是 ES6 时代的“正统方案”,但到今天,很多场景有更好的替代:
- 只想生成新对象并合并多个对象:优先用对象展开运算符
{ ...a, ...b },语义更清晰。 - 需要深拷贝:用
structuredClone(obj),或者引入lodash.cloneDeep,别用JSON.parse(JSON.stringify())硬扛。 - 需要深合并多层配置:
Object.assign只处理第一层,建议手写递归合并,或者用现成的lodash.merge。 - 只需要合并第一层默认配置:
Object.assign({}, defaults, options)依然是非常合适的写法,没有替换的必要。
我在实际开发中还有个习惯:如果某个工具函数里反复用到 Object.assign({}, ...),我会抽一个 merge 函数出来:
javascript复制const merge = (...objs) => Object.assign({}, ...objs);
这样调用方就不用每次记着“第一个参数要写空对象”这个约束,写出来的代码意图也更清楚。说到底,Object.assign 不是一个“复制魔法”,而是一个“批量赋值工具”。想明白这一点,很多坑你就能提前绕开了。
