structuredClone 深拷貝怎麼選:DataCloneError、class 與 transfer 限制
本文目錄
把 JSON.parse(JSON.stringify(value)) 換成 structuredClone(value),可以保留更多資料型別,卻不能把所有 JavaScript 物件原封不動複製。遇到 DataCloneError 或複製後的 format is not a function,先檢查資料裡是否混入函式、DOM 節點或自訂 class 行為。
structuredClone 適合建立可複製資料的獨立副本;需要保留物件行為時,應明確建立新實例。 transfer 則會讓原本的資源失效,不適合用在還要繼續讀取原資料的表單草稿。
深拷貝先確認你要保留什麼
展開運算子 { ...value } 只建立第一層新物件,巢狀物件仍共用參照。只更新某個欄位時,可以沿修改路徑逐層建立副本,不必為整份狀態做深拷貝;但若目的是讓使用者任意編輯一份獨立草稿,則要先盤點其中的型別。
下面使用普通資料物件示範,將整段存成 .mjs 後以 Node.js 執行:
import assert from 'node:assert/strict';
const source = { profile: { name: '小夏' }, createdAt: new Date('2026-10-04T00:00:00Z'), settings: new Map([['theme', 'dark']]), optional: undefined,};source.self = source;
const copy = structuredClone(source);copy.profile.name = '草稿';assert.equal(source.profile.name, '小夏');assert.notEqual(copy.profile, source.profile);assert.ok(copy.createdAt instanceof Date);assert.equal(copy.createdAt.getTime(), source.createdAt.getTime());assert.ok(copy.settings instanceof Map);assert.equal(copy.settings.get('theme'), 'dark');assert.ok(Object.hasOwn(copy, 'optional'));assert.equal(copy.self, copy);
// JSON 無法直接序列化這份循環資料。assert.throws(() => JSON.stringify(source), TypeError);console.log('data clone checks passed');MDN與 HTML Standard提供可複製/可轉移資料的規則。這裡檢查的是值、型別與參照關係;不能只看 console.log(copy) 外觀相似就判定成功。
Map 複製成功,為什麼用原物件 key 查不到?
Map 的物件 key 依身分比對,不是依欄位內容比對。structuredClone() 保留 Map 型別,也會複製其中可複製的物件 key;副本裡的新 key 不再是原本那個物件。MDN Map說明 key equality,而 HTML Standard使用同一次序列化的 memory map 保留物件圖內重複參照的關係。
這表示「將 key 與 Map 一起複製」和「分別呼叫兩次複製」會得到不同結果。以下限定普通物件與 Map,存成獨立 .mjs 執行:
import assert from 'node:assert/strict';
const key = { id: 'post-1' };const value = { title: '原稿' };const source = { key, lookup: new Map([[key, value]]) };
const copy = structuredClone(source);assert.ok(copy.lookup instanceof Map);assert.notEqual(copy.key, key);assert.equal(copy.lookup.has(key), false);assert.equal(copy.lookup.get(copy.key).title, '原稿');assert.equal([...copy.lookup.keys()][0], copy.key);copy.lookup.get(copy.key).title = '草稿';assert.equal(value.title, '原稿');
const separateKey = structuredClone(key);const separateMap = structuredClone(source.lookup);assert.equal(separateMap.has(separateKey), false);
// 跨副本需要相同查詢契約時,可改用穩定的字串 ID。const byId = structuredClone(new Map([[key.id, value]]));assert.equal(byId.get(key.id).title, '原稿');console.log('Map key 與物件圖身分檢查通過');不要因為 copy.lookup.get(key) 回傳 undefined,就用原 Map 補回副本,否則草稿又可能共用原始值。若後續操作都在同一份副本內,可使用 copy.key;若要跨儲存、請求或獨立副本查找,則明確改以穩定 ID 當 key,並處理 ID 唯一性。不能把任意物件先 JSON.stringify() 就當成通用身分規則。
本節由維護流程於 2026-11-02 在 Node.js v24.13.0 執行,確認同一物件圖中的別名關係、兩次複製的獨立身分與字串 ID 查詢;沒有測試 Worker 訊息傳遞、持久化資料庫或框架快取。
DataCloneError 先找不屬於資料的成員
任何一部分無法序列化,都可能讓整次複製失敗。函式、DOM 節點等不能直接用 structured clone 複製;若只是把 callback 與資料放進同一個物件,先拆開兩者,再複製真正需要的資料。
import assert from 'node:assert/strict';
assert.throws( () => structuredClone({ name: '篩選器', onSave() {} }), { name: 'DataCloneError' },);
const data = { name: '篩選器', selected: ['CSS'] };const actions = { onSave: (value) => value.selected.length };const draft = structuredClone(data);assert.equal(actions.onSave(draft), 1);不要 catch 後直接回傳原物件,也不要偷偷改用 JSON 複製。前者重新共用巢狀參照,後者可能改掉資料型別,呼叫端卻以為拿到同樣語意的副本。若資料不能通過複製,應回報失敗或使用已定義欄位的轉換函式。
class 看似複製成功,方法卻不在了
自訂 class 的原型鏈與 private elements 不會被複製;property descriptor、getter/setter 的描述也不會原樣保留。這些限制見 structured clone algorithm。
import assert from 'node:assert/strict';
class Draft { constructor(title) { this.title = title; } display() { return '草稿:' + this.title; }}
const original = new Draft('複製測試');const plain = structuredClone(original);assert.equal(plain.title, '複製測試');assert.equal(plain instanceof Draft, false);assert.equal(typeof plain.display, 'undefined');
// 明確定義還原邊界:這裡只允許 title。const restored = new Draft(plain.title);assert.equal(restored.display(), '草稿:複製測試');只補上 prototype 不會自動恢復 private fields、constructor 驗證或外部資源。要保留業務行為,就把可儲存資料與實例建立流程分開。若來源含 getter,也別假設讀取過程完全沒有副作用;先轉成由已知欄位組成的資料物件,再決定是否複製。
getter 不會保留下來,但複製時仍會執行
「getter 不會被複製」不代表複製過程不會呼叫它。HTML Standard 的一般物件序列化步驟會列出可列舉的自有字串屬性,再取得其值。來源的 getter 因而可能執行;副本取得的是這次回傳的值,屬性會成為普通資料屬性。
這也影響錯誤分類:getter 自己拋出的例外會讓複製失敗,不一定叫 DataCloneError。getter 回傳函式時,則會在序列化那個回傳值時遇到 DataCloneError。不能用「只處理 DataCloneError,其他一律視為成功」驗收草稿建立流程。
下面限定一般物件,存成獨立的 clone-properties.mjs 後執行 node clone-properties.mjs:
import assert from 'node:assert/strict';
let reads = 0;const symbolKey = Symbol('local');const source = { get title() { reads += 1; return '原始標題'; }, [symbolKey]: '符號欄位',};Object.defineProperty(source, 'hidden', { value: '不可列舉' });Object.defineProperty(source, 'fixed', { value: 7, enumerable: true, writable: false, configurable: false,});
const copy = structuredClone(source);assert.equal(reads, 1);assert.deepEqual(copy, { title: '原始標題', fixed: 7 });assert.equal(Object.hasOwn(copy, 'hidden'), false);assert.equal(Object.hasOwn(copy, symbolKey), false);assert.deepEqual(Object.getOwnPropertyDescriptor(copy, 'title'), { value: '原始標題', writable: true, enumerable: true, configurable: true,});copy.title = '草稿標題';copy.fixed = 8;assert.equal(reads, 1); // 修改副本沒有再次呼叫來源 getterassert.equal(source.fixed, 7);assert.equal(copy.fixed, 8);
const failure = new RangeError('getter failed');assert.throws(() => structuredClone({ get title() { throw failure; },}), (error) => error === failure);assert.throws(() => structuredClone({ get action() { return () => {}; },}), { name: 'DataCloneError' });console.log('getter、描述器與省略欄位檢查通過');不可列舉欄位與 Symbol key 在這個一般物件範例中被省略;這和把 Symbol 當成待複製的值而失敗是不同情況。若原始資料用它們保存還原實例所需的資訊,複製沒拋錯也不能代表資料完整。應列出業務必要欄位,逐欄斷言副本是否具備,而非只比較 JSON 輸出外觀。
建立草稿時,優先讓來源本來就是普通資料:例如在資料接收處明確產生 { title, selected }。但從已有 getter 的來源取 source.title,即使只是手動挑欄位仍會觸發讀取;展開運算子或改用 JSON 序列化也不能當成「不執行程式碼」的保證。若需要在讀值前辨識 accessor,可對自己控制的普通物件檢查 Object.getOwnPropertyDescriptor(),再決定拒絕或明確取值;這不是任意 Proxy 的安全檢查器。
本節 fixture 由維護流程於 2026-10-26 在 macOS、Node.js v24.13.0 執行通過,驗證的是一般物件 getter、欄位與描述器行為,沒有操作瀏覽器 DOM 或框架 Proxy。
transfer 會把原 buffer 交出去
一般複製保留來源;transfer 是移交可轉移資源。對 ArrayBuffer 而言,來源會被 detach。適用情境是你確定原路徑不再使用該 buffer,例如交給後續處理流程;不是加速所有深拷貝的通用旗標。
import assert from 'node:assert/strict';
const source = new Uint8Array([10, 20, 30]);const copied = structuredClone(source);assert.equal(source.byteLength, 3);assert.notEqual(copied.buffer, source.buffer);
const moved = structuredClone(source, { transfer: [source.buffer] });assert.deepEqual([...moved], [10, 20, 30]);assert.equal(source.byteLength, 0);assert.equal(source.buffer.byteLength, 0);transfer 清單不會把 buffer 自動加進結果
transfer 控制哪些資源移交;待複製的 value 才決定結果能讀到哪些資料。如果 buffer 只列在清單,沒有放在 value 中,來源仍會被 detach,結果卻沒有它。MDN Transferable objects明確提醒傳遞清單與訊息資料是兩件事。
這不是 DataCloneError,因此不能只在 catch 裡找原因。以下存成獨立的 .mjs 執行:
import assert from 'node:assert/strict';
const orphan = new Uint8Array([10, 20, 30]);const metadataOnly = structuredClone( { name: '匯出檔案' }, { transfer: [orphan.buffer] },);assert.equal(orphan.byteLength, 0);assert.deepEqual(Object.keys(metadataOnly), ['name']);assert.equal(Object.hasOwn(metadataOnly, 'bytes'), false);
// 把同一份 buffer 的 view 明確放進資料,結果才有可讀取的 bytes。const source = new Uint8Array([10, 20, 30]);const payload = { name: '匯出檔案', bytes: source };const received = structuredClone(payload, { transfer: [source.buffer] });assert.equal(source.byteLength, 0);assert.deepEqual([...received.bytes], [10, 20, 30]);assert.equal(received.name, '匯出檔案');
// TypedArray 本身可複製,但 transfer 要列它的 ArrayBuffer。const wrongTransfer = new Uint8Array([7]);assert.throws( () => structuredClone(wrongTransfer, { transfer: [wrongTransfer] }), { name: 'DataCloneError' },);assert.equal(wrongTransfer.byteLength, 1);console.log('transfer payload checks passed');實作匯出或 Worker 工作流時,先建立包含 bytes 的明確 payload,再從該欄位取得 transfer buffer;不要另外維護一份可能已經過期的資源清單。執行前確認原路徑不再使用它,執行後同時檢查來源已失效與接收端 bytes 正確。只看函式沒有拋錯,會漏掉上面第一種資料遺失。
這個例子只處理自己建立的 Uint8Array/ArrayBuffer,沒有替任意物件樹找出所有資源,也不適用於直接移交 Node.js 的 pooled Buffer。不同資源的可轉移規則應分別確認。
subarray 只切出 view,移交的仍是整份 buffer
從大陣列切出一小段交給下一階段,不能只看 chunk.byteLength 就決定 transfer。依 MDN subarray 文件,新 view 與來源共用相同的 ArrayBuffer;移交 chunk.buffer,會讓所有仍指向原 buffer 的 view 一起失去可用資料。
接收端的 view 雖然只顯示指定範圍,底層 buffer 仍可能包含前後其他 bytes。HTML Standard 的序列化演算法會保存 view 的 offset、長度與底層 buffer;structuredClone(chunk) 也不等於只複製那一小段。不要把 subarray 當成隔離其他資料的邊界。
以下限定自己建立的普通 Uint8Array 與固定長度 ArrayBuffer,存成獨立 .mjs 執行:
import assert from 'node:assert/strict';
const whole = new Uint8Array([90, 10, 20, 99]);const chunk = whole.subarray(1, 3);const sibling = whole.subarray(3);assert.equal(chunk.buffer, whole.buffer);
const clone = structuredClone(chunk);assert.deepEqual([...clone], [10, 20]);assert.equal(clone.byteOffset, 1);assert.deepEqual([...new Uint8Array(clone.buffer)], [90, 10, 20, 99]);assert.equal(whole.byteLength, 4);
const moved = structuredClone(chunk, { transfer: [chunk.buffer] });assert.deepEqual([...moved], [10, 20]);assert.deepEqual([...new Uint8Array(moved.buffer)], [90, 10, 20, 99]);assert.equal(whole.byteLength, 0);assert.equal(chunk.byteLength, 0);assert.equal(sibling.byteLength, 0);
// 要保留原始資料、只移交兩個 bytes,先建立獨立副本。const original = new Uint8Array([90, 10, 20, 99]);const isolated = original.subarray(1, 3).slice();assert.notEqual(isolated.buffer, original.buffer);const received = structuredClone(isolated, { transfer: [isolated.buffer] });assert.deepEqual([...received], [10, 20]);assert.equal(received.byteOffset, 0);assert.equal(received.buffer.byteLength, 2);assert.deepEqual([...original], [90, 10, 20, 99]);assert.equal(isolated.byteLength, 0);console.log('subarray ownership checks passed');這裡使用 TypedArray.slice()建立獨立副本,代價是先複製指定範圍;再移交的是這份副本。若同一份大 buffer 有其他使用者,先做這個隔離;若已確定整份資料都交出去,才直接 transfer。這個做法不涵蓋 Node.js Buffer.slice()、自訂 TypedArray subclass、SharedArrayBuffer 或可變長度 buffer,不能直接套用同名方法。
原本四組型別例子與 transfer payload 檢查曾於 2026-10-11 執行;2026-10-19 維護流程另重跑原例與上面的 subarray 測試,環境為 Node.js v24.13.0。沒有執行瀏覽器 DOM 節點、Web Worker 傳遞、框架 reactive proxy 或大型資料效能比較;那些情境要另外驗收,不能從小物件 assertion 推論速度與記憶體收益。
在會保留多份非同步結果的介面中,可搭配 Promise.allSettled 的部分失敗處理:先決定哪些結果要留下,再決定是否建立獨立副本。複製資料不能代替請求生命週期或狀態所有權的管理。
參考資料:
MDN:The structured clone algorithm
WHATWG:Safe passing of structured data