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

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

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

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

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

מהלך המשחק של PROXX.

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

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

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

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

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

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

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

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

ה-[First Meaningful Paint][FMP] בגרסה הלא אופטימלית של PROXX הוא _טכני_ [אינטראקטיבי][TTI] אבל לא שימושי למשתמש.

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

ניתוח Waterfall

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

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

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

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

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

החיבור החדש עבור index.html הוא בלתי נמנע. הדפדפן חייב ליצור חיבור לשרת שלנו כדי לקבל את התוכן. ניתן למנוע את החיבור החדש של Google Analytics על ידי הטמעה של משהו כמו Minimal 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. שיטות נפוצות לביצוע פעולות אלה הן עיבוד מראש ועיבוד בצד השרת, שקשורים אלו לאלו והסברם במאמר עיבוד באינטרנט. שתי הטכניקות מפעילות את אפליקציית האינטרנט בצומת ויוצרות סדרה של ה-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 באותה כמות של JavaScript כמו קודם, לכן לא צפויים שינויים רבים ב-TTI. אם בכלל, ה-index.html שלנו גדל ועשוי לדחוף את ה-TTI שלנו קצת יותר. יש רק דרך אחת לבדוק זאת: הפעלת WebPageTest.

ברצועת השקפים יש שיפור ברור במדד ה-FMP שלנו. TTI לרוב לא מושפע.

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

הטבעה

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

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

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

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

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

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

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

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

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

דף הנחיתה של PROXX. רק רכיבים חיוניים כאן.

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

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

מה שאנחנו צריכים לעשות הוא פיצול קוד. פיצול קוד מפרק את החבילה המונוליטית לחלקים קטנים יותר, שניתן לטעון אותם בהדרגה לפי דרישה. רכיבי Bundle פופולריים כמו Webpack , Rollup ו-Parcel תומכים בפיצול קוד באמצעות שימוש ב-import() דינמי. ה-Bundler ינתח את הקוד שלך ויטביע את כל המודולים המיובאים באופן סטטי. כל מה שמייבאים באופן דינמי יתווסף לקובץ משלו ויאוחזר מהרשת רק אחרי שהקריאה ל-import() תבוצע. כמובן ששימוש ברשת כרוך בתשלום, וצריך לבצע אותה רק אם יש לך זמן פנוי. העיקרון כאן הוא לייבא באופן סטטי את המודולים שנחוצים קריטית בזמן הטעינה, ולטעון באופן דינמי את כל שאר המודולים. אבל אל תחכה לרגע האחרון כדי לבצע טעינה עצלה של מודולים שבהחלט אמורים להיות בשימוש. היצירה של Phil Walton (לא פעילה עד דחיפות) היא תבנית מצוינת ליצירת שביל ביניים תקין בין טעינה עצלה לבין טעינה נמרצת.

ב-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 ועל TTI? הבדיקה של WebPageTest תצביע על כך!

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

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

יותר זריזות יד

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

סיכום

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

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

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

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