העתקה עמוקה ב-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, צריך סוג כלשהו של שרשור כדי שניתן יהיה לאחסן אותו בדיסק ולאחר מכן לבצע שרשור לאחור כדי לשחזר את ערך ה-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(). יופי.