structuredCloneと転送可能オブジェクトの内部
Worker間でデータを速く安全に渡せるようになる。構造化複製が扱える型と循環参照の解決、Transferableによる所有権移動でのゼロコピー、postMessageとの関係を内部動作から正確に整理します。
- 1.構造化複製は深いコピーを作るアルゴリズムで、循環参照を解決でき、Map/Set/ArrayBuffer/Date等も複製するが、関数・DOMノード・プロトタイプ・プロパティ記述子は複製できない。
- 2.Transferable(ArrayBufferやMessagePort等)はコピーされず所有権ごと移動(ゼロコピー)し、移動元はdetachされて使えなくなる。postMessageの第2引数で明示する。
- 3.postMessageとstructuredCloneは同じ構造化複製アルゴリズムを共有する。前者はスレッド境界を越えて転送可能、後者は同一realm内の同期コピーで転送リストも使える。
なぜコピーの「仕組み」を知る必要があるのか
Web Worker にデータを渡したら巨大な配列のコピーで固まった、structuredClone で複製したら関数プロパティが消えていた、postMessage 後に元の ArrayBuffer が空になった——これらはすべて構造化複製(structured clone)アルゴリズムという一つの仕組みから説明できます。structuredClone()・postMessage()・IndexedDB への保存・history.pushState は、いずれも同じこのアルゴリズムを内部で使います。何が複製でき、循環参照がどう解かれ、どの型が「コピー」ではなく「移動」になるのかを正確に押さえると、Worker 通信の性能設計と落とし穴回避が一気に見通せます。
構造化複製は「深いコピー」だが万能ではない
構造化複製は、対象オブジェクトをグラフとしてたどり、各ノードを再帰的に深くコピーして新しいオブジェクトグラフを作るアルゴリズムです。JSON.parse(JSON.stringify(x)) のような素朴なコピーより遥かに広い型を扱えますが、コピーするのは値(データ)であって挙動ではありません。ここが最大の境界線です。
| 分類 | 扱える例 | 扱えない例(DataCloneError 等) |
|---|---|---|
| プリミティブ | number / string / boolean / null / undefined / BigInt | Symbol(複製不可) |
| 標準オブジェクト | Object / Array / Map / Set / Date / RegExp | 関数・class インスタンスの挙動 |
| バイナリ | ArrayBuffer / TypedArray / DataView / Blob / File | — |
| プラットフォーム型 | ImageData / ImageBitmap / MessagePort / Error 等 | DOM ノード・Window・関数 |
複製できない代表は関数とDOM ノードで、これらを含むと DataCloneError という例外(postMessage でも structuredClone でも同じ)が投げられます。理由は単純で、関数はコードと閉じたスコープ(クロージャ)への参照を持ち、DOM ノードは特定の文書ツリーに結び付いた状態を持つため、それらを別のグラフや別スレッドへ意味を保ったまま移すことが定義できないからです。
構造化複製は prototype を引き継ぎません。class Foo のインスタンスを複製すると、データプロパティは写りますが結果は素の Object になり、instanceof Foo は偽になります。さらにプロパティ記述子(getter/setter・enumerable・writable 等)も保たれず、getter は呼び出された結果の値がコピーされます。「メソッドが消えた」「アクセサが普通の値になった」の正体はこれです。
循環参照とエイリアスの解決
素朴な深いコピーが循環参照(a.self = a)で無限再帰に陥るのに対し、構造化複製はメモ(memoization)でこれを解決します。アルゴリズムは「すでに複製したオブジェクト→その複製先」を対応付ける内部マップ(仕様上は memory と呼ばれる)を持ち、グラフをたどる過程で既出のオブジェクトに再会したら、新たに複製せず記録済みの複製先を再利用します。
const a = {};
a.self = a; // 自己参照(循環)
const b = structuredClone(a);
b.self === b; // true(循環構造がそのまま保たれる)
この memory は循環だけでなく共有参照(エイリアス)も正しく保ちます。同じオブジェクトを2か所から指している入力は、複製後も1つの複製を2か所が指す形になり、別々のコピーには分裂しません。これが JSON 経由のコピーとの決定的な違いです(JSON は循環で例外、共有参照は重複コピーに化けます)。
const shared = { v: 1 };
const src = { x: shared, y: shared };
const dst = structuredClone(src);
dst.x === dst.y; // true(共有関係が維持される)
Transferable:コピーではなく所有権の移動
ここまでは「コピー」の話でした。対して Transferable(転送可能オブジェクト)は、構造化複製の中で扱いが反転します。Transferable に指定されたオブジェクトは中身をコピーせず、所有権ごと移動します。移動元は detach(切り離し) され、以降そのオブジェクトは使えなくなる代わりに、移動先はバイト列を一切複製せずゼロコピーで受け取ります。巨大なバイナリを渡すときの性能はここで決まります。
Transferable になれるのは限られた型です。代表は ArrayBuffer、MessagePort、ImageBitmap、OffscreenCanvas、ReadableStream/WritableStream 等で、いずれも「移動しても意味が壊れない、所有者が1つであるべき資源」です。ArrayBuffer の転送は、その上に被せた TypedArray/DataView ビューも巻き込んで無効化します(裏の領域が切り替わるとビューも死ぬという原理はTypedArrayとArrayBufferのメモリレイアウトと同じです)。
const buf = new ArrayBuffer(1024 * 1024); // 1MB
const view = new Uint8Array(buf);
// 第2引数の「転送リスト」に渡したものだけが移動(ゼロコピー)
worker.postMessage({ payload: buf }, [buf]);
buf.byteLength; // 0(移動元はdetach、空になる)
view.byteLength; // 0(元ビューも無効化)
転送リストはメッセージ全体の置き換えではなく、そのオブジェクトだけを移動扱いにする指定です。メッセージ本体(上の例の { payload: buf } というラッパー)は通常どおり構造化複製でコピーされ、その複製の途中で buf に出会ったときだけ「複製せず移動済みの参照を使う」よう切り替わります。だから1回の postMessage で「大部分はコピー+一部だけゼロコピー転送」が両立します。
コピーと転送の使い分け
同じデータを渡すのでも、コピーと転送では性能と後始末がまるで違います。10MB の ArrayBuffer をコピーすれば送受で計20MB相当の確保とバイト複製が走り、転送ならポインタの付け替えに近いコストで済みます。一方で転送は移動元が即座に使えなくなるため、送った後も元データを参照する設計とは両立しません。
| 観点 | コピー(構造化複製) | 転送(Transferable) |
|---|---|---|
| データ量とコスト | サイズに比例(バイト複製が走る) | ほぼ定数(ゼロコピー) |
| 移動元の状態 | そのまま使える | detachされ無効化(byteLength=0) |
| 対象範囲 | 複製可能なほぼ全ての型 | ArrayBuffer・MessagePort 等の限定型のみ |
| 指定方法 | 既定(何もしない) | postMessageの第2引数/transfer オプション |
転送後に元の ArrayBuffer やビューを触ると、長さ0で空振りするか例外になります。送信側ではメッセージを送ったら元の参照を意図的に破棄し、再利用したいなら受信側から結果バッファを転送し返す「往復(ping-pong)」設計にします。グローバルにビューをキャッシュしたまま転送するのが最も事故りやすいパターンです。
postMessageとstructuredCloneの関係
structuredClone() と postMessage() は別物に見えて、同じ構造化複製アルゴリズムを共有する兄弟です。違いは「どこへ渡すか」と「同期か非同期か」にあります。
structuredClone(value, options)は同一 realm(同じスレッド・同じ JS 環境)内で同期的に深いコピーを返す関数です。options.transferに転送リストを渡せば、コピーの中で一部を転送扱いにもできます。postMessage(message, transferList)はスレッド/コンテキストの境界(メインスレッド⇄Worker、ウィンドウ間、MessagePort間)を越えてメッセージを送ります。送信時に構造化複製でシリアライズし、受信側で再構築(デシリアライズ)します。
つまり postMessage は「構造化複製+スレッド越えの配送」、structuredClone は「構造化複製だけを同期で取り出す API」と捉えると関係が整理できます。両者とも複製不可な値で DataCloneError を投げ、両者とも転送リストを受け付けます。同じアルゴリズムはService Workerのキャッシュでのメッセージ受け渡しや、IndexedDBへの値の保存でも使われており、「保存できる値=構造化複製できる値」という制約はストレージ層にも一貫しています。
(1)複製できる型と「prototype/関数/記述子は写らない」ことは別問題——型として可でも挙動は失われます。(2)コピー(既定)と転送(リスト指定)は別動作——転送した型だけが移動し、移動元はdetachされます。(3)structuredCloneとpostMessageはアルゴリズム共通で、違いは同期コピーかスレッド越え配送か、です。
まとめ
構造化複製はオブジェクトグラフを深くたどって複製するアルゴリズムで、Map/Set/Date/ArrayBuffer など広い型を扱い、内部の memory により循環参照と共有参照(エイリアス)を正しく保ちます。ただしコピーされるのは値だけで、関数・DOM ノードは DataCloneError になり、prototype・プロパティ記述子・getter/setter は失われます。Transferable(ArrayBuffer・MessagePort 等)はコピーではなく所有権の移動で、移動元は detach されゼロコピーで渡る代わりに使えなくなります。structuredClone と postMessage はこの同一アルゴリズムを共有し、前者は同一 realm の同期コピー、後者はスレッド境界を越える配送です。「型として複製できるか」「挙動は写らない」「コピーか移動か」の3点を分けて考えれば、Worker 通信の性能と落とし穴はほぼ説明・制御できます。
Web/フロントエンド Article
structuredCloneと転送可能オブジェクトの内部を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
structuredClone
比較で見る軸
難易度: advanced / カテゴリ: Web/フロントエンド / タグ数: 6
導入後に効く点
Transferable(ArrayBufferやMessagePort等)はコピーされず所有権ごと移動(ゼロコピー)し、移動元はdetachされて使えなくなる。postMessageの第2引数で明示する。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- Web/フロントエンド
- タグ数
- 6
判断チェックリスト
- 自社の用途が「structuredClone / Transferable」に近いか確認する。
- 強みである「構造化複製は深いコピーを作るアルゴリズムで、循環参照を解決でき、Map/Set/ArrayBuffer/Date等も複製するが、関数・DOMノード・プロトタイプ・プロパティ記述子は複製できない。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。