尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Object.assign()深度解析:底层原理、避坑指南与替代方案

Object.assign()深度解析:底层原理、避坑指南与替代方案 如果你经常写 JavaScriptObject.assign()这个名字你大概率不陌生。网上关于它的资料铺天盖地但大多数都只告诉你这是用来合并对象的是浅拷贝——然后就没有然后了。可真到项目里用起来你会发现事情远没那么简单为什么合并出来的对象改了原对象也跟着变为什么源对象里明明有值合并完却丢了为什么在某些框架的状态管理里用它更新数据界面死活不刷新这些问题我在实际开发里都遇到过而且不止一次。这篇文章不打算重复那些烂大街的用法介绍我想从一个更实用的角度把Object.assign()的底层规则、边界行为、高频场景和替换方案一次性讲透让看完的人能在真实项目里直接用它而不是在语法层面打转。无论你是刚接触前端不久的新人还是写了几年业务代码但一直没深究过这个 API 的老手这篇内容应该都能给你一些新的东西。1. 对象合并的需求演变从手写 for-in 到原生 API1.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是复制那一刻的静态快照。反过来如果目标对象上有 setterObject.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那就会直接抛TypeErrorObject.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变成字符串性能对大对象一般后端返回的纯数据对象无函数、无 DatestructuredClone(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的类型签名是assignT extends object, U extends object(target: T, source: U): T U; assignT 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 mergeConfigT 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基于 ProxyObject.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 会变成 gettersetter 会变成 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 配置、是否引入框架自行测试验证。
返回列表