2907 字
15 分鐘

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); // 修改副本沒有再次呼叫來源 getter
assert.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:structuredClone()

MDN:Map key equality

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