איך השתמשנו בפיצול קוד, בהטמעת קוד וברינדור בצד השרת ב-PROXX.
ב-Google I/O 2019, Mariko, Jake ואני השקנו את PROXX, גרסת אינטרנט מודרנית של משחק מוקשים. אחד מהדברים שמבדילים את PROXX הוא ההתמקדות בנגישות (אפשר להפעיל אותו באמצעות קורא מסך!) והיכולת לפעול גם בטלפון פשוט וגם במחשב נייח מתקדם. טלפונים רגילים מוגבלים במספר דרכים:
- מעבדים מרכזיים חלשים
- מעבדי GPU חלשים או לא קיימים
- מסכים קטנים ללא קלט מגע
- כמויות זיכרון מוגבלות מאוד
אבל הם פועלים בדפדפן מודרני והם נוחים מאוד. לכן, יש חזרה של טלפונים עם תכונות בשווקים מתפתחים. נקודת המחיר שלהם מאפשרת לקהל חדש לגמרי, שלא היה יכול להרשות לעצמו את זה בעבר, להיכנס לאינטרנט ולהשתמש באינטרנט המודרני. התחזית היא שבשנת 2019 יימכרו כ-400 מיליון טלפונים עם תכונות בסיסיות בהודו בלבד, כך שמשתמשים בטלפונים עם תכונות בסיסיות עשויים להפוך לחלק משמעותי מהקהל שלכם. בנוסף, מהירויות החיבור דומות לאלו של 2G הן הנורמה בשווקים מתפתחים. איך הצלחנו לגרום ל-PROXX לפעול בצורה טובה בתנאים של טלפון נייח?
הביצועים חשובים, והם כוללים גם את ביצועי הטעינה וגם את ביצועי זמן הריצה. הנתונים מראים שביצועים טובים משויכים לשיפור שימור המשתמשים, לשיפור ההמרות, ולחשוב מכל – לשיפור ההכללה. Jeremy Wagner מציג נתונים והבנות נוספים לגבי הסיבות לחשיבות הביצועים.
זהו החלק הראשון מתוך סדרה שכוללת שני חלקים. בחלק 1 נסביר על ביצועי הטעינה, ובחלק 2 נסביר על ביצועים בסביבת זמן הריצה.
תיעוד הסטטוס קוו
חשוב מאוד לבדוק את ביצועי הטעינה במכשיר אמיתי. אם אין לך מכשיר אמיתי, מומלץ להשתמש ב-WebPageTest, ובמיוחד בהגדרה 'הפשוטה'. ב-WPT מתבצעת סדרה של בדיקות טעינה במכשיר אמיתי עם חיבור 3G ממולא.
מהירות 3G היא מהירות טובה למדידה. יכול להיות שאתם רגילים ל-4G, ל-LTE או בקרוב גם ל-5G, אבל המציאות של האינטרנט הנייד נראית שונה לגמרי. אולי אתם ברכבת, בכנס, בקונצרט או בטיסה. סביר להניח שהמהירות שתקבלו שם תהיה קרובה יותר ל-3G, ולפעמים אפילו גרועה יותר.
עם זאת, במאמר הזה נתמקד ב-2G כי קהל היעד של PROXX מוגדר באופן מפורש לטלפונים עם תכונות פשוטות ולשווקים מתפתחים. אחרי ש-WebPageTest מריץ את הבדיקה, מוצגת רשימת אירועים (waterfall) (בדומה לזו שמוצגת ב-DevTools) וגם פס צילום בחלק העליון. בפס ההקרנה מוצג מה שהמשתמש רואה בזמן שהאפליקציה נטענת. ברשת 2G, חוויית הטעינה של הגרסה ללא אופטימיזציה של PROXX היא די גרועה:
כשהמודעה נטענת דרך 3G, המשתמש רואה 4 שניות של רקע לבן. מעל 2G, המשתמש לא רואה כלום במשך יותר מ-8 שניות. אם קראת את המאמר למה הביצועים חשובים, ברור לך שאיבדנו עכשיו חלק גדול מהמשתמשים הפוטנציאליים שלנו בגלל חוסר סבלנות. המשתמש צריך להוריד את כל 62KB של JavaScript כדי שתוכן כלשהו יופיע במסך. הצד החיובי בתרחיש הזה הוא שכל מה שמופיע במסך הוא גם אינטראקטיבי. או שכן?
אחרי שהמשתמש מוריד כ-62KB של קוד JS בפורמט gzip ונוצר DOM, הוא יכול לראות את האפליקציה. האפליקציה טכנית אינטראקטיבית. עם זאת, כשבודקים את התוכן הוויזואלי, רואים מציאות אחרת. גופנים של האינטרנט עדיין נטענים ברקע, ועד שהם מוכנים המשתמש לא יכול לראות טקסט. המצב הזה עומד בדרישות של First Meaningful Paint (FMP), אבל הוא בהחלט לא עומד בדרישות של אינטראקטיביות תקינה, כי המשתמש לא יכול לדעת מהו הנושא של אף אחד מהנתונים שהוזנו. לאחר מכן, נדרשת עוד שנייה ב-3G ו-3 שניות ב-2G עד שהאפליקציה מוכנה לשימוש. סה"כ, האפליקציה הופכת לאינטראקטיבית תוך 6 שניות ב-3G ו-11 שניות ב-2G.
ניתוח רשימת רשתות בתהליך בחירת רשת
עכשיו, אחרי שאנחנו יודעים מה המשתמש רואה, אנחנו צריכים להבין למה. לשם כך, אפשר לעיין בתרשים המפל ולנתח את הסיבה לטעינה המאוחרת של המשאבים. במעקב שלנו אחרי 2G ב-PROXX, אנחנו רואים שתי נורות אדומות גדולות:
- יש כמה קווים דקים בצבעים שונים.
- קובצי JavaScript יוצרים שרשרת. לדוגמה, הטעינה של המשאב השני תתחיל רק אחרי שהטעינה של המשאב הראשון תסתיים, והטעינה של המשאב השלישי תתחיל רק אחרי שהטעינה של המשאב השני תסתיים.
צמצום מספר החיבורים
כל קו דק (dns
, connect
, ssl
) מייצג יצירת חיבור HTTP חדש. הגדרת חיבור חדש היא פעולה יקרה, כי היא נמשכת כ-1 שניות ב-3G וכ-2.5 שניות ב-2G. בתרשים המפלים שלנו מופיע חיבור חדש עבור:
- בקשה מס' 1:
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, וכך להסיר חיבור אחד מיותר. כדי להימנע מהעלות של הגדרת החיבור לקבצים של הגופן, אנחנו יכולים להעתיק אותם לשרת שלנו.
ביצוע טעינות במקביל
כשבודקים את תרשים המפל, רואים שכשהטעינה של קובץ ה-JavaScript הראשון מסתיימת, הטעינה של קבצים חדשים מתחילה מיד. זהו מצב אופייני ליחסי תלות בין מודולים. סביר להניח שבמודול הראשי שלנו יש ייבוא סטטי, ולכן JavaScript לא יכול לפעול עד שהייבוא הזה נטען. חשוב להבין שסוגי יחסי התלות האלה ידועים בזמן ה-build. אנחנו יכולים להשתמש בתגים <link rel="preload">
כדי לוודא שכל יחסי התלות יתחילו להיטען ברגע שנקבל את ה-HTML.
תוצאות
בואו נראה מה השינויים שלנו השיגו. חשוב לא לשנות משתנים אחרים בהגדרת הבדיקה שעשויים להטות את התוצאות, לכן נשתמש בהגדרה הפשוטה של 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 כמו בעבר, ולכן לא צפוי שינוי משמעותי ב-TTI. אם משהו השתנה, index.html
שלנו גדל ויכול להיות שהוא יאריך קצת את TTI. יש רק דרך אחת לבדוק זאת: הפעלת WebPageTest.
זמן ה-First Meaningful Paint ירד מ-8.5 שניות ל-4.9 שניות, שיפור משמעותי. זמן הטעינה הראשוני עדיין מתרחש תוך כ-8.5 שניות, כך שהשינוי הזה לא השפיע עליו במידה רבה. מה שעשינו כאן הוא שינוי חושי. יש כאלה שיגדירו את זה כתרמית. אנחנו משפרים את ביצועי הטעינה של המשחק על ידי עיבוד גרפי ביניים של המשחק.
הטמעה בקוד
מדד נוסף שמוצג גם ב-DevTools וגם ב-WebPageTest הוא זמן אחזור ראשון (TTFB). זהו הזמן שחלף מהבייט הראשון של הבקשה שנשלחה ועד לבייט הראשון של התגובה שהתקבלה. הזמן הזה נקרא גם זמן נסיעה הלוך ושוב (RTT), אם כי מבחינה טכנית יש הבדל בין שני המספרים האלה: זמן הנסיעה הלוך ושוב לא כולל את זמן העיבוד של הבקשה בצד השרת. ב-DevTools וב-WebPageTest, זמן ה-TTFB מוצג בצבע בהיר בתוך הבלוק של הבקשה/התגובה.
כשבודקים את רשימת הבקשות, אפשר לראות שכל הבקשות מבזבזות את רוב הזמן שלהן בהמתנה לקבלת הבייט הראשון בתגובה.
הבעיה הזו היא הסיבה המקורית ליצירת HTTP/2 Push. מפתח האפליקציה יודע שיש צורך במשאבים מסוימים ויכול להעביר אותם. עד שהלקוח מבין שהוא צריך לאחזר משאבים נוספים, הם כבר נמצאים במטמון הדפדפן. התברר שקשה מאוד להשתמש ב-HTTP/2 Push בצורה נכונה, ולכן לא מומלץ להשתמש בו. נבחן מחדש את הבעיה הזו במהלך התקנת התקן של HTTP/3. בינתיים, הפתרון הפשוט ביותר הוא להטמיע את כל המשאבים הקריטיים על חשבון היעילות של האחסון במטמון.
הקוד הקריטי של ה-CSS כבר מוטמע בקוד של הדף, בזכות מודולים של CSS והכלי שלנו לרנדר מראש שמבוסס על Puppeteer. ב-JavaScript, צריך להטמיע את המודולים הקריטיים ואת יחסי התלות שלהם. רמת הקושי של המשימה הזו משתנה בהתאם ל-bundler שבו אתם משתמשים.
כך הצלחנו לקצר את זמן הטעינה הראשוני ב-1 שניות. הגענו לנקודה שבה index.html
מכיל את כל מה שדרוש לעיבוד הראשוני ולהפיכת התמונה לאינטראקטיבית. ה-HTML יכול לעבור עיבוד בזמן ההורדה, וכך נוצר קובץ ה-FMP. ברגע שה-HTML מסתיים בניתוח ובביצוע, האפליקציה הופכת לאינטראקטיבית.
פיצול קוד אגרסיבי
כן, index.html
מכיל את כל מה שדרוש כדי להפוך לאינטראקטיבי. אבל אחרי בדיקה מעמיקה יותר, מתברר שהיא מכילה גם את כל שאר הדברים. ה-index.html
שלנו הוא בערך 43KB. ננסה להבין את זה בהקשר של מה שהמשתמש יכול לבצע איתו אינטראקציה בהתחלה: יש לנו טופס להגדרת המשחק שמכיל כמה רכיבים, לחצן התחלה וכנראה קוד כלשהו לשמירה ולטעינה של הגדרות המשתמש. זה בערך הכול. 43KB נראה כמו הרבה.
כדי להבין מאיפה מגיע גודל החבילה, אפשר להשתמש בכלי לניתוח מפות מקור או בכלי דומה כדי לפרק את החבילה ולבדוק ממה היא מורכבת. כצפוי, החבילה שלנו מכילה את הלוגיקה של המשחק, מנוע הרינדור, מסך הניצחון, מסך ההפסד ועוד כמה כלי עזר. רק קבוצת משנה קטנה של המודולים האלה נדרשת לדף הנחיתה. העברת כל מה שלא נדרש באופן מוחלט לאינטראקטיביות למודול שנטען באיטיות תפחית את זמן הטעינה הראשוני באופן משמעותי.
מה שצריך לעשות הוא פיצול קוד. כשמחלקים את הקוד, החבילה המונוליטית מחולקת לחלקים קטנים יותר שאפשר לטעון לפי דרישה (lazy-load). חבילות פופולריות כמו Webpack, Rollup ו-Parcel תומכות בפיצול קוד באמצעות import()
דינמי. ה-bundler ינתח את הקוד וידביק את כל המודולים שיובאו סטטית. כל מה שמייבאים באופן דינמי יועבר לקובץ משלו, והוא יוחזר מהרשת רק אחרי ביצוע הקריאה import()
. כמובן, לשימוש ברשת יש עלות, וצריך לעשות זאת רק אם יש לכם זמן פנוי. העיקרון הוא לייבא באופן סטטי את המודולים שחיוניים בזמן הטעינה, ולטעון באופן דינמי את כל השאר. עם זאת, אל תחכו לרגע האחרון כדי לטעון באיטרציה מודולים שבהם בהחלט תשתמשו. Idle Until Urgent של Phil Walton הוא דפוס מצוין לאיזון בין טעינת נתונים בזמן השהיה לטעינת נתונים מיידית.
ב-PROXX יצרנו קובץ lazy.js
שמייבא באופן סטטי את כל מה שלא נדרש לנו. לאחר מכן, נוכל לייבא את lazy.js
באופן דינמי בקובץ הראשי. עם זאת, חלק מהרכיבים שלנו ב-Preact הגיעו ל-lazy.js
, וזה התברר כקצת מסובך כי Preact לא יכול לטפל ברכיבים שנטענים באיטיות מחוץ לקופסה. לכן כתבנו מעטפת רכיב 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();
}
};
}
עכשיו אפשר להשתמש ב-Promise של רכיב בפונקציות 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 יגיד לכם!
ההפרש בין FMP ל-TTI הוא רק 100 אלפיות השנייה, כי מדובר רק בניתוח ובביצוע של JavaScript מוטמע. אחרי 5.4 שניות בלבד ב-2G, האפליקציה הופכת לאינטראקטיבית לחלוטין. כל שאר המודולים הפחות חיוניים נטענים ברקע.
עוד טריקים
אם תעיינו ברשימת המודולים הקריטיים שלמעלה, תראו שמנוע הרינדור לא נכלל במודולים הקריטיים. כמובן, המשחק לא יכול להתחיל עד שנקבל מנוע עיבוד גרפי שיעבד את המשחק. אנחנו יכולים להשבית את הלחצן 'התחלה' עד שמנוע הרינדור שלנו יהיה מוכן להפעיל את המשחק, אבל מניסיון שלנו, בדרך כלל המשתמש לוקח מספיק זמן להגדיר את הגדרות המשחק, כך שזה לא הכרחי. ברוב המקרים, מנוע הרינדור והמודולים האחרים יסתיימו בטעינה עד שהמשתמש ילחץ על 'התחלה'. במקרים נדירים שבהם המשתמש מהיר יותר מהחיבור שלו לרשת, אנחנו מציגים מסך טעינה פשוט שממתין לסיום ההתקנה של שאר המודולים.
סיכום
חשוב למדוד. כדי שלא תבזבזו זמן על בעיות לא אמיתיות, מומלץ תמיד למדוד לפני שמטמיעים אופטימיזציות. בנוסף, יש לבצע את המדידות במכשירים אמיתיים עם חיבור 3G, או ב-WebPageTest אם אין מכשיר אמיתי זמין.
פס ההמלצות יכול לספק תובנות לגבי התחושה של המשתמש בזמן טעינת האפליקציה. בעזרת מפל המשימות תוכלו לדעת אילו משאבים אחראים לזמני טעינה ארוכים. ריכזנו כאן רשימת משימות שתוכלו לבצע כדי לשפר את ביצועי הטעינה:
- שולחים כמה שיותר נכסים דרך חיבור אחד.
- טעינה מראש או אפילו משאבים מוטמעים שנדרשים לעיבוד הגרפי הראשון וליכולת האינטראקציה.
- עיבוד מראש של האפליקציה כדי לשפר את ביצועי הטעינה שנראים למשתמש.
- כדאי להשתמש בפיצול קוד אגרסיבי כדי לצמצם את כמות הקוד שנדרשת לאינטראקטיביות.
כדאי לעקוב אחרינו כדי לקבל מידע על חלק 2, שבו נדון באופן שבו מבצעים אופטימיזציה של ביצועי זמן הריצה במכשירים עם מגבלות חמורות.