スプレッド構文でオブジェクトを展開する
JavaScript のスプレッド構文は、オブジェクトの前にドット3つ (...) を付けることで、そのオブジェクトが持つ列挙可能な自身のプロパティを、外側のオブジェクトリテラルにそのまま展開してくれます。オブジェクトのコピー・マージ・一部だけ変更した新しいコピーを作るときに、元のオブジェクトを壊さずに済む、いちばん手軽な書き方です。
copy は user と同じキーと値を持っていますが、別のオブジェクトです。片方を書き換えてももう片方には影響しません。ただしこれは「トップレベルに限っては」の話で、この点については後ほど改めて触れます。
イメージとしては、{ ...obj } は「obj のプロパティを、この新しいオブジェクトリテラルの中に流し込む」という感覚です。スプレッドと並べて書いたものは、そのまま結果に含まれます。
コピーと上書きを一度にやる
スプレッド構文でいちばんよく使うパターンが、オブジェクトを展開しつつ、ついでにいくつかのプロパティを追加・上書きするというものです。後に書いたキーが勝つので、先にスプレッドしてから上書きを書きます。
user はそのまま残り、updated は role だけを差し替えた新しいオブジェクトになります。この「元のオブジェクトを書き換えずに更新する」パターンは、モダンな JavaScript のいたるところで登場します。React の state 更新、Redux のリデューサー、ミューテーションを避けたいあらゆるコードでお馴染みですね。
順番を逆にすると、挙動も真逆になります。
ここでは role: "guest" を先に書いているので、あとから展開される user.role で上書きされます。デフォルト値を用意しておいて、スプレッドしたオブジェクト側で必要に応じて上書きする、というパターンに便利です。
オブジェクトをマージする
javascript でオブジェクトをマージしたいときは、新しいオブジェクトリテラルの中に複数のオブジェクトをスプレッド構文で展開すればOKです。同じキーがある場合は、あとに書いたオブジェクトの値が優先されます。
theme と fontSize は userPrefs から、debug は defaults からそのまま引き継がれます。オブジェクトが 3 つでも 4 つでもルールは同じ。前から順に読んで、最後に書き込んだ値が勝ちます。
これは Object.assign({}, defaults, userPrefs) の現代的な書き換えです。やっていることは同じですが、スプレッド構文のほうが読みやすいですし、Object.assign(defaults, userPrefs) と書いてしまって defaults を破壊的に書き換えるというよくあるバグを踏まずに済みます。
スプレッド構文はシャローコピー(浅いコピー)
ここがハマりどころです。スプレッド構文がコピーするのは、オブジェクトの トップレベル のプロパティだけ。プロパティの値がオブジェクトや配列だった場合、中身ではなく参照がコピーされます。
copy.address.city を書き換えたら、user.address.city まで変わってしまいました。これは、両方のオブジェクトが同じ address オブジェクトを参照しているためです。スプレッド構文でコピーできたのは、あくまで一番外側のラッパーだけというわけですね。
ネストされた値を変更したいときは、変更したい階層ごとにスプレッドを展開してあげましょう。
任意のデータを本当にディープコピーしたいときは、structuredClone(obj) を使いましょう。ネストしたオブジェクト、配列、Date、Map、Set まで面倒を見てくれますし、今どきのランタイムにはすべて組み込まれています。
スプレッド構文とレストパラメータの違い
同じ3つのドットを使いますが、役割は正反対です。スプレッドは 展開 し、レストは 集約 します。
覚え方のコツ:... が = の前(分割代入側)にあればレスト、オブジェクトや配列リテラルの中(代入される側)にあればスプレッドです。
プロパティを非破壊で削除する
レスト分割代入とスプレッド構文を組み合わせると、特定のキーだけを除いたオブジェクトのコピーをきれいに作れます。delete も使わず、元のオブジェクトも書き換えません。
tempToken は専用の変数として取り出され(これは使わない想定)、それ以外はすべて safe に収まります。元の user は一切変更されません。
スプレッド構文でコピーされないもの
知っておくと役立つ細かなポイントをいくつか挙げておきます。
- 列挙不可のプロパティはコピーされません。 自分で作るプロパティはデフォルトで列挙可能ですが、
Object.definePropertyで定義したものや一部の組み込みプロパティは列挙不可になっています。 - プロトタイプはコピーされません。
{ ...instance }の結果はあくまでプレーンなオブジェクトで、元のクラスのインスタンスではありません。クラスのプロトタイプに定義したメソッドはコピー先には存在しないということです。 - ゲッターは評価されます。 ゲッターを持つオブジェクトをスプレッドすると、その時点でゲッターが1回呼び出され、戻り値が新しいオブジェクトの通常のプロパティとして保存されます。
copy には x と y はありますが、あくまで普通のオブジェクトです。distance は Point.prototype 上にあるメソッドなので、スプレッド構文ではコピーされません。クラスのインスタンスを複製したい場合は、そのクラス自身に clone メソッドを用意するのが一般的です。
次は配列メソッド
スプレッド構文は、イミュータブルなデータ操作を支える道具のひとつに過ぎません。もう一方の主役は map、filter、reduce といった配列メソッドたちで、元の配列を書き換えずに新しい配列を返してくれます。次のページで詳しく見ていきましょう。
よくある質問
JavaScriptの ...obj はどういう意味ですか?
オブジェクトリテラルの中で ...obj と書くと、そのオブジェクトが持つ列挙可能な自身のプロパティを、新しいオブジェクトにまるごとコピーしてくれます。たとえば { ...user } と書けば、user と同じキーと値を持つ別オブジェクトが出来上がります。元のオブジェクトを書き換えずにコピーやマージを行う定番のやり方ですね。
2つのオブジェクトをマージするにはどうすればいい?
新しいオブジェクトリテラルの中で両方を展開するだけです。const merged = { ...a, ...b } と書けばOK。後に書いたプロパティが勝つルールなので、同じキーがあると b の値で a が上書きされます。Object.assign({}, a, b) と同じ結果ですが、こちらの方が断然読みやすいです。
スプレッド構文はディープコピーになりますか?
いいえ、あくまでシャローコピー(浅いコピー)です。トップレベルのプロパティはコピーされますが、ネストしたオブジェクトや配列は参照が共有されたままなので、copy.address.city を書き換えると original.address.city も変わってしまいます。本当のディープコピーが欲しいときは structuredClone(obj) を使いましょう。
スプレッド演算子とレストパラメータの違いは?
見た目は同じ ... ですが、役割は真逆です。スプレッドはオブジェクトや配列を個々のプロパティ・要素に「展開」します(例:{ ...user })。レストは残りを1つの変数に「まとめる」働きで、const { name, ...others } = user のように使います。どちらかは前後の文脈で判断できます。