3つのファイルをまたいでバグを追跡した結果、無害なヘルパー関数がユーザー名を書き換えていたことが判明した。元のデータには一切触れていないはずだった。……そう思っていた。JavaScriptでは、ある変数を別の変数に代入することが、必ずしも期待通りの動作になるとは限りません。この言語はデータを2つの格納戦略に分けており、どちらを使用しているかを忘れることが、本番コードにサイレントなミューテーション(状態変化)が紛れ込む原因となります。
これを防ぐには、エンジンがどのように扱っているかと同じ視点で、プリミティブ値とオブジェクトの違いを理解する必要があります。
プリミティブ:実際のコピー
プリミティブとは、単一で分割不可能なデータです。これ以上小さなJavaScriptの値に分解することはできません。言語には、String、Number、Boolean、Undefined、Null、Symbol、BigIntの7つのプリミティブ型が定義されています。
プリミティブはアトミック(不可分)であるため、通常は変数バインディングの中に直接格納されます。あるプリミティブ変数を別の変数にコピーすると、エンジンは実際のデータを複製します。各変数はメモリ内に独自の独立したスロットを持ちます。
let a = "Alina";
let b = a;
b = "Ali";
console.log(a); // "Alina"
console.log(b); // "Ali"
ここでは、a は変更されません。b を再代入すると新しい値が作成され、b がそれを指すようになりますが、a は元の文字列を保持したままです。この挙動は「値によるコピー(copy by value)」と呼ばれます。これは数値、Boolean、Symbol、およびその他のプリミティブ型でも同様に動作します。これらを関数に渡したり、再代入したり、返したりしても、他の変数への副作用を心配する必要はありません。
オブジェクト:共有されるアドレス
オブジェクトは異なります。オブジェクトは、複数のデータを一つにまとめた複合的なコンテナです。このカテゴリには、プレーンなオブジェクト、配列、関数、Date、およびその他のすべての非プリミティブ型が含まれます。これらの構造は大規模になったりネストしたりする可能性があるため、JavaScriptはオブジェクト全体を変数の中に格納しません。その代わりに、変数は「参照(reference)」、つまり他の場所に格納されている実際のデータを指し示すメモリ上のアドレスを保持します。
オブジェクトを新しい変数に代入すると、エンジンはオブジェクトではなくアドレスをコピーします。これにより、2つの変数が全く同じ「家」を指している状態になります。
const user = { name: "Alina" };
const copy = user;
copy.name = "Ali";
console.log(user.name); // "Ali"
copy.name を変更すると user.name も変更されます。なぜなら、両方の名前が同じ基底オブジェクトを解決するからです。これが「参照によるコピー(copy by reference)」です。配列でも同じような驚きがあります。
const scores = [82, 91, 74];
const backup = scores;
backup.push(88);
console.log(scores); // [82, 91, 74, 88]
メモリ内には配列が1つしか存在しません。scores と backup は、単にその配列を指し示している2つの標識に過ぎません。
const キーワードは混乱を招く要素となります。const でオブジェクトを宣言すると、変数バインディングがロックされ、新しいアドレスを指すことができなくなります。しかし、オブジェクト自体がロックされるわけではありません。
const settings = { theme: "dark" };
settings.theme = "light"; // Works perfectly.
settings = { theme: "dark" }; // TypeError
開発者はしばしば const が不変性(immutability)を保証するものだと期待してしまいます。しかし、そうではありません。const は参照の再代入を防ぐだけです。中身を固定したい場合は、意図的にコピーを行う必要があります。
真のバグが潜む場所
参照に関するバグが、明らかな変数の再代入として現れることは稀です。それらは関数呼び出しの中に隠れています。
function addTimestamp(record) {
record.timestamp = Date.now();
return record;
}
const original = { id: 1 };
addTimestamp(original);
console.log(original.timestamp); // A number now exists here. Oops.
引数 record は参照のコピーを受け取りました。関数内で行われたプロパティのミューテーションは、呼び出し元のオブジェクトに直接書き込まれました。この関数は単純な変換処理のように見えましたが、スコープの境界を越えて状態を漏洩させてしまったのです。
このパターンは、ReactのようなUIフレームワークにおいて特に厄介です。そこでは、状態の更新が