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) 外觀相似就判定成功。
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,也別假設讀取過程完全沒有副作用;先轉成由已知欄位組成的資料物件,再決定是否複製。
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