JavaScript Object.assign:目标写到一半报错,前面的修改为什么还在
给现有配置对象合并新选项时,一次调用抛出了异常,很容易误以为目标完全没变。如果业务在捕获异常后继续使用旧配置,就可能混入已经成功写入的字段。要判断这种问题,需要观察每一次读取和赋值,而不能只看最后有没有返回对象。
Object.assign 按顺序读取来源的可枚举自有属性,再向目标赋值。来源的 getter 可能执行,目标已有的 setter 也可能执行。它复制的是当时读到的值,并没有承诺把整组写入包装成可以回滚的事务。下面把这个差别缩小到三个字段。
先让第二个字段明确失败
把代码保存为 demo.mjs,用 node demo.mjs 运行。例子只创建内存对象。来源先给出 first,然后读取 blocked,最后才轮到 last;目标的 blocked 是不可写字段。events 同时记录来源读取和目标 setter,以免单靠对象打印误判执行顺序。
AI概念示意图:以抽象物件说明本文主题,不代表真实界面或运行结果。
import assert from 'node:assert/strict';
const events = [];
let received = 0;
const target = {set first(v) {events.push(`set:first:${v}`); received = v;}};
Object.defineProperty(target, 'blocked', {value: 9, writable: false});
const source = {
get first() {events.push('get:first'); return 1;},
get blocked() {events.push('get:blocked'); return 2;},
get last() {events.push('get:last'); return 3;}
};
try {Object.assign(target, source);}
catch (error) {assert.ok(error instanceof TypeError); console.log(error.name);}
assert.equal(received, 1);
assert.equal(target.blocked, 9);
assert.equal(Object.hasOwn(target, 'last'), false);
assert.deepEqual(events, ['get:first', 'set:first:1', 'get:blocked']);
console.log(events.join('|'));
let level = 3;
let reads = 0;
const original = {get level() {reads++; return level;}};
const snapshot = Object.assign({}, original);
const live = Object.defineProperties({}, Object.getOwnPropertyDescriptors(original));
assert.equal(reads, 1);
assert.equal(typeof Object.getOwnPropertyDescriptor(live, 'level').get, 'function');
level = 4;
console.log(`reads=${reads} live=${live.level} snapshot=${snapshot.level}`);
assert.equal(snapshot.level, 3);
assert.equal(live.level, 4);怎样读这三行结果
第一行是 TypeError,第二行是 get:first|set:first:1|get:blocked。它证明 first 的读取与 setter 已经发生,随后才读 blocked 并尝试赋值。last 没有出现,是因为异常立即结束了后续复制。
断言进一步确认 received 已经变成一,blocked 仍然为九,目标也没有 last。first 是只有 setter 的访问器,直接读取 target.first 会得到 undefined,因此用 received 检查副作用更可靠。捕获异常并不会撤销 setter 已经做过的修改。
需要的是快照,还是属性本身
最后一行 reads=1 live=4 snapshot=3 对照两种复制。assign 读取 getter,得到当时的三;之后 level 变成四,快照依然是三。描述符复制保留 getter 函数,建立新对象时没有调用它,后来访问才得到四。描述符断言还检查复制后的属性确实拥有 getter。
复制描述符会连同可枚举、可写和可配置等标志一起带过去,也会包括不可枚举的自有属性。这适合确实要保留对象接口的场景,但与普通配置合并的目标不同。访问器闭包和嵌套对象引用仍可共享,不能把它当作隔离全部状态的深复制。
排查时还要确认复制方向:第一个参数才是被写入的对象,后面的对象是来源。给一个新的空对象赋值,可以避免直接修改现有目标,但来源 getter 仍会执行。如果业务只想读取公开配置字段,先规定允许的键,比遍历一个未知对象上的所有可枚举属性更容易控制副作用。
同样的值可以来自普通字段,也可以来自访问器。控制台把两者都显示成数字时,不能据此推定行为一致;应查看属性描述符,确认 getter 和 setter 是否存在,再决定是否需要把访问次数也列入测试。
让配置更新具有明确的失败边界
若业务只接受普通数据,可先按白名单读取字段,检查类型和范围,构造一个新的普通配置对象,再替换应用持有的引用。验收时应故意让中间字段失败,检查旧配置仍是原值,并检查新的配置没有提前被其他组件看到。这里的替换也需要调用者配合。
如果来源含 getter,预读取本身仍可能有副作用;如果目标必须触发 setter,创建候选对象也不会自动替你撤销外部操作。此时要为具体操作设计补偿,或缩小一次修改的范围。不要仅在 assign 外面包一层 try/catch,就把“异常已处理”等同于“状态已恢复”。


