Menu

Suspense ו-lazy ב-React: מצבי טעינה ו-code splitting

Suspense מציג fallback בזמן שהקומפוננטות שבתוכו עוד לא מוכנות, ו-React.lazy טוען את הקוד של קומפוננטה רק כשהיא מתרנדרת לראשונה. למדו code splitting, גבולות מקוננים, Suspense עם use() ב-React 19, וטיפול בשגיאות.

בדף הזה יש עורכים שאפשר להריץ - לערוך, להריץ ולראות את הפלט מיד.

Suspense מציג fallback, כמו הודעת טעינה, בזמן שהקומפוננטות שבתוכו עוד לא מוכנות. React.lazy הוא הדבר הנפוץ ביותר לחכות לו: הוא טוען את הקוד של קומפוננטה רק כשהקומפוננטה מתרנדרת לראשונה, מה ששומר על ההורדה הראשונית קטנה.

לחצו על Show chart: Loading chart... מופיע לשנייה, ואז הגרף. הסתירו אותו והציגו שוב, והוא מופיע מיד בלי שורת לוג חדשה, כי lazy שומר את המודול שנטען.

code splitting עם lazy

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

import { lazy, Suspense } from 'react';

const Chart = lazy(() => import('./Chart.jsx'));

export default function Dashboard() {
    return (
        <Suspense fallback={<p>Loading chart...</p>}>
            <Chart />
        </Suspense>
    );
}

import('./Chart.jsx') מחזיר promise למודול. bundlers כמו Vite ו-webpack רואים את ה-import הדינמי ושמים את Chart.jsx ואת כל מה שרק הוא משתמש בו בקובץ נפרד, שמורד בפעם הראשונה ש-<Chart /> מתרנדר. כללים שכדאי לדעת:

  • המודול צריך default export. lazy קורא את המאפיין default של מה שה-promise מחזיר. ל-named export, מפו אותו: lazy(() => import('./charts.js').then((m) => ({ default: m.LineChart }))).
  • קראו ל-lazy ברמה העליונה של מודול. בתוך קומפוננטה הוא היה יוצר סוג קומפוננטה חדש בכל רינדור, כך ש-React הייתה עושה unmount לישנה, מאבדת את ה-state שלה וטוענת אותה שוב.
  • פצלו במקומות שבהם המשתמש מחכה בכל מקרה. routes, מודאלים, פאנלים שנפתחים לעיתים רחוקות ו-widgets כבדים (עורכים, גרפים, מפות) הם מועמדים טובים. פיצול של כל קומפוננטה קטנה מוסיף בקשות ומצבי טעינה בלי שום רווח.

טעינה מוקדמת לפני הלחיצה

קומפוננטה lazy מתחילה להיטען כשהיא מתרנדרת לראשונה, כך שהמשתמש תמיד מחכה לפחות להורדה אחת אחרי הלחיצה. אם אפשר לנחש שהלחיצה מגיעה, התחילו את ההורדה מוקדם יותר. שמרו את פונקציית ה-import במשתנה וקראו לה ב-hover או ב-focus; הדפדפן שומר את המודול, כך שכש-lazy קוראת לאותו import אחר כך, הוא מסתיים בלי הורדה שנייה.

const loadChart = () => import('./Chart.jsx');
const Chart = lazy(loadChart);

<button onMouseEnter={loadChart} onFocus={loadChart} onClick={() => setShow(true)}>
    Show chart
</button>

איך Suspense מחליט מה להציג

כשקומפוננטה בתוך <Suspense> לא מוכנה, היא עושה suspend: React מפסיקה לרנדר את החלק הזה ומציגה את ה-fallback של ה-Suspense הקרוב ביותר מעליה. כל מה שבתוך הגבול הזה מוחלף ב-fallback, לא רק הקומפוננטה שמחכה. כשהדבר שהיא חיכתה לו מוכן, React מרנדרת שוב את התוכן ומחליפה את ה-fallback.

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

קודם כל הדף מציג Loading page..., כי Header שייכת לגבול החיצוני. ברגע שהכותרת מוכנה, המאמר מופיע עם Loading comments... מתחתיו, והתגובות מגיעות אחרונות. שנו את 1500 ל-3000 והתצוגה המקדימה נטענת מחדש עם המתנה ארוכה יותר רק לתגובות. מחקו את ה-<Suspense> הפנימי (השאירו את <Comments />) והדף מחכה לתגובות לפני שהוא מציג משהו.

Suspense עם use() ב-React 19

ב-React 19 קומפוננטה יכולה לקרוא promise עם use(promise). אם ה-promise עדיין ממתין, הקומפוננטה עושה suspend וה-Suspense הקרוב מציג את ה-fallback שלו; כשהוא מסתיים, use מחזיר את הערך. הדף על ההוק use מכסה את זה במלואו. ה-fetchUser המזויף שמתחת מחליף בקשה אמיתית.

לחצו על המשתמשים אחד אחרי השני, ואז חזרו ל-User 1: הוא מופיע מיד וה-Console לא רושם fetch חדש, כי ה-promise כבר נמצא ב-cache.

למה ה-promise חייב להישמר ב-cache

מפת ה-cache היא לא אופטימיזציה כאן, היא חובה. קומפוננטה שעושה suspend לא שומרת שום דבר מהניסיון הזה: כשה-promise מסתיים, React מרנדרת אותה שוב מההתחלה. אם Profile הייתה קוראת ל-fetchUser(id) ישירות, כל ניסיון היה יוצר promise חדש, מתחיל בקשה חדשה ועושה עליה suspend שוב. הפרופיל אף פעם לא מופיע, וה-Console מתמלא בשורות fetching user. לכן ה-promise חייב להגיע ממקום שחי יותר מהרינדור:

  • cache לפי הבקשה, כמו המפה שלמעלה (ספריות נתונים כמו TanStack Query ו-loaders של frameworks עושים את זה בשבילכם);
  • הורה שיוצר את ה-promise פעם אחת, ב-event handler או ב-Server Component, ומעביר אותו מטה כ-prop.

שימו לב שכל משתמש חדש עדיין מחליף את הפרופיל ב-fallback. אם אתם מעדיפים להשאיר את המשתמש הישן על המסך עד שהבא מוכן, עטפו את העדכון ב-transition: startTransition(() => setId(n)). React לא מסתירה תוכן שכבר גלוי בשביל transition (ראו useTransition).

שגיאות צריכות error boundary

Suspense מטפל בהמתנה, לא בכישלון. אם import עצל נכשל (המשתמש התנתק מהרשת, deploy חדש הסיר את ה-chunk הישן) או ש-promise שהועבר ל-use נכשל, React זורקת את השגיאה ל-error boundary הקרוב. בלי אחד כזה, כל העץ מתחת ל-root עושה unmount. error boundaries הם עדיין קומפוננטות class (ראו error boundaries):

<ErrorBoundary fallback={<p>Could not load the chart.</p>}>
    <Suspense fallback={<p>Loading chart...</p>}>
        <Chart />
    </Suspense>
</ErrorBoundary>

מה Suspense לא מזהה

Suspense מגיב רק לקומפוננטות שעושות suspend: קומפוננטות lazy, use(promise), ומקורות נתונים שנבנו בשביל Suspense (loaders של frameworks, ספריות שתומכות ב-Suspense). fetch בתוך useEffect שקובע state כשהוא מסתיים לא עושה suspend, ולכן גבול Suspense סביבו אף פעם לא מציג את ה-fallback שלו. בדפוס הזה מחזיקים state loading משלכם, כמו שמוצג בדף על טעינת נתונים.

שאלות נפוצות

מה זה React Suspense?

<Suspense fallback={...}> היא קומפוננטה שמציגה את ה-fallback שלה בזמן שקומפוננטה כלשהי בתוכה מחכה למשהו, כמו קוד שנטען בעצלות או נתונים שנקראים עם use. כשכל מה שבפנים מוכן, React מחליפה את ה-fallback בתוכן.

מה React.lazy עושה?

lazy(() => import('./Chart.jsx')) יוצר קומפוננטה שהקוד שלה מורד בפעם הראשונה שהיא מתרנדרת. bundlers שמים את הקובץ הזה ב-chunk נפרד, כך שהדף הראשוני טוען פחות JavaScript.

האם Suspense עובד לטעינת נתונים?

כן, כשמקור הנתונים תומך בזה. ב-React 19 קומפוננטה יכולה לקרוא promise עם use(promise) ולעשות suspend עד שהוא מסתיים. frameworks כמו Next.js גם משלבים את Suspense בטעינת הנתונים שלהם. fetch בתוך useEffect לא מפעיל Suspense.

איך מטפלים בשגיאות עם Suspense?

Suspense מטפל רק בהמתנה. אם import עצל או promise נכשלים, השגיאה הולכת ל-error boundary הקרוב, אז עטפו את גבול ה-Suspense (או את ההורה שלו) באחד כזה.

איפה לקרוא ל-lazy?

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

איור של שפות התכנות ב-Coddy

ללמוד תכנות עם Coddy

להתחיל