JavaScriptとTypeScriptでは、オブジェクトの参照を使い捨てのように扱うことが非常に容易になっており、それは危険を伴います。オブジェクトを作成し、関数に渡し、キャッシュに保存し、後でその変数を新しいインスタンスで置き換える。言語は何も文句を言いません。古い参照はプログラムのどこかに残り続け、もはや最新ではないデータを指し示しています。これはクラッシュではありません。もっと悪いことです。コードベースの2つの部分が、どちらも自分が「真実」を所有していると思い込んでいる中で、音もなく乖離が生じるのです。
これを防ぐ規律は「ハード・オブジェクト・リファレンス (Hard Object References)」と呼ばれます。これはライブラリでもコンパイラ機能でもありません。コード全体で遵守すべき「契約」です。
ステイル・エイリアス問題
ステイル・エイリアス(古いエイリアス)とは、あるモジュールがオブジェクトへの参照を保持している間に、別のモジュールがそのオブジェクトを新しいものに置き換えてしまうことで発生します。最初の参照は依然として有効なコードですが、もはや最新のデータを指していません。
一般的なWebアプリケーションにおけるユーザーレコードを想定してみましょう。
const user = {
name: "Alice",
address: {
city: "Seoul",
country: "KR"
}
};
配送コンポーネントは、早い段階で住所を取得します。
const shippingAddress = user.address;
その後、プロフィールの更新が届きます。リデューサーやサービスハンドラーが、オブジェクト全体を置き換えることを決定します。
user.address = { city: "Tokyo", country: "JP" };
この時点で、user.address は東京を指しています。しかし、shippingAddress は依然としてソウルにある古いオブジェクトを指しています。例外はスローされません。TypeScriptは型が一致しているため、満足しています。UIはプロフィールページに更新された都市を表示している一方で、配送ラベルには静かに古い都市が印刷されるかもしれません。このバグが表面化するのは、ユーザーが「荷物が違う国に届いた」と苦情を申し立てた時だけです。
これは、JavaScriptが「同一性 (identity)」と「値 (value)」を切り離しているために起こります。オブジェクトのプロパティを新しいオブジェクトリテラルで置き換えると、その連鎖が断ち切られます。古いオブジェクトは破棄されるのではなく、単に孤立するだけです。それを保持し続けている者は、いわば「亡霊」を扱っていることになります。
ハード・オブジェクト・リファレンスとは何か
ルールは単純です。プリミティブな値は置き換えても構いませんが、オブジェクトや配列の参照は決して置き換えないでください。新しいデータが届いたら、コンテナ自体を入れ替えるのではなく、既存のコンテナの中にデータをコピーします。
これには3つの具体的な習慣が必要です。
第一に、オブジェクトや配列は const で宣言します。これにより、トップレベルの変数を新しいインスタンスに再バインドしたいという誘惑を排除できます。変数は、そのスコープの生存期間中、固定されている必要があります。
第二に、オブジェクトや配列を保持しているプロパティを、新しく作成したものに置き換えないでください。住所を更新する必要がある場合は、その内部のプロパティを変更します。
第三に、状態をクリアまたはリセットする必要がある場合は、新しい空のオブジェクトや配列に捨ててしまうのではなく、既存の構造を空にします。
住所の例を振り返ると、正しい更新方法は次のようになります。
user.address.city = "Tokyo";
user.address.country = "JP";
もし入力データが部分的であったり動的であったりする場合は、Object.assign を使用して既存のターゲットに書き込みます。
Object.assign(user.address, incomingAddressData);
shippingAddress 変数は、メモリ上の全く同じオブジェクトを指しているため、新しいフィールドを即座に認識します。信頼できる唯一の情報源 (source of truth) として機能する、正準なオブジェクトはただ一つだけになります。
最も重要となる場面
フラットな設定オブジェクトに対しては、この規律は過剰に思えるかもしれません。しかし、状態がグラフ構造へと成長し、複数のサブシステムが重なり合うノードへのポインタを保持するようになると、これは不可欠になります。
リッチテキストエディタを考えてみましょう。ドキュメントモデルはノードのツリーです。選択モデルは開始ノードと終了ノードへの参照を保持しています。履歴バッファは、直前の操作で変更されたノードへの参照を保持しています。レンダリング層は、レイアウトのために計測したノードへの参照を保持しています。もし状態管理者が、テキストが変更されたという理由で段落ノードを新しいオブジェクトに置き換えてしまったら、それらのサブシステムはすべてステイル・エイリアスを保持することになります。選択範囲は間違った領域をハイライトし、履歴システムは正しく元に戻すことができなくなります。レンダラーはクラッシュするか、さらに悪いことに、ゴーストカーソルを表示することになります。
同じリスクは、UI、アクセス制御層、オートセーブルーチンによって参照される、ネストされた設定や権限を持つユーザープロファイルにも存在します。親コンテナが子ノードの計測値をキャッシュするレイアウトエンジンにも存在します。実行時のコントローラーがアクティブなエンティティをリファレンスで追跡するビジュアルエディタやキャンバスツールにも存在します。これらの領域すべてにおいて、コンポーネントはオブジェクトのハンドルを取得し、そのハンドルが「真実」のライブビューであり続けることを期待しているのです。
ハード・オブジェクト・リファレンスは、オブジェクトを「安定した住所」として扱います。中の家具は変えることができますが、ドアの位置は変わりません。その住所を知っている人なら誰でも、中に入って現在のレイアウトを確認できるのです。
置換ではなくリアクティビティを
Reduxや同様のイミュータブルな状態管理ライブラリを使用したことがある人にとって、このモデルはおそらく逆転しているように聞こえるでしょう。それらのシステムでは、新しいオブジェクトを生成することで変更が通知されます。参照の変更そのものがシグナルなのです。コンポーネントは prevProps.data === nextProps.data を比較して、再レンダリングを行うかどうかを判断します。
Hard Object Referencesでは、その前提を覆す必要があります。参照が一定に保たれるため、参照の等価性(reference equality)だけではデータが変更されたかどうかを判断できません。更新を通知するためには、別の方法が必要になります。
実際には、リアクティビティ・システム、明示的なオブザーバー、またはダーティフラグ(dirty flags)に頼ることになります。user.address.city をミューテートすることで、購読者に通知するセッターをトリガーできます。オブジェクトがイベントバスを通じて変更イベントを発行することも可能です。ゲームループやキャンバスツールであれば、グローバルなダーティフラグを設定し、フレームの最後にグラフを再スキャンするかもしれません。参照は安定しているため、他のメカニズムを通じてデータフローを可視化する必要があります。
このアーキテクチャの転換こそが、このアプローチが複雑なフロントエンドの状態、大規模なコンポーネントローカルな状態、ビジュアルエディタ、キャンバスツール、およびランタイムコントローラーに最適である理由です。これらのシステムは、すでに粒度の細かい更新、直接的なミューテーション、または命令的なAPIに依存しています。その上にイミュータビリティを強制すると、それに見合う明快さを得ることもなく、過剰なメモリ割り当ての負荷(allocation pressure)や参照の激しい入れ替わり(reference churn)を引き起こすことがよくあります。1フレームの重みが大きい場合、スライダーを動かすためだけに新しいオブジェクトグラフを割り当てるのは無駄です。参照を固定(hard)に保ち、内部をミューテートすることは、問題の実際のメカニズムに合致しています。
定着させるために
このルールの見落とされがちな利点の一つは、
