JavaScript ו-TypeScript הופכות את ההתייחסות ל-references של אובייקטים כדבר שניתן להחליף בקלות לפעולה מסוכנת. אתם יוצרים אובייקט, מעבירים אותו לפונקציה, שומרים אותו ב-cache, ומאוחר יותר מחליפים את המשתנה במופע (instance) חדש. השפה לא מתלוננת. ה-reference הישן עדיין קיים במקום אחר בתוכנית שלכם, ומצביע על נתונים שכבר אינם עדכניים. זה לא קריסה. זה משהו גרוע יותר: סטייה שקטה בין שני חלקים בבסיס הקוד שלכם, ששניהם חושבים שהם מחזיקים באמת היחידה.
המשמעת שמונעת זאת נקראת Hard Object References. זו לא ספרייה ולא תכונה של קומפיילר. זה חוזה שאתם אוכפים על פני הקוד שלכם.
בעיית ה-Stale Alias
Stale alias קורה כאשר מודול אחד מחזיק reference לאובייקט, בעוד מודול אחר מחליף את האובייקט הזה באובייקט חדש. ה-reference הראשון עדיין נחשב לקוד תקין, אך הוא כבר לא מצביע על הנתונים העדכניים.
דמיינו רשומת משתמש באפליקציית ווב טיפוסית:
const user = {
name: "Alice",
address: {
city: "Seoul",
country: "KR"
}
};
רכיב משלוחים קולט את הכתובת בשלב מוקדם:
const shippingAddress = user.address;
מאוחר יותר, מגיעה עדכון פרופיל. reducer או service handler מחליט להחליף את האובייקט כולו:
user.address = { city: "Tokyo", country: "JP" };
בנקודה זו, user.address מצביע על טוקיו. אך shippingAddress עדיין מצביע על האובייקט הישן בסיאול. לא נזרקת שגיאה. TypeScript מרוצה כי הטיפוסים עדיין תואמים. ממשק המשתמש (UI) עשוי להציג את העיר המעודכנת בדף הפרופיל, בעוד שתווית המשלוח תדפיס בשקט את הכתובת הישנה. הבאג צץ רק כאשר משתמש מתלונן שהחבילה שלו הגיעה למדינה הלא נכונה.
זה קורה מכיוון ש-JavaScript מפרידה בין זהות (identity) לבין ערך (value). כשאתם מחליפים מאפיין של אובייקט ב-object literal חדש, אתם שוברים את השרשרת. האובייקט הישן לא נהרס; הוא פשוט נותר יתום. כל מי שעדיין מחזיק בו עובד עם "רוח רפאים".
מה המשמעות של Hard Object References
הכלל הוא פשוט: החליפו ערכים פרימיטיביים, אך לעולם אל תחליפו references של אובייקטים או מערכים. כשמגיעים נתונים חדשים, העתיקו אותם לתוך המכולה (container) הקיימת במקום להחליף את המכולה עצמה.
זה דורש שלוש הרגלים קונקרטיים.
ראשית, הצהירו על אובייקטים ומערכים באמצעות const. זה מסיר את הפיתוי לקשור מחדש (rebind) את המשתנה ברמת ה-top-level למופע חדש. המשתנה צריך להישאר קבוע לאורך כל חיי ה-scope.
שנית, לעולם אל תחליפו מאפיין שמחזיק אובייקט או מערך באחד שנוצר זה עתה. אם אתם צריכים לעדכן כתובת, בצעו מוטציה (mutate) למאפיינים שבתוכה.
שלישית, אם עליכם לנקות או לאפס מצב (state), רוקנו את המבנה הקיים במקום להשליך אותו לטובת אובייקט או מערך ריק חדש.
בחזרה לדוגמת הכתובת, העדכון הנכון נראה כך:
user.address.city = "Tokyo";
user.address.country = "JP";
אם הנתונים הנכנסים הם חלקיים או דינמיים, השתמשו ב-Object.assign כדי לכתוב לתוך היעד הקיים:
Object.assign(user.address, incomingAddressData);
המשתנה shippingAddress, שמצביע על אותו אובייקט בדיוק בזיכרון, רואה כעת את השדות החדשים באופן מיידי. קיים רק אובייקט קנוני אחד המשמש כמקור האמת החי.
איפה זה הכי חשוב
המשמעת הזו עשויה להיראות מוגזמת עבור אובייקט קונפיגורציה שטוח. היא הופכת לחיונית ברגע שה-state שלכם צומח לגרף שבו תתי-מערכות מרובות מחזיקות מצביעים (pointers) לצמתים (nodes) חופפים.
שקלו עורך טקסט עשיר (rich text editor). מודל המסמך הוא עץ של צמתים. מודל הבחירה (selection model) מחזיק references לצמתי ההתחלה והסוף. חוצץ ההיסטוריה (history buffer) מחזיק references לצמתים שהשתנו בפעולה האחרונה. שכבת הרינדור (rendering layer) מחזיקה references לצמתים אותם מדדה לצורך פריסה (layout). אם מנהל המצב (state manager) מחליף צומת פסקה באובייקט חדש בגלל שהטקסט שלו השתנה, כל אחד מתתי-המערכות הללו מחזיק כעת stale alias. הבחירה מדגישה אזור לא נכון. מערכת ההיסטוריה לא יכולה לבצע Undo בצורה נכונה. הרינדור קורס, או גרוע מכך, מציג סמנים (cursors) רפאים.
אותו סיכון מופיע בפרופילי משתמשים עם הגדרות והרשאות מקוננות (nested) שמוצגות על ידי ה-UI, שכבת בקרת הגישה ושגרת השמירה האוטומטית. זה מופיע במנועי פריסה (layout engines) שבהם מכולות אב (parent containers) שומרות ב-cache מדידות של צמתי בן. זה מופיע בעורכי ויזואליים ובכלי canvas שבהם בקרת זמן-ריצה עוקבת אחר ישויות פעילות באמצעות reference. בכל התחומים הללו, רכיבים תופסים ידית (handle) לאובייקט ומצפים שהידית הזו תישאר תצוגה חיה של האמת.
Hard Object References מתייחסים לאובייקט כאל כתובת יציבה. הרהיטים שבתוכו יכולים להשתנות, אך הדלת נשארת באותו מקום. כל מי שמחזיק בכתובת יכול להיכנס ולראות את הסידור הנוכחי.
ריאקטיביות במקום החלפה
אם עבדת עם Redux או ספריות state בלתי-משתנות (immutable) דומות, המודל הזה כנראה נשמע הפוך. במערכות כאלה, שינוי מסומן על ידי יצירת אובייקט חדש. שינוי ההפניה הוא הסימן. רכיבים משווים בין prevProps.data === nextProps.data כדי לדעת אם לבצע רינדור מחדש.
Hard Object References מחייבות אתכם להפוך את ההנחה הזו. מכיוון שההפניה נשארת קבועה, שוויון הפניות (reference equality) לא אומר לכם דבר על כך שהנתונים השתנו. אתם זקוקים לדרך אחרת להפצת עדכונים.
בפועל, זה אומר להסתמך על מערכות ריאקטיביות, observers מפורשים, או dirty flags. שינוי (Mutating) של user.address.city יכול להפעיל setter שמודיע ל-subscribers. אובייקט יכול לשדר אירוע שינוי דרך event bus. לולאת משחק (game loop) או כלי canvas עשויים להגדיר dirty flag גלובלי ולסרוק מחדש את הגרף בסוף ה-frame. ההפניה יציבה, לכן עליכם להפוך את זרימת הנתונים לנראית באמצעות מנגנונים אחרים.
השינוי הארכיטקטוני הזה הוא הסיבה שהגישה מתאימה ביותר ל-frontend state מורכב, ל-state מקומי גדול של רכיבים, לעורכים ויזואליים, לכלי canvas ול-runtime controllers. מערכות אלו כבר מסתמכות על עדכונים גרנולריים, שינויים ישירים (direct mutations) או imperative APIs. כפיית immutability מעליהן יוצרת לעיתים קרובות עומס הקצאות (allocation pressure) מוגזם ותנודות בהפניות (reference churn) מבלי להשיג בהתאמה בהירות מוגברת. כשכל frame קובע, הקצאת גרף אובייקטים חדש רק כדי להזיז סליידר היא בזבוז. שמירה על הפניה קשיחה ושינוי התוכן הפנימי תואמים את המכניקה האמיתית של הבעיה.
להפוך את זה לקבוע
אחד היתרונות שמתעלמים מהם בכלל זה הוא
