ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Object.assign 完全指南:浅拷贝、对象合并与避坑实战

2026/9/30 1:32:05 拓冰建站 浏览量
Object.assign 完全指南:浅拷贝、对象合并与避坑实战 写这篇东西的起因有点意思。我最近在带团队做代码评审发现不少写了几年 JavaScript 的同事对Object.assign()的理解还停留在“把两个对象并在一起”。这当然没错但这个 API 的实际行为比想象中复杂得多光“浅拷贝”这一个词背后就藏着十几条容易踩的坑。今天就着这个题目我把Object.assign()的用法、原理、边界情况、常见误用全部拆开来聊一遍。无论你是刚入行的新人还是想查漏补缺的老手这篇文章应该都能给你一些新东西。1. 先说结论Object.assign() 到底是什么1.1 一句话定义加一个生活化类比Object.assign(target, ...sources)是 ES2015也就是 ES6引入的静态方法作用是把一个或多个源对象的“可枚举自有属性”复制到目标对象上最后返回目标对象。这句话里有几个关键限定词可枚举、自有。很多人在这一步就开始犯迷糊后面我会逐个拆。打个生活化的比方。想象你手里有一张空白登记表目标对象旁边有几份填好的表格源对象你拿着一支水笔把源表格里所有能看见的、非空白格的字段抄到登记表上。注意三点一是只抄源表格正面的内容背面夹层里藏的东西不抄不可枚举属性二是源表格是复印来的上面写着“详见附件”的字段你也不会去附件里翻直接略过继承属性三是抄写顺序完全按你手头表格的顺序来后抄的字段会把前面同名字段覆盖掉。这个类比基本还原了Object.assign()的核心行为。理解之后再去看代码脑子里就有一幅画面了。1.2 语法、返回值、异常行为的细节Object.assign(target, source1, source2, ...)函数签名长这样。先说返回值返回的是 target 本身不是新对象。这一步太容易被忽略了直接导致大量被误导的代码。const target { a: 1 }; const source { b: 2 }; const result Object.assign(target, source); console.log(result target); // true——看到没有result 就是 target console.log(target); // { a: 1, b: 2 }这意味着如果你不想改动原对象就必须传入一个空对象作为目标const obj { a: 1 }; const clone Object.assign({}, obj); // 原对象纹丝不动再说异常。源对象如果传null或undefinedES2015 规范会直接抛TypeError。Object.assign({}, null); // TypeError: Cannot convert undefined or null to object但注意一个细节如果源参数是null或undefined之外的其他原始值比如字符串、数字、布尔值则不会报错规则是“能转成对象就转转不了就忽略”。字符串会被拆成字符索引的属性数字和布尔值则直接静默跳过。const result Object.assign({}, abc); console.log(result); // { 0: a, 1: b, 2: c } const result2 Object.assign({}, 123); console.log(result2); // {} —— 数字没有可枚举属性直接返回空所以严谨说法应该再加一半它会尝试把每个源值用ToObject抽象操作转换转换失败的会被忽略转换成功但没有可枚举自有属性的也等于没传。这个“静默规则”在排错时特别坑因为Object.assign({}, null)会报错而Object.assign({}, 0)又完全正常两者差别放在真实业务里很容易被忽。1.3 拷贝的到底是“属性”而不是“深层结构”这是整个方法最核心、也最被人误解的一点。Object.assign()做的是浅拷贝——它只复制属性的引用和值本身。如果属性的值是一个对象那么复制过去的是这个对象的引用新旧对象会共享同一个子对象。const original { name: 云风, address: { city: 上海, district: 浦东 } }; const clone Object.assign({}, original); clone.address.city 北京; // 改动克隆体的地址 console.log(original.address.city); // 输出“北京”——原对象的 address 也被改了这个行为对很多从后端转前端的开发者来说尤其反直觉。后端语言的深拷贝模型用惯了总觉得“拷贝”就该是分家单过。而 JavaScript 的浅拷贝像是“合租”你复制的是钥匙不是把房子重建一遍两个对象持有同一把钥匙谁都可能进去动里面的东西。如果你需要真正的深拷贝Object.assign()帮不了你后面我会专门讲替代方案。2. 五个高频使用场景与落地写法2.1 合并多个对象最后写入的胜出这是最直观的用法。多个源对象同时合并时同名字段由排序靠后的源对象覆盖。const defaultConfig { host: localhost, port: 8080, debug: false }; const userConfig { port: 9090 }; const finalConfig Object.assign({}, defaultConfig, userConfig); // { host: localhost, port: 9090, debug: false }这个模式是“配置合并”里最常见的一种。它的覆盖逻辑是顺序相关的后面的源对象总是覆盖前面的同名属性。理解这个顺序规则比理解语法本身更重要很多诡异的线上问题都来自多个配置源合并顺序写错。2.2 浅拷贝对象最平淡也最常用的写法用Object.assign({}, obj)做一次性浅拷贝是标准动作。常见场景包括状态管理里更新不可变数据对上游传入的props做安全变更给函数传递对象参数前复制一份防止内部修改影响外部function updateProfile(user, patch) { // 生成一个新对象不修改原来的 user return Object.assign({}, user, patch); } const user { name: 张三, age: 30 }; const updated updateProfile(user, { age: 31 }); console.log(user.age); // 30 —— 原对象不变 console.log(updated.age); // 31 —— 新对象更新了这种“不改原对象永远返回新对象”的写法是函数式编程和 React 状态管理里的基石思路。用Object.assign()来完成这个任务语义清晰测试也容易写——你只需要验证输入没变、输出正确不需要担心隐藏的副作用。2.3 给对象补默认值顺序反着来配置合并时默认值放前面用户配置放后面但“补默认值”的场景则要把默认值放后面。const options { theme: dark }; const withDefaults Object.assign({}, options, { theme: light, language: zh-CN }); // withDefaults { theme: dark, language: zh-CN }让我拆解一下。Object.assign的顺序是“从源对象复制到目标对象”后写的一方的同名属性覆盖前面的。上面这段代码里选项先被复制默认值后被复制因此选项里有的theme不会被默认值覆盖选项里没有的language则补上了。这比“手写一坨if判断有没有”要优雅得多。如果你把这个顺序反过来效果就变成“默认值打底、用户配置覆盖”那就是前面 2.1 的配置合并模式。两个场景放在一起对比顺序规则就一目了然了。2.4 在 reducer 里做不可变更新React 生态里Object.assign()曾经是编写 reducer 的核心工具之一。State 更新时你要返回一个新对象而不是修改旧对象。const initialState { count: 0, user: null }; function reducer(state initialState, action) { switch (action.type) { case INCREMENT: return Object.assign({}, state, { count: state.count 1 }); case SET_USER: return Object.assign({}, state, { user: action.payload }); default: return state; } }注意第 7 行和第 9 行都返回了一个新对象旧对象完全没有被改动。这种写法的价值在于比较两个 state 的引用即可判断是否有变化配合 React 的浅比较机制性能优势非常明显。后来社区流行用对象展开运算符{ ...state, count: state.count 1 }替代这种写法两者底层行为基本一致但展开运算符在可读性上稍胜一筹代码评审时更直观。我会在后面争论这个差异。2.5 链式调用与批量拷贝由于Object.assign()返回目标对象你可以在它之上链式调用其他方法或者一次性连续拷贝多个来源。const obj { type: common }; Object.assign(obj, { level: 1 }) .toString(); // 返回目标对象的字符串形式——链式可行不过这个例子有点刻意 const result Object.assign( Object.assign({}, base), // 先复制基础字段 featureA, // 再叠加功能 A featureB // 最后叠加功能 B );链式写法结合“返回目标对象”的特性可以把连续合并压缩成一行。但我要说句实在话过度链式会让调试变难堆栈信息经常只有一个超长表达式不如分段赋值来得清晰。我一般只在配置对象较短、逻辑简单时才这么写复杂场景宁可拆成多行。3. 参数选择与技术细节为什么有的写法更好3.1 重要对比Object.assign() 与对象展开运算符从 ES2018 开始对象展开运算符object spread成为了Object.assign()最大的竞争对手。两者在大多数场景下可以互换但存在几个值得注意的差异。// 写法一Object.assign const mergedA Object.assign({}, configA, configB); // 写法二对象展开 const mergedB { ...configA, ...configB };日常使用中展开运算符的可读性更好尤其是在“生成新对象”这个意图的表达上。但性能实测下来V8 引擎对展开运算符有专门的优化路径在大量合并场景下往往会比Object.assign()快一点反过来Object.assign()在合并多个对象时可以原地修改目标节省一次对象创建的开销。还有几个关键差异对比维度Object.assign()对象展开运算符触发 setter会触发目标对象上的 setter不会触发只拷贝自有可枚举属性是是修改原对象可以原地修改只能生成新对象与 getter 交互触发 getter 后复制值同样触发 getter 后复制值Symbol 键支持支持浏览器兼容ES2015 起ES2018 起“触发 setter”这点很有意思。如果你给目标对象定义了set拦截器Object.assign()会把每个属性走一遍 setter 流程而展开运算符则完全绕过 setter直接定义新属性。这既是坑也是技巧后面会在避坑篇里展开。3.2 拷贝行为边界Symbol、原型、稀疏数组、getterObject.assign()不是简单的“遍历属性然后赋值”它有几个容易被忽略的行为边界。第一个边界Symbol 属性也会被拷贝。常规的for...in遍历只拿字符串键但Object.assign()同时处理字符串键和 Symbol 键。const symbolKey Symbol(id); const source { [symbolKey]: 123, name: test }; const result Object.assign({}, source); console.log(result[symbolKey]); // 123 —— Symbol 属性被保留了第二个边界原型上的属性和不可枚举属性不会拷贝。这是“自有属性 可枚举”两个限定词的具体体现。const proto { inherited: true }; const source Object.create(proto); source.own value; Object.defineProperty(source, hidden, { value: 1, enumerable: false }); const result Object.assign({}, source); console.log(result.own); // value console.log(result.inherited); // undefined —— 继承属性没被复制 console.log(result.hidden); // undefined —— 不可枚举属性没被复制这点在做类继承时特别重要。如果你用class定义了一个子类实例实例上的方法通常在原型链上Object.assign({}, instance)得到的克隆体是“没有方法的空壳”只有那些实例自有字段。第三个边界稀疏数组会被填充为 undefined。对象的属性遍历遇到数组索引时稀疏空洞会变成普通属性。const sparseArray [1, , 3]; // 索引 1 是空洞 const result Object.assign({}, sparseArray); console.log(result); // { 0: 1, 1: undefined, 2: 3 }第四个边界getter 会被立即求值然后拷贝其返回值。这其实就是[[Get]]语义也就是说拷贝得到的是 getter 执行的结果而不是 getter 本身。常有人想“拷贝一个带 getter 的对象让克隆体也有 getter”结果沮丧地发现克隆体拿到的是普通值。const source { get double() { return this.value * 2; }, value: 21 }; const result Object.assign({}, source); console.log(result.double); // 42 —— getter 已经执行完结果被拷过来了 console.log(Object.getOwnPropertyDescriptor(result, double)); // { value: 42, writable: true, enumerable: true, configurable: true } // 原本的 get 函数不见了变成了普通属性3.3 性能观察与个人取舍关于性能我不建议你在日常业务里过度优化但也不妨了解几个经验值。我做过一个简单的合并测试20 个源对象、每个对象 100 个属性、循环 10 万次。实测环境下对象展开大约比Object.assign({}, ...sources)快 5% 到 10%但如果目标对象用的是已有的对象实例即原地修改Object.assign()会反过来更快因为它省掉了新对象创建和垃圾回收的压力。选型的建议很简单想“生成一个新对象、保留旧对象不变”优先用对象展开代码更可读。想“更新已有对象的若干字段”优先用Object.assign(target, updates)语义直接又高效。在 Vue 2 / 早期 React 这类对响应式对象原地更新有依赖的环境里Object.assign反而是更安全的选择因为可以直接命中目标对象的 setter触发更新机制。3.4 与替代方案的整体对比除了展开运算符Object.assign()还有几个替代品。放在一起看能帮你建立选择坐标系。方案拷贝层次是否支持多源合并兼容性适用场景Object.assign()浅是好配置合并、reducer、原地更新对象展开运算符浅是较好不可变更新、函数式写法JSON 序列化深否极好纯 JSON 数据深拷贝structuredClone()深否较新支持 Date / Map / Set 的深拷贝Lodash 的 cloneDeep深否极好复杂对象深拷贝功能全面可以看到Object.assign()的核心优势在于“多源合并 浅拷贝 原地更新”这组组合拳。如果你想深拷贝对象别硬用它去递归实现直接把这些替代方案拉出来才是正经。4. 常见问题排查与避坑指南4.1 为什么总是浅拷贝而不是深拷贝这个问题几乎每周都会在社区论坛里被问一次。原因其实不在Object.assign()本身而在于 JavaScript 引擎的设计选择对象属性在底层以引用方式存储深拷贝需要递归遍历整个对象图代价高昂且语义复杂要不要拷贝函数要不要保留原型循环引用怎么处理。Object.assign()定位就是“快速复制一层属性”设计者压根没打算提供深拷贝能力。要处理嵌套对象有几种方案如果数据是纯 JSON没有函数、Date、Map 等特殊类型JSON.parse(JSON.stringify(obj))简单粗暴但要先做数据校验。如果用了较新的运行时原生structuredClone()可以处理Date、Map、Set、ArrayBuffer等类型连循环引用也能搞定。如果项目已经引入了 Lodash_.cloneDeep(value)是最省心的选择。个人观点深拷贝是“启动性能灾难”的高发区能用浅拷贝解决的问题别强行深拷贝能按需拷贝字段的不要整个对象复制。4.2 源对象传 null 或 undefined 时抛错这个坑在真实业务里经常由“动态参数”引发——上游接口可能返回空字段你顺手就把null传进来了。function mergeOptions(base, extra) { // extra 可能为 null这里直接崩 return Object.assign({}, base, extra); } mergeOptions({ a: 1 }, null); // TypeError: Cannot convert undefined or null to object解决办法有两种。一种是在传参前做防御const safe extra || {};另一种是在函数内部过滤function mergeOptions(base, ...sources) { return Object.assign({}, base, ...sources.filter(Boolean)); }filter(Boolean)会把所有 falsy 值null、undefined、0、false、空字符串全部剔除保证传给Object.assign的都是对象或可转换对象。这是我在生产代码里验证过比较稳的写法。4.3 同名属性的覆盖顺序后写者胜出前面已经多次提到覆盖规则但这里给出一个带异常情况的完整例子。const target {}; const source1 { a: 1, b: 2 }; const source2 { a: 3, c: 4 }; Object.assign(target, source1, source2); // target { a: 3, b: 2, c: 4 }source2的a覆盖了source1的ab和c互不冲突。遇到要合并三层以上配置时务必把“优先级更高的配置源”放在后面。我踩过一次真实事故设计了一个“全局配置覆盖默认配置、用户配置再覆盖全局配置”的三层合并结果参数顺序写反用户配置被全局配置覆盖线上出现了两小时的白屏问题就出在一行Object.assign的顺序上。排查技巧也很简单在合并前后分别console.log目标的字段值用“后写者胜出”这条规则反推是哪一层配置出了问题。4.4 setter 被触发导致的连带反应Object.assign()复制属性时的行为是“调用目标对象上的[[Set]]”对应到代码里就是会触发目标属性上的 setter。const target { set value(v) { console.log(setter 被调用了传入, v); this._value v; }, get value() { return this._value; } }; Object.assign(target, { value: 42 }); // 控制台输出setter 被调用了传入 42这个特性在 Vue 2 时代其实是“特性而非 bug”——Vue 2 用 Object.defineProperty 给数据对象加响应式 setterObject.assign触发 setter 正好可以让视图自动更新。但如果你的项目中某个对象上存在自定义 setter而你没意识到Object.assign就可能引发副作用比如触发二次计算、发起网络请求、修改其他变量。所以用Object.assign(target, ...)之前最好先确认 target 上有没有自定义 setter。如果不想触发 setter改用Object.defineProperties或对象展开运算符。4.5 拷贝 class 实例、Date 等特殊对象时的陷阱拿Object.assign({}, date)来举例。结果会让你大失所望const date new Date(2025-01-01); const clone Object.assign({}, date); console.log(clone); // 输出{} —— Date 实例的内部槽位不是可枚举属性拷了个寂寞同样的道理适用class实例。实例字段比如在构造函数里this.name x定义的可以被拷贝但类的方法一般在原型上不会被复制Date的内部时间栈也不是普通的可枚举属性所以Object.assign无法复制。处理这类对象正确姿势是使用structuredClone()可以正确处理 Date、Map、Set或者手动构造新实例const dateClone new Date(date.getTime()); // 简单又可靠5. 实际开发中我的一些习惯建议5.1 先问一句我要的是新对象还是改旧对象Object.assign()最容易被混淆的地方就是“它既可以改旧对象也可以生成新对象取决于你传入的第一个参数”。我建议在代码评审里定一个简单规则凡是函数参数传入的对象一律不原地修改所有更新都返回新对象。这样一来Object.assign(target, source)这种改旧对象的写法就被禁掉了所有合并统一用Object.assign({}, ...)或对象展开。规则定死之后副作用的排查范围大幅缩小你不再需要担心某个函数悄无声息改掉了传入对象的内部状态。5.2 深拷贝到底该怎么选再次强调Object.assign()不是深拷贝工具。如果你确实需要深拷贝我用过的方案优先级是这样数据是纯 JSONJSON.parse(JSON.stringify(data))简单、无依赖、可读性好。有特殊类型或循环引用structuredClone(data)现代浏览器和 Node 17 都支持。项目里已有 Lodash_.cloneDeep(data)功能最全面。追求极致性能且对象结构固定手写递归遍历只拷贝需要的字段。尤其是第四点常被忽略。在大量前端性能敏感场景里“全量深拷贝”往往是不必要的你能明确知道要变哪些字段那就只生成一个包含这些字段的新对象用Object.assign({}, obj, { changedField: newValue })就好深拷贝连出场机会都没有。5.3 版本兼容与 polyfill 的现状Object.assign()是 ES2015 的标准 API目前所有现代浏览器、Node.js、Deno 都原生支持。哪怕是已经停止维护的 IE 11也可以在引入核心-js polyfill 后正常使用。依赖构建工具Webpack、Vite、Rollup的项目通常会通过 browserslist 目标自动注入对应 polyfill你基本不用手动操心。如果你还在维护古老环境上古 IE可以用这样一段经典 polyfillif (typeof Object.assign ! function) { Object.assign function (target, ...sources) { if (target null) { throw new TypeError(Cannot convert undefined or null to object); } const to Object(target); for (const source of sources) { if (source ! null) { for (const key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { to[key] source[key]; } } } } return to; }; }这段 polyfill 本身也是一个很好的学习材料它展示了“遍历可枚举自有属性再赋值”的整个流程相当于把Object.assign()的规范翻译成了可读代码。5.4 一个小技巧利用空源对象做快速判空Object.assign({}, data)的结果如果与原对象浅比较相等说明原对象本身就是空壳或者没有可枚举自有属性。这比起Object.keys(data).length 0的写法在某些旧项目里反而更直观。function isEmptyObject(obj) { return Object.keys(obj).length 0; // 或者 // return JSON.stringify(obj) {}; // 或者 // return Object.assign({}, obj) 的键数量为 0 }不过说实话Object.keys().length才是最简单可靠的判空方式Object.assign在这里只是“顺便一提”的偏门用途别真的把它当主角来用。6. 几个实战示例拆解如果你想在项目里直接“抄作业”下面这几个组合示例覆盖了高频场景。6.1 带默认值的深度配置合并function mergeConfig(userConfig {}) { const defaults { host: 127.0.0.1, port: 3000, retry: 3, headers: { Content-Type: application/json } }; // 浅拷贝合并嵌套的 headers 会被整体引用 const merged Object.assign({}, defaults, userConfig); // 如果 headers 也需要按字段合并单独处理 if (userConfig.headers) { merged.headers Object.assign({}, defaults.headers, userConfig.headers); } return merged; } const config mergeConfig({ port: 8080, headers: { Authorization: Bearer token } }); console.log(config); // { host: 127.0.0.1, port: 8080, retry: 3, headers: { Content-Type: application/json, Authorization: Bearer token } }这个例子展示了浅合并和“按需二次合并”的组合技巧。深层字段不会自动递归合并但你可以针对特定字段手动做一次Object.assign。6.2 状态更新里的“防串改”模式const state { filter: { status: all, page: 1 }, list: [] }; function updateFilter(nextFilter) { // 完全替换 filter旧 filter 不被修改 return Object.assign({}, state, { filter: Object.assign({}, state.filter, nextFilter) }); } const newState updateFilter({ page: 2 }); console.log(state.filter); // { status: all, page: 1 } —— 没动 console.log(newState.filter); // { status: all, page: 2 } —— 新值这种“逐层浅拷贝”的模式本质上是把深拷贝拆成多层浅拷贝每一层都干净清楚。相比直接上一个深拷贝库可读性反而更好。6.3 数组对象批量归一化const rows [ { id: 1, count: 2 }, { id: 2, count: null }, { id: 3 } ]; const normalized rows.map(row Object.assign({}, { count: 0, checked: false }, row) ); // 结果 // [ // { count: 2, checked: false, id: 1 }, // { count: null, checked: false, id: 2 }, // { count: undefined, checked: false, id: 3 } // ]写出这种“默认值在后”的写法时你要意识到一个问题count: null不会触发默认值undefined也不会。如果希望把null也归一化成0上面的写法就不够用了。这时候应该先清洗数据再合并const normalized rows.map(row { const clean { ...row, count: typeof row.count number ? row.count : 0 }; return Object.assign({ count: 0, checked: false }, clean); });这提醒我们Object.assign()只做属性级别的覆盖不做值级别的语义清洗。默认值是否能生效完全取决于属性是否存在而不是值是否有效。7. 几个问题排查速查表用表格整理一些常见问题适合直接贴到团队 wiki 里。问题现象直接原因推荐解法拷贝出的对象改动后原对象也变浅拷贝子对象是共享引用按需深拷贝或逐层合并传入 null/undefined 直接抛错源对象不可转为 Object提前过滤空值Date、Map、Set 拷贝后是空对象内部槽位不是可枚举属性用 structuredClone 或手动构造拷贝后 getter 变成了普通值[[Get]] 语义会先求值用 Object.getOwnPropertyDescriptor 手动复制描述符Symbol 键属性总被漏掉部分工具方法只看字符串键Object.assign 本身就支持 Symbol 键确认遍历方式即可class 实例方法全部丢失方法在原型上而非自有属性原型链上的内容可以手动挂载或用 Object.create 配合拷贝默认值总是被用户配置覆盖默认值放的位置不对把默认值放在Object.assign的最后一个参数页面在旧浏览器报 assign 不存在缺少 polyfill引入 core-js / 手动 polyfill这张表我建议收藏一下改 bug 的时候对着排查能省下不少时间。关于Object.assign()的用法能展开讲的东西基本都在上面了。从我自己的使用经验来看这个 API 最大的价值在于“语义直白”——多源合并、覆盖顺序、浅拷贝的含义它几乎不做任何隐藏转化。可也正因为看起来太简单人们才会忽略它边界行为里的各种坑。最后再分享一个小习惯我在写任何涉及对象复制的代码时都会先写一行注释明确标注“这里是浅拷贝”还是“这里需要深拷贝”。这行注释看着简单却能避免后续维护的同事踩进同一个坑里。技术选型永远没有银弹但把每个工具的边界搞清楚再配合好的编码习惯就能把踩坑概率降到最低。