原创

Object.assign 会执行 getter 和 setter:对象复制前先选对合同

合并一份配置对象,为什么日志突然多了一次读取,目标对象里的校验器也被调用了?Object.assign并不只是搬运一组静止的字段描述。它会读取源属性的值,再把值赋给目标属性;源上的getter和目标上的setter都可能在中途执行。

平时使用普通JSON对象,很容易看不见这层行为。把同一写法用于带访问器的状态模型、SDK实例或响应式包装后,所谓“复制对象”就可能触发计算、副作用甚至异常。先把值复制与属性描述符复制分清,才能知道应当选哪种工具。

一次读取,可能就是一次函数调用

下面的程序可保存为assign-contract.js,在现代浏览器控制台或Node.js运行。它只使用语言内置能力,不依赖框架。

'use strict';
const assert = (condition, message) => {
  if (!condition) throw new Error(message);
};
let reads = 0;
const token = Symbol('token');
const source = {
  get title() { reads++; return 'draft'; },
  nested: { enabled: true },
  [token]: 7
};
Object.defineProperty(source, 'hidden', { value: 9, enumerable: false });
const calls = [];
const target = {
  set title(value) { calls.push(value); }
};
Object.assign(target, source);
console.log('assign reads:', reads, 'setter calls:', calls.length);
assert(reads === 1 && calls[0] === 'draft', 'assign accessor behavior');
assert(target[token] === 7 && !Object.hasOwn(target, 'hidden'), 'enumerability');

const spread = { ...source };
const spreadDescriptor = Object.getOwnPropertyDescriptor(spread, 'title');
assert(reads === 2, 'spread also reads source getter');
assert(spreadDescriptor.value === 'draft' && !spreadDescriptor.get, 'spread data property');
console.log('spread title is data:', 'value' in spreadDescriptor);

const preserved = Object.create(
  Object.getPrototypeOf(source), Object.getOwnPropertyDescriptors(source)
);
assert(reads === 2, 'descriptor copying should not read getter value');
assert(typeof Object.getOwnPropertyDescriptor(preserved, 'title').get === 'function', 'getter retained');
assert(Object.hasOwn(preserved, 'hidden'), 'non-enumerable property retained');
console.log('preserved title:', preserved.title, 'reads now:', reads);
assert(reads === 3, 'getter runs when preserved property is read');
spread.nested.enabled = false;
assert(source.nested.enabled === false && preserved.nested.enabled === false, 'all shallow');
console.log('nested reference shared:', spread.nested === source.nested);

const partial = {};
Object.defineProperty(partial, 'locked', { value: 1, writable: false });
try {
  Object.assign(partial, { first: 2, locked: 3, last: 4 });
  throw new Error('expected write failure');
} catch (error) {
  if (!(error instanceof TypeError)) throw error;
}
assert(partial.first === 2 && partial.locked === 1 && !Object.hasOwn(partial, 'last'), 'partial update');
console.log('after failure:', partial.first, partial.locked, Object.hasOwn(partial, 'last'));

输出里,assign读取一次getter并调用一次目标setter;展开语法又读取一次源getter,但新对象上得到普通数据属性;描述符复制在创建时不读取getter,稍后访问preserved.title才执行它。最后一次失败仍然留下已经写好的first,last没有被处理。

展开语法改变了目标写入方式,没有消除源读取

{...source}经常被描述为Object.assign的简写,但两者不能在有访问器时随意互换。展开生成新对象上的属性,不会拿一个已有目标上的setter来完成那次赋值;源属性仍然需要取值,因此getter照样运行。

所以把assign换成展开,不是“不触发任何逻辑”的保证。源对象如果是Proxy,枚举和读取也可能进入相应陷阱。一个函数是否接收普通数据,应由输入边界约束,而不是看调用处只有一行对象语法就默认没有执行行为。

示例用计数器让这种读取可见,没有让getter访问网络或改业务状态。真实getter若昂贵,重复复制可能重复计算;getter若依赖当前状态,前后两份复制甚至可能得到不同值。需要稳定快照时,要明确在哪个时点取值、哪些字段属于快照。

保留访问器时,复制的是行为合同

Object.getOwnPropertyDescriptors取得自有属性的描述,包含可枚举、可配置、可写以及访问器函数等信息。把它交给Object.create,可以在新对象上定义相同形态的自有属性,并显式选择原型。

这种方式会包含示例中不可枚举的hidden,而assign与展开不会。Symbol属性也不能笼统说成“对象复制都会丢”:可枚举自有Symbol可以进入assign和展开,是否保留取决于相应操作规则。程序用实际Symbol断言避免只靠Object.keys观察不完整结果。

复制getter函数还会保留其闭包引用。这里两份对象读取同一个reads计数,证明新对象并没有得到一份独立计数器。getter使用this时,读取新对象会以新接收者执行;getter捕获外部变量时,那份外部状态仍然共享。描述符保留不是行为隔离。

也不要把这份方式直接用于任意内置实例。某些对象依赖内部槽或私有状态,只复制原型与属性描述并不能制造一个合法实例。程序范围限定为普通对象,避免把属性层的实验扩成所有对象的克隆承诺。

三种复制都没有递归拆开 nested

代码把spread.nested.enabled改为false,source和preserved观察到的都是false。这三条路径都保留同一嵌套引用。是否保留访问器,与是否深拷贝是两条独立问题,选择描述符复制不会自动把对象图复制一遍。

需要独立业务模型时,最好明确字段与转换规则:哪些嵌套对象要重建,哪些共享不可变值,哪些资源根本不应复制。通用深克隆又会遇到函数、循环引用、原型与资源所有权,不能在一个配置合并函数里悄悄做出全部决定。

多份配置的覆盖顺序,也是一项输入协议

assign按来源顺序处理,后面同名属性可以覆盖前面的值。undefined若作为可枚举自有属性存在,也会参与覆盖;它不是自动的“忽略此配置”。默认值与用户覆盖合并时,需要明确缺省、显式undefined和null各代表什么,不要借合并函数的偶然行为推断产品含义。

多个来源之间也没有统一快照。前一个getter可能修改后一个来源,导致后面的读取观察到新状态。普通不可变数据通常不会遇到这种交互,但对模型实例做通用合并时,这正是限制输入类型的理由。不要用一次最终对象打印,替代对执行顺序的理解。

异常不会撤回前面已经完成的赋值

最后一个样本先成功写first,再遇到只读locked抛出TypeError。目标对象仍保留first,操作没有事务式回滚。若配置验证发生在setter里,后一个字段被拒绝时,前一个字段可能已经改变运行状态。

需要“全部合法才采用”时,可以先把输入解析并验证成独立候选,再一次替换持有者引用;如果必须逐项更新有副作用的实例,就要设计专门的提交与补偿合同。换一种对象复制语法,无法替代这层业务一致性。

实际排查可以先给源getter和目标setter加受控计数,再检查新对象的属性描述,最后故意安排一个中途失败的字段。三步分别回答取值、定义与失败后状态,比打印一份看起来相同的对象更有解释力。

正文到此结束
评论插件初始化中...
Loading...