העתקה עמוקה ב-JavaScript באמצעות structuredClone

הפלטפורמה כוללת עכשיו את הפונקציה המובנית structuredClone()‎ להעתקה מעמיקה.

במשך זמן רב, כדי ליצור עותק מעמיק של ערך JavaScript, הייתם צריכים להשתמש בפתרונות זמניים ובספריות. עכשיו אפשר למצוא את הפלטפורמה עם structuredClone(), פונקציה מובנית להעתקה עמוקה.

תמיכה בדפדפן

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

מקור

העתקת ערך ב-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, בוליאני, לא מוגדר, סמל ו-null.

MDN – Primitive

ערכים לא פרימיטיביים מטופלים כהפניות, כלומר פעולת העתקת הערך היא למעשה רק העתקה של הפניה לאותו אובייקט בסיסי, וכתוצאה מכך מתקבלת התנהגות של העתקה רדודה.

עותקים עמוקים

ההפך מעתק שטחי הוא עותק מעמיק. אלגוריתם של עותק עמוק מעתיק גם את המאפיינים של אובייקט בזה אחר זה, אבל מפעיל את עצמו באופן רקורסיבי כשהוא מוצא הפניה לאובייקט אחר, וכך יוצר עותק של האובייקט הזה גם כן. זה יכול להיות חשוב מאוד כדי להבטיח ששני קטעי קוד לא ישתפו אובייקט בטעות ומשפיעים ללא כוונה אחד על השני.

בעבר לא הייתה דרך קלה או נחמדה ליצור עותק עמוק של ערך ב-JavaScript. אנשים רבים הסתמכו על ספריות של צד שלישי כמו הפונקציה cloneDeep() של Lodash. אפשר לטעון שהפתרון הנפוץ ביותר לבעיה הזו היה פריצה מבוססת-JSON:

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

למעשה, זו הייתה דרך פופולרית כל כך לעקיפת הבעיה, שב-V8 בוצע אופטימיזציה אגרסיבית של JSON.parse(), ובמיוחד של התבנית שלמעלה, כדי שהיא תפעל מהר ככל האפשר. למרות שהוא מהיר, יש בו כמה חסרונות ופונקציות:

  • מבני נתונים רפלקסיביים: JSON.stringify() יפיק שגיאה אם תספקו לו מבנה נתונים רפלקסיבי. מצב כזה יכול לקרות בקלות רבה כשעובדים עם רשימות מקושרות או עצים.
  • סוגי נתונים מובנים: הפונקציה JSON.stringify() תגרום להשלכה אם הערך מכיל סוגי נתונים מובנים אחרים של JS, כמו Map,‏ Set,‏ Date,‏ RegExp או ArrayBuffer.
  • פונקציות: JSON.stringify() תשליך את הפונקציות "מאחורי הקלעים".

שכפול מובנה

בפלטפורמה כבר הייתה צורך ביכולת ליצור עותקים מעמיקים של ערכים של JavaScript בכמה מקומות: כדי לאחסן ערך JS ב-IndexedDB, צריך סוג כלשהו של שרשור (serialization) כדי שניתן יהיה לאחסן אותו בדיסק, ולאחר מכן לבצע שרשור לאחור (deserialization) כדי לשחזר את ערך ה-JS. באופן דומה, כדי לשלוח הודעות ל-WebWorker דרך postMessage(), צריך להעביר ערך JS מתחום 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(). יופי.