שיטות להאצת הטעינה של אפליקציית אינטרנט, גם בטלפון נייד פשוט

איך השתמשנו בפיצול קוד, בהטבעת קוד וברינדור בצד השרת ב-PROXX.

ב-Google I/O 2019 Mariko, Jake and I שלח את PROXX, שכפול מודרני של שולה המוקשים לאינטרנט. ההבדל בין PROXX הוא ההתמקדות בנגישות (אפשר לשחק בו בעזרת קורא מסך!) וביכולת לפעול בטלפון נייד פשוט כמו במחשב מתקדם. טלפונים ניידים מוגבלים במספר דרכים:

  • מעבדים חלשים
  • מעבדי GPU חלשים או לא קיימים
  • מסכים קטנים ללא קלט מגע
  • כמויות מוגבלות מאוד של זיכרון

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

גיימפליי PROXX.

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

זהו החלק הראשון מתוך סדרה של שני חלקים. חלק 1 מתמקד בביצועי הטעינה, וחלק 2 יתמקד בביצועים בזמן הריצה.

תיעוד הסטטוס קוו

חשוב מאוד לבדוק את ביצועי הטעינה במכשיר אמיתי. אם אין לך מכשיר אמיתי בהישג יד, אני ממליץ על WebPageTest, ובמיוחד על הפשוטים הגדרה. WPT מריץ סוללה של טעינת בדיקות במכשיר אמיתי עם אמולציה של חיבור 3G.

רשת 3G היא מהירות טובה למדידה. יכול להיות שאתם כבר רגילים לרשת 4G, ל-LTE או בקרוב אפילו לרשת 5G, אבל המציאות של האינטרנט בנייד נראית אחרת. למשל, ברכבת, בכנס, בהופעה או בטיסה. מה שתחוו שם יהיה קרוב יותר לרשת 3G, ולפעמים אפילו יותר גרוע.

עם זאת, נתמקד ברשת 2G במאמר הזה כי PROXX מטרגט במפורש טלפונים ניידים פשוטים ושווקים מתעוררים בקהל היעד שלו. אחרי ש-WebPageTest יריץ את הבדיקה, תקבלו Waterfall (דומה לזה שמופיע בכלי הפיתוח) וכן רצועת תמונות בחלק העליון של הדף. ברצועת השקפים מוצג מה שהמשתמש רואה בזמן שהאפליקציה נטענת. ב-2G, חוויית הטעינה של הגרסה הלא מותאמת של PROXX די גרועה:

הסרטון ברצועת התמונות מראה מה המשתמש רואה כש-PROXX נטען במכשיר אמיתי פשוט באמצעות אמולציה של חיבור 2G.

בטעינה דרך 3G, המשתמש רואה 4 שניות של אפס לבן. יותר מ-2G המשתמשים לא רואים כלום במשך יותר מ-8 שניות. אם תקראו למה הביצועים חשובים, אתם יודעים שאיבדנו חלק משמעותי מהמשתמשים הפוטנציאליים שלנו בגלל חוסר שביעות רצון. המשתמש צריך להוריד את כל 62KB של ה-JavaScript כדי שמשהו יופיע על המסך. היתרון בתרחיש הזה הוא שהדבר השני שמופיע במסך הוא גם אינטראקטיבי. או שלא?

[הציור המשמעותי הראשון][FMP] בגרסה הלא מותאמת של PROXX הוא _Technically_ [אינטראקטיבי][TTI], אבל הוא לא שימושי למשתמש.

לאחר ההורדה של כ-62KB מתוך gzip'd JS ונוצר ה-DOM, המשתמש יכול לראות את האפליקציה שלנו. האפליקציה היא טכנית אינטראקטיבית. עם זאת, התבוננות החזותית מראה מציאות שונה. גופני האינטרנט עדיין נטענים ברקע ועד שהם יהיו מוכנים, המשתמש לא יוכל לראות טקסט. המצב הזה מוגדר כשלב ראשוני משמעותי (FMP), אבל הוא בהחלט לא נחשב כאינטראקטיבי בצורה תקינה, כי המשתמש לא יכול לדעת במה עוסק הקלט. לרשת 3G נדרשות עוד שנייה ו-3 שניות ב-2G עד שהאפליקציה מוכנה. סה"כ, לאפליקציה צריך 6 שניות כדי להתחבר לרשת 3G ו-11 שניות ברשת 2G כדי להפוך לאינטראקטיבית.

ניתוח Waterfall

עכשיו, אחרי שאנחנו יודעים מה המשתמש רואה, אנחנו צריכים להבין למה. לכן, אנחנו יכולים לבחון את ה-Waterfall ולנתח למה משאבים נטענים מאוחר מדי. במעקב 2G של PROXX, אפשר לראות שני נורות אדומות עיקריות:

  1. יש מספר קווים דקים וצבעוניים.
  2. קובצי JavaScript יוצרים שרשרת. לדוגמה, המשאב השני מתחיל להיטען רק לאחר שהמשאב הראשון מסיים, והמשאב השלישי מתחיל רק כשהמשאב השני מסיים.
מערכת ה-Waterfall מאפשרת לכם להבין אילו משאבים נטענים, מתי וכמה זמן הם נמשכים.

מספר החיבורים בירידה

כל קו דק (dns, connect, ssl) מייצג יצירה של חיבור HTTP חדש. הגדרת חיבור חדש כרוכה בתשלום של שנייה אחת ברשת 3G, ובערך 2.5 שניות ברשת 2G. ב-Waterfall שלנו אנחנו רואים חיבור חדש עבור:

  • בקשה ראשונה: index.html
  • בקשה מס' 5: סגנונות הגופן של fonts.googleapis.com
  • בקשה מס' 8: Google Analytics
  • בקשה מס' 9: קובץ גופן מ-fonts.gstatic.com
  • בקשה מס' 14: המניפסט של אפליקציית האינטרנט

החיבור החדש אל index.html הוא בלתי נמנע. הדפדפן צריך ליצור חיבור לשרת שלנו כדי לקבל את התוכן. אפשר היה למנוע את החיבור החדש ל-Google Analytics על ידי הגדרה של משהו כמו ניתוח נתונים מינימלי, אבל Google Analytics לא מונע את עיבוד האפליקציה שלנו או להפוך אותה לאינטראקטיבית, ולכן לא ממש חשוב לנו כמה מהר היא נטענת. במצב אידיאלי, כדאי לטעון את Google Analytics בזמן חוסר פעילות, שבו כל השאר כבר נטען. כך הוא לא צורך רוחב פס או כוח עיבוד במהלך הטעינה הראשונית. החיבור החדש למניפסט של אפליקציית האינטרנט מוגדר במפרט האחזור, כי צריך לטעון את המניפסט באמצעות חיבור ללא פרטי כניסה. שוב, המניפסט של אפליקציית האינטרנט לא חוסם את הרינדור של האפליקציה או להפוך אותה לאינטראקטיבית, כך שלא צריך לדאוג לנו כל כך.

עם זאת, שני הגופנים והסגנונות שלהם מהוות בעיה מכיוון שהם חוסמים את העיבוד וגם את האינטראקטיביות. אם מסתכלים על שירות ה-CSS שנשלח על ידי fonts.googleapis.com, רואים רק שני כללי @font-face, אחד לכל גופן. למעשה, סגנונות הגופנים קטנים כל כך, עד שהחלטנו להטמיע אותו ב-HTML שלנו ולהסיר חיבור מיותר אחד. כדי להימנע מהעלות של הגדרת החיבור לקובצי הגופנים, אנחנו יכולים להעתיק אותם לשרת שלנו.

טעינות במקביל

כשנבחן את ה-Waterfall, אפשר לראות שברגע שקובץ ה-JavaScript הראשון נטען, קבצים חדשים נטענים מיד. זה אופייני ליחסי תלות של מודולים. סביר להניח שהמודול הראשי שלנו כולל ייבוא סטטי, ולכן קוד ה-JavaScript לא יכול לפעול עד שהייבוא הזה יטען. מה שחשוב להבין כאן הוא שסוגי התלות האלה ידועים בזמן ה-build. אפשר להשתמש בתגי <link rel="preload"> כדי לוודא שכל יחסי התלות יתחילו להיטען ברגע שנקבל את ה-HTML.

תוצאות

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

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

השינויים האלה קיצרו את מדד ה-TTI מ-11 ל-8.5. זה בערך 2.5 שניות של הגדרת החיבור שניסינו להסיר. כל הכבוד.

עיבוד מראש

אמנם צמצמנו את TTI, אבל לא ממש השפיעו על המסך הלבן הנצחי שהמשתמש צריך לסבול במשך 8.5 שניות. אולי אפשר להשיג את השיפורים הגדולים ביותר ב-FMP על ידי שליחת תגי עיצוב בסגנון index.html. שיטות נפוצות להשגת המטרה הזו הן עיבוד מראש ורינדור בצד השרת. יש ביניהם קשר הדוק ומוסבר עליהם במאמר עיבוד באינטרנט. שתי הטכניקות מפעילות את אפליקציית האינטרנט ב-Node ויוצרות סריאליות ל-DOM שנוצר ל-HTML. רינדור בצד השרת עושה זאת לפי בקשה בצד השרת, ואילו העיבוד מראש מבצע זאת בזמן ה-build ושומר את הפלט בתור index.html החדש. PROXX היא אפליקציית JAMStack ואין לה צד שרת, ולכן החלטנו להטמיע עיבוד מראש.

יש הרבה דרכים להטמיע כלי לעיבוד מראש. ב-PROXX בחרנו להשתמש ב-Puppeteer, שמפעיל את Chrome ללא ממשק משתמש כלשהו ומאפשר לשלוט במכונה הזו מרחוק באמצעות Node API. אנחנו משתמשים בו כדי להחדיר את תגי העיצוב ואת ה-JavaScript שלנו, ולאחר מכן קוראים את ה-DOM כמחרוזת של HTML. מכיוון שאנחנו משתמשים במודולים של CSS, אנחנו מקבלים הנחיות CSS בנוגע לסגנונות הנדרשים לנו בחינם.

  const browser = await puppeteer.launch();
  const page = await browser.newPage();
  await page.setContent(rawIndexHTML);
  await page.evaluate(codeToRun);
  const renderedHTML = await page.content();
  browser.close();
  await writeFile("index.html", renderedHTML);

כתוצאה מכך, אנחנו יכולים לצפות לשיפור ב-FMP. אנחנו עדיין צריכים לטעון ולהפעיל את אותה כמות של JavaScript כמו קודם, לכן לא צפויים שינויים משמעותיים ב-TTD. אם בכלל, index.html שלנו גדל ועשויה להדאיג קצת יותר את ה-TTD. יש דרך אחת לבדוק זאת: הרצת WebPageTest.

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

המהירות שבה נטען רכיב התוכן הראשון שלנו עברה מ-8.5 שניות ל-4.9 שניות, שיפור משמעותי. מדד ה-TTDI עדיין מופיע בערך 8.5 שניות, ולכן הוא לא מושפע מהשינוי הזה. מה שעשינו כאן הוא שינוי תפיסתי. לפעמים הם אפילו יעשו את זה בזריזות. על ידי עיבוד חזותי ברמת הביניים של המשחק, אנחנו משנים את ביצועי הטעינה הנתפסים לטובה.

פנימי

מדד נוסף שאנחנו מקבלים מכלי הפיתוח וגם מ-WebPageTest הוא Time To First Byte (TTFB). זהו הזמן שחולף מהבייט הראשון של הבקשה שנשלחת לבייט הראשון של התגובה שמתקבלת. זמן זה נקרא גם 'זמן הלוך ושוב' (RTT), למרות שמבחינה טכנית יש הבדל בין שני המספרים האלה: RTT לא כולל את זמן העיבוד של הבקשה בצד השרת. בעזרת DevTools ו-WebPageTest, כלי תצוגה חזותית של 'TTDFB' יכול להופיע בצבע בהיר בבלוק של הבקשה/התגובה.

הקטע הקל של הבקשה מציין שהבקשה ממתינה לקבלת הבייט הראשון של התגובה.

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

הבעיה הזו הייתה הבעיה שנוצרה במקור ב-HTTP/2 Push. מפתח האפליקציה יודע שמשאבים מסוימים נחוצים, והוא יכול לדחוף אותם למטה. כשהלקוח מבין שצריך לאחזר משאבים נוספים, הוא כבר נמצא במטמון של הדפדפן. ה-HTTP/2 Push נראה שקשה מדי לבצע את הפעולה כראוי ונחשב ללא מומלץ. המרחב הבעיות הזה יטופל מחדש במהלך הסטנדרטיזציה של HTTP/3. נכון לעכשיו, הפתרון הקל ביותר הוא להטמיע את כל המשאבים הקריטיים על חשבון יעילות השמירה במטמון.

שירות ה-CSS הקריטי שלנו כבר מחובר בזכות מודולים של CSS והכלי לעיבוד מראש שמבוסס על 'יצירת בובות'. בשביל JavaScript, אנחנו צריכים להטביע את המודולים הקריטיים ואת יחסי התלות שלהם. רמת הקושי של המשימה הזו משתנה בהתאם ל-bundler שבו אתם משתמשים.

בעקבות ההטבעה של JavaScript, הורדנו את ה-TTI מ-8.5 שניות ל-7.2.

התמונה הזו חסכה שנייה אחת על ה-TTI שלנו. הגענו לנקודה שבה index.html מכיל את כל מה שדרוש לעיבוד הראשוני והופך לאינטראקטיבי. ניתן לעבד את ה-HTML תוך כדי ההורדה, תוך יצירת ה-FMP שלנו. ברגע סיום הניתוח והפעלה של ה-HTML, האפליקציה תהיה אינטראקטיבית.

פיצול קוד אגרסיבי

כן, בindex.html שלנו יש את כל מה שצריך כדי להפוך לאינטראקטיבי. אבל לאחר בדיקה מדוקדקת יותר, מתברר שהוא מכיל גם את כל שאר הפרטים. הגודל של index.html הוא כ-43KB. בהקשר של מה שהמשתמש יכול לבצע איתו אינטראקציה בהתחלה: יש לנו טופס להגדרת המשחק שמכיל כמה רכיבים, לחצן התחלה וככל הנראה קוד כלשהו שיישמר ולטעון את הגדרות המשתמש. זה פחות או יותר. 43KB נראה גבוה מדי.

דף הנחיתה של PROXX. המערכת משתמשת רק ברכיבים קריטיים.

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

ניתוח התוכן של 'index.html' של PROXX מראה הרבה משאבים שלא נחוצים. משאבים קריטיים מודגשים.

מה שאנחנו צריכים לעשות הוא פיצול קוד. פיצול הקוד מפרק את החבילה המונוליתית לחלקים קטנים יותר שאפשר לטעון אותם בהדרגה לפי דרישה. ספקי חבילות פופולריים כמו Webpack, Rollup ו-Parcel תומכים בפיצול קוד באמצעות import() דינמי. ה-bundler ינתח את הקוד והטמיע את כל המודולים שמיובאים באופן סטטי. כל מה שמייבאים באופן דינמי יישמר בקובץ נפרד, ויאוחזר מהרשת רק אחרי שהקריאה import() תבוצע. כמובן שהצטרפות לרשת כרוכה בתשלום, וצריך לעשות זאת רק אם יש לכם זמן פנוי. העיקרון כאן הוא לייבא באופן סטטי את המודולים שנחוצים מאוד בזמן הטעינה ולטעון באופן דינמי את כל השאר. אבל לא כדאי להמתין לרגע האחרון כדי לבצע טעינה מדורגת של מודולים שבהחלט מותר להשתמש בהם. הסרטון Idle till Uבתent של פיל וולטון הוא דוגמה מצוינת לשביל ביניים בריאה בין טעינה מדורגת לטעינה נמרצת.

ב-PROXX יצרנו קובץ lazy.js שמיובא באופן סטטי את כל מה שאנחנו לא צריכים. לאחר מכן, בקובץ הראשי שלנו נוכל לייבא באופן דינמי lazy.js. עם זאת, חלק מהרכיבים של Preact הסתיימו ב-lazy.js, מה שהסתבך בקצת הוא מסובך, מכיוון ש-Preact לא יכול לטפל ברכיבים שנטענים בהדרגה מחוץ לאריזה. לכן כתבנו wrapper קטן של deferred שמאפשר לנו לעבד placeholder עד שהרכיב עצמו נטען.

export default function deferred(componentPromise) {
  return class Deferred extends Component {
    constructor(props) {
      super(props);
      this.state = {
        LoadedComponent: undefined
      };
      componentPromise.then(component => {
        this.setState({ LoadedComponent: component });
      });
    }

    render({ loaded, loading }, { LoadedComponent }) {
      if (LoadedComponent) {
        return loaded(LoadedComponent);
      }
      return loading();
    }
  };
}

לאחר מכן, אנחנו יכולים להשתמש בהבטחה של רכיב בפונקציות render() שלנו. לדוגמה, הרכיב <Nebula>, שמשמש לעיבוד תמונת הרקע המונפשת, יוחלף ב-<div> ריק בזמן שהרכיב בטעינה. כשהרכיב ייטען ומוכן לשימוש, ה-<div> יוחלף ברכיב עצמו.

const NebulaDeferred = deferred(
  import("/components/nebula").then(m => m.default)
);

return (
  // ...
  <NebulaDeferred
    loading={() => <div />}
    loaded={Nebula => <Nebula />}
  />
);

כשכל זה נכון, הקטנו את index.html ל-20KB בלבד, פחות ממחצית מהגודל המקורי. איך זה משפיע על FMP ועל 'דברים שאפשר לעשות'? מה אפשר לעשות בעזרת WebPageTest?

רצועת השקפים מאשרת: הצהרת הכוונות שלנו נמצאת עכשיו ב-5.4 שניות. שיפור משמעותי ביחס ל-11 השניות המקוריות שלנו.

הפער בין ה-FMP לבין ה-TTI שלנו הוא רק 100 אלפיות השנייה, כי צריך רק ניתוח וביצוע של ה-JavaScript המוטבע. לאחר 5.4 שניות ברשת 2G בלבד, האפליקציה היא אינטראקטיבית לחלוטין. כל שאר המודולים פחות חיוניים נטענים ברקע.

יותר זריזות

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

סיכום

המדידה חשובה. כדי להימנע מבזבוז זמן על בעיות שאינן אמיתיות, מומלץ תמיד למדוד תחילה לפני יישום האופטימיזציה. בנוסף, צריך לבצע מדידות במכשירים אמיתיים בחיבור 3G, או ב-WebPageTest אם לא נמצא מכשיר אמיתי.

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

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

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