Object.assign({}, obj) と { ...obj } の違い


オブジェクトリテラル内のスプレッド構文は、ES2018で追加されたたいへん便利な構文です。特に、{ ...obj }という形のコードでオブジェクトをコピーするのはJavaScriptプログラミングでは極めて頻出です。

スプレッド構文が無かった時代はObject.assign({}, obj)として同様のことを達成していた方も多いと思われます。Object.assignはES2015から使用可能でした。

では、この2種類の方法は同じでしょうか。タイトルにもある通り、もちろん違います。今回は、この違いに触れている日本語資料がMDN日本語版で一瞬触れているくらいしか無かったので記事にまとめました。

結論

最初に結論を述べると、Object.prototypeが汚染されていた場合にのみ違いが発生します。特に、Object.prototypeにsetterを持つプロパティ名が存在し、そのプロパティ名でコピーしようとした場合に違いが現れます。

Object.defineProperty(Object.prototype, "foo", {
  get() { return 100; },
  set(n) { console.log(`foo is set to ${n}`); }
});

console.log({}.foo); // 100

const sourceObj = { foo: 999 };

const obj1 = Object.assign({}, sourceObj); // foo is set to 999 と表示される
const obj2 = { ...sourceObj };             // 何も表示されない

console.log(obj1.foo, obj2.foo); // 100 999

obj1obj2を2つの方法で作ったあと、それぞれのfooプロパティの値を調べると異なる値となっています。

解説

Object.assignとスプレッド構文は非常に似た動作をしますが、その違いを一言でまとめるとこうです。すなわち、Object.assign代入によってプロパティをコピーする一方で、スプレッド構文はプロパティ自体の作成によってプロパティをコピーします。

すなわち、sourceObj{ foo: 999 }であるという前提で、const obj1 = Object.assign({}, sourceObj)の挙動は以下とおおよそ同様です。

const obj1 = {};
obj1.foo = sourceObj.foo;

一方、const obj2 = { ...sourceObj };の挙動は以下とおおよそ同様です。

const obj2 = {};
Object.defineProperty(obj2, "foo", {
  configurable: true,
  enumerable: true,
  writable: true,
  value: sourceObj.foo
});

前者はobj1.fooに対する代入ですから、obj1.fooがセッタを持っていた場合はそれが呼び出されることになります。一方、後者はObject.definePropertyによるプロパティの作成なので、セッタが呼び出されません。今回は{}で新規オブジェクトを作ってそれを対象としているので、その違いを引き出すためにはObject.prototypeを汚染する必要がありました。

仕様書を実際に見てみると、Object.assign...objは非常に似た処理をしていることが分かります。

まずObject.assignの定義を仕様書から画像で引用します。

次に、...objの処理の本体部分を使用書から画像で引用します。なお、この場合excludedItemsは空リストになります。

この2つの仕様を注意深く比べると、本質的な違いは下から2行目だけであることが見て取れます。Object.assignはここでSetを使っているのに対し、スプレッド構文ではCreateDataPropertyOrThrowを使っています。深入りするのはやめておきますが、前者がプロパティへの代入に相当する操作である一方、後者はObject.definePropertyに相当する操作です。

まとめ

Object.prototypeが汚染されていたときのことを考えながらコードを書きましょう!Object.prototypeを汚染するのはやめましょう。