順番に読み進めながら学べます

参照とコピー — 配列・オブジェクトの落とし穴

代入したつもりが同じものを指していて、片方を直すともう片方も変わる仕組みを扱います。スプレッド構文による浅いコピー、structuredClone による深いコピー、Object.freeze まで進みます。

注文内容を書き換える前にバックアップを取っておこうとして const backup = order; と書くと、あとから order を直したときに backup まで同じ値に変わります。数値や文字列ではこうしたことは起きません。

この記事では、配列やオブジェクトの代入で何が渡っているのか、そして安全にコピーを作る書き方を扱います。

代入で渡るもの — プリミティブと参照

この食い違いを追いかけるのが難しいのは、代入したときに渡るものが値の種類によって違うからです。何が代入でそのまま写され、何が写されないのかを、ここで整理します。

数値・文字列・真偽値・nullundefined の 5 種類(プリミティブと呼びます。オブジェクトでも配列でもない値です)は、代入すると値そのものが写されます。

写したあとの 2 つの変数は無関係なので、片方を書き換えても、もう片方は変わりません。

配列とオブジェクトは 参照型(値そのものではなく、値が置かれている場所を指す情報を持つ型)です。代入で渡るのは場所を指す情報だけなので、2 つの変数が同じ 1 つの配列やオブジェクトを指します。

どちらの名前から書き換えても、指している先は同じ 1 つです。

代入で渡るのは値か、場所か
数値や文字列を代入する値そのものが写される別々の値として持つ配列やオブジェクト場所を指す情報だけ渡る1 つを 2 つの変数が指す片方の名前で書き換える指している先は同じ 1 つもう片方から見ても変わる
プリミティブは値が写されて別々になり、配列やオブジェクトは同じ 1 つを 2 つの変数が指します
// プリミティブは値が写される
let price = 3980;
let salePrice = price;
salePrice = 2980;
console.log(price);          // 3980(影響を受けない)
console.log(salePrice);      // 2980

// 配列は同じ 1 つを指す
const cart = ["A4 ノート"];
const savedCart = cart;
savedCart.push("油性ペン");
console.log(cart.length);    // 2(cart 側も増えている)
console.log(cart === savedCart);  // true(同じものを指している)

const でも中身は書き換えられる

const が固定するのは、変数がどこを指すかだけです。const cart = []; のあとに cart.push("油性ペン") と書けば中身は増えますし、const order = {} のあとに order.total = 0 も通ります。

禁止されるのは cart = [...] のように別のものを指し直す代入だけです。

注文内容のバックアップを取ったつもりで書き換えて、何が起きるかを確かめます。orderbackup は宣言済みで、backup には order をそのまま代入してあります。

① 合計金額を別の変数に取り出し(あとで入れ替えるので let で宣言してください)、その変数だけを 500 に書き換えて、その変数と order の合計金額を続けて表示してください。

backup の合計金額を 0 に書き換えて、backup の合計金額を表示してください。

③ 元の order の合計金額を表示してください。

orderbackup が同じものを指しているかどうかを表示してください。

(正しく実行できれば解説が表示されます)

JavaScript / TypeScript エディタ

コードを実行してください

コピーを作る — スプレッド構文と === の見ているもの

ここまで見たとおり、代入だけでは元と切り離された安全なコピーになりません。元の注文を残したまま金額だけを変えた別の注文を作りたいときは、{ ...order } のようにスプレッド構文で中身を並べた新しいオブジェクトを用意します。ここで確かめたいのは、そうして作ったものが元とどういう関係にあるのかです。

const copiedOrder = { ...order }; で作られるのは別のオブジェクトなので、order === copiedOrderfalse です。=== は配列やオブジェクトに対して中身が同じかどうかではなく、同じ 1 つを指しているかどうかを判定します。

中身を比べたいときは、キーの数や個々の値のように、比べたい観点を自分で書きます。

=== が見ているのは中身ではない
backup = order同じ 1 つを指している=== は true{ ...order }別のものを新しく作る=== は false中身がまったく同じ=== は中身を見ないそれでも false
同じものを指していれば true、別々に作ったものなら中身が同じでも false です。
const cart = ["A4 ノート"];
const copiedCart = [...cart];

// 別々のものなので === は false
console.log(cart === copiedCart);        // false

// 中身が同じでも false になる
console.log(["A"] === ["A"]);            // false

// 中身どうしを比べれば true
console.log(cart[0] === copiedCart[0]);  // true

// 同じものを指しているときだけ true
const savedCart = cart;
console.log(cart === savedCart);         // true

元の注文を残したまま、金額だけを変えた別の注文を作ります。order は宣言済みです。

order の中身を写した別のオブジェクトを作り、その合計金額を 0 に書き換えて表示してください。

② 元の order の合計金額を表示してください。

order と ① で作ったオブジェクトが同じものかどうかを表示してください。

④ 2 つのキーの数が等しいかどうかを表示してください。

JavaScript / TypeScript エディタ

コードを実行してください

入れ子の内側は写らない — 浅いコピーと深いコピー

ここまでのスプレッド構文は、金額のようなプリミティブな値については安全なコピーを作れていました。ただし顧客情報のように内側にオブジェクトを持つ注文をコピーすると、外側は別になっても内側は元と同じものを指したままです。コピーしたはずの側で住所を直したら、元の注文の住所まで変わっていた、という食い違いが起きます。

スプレッド構文が作るのは 浅いコピー(1 段目のプロパティだけを写し、内側のオブジェクトは同じものを指したままにするコピー)です。1 段目の値がプリミティブなら独立しますが、内側がオブジェクトや配列のときは、場所を指す情報(参照)がそのまま写ります。

内側まで作り直したいときは 深いコピー(入れ子の内側まで新しく作り直すコピー)を使います。structuredClone(order) と書くと、入れ子の何段目でも別のオブジェクトとして作り直されます。

ブラウザに用意されている関数なので、本サイトのコンソールでもそのまま使えます。ただし関数が入っているオブジェクトには使えません。

コピーが届く深さの違い
{ ...order }1 段目だけ作り直すorderId は独立する内側のcustomer参照がそのまま写る書き換えると元も変わるstructuredClone内側まで作り直す書き換えても元は無事
スプレッド構文は 1 段目だけを作り直します。内側まで独立させたいときは structuredClone を使います
const order = {
  orderId: "ORD-1342",
  customer: { name: "森田 圭", city: "大阪市" },
};

// 浅いコピー: 1 段目は別、内側は同じものを指す
const shallow = { ...order };
shallow.orderId = "ORD-9999";
console.log(order.orderId);                      // ORD-1342(独立している)

shallow.customer.city = "京都市";
console.log(order.customer.city);                // 京都市(元も変わった)
console.log(order.customer === shallow.customer);  // true

// 深いコピー: 内側まで別になる
const deep = structuredClone(order);
deep.customer.city = "神戸市";
console.log(order.customer.city);                // 京都市(元は無事)
console.log(order.customer === deep.customer);   // false

顧客情報を内側に持つ注文をコピーして、書き換えがどこまで届くかを確かめます。order は宣言済みです。

① スプレッド構文でコピーを作り、コピー側の市区町村を「川崎市」に書き換えてから、元の order の市区町村を表示してください。

② 元とコピーの customer が同じものかどうかを表示してください。

③ 内側まで作り直すコピーを別に作り、そちらの市区町村を「千葉市」に書き換えてから、元の order の市区町村を表示してください。

④ 元と ③ のコピーの customer が同じものかどうかを表示してください。

JavaScript / TypeScript エディタ

コードを実行してください

書き換えを止める — Object.freeze

ここまで見てきたとおり、配列やオブジェクトは参照を共有しているだけで、意図せず書き換わってしまうことがあります。既定の設定や税率のように、そもそも後から書き換わってほしくない値には、const とは別の対策が要ります。const で宣言しても中身の書き換えは止められないので、オブジェクト自体を変更不可にする方法が必要です。

Object.freeze(settings) と書くと、そのオブジェクトはプロパティの変更・追加・削除を受け付けなくなります。凍結したあとに代入・追加・削除を書くと、その場で TypeError が発生して実行が止まります。

凍結済みかどうかは Object.isFrozen(settings) で調べられます。凍結は 1 段目にだけかかるので、内側のオブジェクトは別に凍結する必要があります。

凍結後の書き換えは TypeError で止まる
凍結する前の代入そのまま受け付ける新しい値になる凍結した後の代入TypeError が発生するそこで実行が止まる凍結した後の追加と削除TypeError が発生するそこで実行が止まる
凍結すると代入も追加も削除も通りません。その場で TypeError が発生し、そこから先の行は実行されません
const settings = { theme: "dark" };

// 凍結する前は書き換えられる
settings.theme = "light";
console.log(settings.theme);             // light

Object.freeze(settings);
console.log(Object.isFrozen(settings));  // true

// 凍結後に代入すると、その行で止まる
// settings.theme = "dark";
// TypeError: Cannot assign to read only property 'theme' of object '#<Object>'

// 凍結は 1 段目だけ。内側は止まらない
const nested = Object.freeze({ display: { theme: "dark" } });
nested.display.theme = "light";
console.log(nested.display.theme);       // light

// 変えたいときは新しいオブジェクトを作る
const updated = { ...settings, theme: "dark" };
console.log(updated.theme);              // dark
console.log(settings.theme);             // light(元は無事)

変えたいときは新しいオブジェクトを作る

凍結したオブジェクトに代入・追加・削除をすると TypeError が発生し、そこから先の行は実行されません。凍結したオブジェクトを変えたいときは、書き換えるのではなく { ...settings, theme: "light" } のように新しいオブジェクトを作る形にします。

用意されたコードは、凍結した料率の設定をあとから書き換えようとしています。そのまま実行して、どこまで表示されて、どのメッセージで止まるかを確認してください。

JavaScript / TypeScript エディタ

コードを実行してください
QUIZ

理解度チェック

まずは1問ずつ答えてみましょう。

Q1const backup = order; と書いたあと backup.total = 0; を実行すると、order.total はどうなりますか?

Q2const a = ["A"]; const b = ["A"]; のとき、a === b は何を返しますか?

Q3内側にオブジェクトを持つデータを { ...order } でコピーしたとき、内側のオブジェクトはどうなりますか?