如果你经常写 JavaScript,Object.assign()这个名字你大概率不陌生。网上关于它的资料铺天盖地,但大多数都只告诉你"这是用来合并对象的""是浅拷贝"——然后就没有然后了。可真到项目里用起来,你会发现事情远没那么简单:为什么合并出来的对象改了原对象也跟着变?为什么源对象里明明有值,合并完却丢了?为什么在某些框架的状态管理里用它更新数据,界面死活不刷新?这些问题我在实际开发里都遇到过,而且不止一次。这篇文章不打算重复那些烂大街的"用法介绍",我想从一个更实用的角度,把Object.assign()的底层规则、边界行为、高频场景和替换方案一次性讲透,让看完的人能在真实项目里直接用它,而不是在语法层面打转。无论你是刚接触前端不久的新人,还是写了几年业务代码但一直没深究过这个 API 的老手,这篇内容应该都能给你一些新的东西。
1. 对象合并的需求演变:从手写 for-in 到原生 API
1.1 在 Object.assign() 出现之前,我们是怎么合并对象的
在 ES2015 之前,JavaScript 里没有一个真正意义上的"合并对象"的原生方法。需求又很常见——最常见的场景就是选项参数的默认值填充。比如我写一个工具函数,接收一个配置对象,用户可能只传一部分字段,剩下的要用默认值:
function createPopup(options) { var config = {}; for (var key in defaultOptions) { config[key] = defaultOptions[key]; } if (options) { for (var key in options) { if (options.hasOwnProperty(key)) { config[key] = options[key]; } } } // 然后用 config 去做后续逻辑 }这是我早年经常写的代码,也是当时最主流的做法。用for...in遍历默认配置,再遍历用户传入的 options,逐个覆盖。写法倒也不复杂,但它有几个隐藏问题:
for...in会把原型链上的可枚举属性也遍历出来,所以必须用hasOwnProperty做一层过滤,这一步特别容易忘;- 代码重复度高,如果合并的对象有多个来源(默认值、用户配置、运行时动态补丁),就需要写很多层循环嵌套;
- 性能不是最优,每次遍历都有额外的函数调用和属性检查开销,虽然多数场景下感觉不到,但框架内部高频调用时会放大。
后来很多库自己封装了工具函数,比如 jQuery 的$.extend、Underscore 的_.extend。它们内部干的事情本质上仍然是手写遍历,只是把复杂度隐藏在了库的内部。现在再看,Object.assign()的出现,其实是把这种高频需求从社区方案收编进了语言标准。
1.2 Object.assign() 的基本契约:目标对象、源对象、返回值
Object.assign(target, ...sources)的语法很简单,但它的设计有几个容易被忽略的点。先说基本行为:把每个源对象(sources)自身的可枚举属性复制到目标对象(target)上,最后返回目标对象本身。
注意几个关键词:
- 自身的:不包含原型链上的属性。这点和
for...in不同,反而省掉了hasOwnProperty的过滤步骤。 - 可枚举的:属性描述符中
enumerable: true的属性才会被复制。用Object.defineProperty定义的、默认不可枚举的属性,不会被Object.assign()带过去。 - 返回目标对象:这一点极容易被误解。很多人以为它返回的是一个"新对象",就像
[].concat()返回新数组一样。但Object.assign()返回的是传入的 target 本身,如果你没有传入 target,或者传入的 target 后续被其他逻辑修改,返回的引用也会跟着变。
看这个例子就明白了:
const target = { a: 1 }; const returned = Object.assign(target, { b: 2 }); console.log(returned === target); // true console.log(target); // { a: 1, b: 2 } target.c = 3; console.log(returned.c); // 3,说明 returned 和 target 是同一个引用这个行为在函数式编程风格里很碍事。你写return Object.assign({}, base, overrides)时,只要保证第一个参数是{},返回的确实是一个新对象。但如果你写return Object.assign(target, overrides),返回的就是被修改过的 target,调用方拿到后如果继续对它做操作,很可能污染了你想保留的原对象。所以在 React 或 Redux 风格的不变更新中,正确的姿势永远是让 target 是一个空对象。
1.3 多源对象合并的顺序规则
Object.assign()支持传入多个源对象,顺序从左到右。后面的源对象的同名属性会覆盖前面的。这个覆盖是基于属性名做的,不是基于"某个对象整体":
const result = Object.assign( {}, { name: '张三', age: 20 }, { name: '李四' } ); // result: { name: '李四', age: 20 }这里的name被第二个源覆盖,但age保留了下来。实际业务里这个特性很实用,比如做"默认配置 → 用户配置 → 环境变量覆盖"的三层合并:
const config = Object.assign( {}, DEFAULT_CONFIG, // 基础默认值 userConfig, // 用户覆盖 process.env.NODE_ENV === 'production' ? prodConfig : devConfig );每一层只覆盖需要的字段,其他字段自然保留。这个"按属性覆盖"的行为,比很多初学者想象的"整个对象替换"要精细得多,也是Object.assign()在配置合并场景下能成为主角的根本原因。
2. 属性拷贝的底层细节:不只是"循环赋值"那么简单
2.1 属性读取与赋值的差别:getter 的实际值
Object.assign()在复制属性时,对源对象执行的是Get操作,对目标对象执行的是Set操作。这个描述看起来很学术,但它解释了一个非常重要的问题:如果源对象的属性是通过 getter 定义的,Object.assign() 复制过去的是 getter 函数的执行结果,而不是 getter 函数本身。
const source = { get count() { return 42; } }; const result = Object.assign({}, source); console.log(result); // { count: 42 } console.log(source.count); // 42这里result.count是一个普通的数据属性,值是 42。源对象的 getter 并不会被"搬"到目标对象上。如果不懂这个机制,你可能会疑惑:为什么我复制完了以后,目标对象里的属性不再"自动更新"了?因为在源对象上,每次访问source.count都会调用 getter,可能返回动态值;但复制后的result.count是复制那一刻的静态快照。
反过来,如果目标对象上有 setter,Object.assign()也会触发它:
const target = { set value(v) { console.log('setter called with:', v); } }; Object.assign(target, { value: 10 }); // 输出: setter called with: 10这一点在写一些"观察者模式"或"响应式系统"的底层时特别重要。Vue 2 的响应式原理里用Object.defineProperty定义 setter,如果你用Object.assign()去更新一个已经是响应式的对象,会触发 setter,从而触发视图更新。但如果你用"新建一个普通对象再赋值"的方式(比如this.obj = Object.assign({}, this.obj, newData)),就变成了给整个对象重新赋值,这个过程中原本对象上通过defineProperty定义的 getter/setter 全部丢失,响应式也就失效了。这个问题我在实际项目里踩过一次很深的坑,后面讲状态管理时还会详细展开。
2.2 Symbol 属性与不可枚举属性的处理
Object.assign()有一个比较容易被忽略的能力:它能复制对象的Symbol 属性。前提同样是这些 Symbol 属性是可枚举的。
const sym = Symbol('unique'); const source = { [sym]: 'symbol value', normal: 'normal value' }; const result = Object.assign({}, source); console.log(result[sym]); // 'symbol value' console.log(Object.getOwnPropertySymbols(result)); // [ Symbol(unique) ]这个行为在设计之初是特意考虑的。ES2015 引入 Symbol 之后,很多扩展元数据的手段都依赖 Symbol 属性。比如Array.prototype[Symbol.iterator]、Symbol.toStringTag等,如果你需要复制一个"完整"的对象,用for...in是不够的,它遍历不到 Symbol 属性。Object.assign()在这点上更贴近"复制自身的所有可枚举属性"这个语义。
但要注意,不可枚举的属性是复制不过来的:
const source = {}; Object.defineProperty(source, 'hidden', { value: 1, enumerable: false }); const result = Object.assign({}, source); console.log(result.hidden); // undefined源码里实际上内部用的是EnumerableOwnProperties这个抽象操作,它只挑选[[Enumerable]]为 true 的属性。所以如果你想通过Object.assign()做"深层次的对象克隆",必须知道这个边界——很多类实例的方法定义在原型上,且大多是不可枚举的,Object.assign()根本不会碰它们。
2.3 源对象为 null 或 undefined 时的静默处理
这可能是Object.assign()在使用中最"宽松"的一点了:当源对象是null或undefined时,它不会报错,而是直接跳过。
const result = Object.assign({}, null, undefined, { a: 1 }); console.log(result); // { a: 1 }这个设计在实际项目中非常友好。比如你从接口拿到一个可能是null的对象字段,想把它合并到默认值里,不用额外判空:
const userInfo = apiResponse?.data?.user ?? null; const merged = Object.assign({}, baseInfo, userInfo);如果userInfo是null,合并结果就是baseInfo的拷贝,不会崩溃。但注意,这里说的是"源对象"为空的情况。如果目标对象是null或undefined,那就会直接抛TypeError:
Object.assign(null, { a: 1 }); // TypeError: Cannot convert undefined or null to object这个行为也符合直觉:Object.assign(null)没有地方可以放属性,自然无从谈起。理解这两者的差异后,你在写封装函数时就清楚该防谁了。
3. 浅拷贝的"锅",不全是它背的
3.1 浅拷贝有哪些具体表现
网上几乎所有的教程都会说一句话:Object.assign()是浅拷贝,不是深拷贝。这句话没错,但它太笼统了,导致很多新人形成了一个误解:好像只要用了Object.assign(),对象里面嵌套的数据就一定会出问题。
先用一个标准例子展示浅拷贝的行为:
const original = { name: 'object', contents: { version: 1, list: [1, 2, 3] } }; const copied = Object.assign({}, original); copied.contents.version = 2; copied.contents.list.push(4); console.log(original.contents.version); // 2 console.log(original.contents.list); // [1, 2, 3, 4]真正被"浅拷贝"的,是顶层属性。name这种基本类型值是独立复制的,改copied.name不会影响original.name。但contents这种引用类型值,复制的是引用地址,所以通过copied.contents修改嵌套内容时,original.contents指向的是同一个堆内存对象,自然也被修改了。
"浅拷贝"这个定义描述的是一种机制,而不是说"对象完全不能动"——动了嵌套层才出问题,顶层属性是安全的。这个区分在实际编码中很关键,因为后面讲状态管理时,很多 Redux 类库的"不可变更新"方案,本质也是利用这个特性:每次只替换变化的层级,没变化的层级保留原引用。
3.2 为什么浅拷贝是设计使然而不是缺陷
很多人希望Object.assign()内部做递归深拷贝,但这么做会引入几个非常严重的问题。
首先,性能不可控。深拷贝需要对对象做递归遍历,如果对象嵌套很深、数据量大,一次拷贝的代价可能是指数级的。而合并对象很多时候只是希望覆盖几个顶层字段,深拷贝完全是浪费。
其次,递归会打破循环引用。如果你的对象里有自引用:
const obj = {}; obj.self = obj;JSON 的JSON.parse(JSON.stringify())遇到这种结构会直接抛错。Object.assign()如果也做深拷贝,必须处理循环引用,这会让规范复杂很多。
第三,深拷贝语义容易被用户误解。你想复制的可能是一个类的实例,实例内部有原型链、有闭包、有私有字段,深拷贝会把这些都"拍扁"成普通对象,反而丢失原来的类行为。浅拷贝至少保留了"我们有意识地只复制顶层"这个边界。
所以Object.assign()选择浅拷贝,是为了确保它能在绝大多数场景下正确、高效、可预期地运行。真正需要深拷贝的场景,应该明确选择合适的工具,比如structuredClone()、lodash.cloneDeep,而不是希望通用 API 一把梭。
3.3 深拷贝的几种替代方案横向对比
既然浅拷贝解决不了所有问题,那就得知道深拷贝有哪些路可以走。我把常用方案整理了一个表:
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
JSON.parse(JSON.stringify(obj)) | 写法极简,无依赖 | 丢失undefined、Function、Symbol,不支持循环引用,Date变成字符串,性能对大对象一般 | 后端返回的纯数据对象(无函数、无 Date) |
structuredClone(obj) | 浏览器原生,支持循环引用,保留Date、Map、Set | 不支持函数,兼容性较新(Node 17+),部分旧浏览器无此 API | 现代项目里的组合类数据结构 |
lodash.cloneDeep(obj) | 功能全,处理各种边界,社区成熟 | 引入第三方库,打包体积增加 | 复杂业务场景,尤其是需要处理自定义类、混合类型时 |
| 手写递归深拷贝 | 最可控,完全定制 | 需要处理类型、循环引用、原型、Symbol、getter/setter 等大量边界,代码量大 | 有明显性能瓶颈,或需要极致定制化 |
我在项目里的经验是:能确定对象是纯 JSON 数据时,JSON.parse(JSON.stringify())足够了。但如果对象可能来自表单模型、带方法、带正则、带 Date,或者你不能确定内部有没有循环引用,那还是上structuredClone或 lodash 更稳。手写递归只在我面试别人时会让他们做一遍作为基本功考察,生产环境里真没必要重新造轮子。
4. 实际开发中的高频用法:从配置合并且到状态更新
4.1 默认配置与用户配置的合并
这是Object.assign()最经典的用法,我在无数开源库里都见过。以我当时写的一个拖拽上传组件为例:
const DEFAULT_OPTIONS = { url: '', method: 'POST', headers: {}, multiple: true, maxSize: 10 * 1024 * 1024, onSuccess: () => {}, onError: () => {} }; function uploader(customOptions = {}) { const options = Object.assign({}, DEFAULT_OPTIONS, customOptions); // 后续所有逻辑都基于 options options.headers['X-Custom-Header'] = 'foo'; }这里要注意的一个坑:如果调用方传入了headers对象,这个headers和DEFAULT_OPTIONS.headers是同一个引用。"合并"只是把引用复制过去了,后续如果你往options.headers里加字段,会直接修改customOptions.headers,从而污染调用方传入的对象。如果不想出现这种"副作用",就得对可能需要"深合并"的字段单独做处理:
const options = Object.assign({}, DEFAULT_OPTIONS, customOptions, { headers: Object.assign({}, DEFAULT_OPTIONS.headers, customOptions.headers) });这种模式在开发配置型组件时几乎是标配。有人觉得麻烦,但这其实是在"性能"和"语义清晰"之间做权衡——大多数场景根本不需要深合并,只需要几个特定的嵌套字段做二次合并。
4.2 不可变更新模式:为什么 target 始终是 {}
在 React 或 Vue 的生态里,"不可变更新"是一个绕不开的概念。Object.assign({}, state, newData)可以保证返回一个新的 state 对象,让 diff 算法能识别出"状态变了"。
以 Redux reducer 为例:
const initialState = { user: null, loading: false, error: null }; function userReducer(state = initialState, action) { switch (action.type) { case 'FETCH_START': return Object.assign({}, state, { loading: true, error: null }); case 'FETCH_SUCCESS': return Object.assign({}, state, { loading: false, user: action.payload }); case 'FETCH_FAILURE': return Object.assign({}, state, { loading: false, error: action.error }); default: return state; } }这里每个分支都返回了一个全新的对象。此时 React 的浅比较prevState === nextState会得到false,从而触发重新渲染。如果你图省事写成state.loading = true; return state;,新旧 state 指向同一引用,React 会认为没变化,界面就不更新了。
但嵌套更新才是真正考验人的地方。比如 state 里一个filters对象:
case 'UPDATE_FILTER': return Object.assign({}, state, { filters: Object.assign({}, state.filters, action.payload) });这种写法每层都需要一个新的Object.assign({}, ...),做两层嵌套已经有点啰嗦了。如果嵌套三层以上,代码会不可控地膨胀。这也是后来涌现出 immer、immutable.js 这类库的直接原因——我个人的建议是,一至二层的不可变更新可以直接用Object.assign(),再深就直接上 immer,用 mutable 的写法换 immutable 的结果,代码会清爽很多。
4.3 合并多个 API 响应或数据源
前端经常遇到"把几个接口的数据拼接成一个完整对象"的需求。比如一个商品详情页,基础信息、库存、价格分别来自三个接口:
async function loadProductDetail(skuId) { const [basic, stock, price] = await Promise.all([ fetchBasic(skuId), fetchStock(skuId), fetchPrice(skuId) ]); const detail = Object.assign( {}, basic, { skuId }, stock, price ); return detail; }这里的覆盖顺序很有讲究:skuId无论来自哪个接口,都用一个显式属性放在中间,后面如果stock或price响应里恰好也有skuId,就会被后面的覆盖成别的值。为了避免这种事情,更稳妥的写法是把它放在最后面:
const detail = Object.assign({}, basic, stock, price, { skuId });这个细节提醒我们:Object.assign()的"后覆盖前"规则对属性级的作用是绝对的,当你混合合并多个来源时,一定要想清楚哪个字段的优先级最高。
4.4 向对象追加方法或状态
还有一类用法是给对象"打补丁"。比如在单元测试里 mock 掉一个模块的方法:
const renderer = require('./renderer'); const spiedRenderer = Object.assign({}, renderer, { render: jest.fn(() => 'mocked'), log: jest.fn() });这里先用Object.assign({}, renderer)复制了原对象的所有可枚举属性,再覆盖或追加方法。注意,如果renderer的某些方法定义在原型上,Object.assign()是复制不过来的。真要 mock 一个类实例的方法,通常更合适的做法是用Object.create或直接修改原型,而不是靠 object assign。
5. 避坑实录:五个让 Object.assign() 失效的边界情况
5.1 坑一:合并数组时的"索引即属性"陷阱
数组也是对象,Object.assign()复制数组时,本质上是按索引属性来复制的。看这个对比:
const arr = [1, 2, 3]; const result = Object.assign([], arr); console.log(result); // [1, 2, 3],好像没问题 const merged = Object.assign([], [4, 5, 6], [7, 8]); console.log(merged); // [7, 8, 6]第二个merged的结果很反直觉:数组长度没有变成 5,而是 3,元素是[7, 8, 6]。原因在于Object.assign()把数组的下标当作普通的属性名进行覆盖。第一个源数组[4, 5, 6]覆盖了位置的 0、1、2,第二个源数组[7, 8]又覆盖了位置 0、1,但位置 2 的6来自第一个源数组,所以最终是[7, 8, 6]。
真想在数组上做合并,用Array.concat或展开运算符更符合直觉:
const mergedArr = [...[4, 5, 6], ...[7, 8]]; // [4, 5, 6, 7, 8]我在项目中看到过有人误用Object.assign([], oldArr, newArr)来做数组更新,结果怎么都调不对,真的别把数组合并这个场景硬塞给Object.assign()。
5.2 坑二:值为 undefined 时表现令人误解
Object.assign()会复制值为undefined的属性。这本身没问题,问题在于很多人以为它和"解构赋值默认值"一样,会自动忽略undefined。
const result = Object.assign({ a: 1, b: 2 }, { b: undefined }); console.log(result); // { a: 1, b: undefined }注意,b被undefined覆盖了,而不是保留原来的 2。如果你期望的是"只覆盖有值的字段",那就得自己过滤:
const newData = { b: undefined }; const filtered = Object.fromEntries( Object.entries(newData).filter(([, value]) => value !== undefined) ); const result = Object.assign({ a: 1, b: 2 }, filtered); // { a: 1, b: 2 }Object.assign()的设计思路是"源对象里有什么字段,目标对象就覆盖什么字段",它不做任何值过滤。理解了这一点,你在处理接口返回的可选字段时就不会踩坑了。类似地,值为null也会被覆盖,同样不会自动跳过。
5.3 坑三:合并的引用共享问题
前面提过浅拷贝的引用共享。这里用一个更实战的场景说明它有多隐蔽。
假设你要做一个"撤销/重做"功能,把操作前后的状态快照存在历史栈里:
let formData = { name: 'Jack', address: { city: '北京', street: '中关村大街' } }; const historyStack = []; historyStack.push(Object.assign({}, formData)); formData.address.city = '上海'; // 你以为是这样的历史: // [{ name: 'Jack', address: { city: '北京', ... } }] // 实际上的历史: historyStack[0].address.city; // '上海'因为Object.assign()浅拷贝了address这个嵌套对象,历史快照中的address和当前formData.address指向同一个内存地址。你修改了formData.address.city,历史快照也跟着变,"撤销"功能就彻底废了。这就是为什么做快照类功能时,必须用深拷贝,浅拷贝在这里不是"性能优化",而是功能性 bug。
5.4 坑四:不能处理原型链和类继承
Object.assign()只复制对象自身的可枚举属性,这意味着它不会处理原型链上的东西。定义一个类实例时常用的方法,都挂在Class.prototype上,不会被复制到目标对象。
class Animal { constructor(name) { this.name = name; } speak() { console.log(`${this.name} makes a sound`); } } const dog = new Animal('旺财'); const plainCopy = Object.assign({}, dog); console.log(plainCopy.name); // '旺财' console.log(plainCopy.speak); // undefined如果把Object.assign()当成"克隆实例"的工具,你会得到一堆纯数据属性,而不是一个仍然"活着的"对象。我在实际业务里见过有人这么用,结果就是代码到处补 polyfill 和兼容逻辑,最后不得不改用Object.create(Object.getPrototypeOf(obj))配合Object.assign()两步来保留原型:
const prototypeCopy = Object.assign(Object.create(Object.getPrototypeOf(dog)), dog); prototypeCopy.speak(); // '旺财 makes a sound'这个写法可以工作,但它是"两条腿走路",不如直接用专门做克隆的库省心。如果你的对象有原型、有私有字段、有 getter/setter,请优先考虑structuredClone或lodash.cloneDeep。
5.5 坑五:不可枚举属性的静默丢失
前面说过Object.assign()只复制可枚举属性,这里再深入一层。很多类库在定义内部状态时,会用Object.defineProperty设置不可枚举属性,比如用来存储私有缓存、内部标记等。如果你对这些对象做Object.assign()合并,这些字段会完全丢失,而且没有任何报错和提示。
const source = {}; Object.defineProperty(source, 'privateFlag', { value: true, enumerable: false }); source.publicFlag = false; const result = Object.assign({}, source); console.log(result.privateFlag); // undefined console.log(result.publicFlag); // false这种"静默丢失"比直接报错更糟糕,因为问题往往在运行很久之后才暴露。我在排查一个"数据上报少了关键字段"的 bug 时,最后的根因就是某个中间层用Object.assign()做对象合并,把不可枚举的内部状态搞丢了。排查思路是:先看对象上是不是有非枚举属性,Object.getOwnPropertyNames(source)能列出来所有自身属性,包括不可枚举的,再对比一下复制后的结果。
6. 替代与对比:Object.assign()、扩展运算符与解构赋值
6.1 功能对比表:什么时候它们等价,什么时候不等
ES2018 引入了对象展开运算符{...obj},从某种意义上说,它和Object.assign({}, obj)在"拷贝对象"这个场景下是等价的。但两者有不少细微差别,先看对比表:
| 比较维度 | Object.assign(target, ...sources) | {...source} 对象展开 |
|---|---|---|
| 是否调用 setter | 会触发 target 上的 setter | 也会触发目标对象上的 setter,但通常{}上没有 |
| 返回目标对象还是新对象 | 返回 target 本身,target 可以是已有对象 | 总是生成一个全新的对象字面量 |
| 源对象为 null/undefined | 静默跳过 | 同样会跳过,不会报错 |
| Symbol 属性 | 可复制 | 可复制 |
| 合并多个源对象 | 语法直接支持多参数 | 需要多次展开,每次生成中间对象 |
| 可读性 | 稍显命令式 | 语义更声明式,更直观 |
| 性能 | 通常略快(展开运算符会被转成类似 assign 的调用,但有额外中间层) | 通常稍慢,但差异在绝大多数业务场景可忽略 |
一个关键区别是,展开运算符不能用来修改已有对象。const result = {...existingObj, newProp: 1}总是生成新对象;而Object.assign(existingObj, { newProp: 1 })可以修改已有对象。所以当你明确想"保留原对象引用"时,展开运算符做不到,必须用Object.assign。
6.2 展开运算符取代 Object.assign() 的典型场景
在现代前端代码里,我已经很少直接写Object.assign({}, ...)这种形式了,因为展开运算符更短、更直观。看几个典型替换:
// 原来 const newState = Object.assign({}, oldState, { loading: true }); // 现在 const newState = { ...oldState, loading: true };// 原来 const config = Object.assign({}, DEFAULT_CONFIG, customConfig); // 现在 const config = { ...DEFAULT_CONFIG, ...customConfig };展开运算符还有一个优势:在 JSX 里可以直接展开 props,而Object.assign()做不到这种语法层面的表达:
<ChildComponent {...defaultProps} {...userProps} />这种写法非常常见,如果我用Object.assign()就必须先在外面合并好再传进去,代码上多一层。所以在"创建新对象并合并"的场景里,我首选展开运算符。但展开运算符也有做不到的地方,就是上面提到的"修改已有对象"以及"多参数合并时中间对象的额外开销",如果性能敏感或者必须原地操作,还是要回退到Object.assign()。
6.3 解构赋值和 Object.assign() 不是替代关系
解构赋值是用来提取属性到变量的,不是用来合并对象的。但两者常常一起出现在类似的业务场景里,容易让人混淆。
解构赋值相当于"读取 + 声明变量":
const source = { a: 1, b: 2, c: 3 }; const { a, ...rest } = source; console.log(a); // 1 console.log(rest); // { b: 2, c: 3 }如果要把rest中缺失的字段补上默认值,解构配合展开再配合Object.assign()的场景就会出现:
const { a = 0, ...rest } = source; const finalized = Object.assign({ a }, rest, { extra: true });这种混合写法在实际项目里不少见,但我建议尽量少用——它很容易让读者迷失在"数据来源是什么、优先级是什么"的困惑里。如果逻辑复杂,不妨拆成两步走,先用解构提取关键字段,再用一次合并做兜底,中间加注释说明优先级关系。
7. 浏览器与运行时兼容性:尤其中间件和数组更新时要小心
7.1 兼容性的基本情况
Object.assign()是 ES2015 引入的 API,主流浏览器和 Node.js 早就支持了。如果你的项目面向老版本 IE,那确实要考虑 polyfill。常见的处理方式是引入core-js的Object.assignpolyfill,或者自己写一个关于它的兼容判断:
if (typeof Object.assign !== 'function') { Object.assign = function (target) { if (target === null || target === undefined) { throw new TypeError('Cannot convert undefined or null to object'); } const to = Object(target); for (let i = 1; i < arguments.length; i++) { const nextSource = arguments[i]; if (nextSource === null || nextSource === undefined) continue; const keys = Object.keys(Object(nextSource)); for (let j = 0; j < keys.length; j++) { const key = keys[j]; to[key] = nextSource[key]; } } return to; }; }这个简化版 polyfill 的核心逻辑和你自己手写合并差不多,但它体现了一个真实约束:polyfill 无法完整模拟原生实现的底层细节(例如Object.assign在复制属性时用的是[[Set]]而不是普通赋值,Symbol 可枚举属性的处理在低声明的 polyfill 里也容易丢)。所以我的建议是,如果必须要兼容旧环境,直接引入经过充分测试的 core-js polyfill,别自己造。
7.2 在框架底层、工具库里的性能与语义注意点
在写公共库或框架底层时,Object.assign()的使用频率会非常高,这里有两个细节值得留意。
第一是避免在热路径上反复拷贝大对象。比如一个事件总线、状态管理库,每个 action 分发时都可能触发一次Object.assign。如果状态对象非常大,或者嵌套层次很深,反复浅拷贝的时间成本会累积。我在优化一个小型状态管理库时曾经把一个热点函数里的Object.assign({}, state, patch)改成"仅当 patch 不为空时才拷贝,否则直接返回原 state",性能提升非常明显:
function applyPatch(state, patch) { if (!patch || Object.keys(patch).length === 0) { return state; } return Object.assign({}, state, patch); }第二是注意 Object.assign 的调用开销在极高频场景会被放大。比如一个每秒触发几十次的动画更新函数里,每次都用Object.assign创建新对象,GC 压力会明显增大。此时更合理的做法是原地修改已有对象:
// 高频更新中,如果确定可以原地修改 function updatePosition(position, delta) { return Object.assign(position, { x: position.x + delta.x, y: position.y + delta.y }); }这种写法虽然"不可变更新"的纯度下降了,但高频场景下可以明显减少垃圾回收压力。关键想清楚:你的函数调用是"边改边扔"还是"需要历史快照",不同目标对应不同写法。
7.3 构建工具和Tree Shaking背景下它带来的体积影响
Object.assign()是内置 API,不会被打包器引入任何额外代码,这是它比 lodash 系工具函数有优势的地方。你可能因为一个_.assign或_.extend而引入一整个 lodash 依赖,虽然现代打包器支持 tree shaking,但处理不当仍会带来不小的体积开销。
如果你只是想要"合并对象 + 浅拷贝",用内置 API 是最轻量的方案。相应的,功能更复杂的深拷贝工具按需引入即可,没必要为了一个 API 引入全家桶。前端项目体积敏感,做到"按需而非随便装库",是性能优化的第一步。
8. 结合 TypeScript:类型标注下的 Object.assign()
8.1 类型推断的本质:返回交叉类型
在 TypeScript 中,Object.assign的类型签名是:
assign<T extends object, U extends object>(target: T, source: U): T & U; assign<T extends object, U extends object, V extends object>(target: T, source1: U, source2: V): T & U & V;也就是说,编译器会把返回类型推断为所有参数的"交叉类型"。这在绝大多数情况下都很好用:
interface Base { name: string; age?: number; } interface Extra { email: string; } const merged = Object.assign({} as Base, { age: 30 } as Extra); // merged 类型是 Base & Extra但交叉类型也带来一个麻烦:如果多个源对象里有同名但不同类型的属性,交叉类型会把这些类型合并成一个"不可能同时满足"的 weird 类型,比如string & number,结果就是类型变成never,编译器报错或者最终推导出无意义的类型。
8.2 更友好的类型用法:结合泛型与类型断言
在封装通用函数时,我通常不给Object.assign硬编类型,而是用泛型约束:
function mergeConfig<T extends object, U extends object>( defaults: T, custom: U ): T & U { return Object.assign({}, defaults, custom); }如果调用方需要指定返回类型,可以用类型断言兜底:
interface AppConfig { url: string; timeout: number; retry?: boolean; } const config = Object.assign({}, defaults, custom, { retry: true }) as AppConfig;这里的as AppConfig是给编译器一个明确约定,也减少了因为交叉类型推导异常导致的排查成本。在我的经验里,TS 里的Object.assign最大的价值是"多参数交叉类型推导"能直接给到正确的大多数场景;它的主要困扰是"同名属性类型冲突时提示不直观",为此最好把合并后的结果显式断言成一个接口类型,别依赖推导。
8.3 使用 Object.assign 做枚举或常量对象的安全扩展
还有一个小技巧:用Object.assign在 TypeScript 的as const对象上继续扩展,能保持字面量类型的推断。
const baseUrls = { api: '/api', login: '/api/login' } as const; const urls = Object.assign({}, baseUrls, { logout: '/api/logout' } as const); // urls.api 的类型是 '/api',而不是被拓宽成 string urls.logout; // 类型是 '/api/logout'这里需要注意as const对字面量类型的锁定效果。如果直接写{ logout: '/api/logout' },TS 会把它推断成string,但加上as const后就能和baseUrls里的字面量类型对齐。这个小技巧在为常量配置做扩展时非常实用。
9. 何时避免使用 Object.assign():换一个更合适的工具
9.1 深拷贝场景:用 structuredClone 或第三方库
如果你确定需要的不是浅拷贝而是深拷贝,那Object.assign()从头就不是正确答案。现代运行环境里有structuredClone,它支持循环引用、Date、Map、Set,语义也更接近"真正复制一份数据快照":
const original = { name: 'deep object', createdAt: new Date(), tags: new Set(['a', 'b']), nested: { count: 1 } }; const cloned = structuredClone(original); cloned.nested.count = 2; console.log(original.nested.count); // 1,互不影响要注意structuredClone也无法克隆函数和 DOM 元素,这是它的边界。当对象里有函数类型时,还是得靠lodash.cloneDeep或手写方案。
9.2 响应式状态框架里的特殊要求
在 Vue 3 里,如果我用Object.assign()去合并一个reactive()对象,需要注意它和 Vue 2 里行为的不同。Vue 3 的reactive基于 Proxy,Object.assign()在 Proxy 上运行时,属性设置的拦截(settrap)会被触发,通常可以正确维持响应式。但如果你用Object.assign({}, reactiveObj, patch),你得到的是一个完全脱离响应式系统的新普通对象,后面再改它,界面不会更新。
正确的做法是像这样:
import { reactive } from 'vue'; const state = reactive({ user: { name: '张三', age: 20 } }); // 要修改嵌套属性,最好直接写清楚路径,而不是用一个合并的新对象整体替换 state.user = Object.assign({}, state.user, { age: 21 });这种情况下,state.user = ...会触发state的settrap,把整个user替换成一个新的响应式代理,所以能正常工作。但如果你图省事写Object.assign(state.user, { age: 21 }),虽然也能触发嵌套响应式(浅层),但语义上容易混淆。
9.3 需要保留 getter/setter 或原型链时
前面讲过了,Object.assign()复制的是"读取属性后的值",不会保留原对象的访问器属性(getter/setter),也不能保留原型链。如果对象需要保留这些特性,正确做法比较绕:先创建带原型的目标对象,再把源对象的属性描述符复制过去。
function copyWithPrototypeAndAccessors(source) { const target = Object.create(Object.getPrototypeOf(source)); const descriptors = Object.getOwnPropertyDescriptors(source); Object.defineProperties(target, descriptors); return target; }这里的核心思路是:取出源对象上所有属性的完整描述符(包括 get、set、enumerable、configurable),再用Object.defineProperties原样定义到新目标对象上。这样一来,getter 会变成 getter,setter 会变成 setter,原型链也保留了。但要注意,这仍然不是深拷贝——嵌套对象还是共享引用。
9.4 合并不可变数据结构时:考虑 Immutable.js 或 Immer
在复杂状态管理场景下,手动用Object.assign()做多层的不可变更新,代码会变得难以维护。比如一个三层嵌套的表单状态:
// 用 Object.assign() 做三层更新 const newForm = Object.assign({}, form, { step2: Object.assign({}, form.step2, { contact: Object.assign({}, form.step2.contact, { phone: newPhone }) }) });这个写法虽然正确,但可读性很差,而且每层都要确保 "前面的对象不突变 + 后面的对象不突变",心智负担很重。此时用 immer 会舒服很多:
import produce from 'immer'; const newForm = produce(form, draft => { draft.step2.contact.phone = newPhone; });immer 内部自动生成可变草稿,提交时自动生成不可变结果,代码量直接从三行嵌套变成一行赋值。我的建议是:一至二层的不可变更新,Object.assign()足够清晰;超过两层或者频繁有深编辑操作,直接引入 immer,别硬撑。
10. 总结与经验判断:我最后想说的几件事
说了这么多,最后梳理一下我对Object.assign()的几点整体判断,也算是对我这些年使用经验的沉淀。
第一,Object.assign()是一个底层工具 API,不是业务逻辑的主角。它的核心价值是"把多个对象的可枚举属性按顺序合并到一个目标对象上",底层规则包括属性覆盖顺序、浅拷贝特性、getter 取值、Symbol 支持、null 源对象跳过等。这些规则理解了,你就不会在它身上踩弯路。
第二,大多数"坑"其实不是这个 API 本身的问题,而是使用场景错了。想深拷贝却用了浅拷贝,想复制类实例却忽略原型链,想保留 getter 却拿到静态快照——这些本质上都是"工具和需求不匹配"。明确你的数据形态和更新语义,比记住一堆 API 细节更重要。
第三,在现代 JavaScript 里,新代码优先考虑展开运算符{...obj},只有在"需要原地更新已有对象"或"需要明确返回目标对象引用"时才用Object.assign()。展开运算符不是银弹,但它表达力更强,语义更直观,也能避免一些target被意外修改的问题。
第四,在 TS、状态管理、响应式框架等场景中,类型和响应式机制比 API 本身的性能更值得关注。一个类型断言、一个reactive包裹,往往能让整个系统稳定不少。不要为了省事而放弃类型安全或响应式边界。
最后,还是要强调一点:这类内置 API 的文档一搜一大把,但真正让你"会用它"的,永远是在真实项目里一遍遍调试、踩坑、总结出来的经验。如果你看完这篇文章能少踩几个坑,那就是我写它的意义所在。
提示:文中部分代码示例为演示用途,直接使用前请根据你的项目实际环境(浏览器版本、Node 版本、TypeScript 配置、是否引入框架)自行测试验证。