הפונקציות useMemo ו useCallback ב React Hooks


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

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

1. מהם Render-ים מיותרים

נתבונן בקוד הבא:

המערכת מציגה על המסך קומפוננטה בשם Counter המורכבת ממספר תתי-קומפוננטות. כל לחיצה על הכפתור משנה את ה State, וכמו שאפשר לראות כשנכנסים לקונסול - כל לחיצה על הכפתור גם מפעילה את פונקציית ה render של כל תתי הקומפוננטות.

באמצעות טכניקות Memoization נוכל לבטל חלק גדול מהקריאות האלה:

  1. הקומפוננטה Header היא קבועה. אין שום סיבה לקרוא ל render שלה יותר מפעם אחת.

  2. הקומפוננטה DisplayMod5 משתנה רק כשערך החלוקה ב-5 של המספר משתנה (מתחלק או לא מתחלק ב-5). אין טעם להפעיל את ה render שלה בשינוי למשל מ-2 ל-3.

  3. הקומפוננטה DisplayValue משתנה כל פעם שלוחצים על הכפתור, אבל אין טעם לקרוא ל render שלה כשרק משנים את הערך של delta בתיבת הטקסט.

  4. הקומפוננטה MyButton מושפעת רק מפונקציית ה inc. היא צריכה להשתנות לכן רק כשערך ה delta משתנה ואיתו פונקציית ה inc.

2. שימוש ב React.memo כדי לצמצם render-ים מיותרים

הדרך הראשונה והקלה ביותר לצמצם קריאות מיותרות ל render היא הפונקציה React.memo. פונקציה זו מקבלת פקד ומחזירה פקד חדש שירונדר רק אם היה שינוי ב props. זה אומר שבמקום קוד ה Header בעמוד נוכל לכתוב:

const Header = React.memo(function Header(props) {
  console.count("Header.render");

  return <h1>My Counter Demo</h1>;
});

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

פקד נוסף שאפשר לתקן אותו בצורה כזו הוא DisplayMod5: הפעם אנחנו נרצה להעביר כפרמטר שני ל memo פונקציית השוואה - אם פונקציה זו תחזירה true זה אומר שלא היה שינוי משמעותי ב props ולא צריך לרנדר מחדש:

function compareMod5(oldProps, newProps) {
  const oldValue = oldProps.val;
  const newValue = newProps.val;

  return (oldValue % 5 === 0) === (newValue % 5 === 0);
}

const DisplayMod5 = React.memo(function DisplayMod5(props) {
  console.count("DisplayMod5.render");

  const { val } = props;
  const text =
    val % 5 === 0 ? "Value is divisible by 5" : "Value does not divide by 5";

  return <p>{text}</p>;
}, compareMod5);

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

3. שימוש ב useCallback כדי לצמצם render-ים מיותרים

נמשיך לדבר על MyButton. תחילה נוסיף גם לו React.memo כדי לבטל את הרנדרים החוזרים שלו:

const MyButton = React.memo(function MyButton(props) {
  console.count("MyButton.render");
  return <button onClick={props.onClick}>Click Me</button>;
});

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

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

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

במקרה שלנו הפונקציה inc תלויה ב setCount וב delta ולכן נכתוב:

  const inc = useCallback(
    function inc() {
      setCount(val => val + delta);
    },
    [setCount, delta]
  );

וזה כבר תיקן את הבעיה ובאמת אנחנו יכולים לראות בקונסול שהקריאות ל MyButton.render נעלמו.

4. שימוש ב useMemo כדי לצמצם render-ים מיותרים

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

זה אומר שבאמצעות useMemo אנחנו יכולים ליישם את ההתנהגות של React.memo מתוך הפקד העוטף Counter בלי שינוי בפקדים המקוריים, וגם אנחנו מקבלים כתיב קצת יותר קצר בהשוואה להגדרת פונקציה השוואה נפרדת.

בואו נתקן את DisplayValue באמצעות useMemo. אז אני מזכיר שהבעיה שלנו היא ש DisplayValue מרונדר מחדש גם כשאנחנו לא משנים את הערך, אלא משנים רק את ה delta. דרך אחת שכבר ראינו לתקן את זה היתה להגדיר את DisplayValue בתוך React.memo, ולהשתמש בפונקציית השוואה שתחזיר true אם ה value נשאר קבוע.

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

הקוד נראה כך:

  const displayValue = useMemo(() => <DisplayValue val={count} />, [count]);

  return (
    <>
      <Header />
      <label>
        Increase by:
        <input
          type="number"
          value={delta}
          onChange={e => setDelta(Number(e.target.value))}
        />
      </label>
      {displayValue}
      <DisplayMod5 val={count} />
      <MyButton onClick={inc} />
    </>
  );

את הקוד המלא עם כל השינויים תוכלו למצוא בקודסנדבוקס בקישור כאן: https://codesandbox.io/s/xenodochial-shaw-rotyk