使用 StructuredClone 在 JavaScript 中進行深度複製

平台現在隨附 structuredClone(),這是用於深層複製的內建函式。

長久以來,您必須使用變通方法和程式庫,才能建立 JavaScript 值的深層副本。平台現在隨附 structuredClone(),這是用於深層複製的內建函式。

Browser Support

  • Chrome: 98.
  • Edge: 98.
  • Firefox: 94.
  • Safari: 15.4.

Source

淺層副本

在 JavaScript 中複製值幾乎一律是「淺層」,而非「深層」。這表示對深層巢狀值的變更會同時顯示在副本和原始物件中。

在 JavaScript 中,使用物件展開運算子 ... 建立淺層副本的方法如下:

const myOriginal = {
  someProp: "with a string value",
  anotherProp: {
    withAnotherProp: 1,
    andAnotherProp: true
  }
};

const myShallowCopy = {...myOriginal};

直接在淺層副本上新增或變更屬性,只會影響副本,不會影響原始物件:

myShallowCopy.aNewProp = "a new value";
console.log(myOriginal.aNewProp)
// ^ logs `undefined`

不過,新增或變更深層巢狀屬性會同時影響副本和原始物件:

myShallowCopy.anotherProp.aNewProp = "a new value";
console.log(myOriginal.anotherProp.aNewProp) 
// ^ logs `a new value`

運算式 {...myOriginal} 會使用展開運算子,疊代 myOriginal 的 (可列舉) 屬性。它會使用屬性名稱和值,並將其逐一指派給新建立的空白物件。因此,產生的物件形狀相同,但有自己的屬性和值清單副本。值也會複製,但 JavaScript 值處理所謂原始值的方式與非原始值不同。引用 MDN 的說明:

在 JavaScript 中,原始值 (原始值、原始資料類型) 是指非物件且沒有方法的資料。原始資料類型有七種:字串、數字、bigint、布林值、未定義、符號和空值。

MDN — Primitive

非原始值會視為「參照」,也就是說,複製值的行為實際上只是複製相同基礎物件的參照,因此會產生淺層複製行為。

深層副本

與淺層複製相反的是深層複製。深層複製演算法也會逐一複製物件的屬性,但如果找到另一個物件的參照,就會遞迴叫用自身,並建立該物件的副本。這點非常重要,可確保兩段程式碼不會意外共用物件,並在不知情的情況下操控彼此的狀態。

過去在 JavaScript 中,沒有簡單或理想的方法可以建立值的深層副本。許多人會依賴第三方程式庫,例如 Lodash 的 cloneDeep() 函式。這個問題最常見的解決方法,應該是採用 JSON 形式的駭客攻擊:

const myDeepCopy = JSON.parse(JSON.stringify(myOriginal));

事實上,這種解決方法非常受歡迎,因此 V8 積極最佳化 JSON.parse(),尤其是上述模式,盡可能提高速度。雖然速度很快,但也有一些缺點和陷阱:

  • 遞迴資料結構:如果提供遞迴資料結構,JSON.stringify() 會擲回。處理連結清單或樹狀結構時,很容易發生這種情況。
  • 內建類型:如果值包含其他 JS 內建類型 (例如 Map、Set、Date、RegExp 或 ArrayBuffer),JSON.stringify() 會擲回錯誤。
  • 函式:JSON.stringify() 會自動捨棄函式。

結構化複製

平台已經需要在多個位置建立 JavaScript 值的深層副本:在 IndexedDB 中儲存 JS 值需要某種形式的序列化,才能儲存在磁碟上,並在稍後反序列化以還原 JS 值。同樣地,透過 postMessage() 將訊息傳送至 WebWorker 時,必須將 JS 值從一個 JS 領域轉移至另一個領域。用於這項作業的演算法稱為「結構化複製」,直到最近,開發人員才容易存取。

但現在情況已有改變!HTML 規格經過修訂,公開名為 structuredClone() 的函式,該函式會執行該演算法,讓開發人員輕鬆建立 JavaScript 值的深層副本。

const myDeepCopy = structuredClone(myOriginal);

這樣就大功告成了!這就是整個 API。如要深入瞭解細節,請參閱 MDN 文章。

功能與限制

結構化複製可解決 JSON.stringify() 技術的許多缺點 (但並非全部)。結構化複製可處理循環資料結構、支援多種內建資料類型,而且通常更穩健、更快速。

不過,這項功能仍有一些限制,可能會讓你措手不及:

  • 原型:如果將 structuredClone() 與類別執行個體搭配使用,會取得一般物件做為傳回值,因為結構化複製會捨棄物件的原型鏈。
  • 函式:如果物件包含函式,structuredClone() 會擲回 DataCloneError 例外狀況。
  • 不可複製:部分值無法結構化複製,最明顯的是 Error 和 DOM 節點。這會導致 structuredClone() 擲出。

如果上述任何限制會影響您的用途,Lodash 等程式庫仍會提供其他深層複製演算法的自訂實作,可能適合您的用途。

效能

雖然我尚未進行新的微基準比較,但我在 2018 年初 (structuredClone() 公開前) 進行了比較。當時,JSON.parse() 是傳送極小物件最快的選項。我預期這點不會改變。對於較大的物件,依賴結構化複製的技術速度 (明顯) 較快。考量到新的 structuredClone() 不會產生濫用其他 API 的負擔,且比 JSON.parse() 更強大,建議您將其設為建立深層副本的預設方法。

結論

如果您需要在 JS 中建立值的深層副本 (可能是因為您使用不可變動的資料結構,或想確保函式可以操控物件而不影響原始物件),就不再需要使用變通方法或程式庫。JS 生態系統現在有 structuredClone()。太棒了!