1858 字
9 分鐘

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:structuredClone()

MDN:The structured clone algorithm

WHATWG:Safe passing of structured data

MDN:Transferable objects

MDN:TypedArray.prototype.subarray()

MDN:TypedArray.prototype.slice()

structuredClone 深拷貝怎麼選:DataCloneError、class 與 transfer 限制
https://laplusda.com/posts/javascript-structuredclone-datacloneerror/
作者
Zero
發佈於
2026-10-04
許可協議
CC BY-NC-SA 4.0