צרות עם שינויים באוביקטים

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

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

1. הקוד עם הטעות

הקוד הבא מציג כפתור שמוסיף תיבת טקסט. מתחת לכל תיבות הטקסט מופיע אוביקט JSON המסכם את תוכן כל התיבות. אבל הבעיה שכל פעם שמשנים תוכן של תיבה משתנה התוכן של כל השדות באוביקט (ולא רק זה שמתאים לתיבה ששינינו):

ועבור מי שקורא את זה במייל והאייפריים של קודפן לא מופיע הנה הקוד המלא:

const template = (name) => `
  <input type='text' value="${name}" />
`;

const NEW_ITEM = { name: 'New Item...' };
const items = [];

function addItem(item) {
  const id = items.length;

  items.push(NEW_ITEM);
  const newChild = document.createElement('div');
  newChild.innerHTML = template(items.slice(-1)[0].name);
  newChild.addEventListener('input', (e) => setItemName(id, e.target.value));
  container.appendChild(newChild);
  showItems();
}

function setItemName(id, value) {
  items[id].name = value;
  showItems();
}

function showItems() {
  document.querySelector('#items').innerHTML = JSON.stringify(items);
}

document.querySelector('#btn-add').addEventListener('click', addItem);

2. מה קרה כאן

התקלה היא בשורה הבאה מהקוד:

items.push(NEW_ITEM);

ניזכר רגע איך מוגדר NEW_ITEM:

const NEW_ITEM = { name: 'New Item...' };

הדבר המדהים הראשון זה שהתיקון בסך הכל דורש לוותר על המשתנה NEW_ITEM, כלומר במקום שתי השורות לכתוב רק את השורה:

items.push({ name: 'New Item...' });

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

אחדד את זה באמצעות דוגמא נוספת:

const a = { foo: 10 };
const b = a;

a.foo = 20;
console.log(b.foo);

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

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

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

3. תיקון והשארת המשתנה

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

הפיתרון ב JavaScript הוא לשכפל את האוביקט באמצעות הפונקציה Object.assign. זה נראה כך:

items.push(Object.assign({}, NEW_ITEM));

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

4. תבנית טובה יותר

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

נתבונן בגירסא הבאה:

const NEW_ITEM = () => ({ name: 'New Item...' });

// and inside addItem function
items.push(NEW_ITEM());

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