איך השתמשנו בפיצול קוד, בהטמעת קוד וברינדור בצד השרת ב-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 יריץ את הבדיקה, יוצג לכם מפל (דומה לזה שמופיע בכלי הפיתוח) וכן רצועת תמונות בחלק העליון של הדף. בפס ההקרנה מוצג מה שהמשתמש רואה בזמן הטעינה של האפליקציה. ברשת 2G, חוויית הטעינה של הגרסה ללא אופטימיזציה של PROXX היא די גרועה:
כשהמודעה נטענת דרך 3G, המשתמש רואה 4 שניות של רקע לבן. מעל 2G, המשתמש לא רואה כלום במשך יותר מ-8 שניות. אם קראת את המאמר למה הביצועים חשובים, ברור לך שאיבדנו עכשיו חלק גדול מהמשתמשים הפוטנציאליים שלנו בגלל חוסר סבלנות. המשתמש צריך להוריד את כל 62KB של JavaScript כדי שתוכן כלשהו יופיע במסך. הצד החיובי בתרחיש הזה הוא שכל מה שמופיע במסך הוא גם אינטראקטיבי. או שלא?
לאחר ההורדה של כ-62KB מתוך gzip'd JS וה-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, וכך להסיר חיבור מיותר אחד. כדי להימנע מהעלות של הגדרת החיבור לקבצים של הגופן, אנחנו יכולים להעתיק אותם לשרת שלנו.
ביצוע טעינות במקביל
כשנבחן את ה-Waterfall, אפשר לראות שברגע שקובץ ה-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
שלנו גדל ויכול להיות שהוא יאריך קצת את זמן הטעינה הראשוני. יש רק דרך אחת לבדוק זאת: הפעלת WebPageTest.
המהירות שבה נטען רכיב התוכן הראשון שלנו עברה מ-8.5 שניות ל-4.9 שניות, שיפור משמעותי. מדד ה-TTDI עדיין מופיע בערך 8.5 שניות, ולכן הוא לא מושפע מהשינוי הזה. מה שעשינו כאן הוא שינוי תפיסתי. יש כאלה שיגדירו את זה כתרמית. אנחנו משפרים את ביצועי הטעינה של המשחק על ידי עיבוד גרפי ביניים של המשחק.
הטמעה בקוד
מדד נוסף שמוצג גם ב-DevTools וגם ב-WebPageTest הוא זמן אחזור ראשון (TTFB). זהו הזמן שחלף מהבייט הראשון של הבקשה שנשלחה ועד לבייט הראשון של התגובה שהתקבלה. הזמן הזה נקרא גם זמן נסיעה הלוך ושוב (RTT), אם כי מבחינה טכנית יש הבדל בין שני המספרים האלה: זמן הנסיעה הלוך ושוב לא כולל את זמן העיבוד של הבקשה בצד השרת. בעזרת DevTools ו-WebPageTest, כלי תצוגה חזותית של 'TTDFB' יכול להופיע בצבע בהיר בבלוק של הבקשה/התגובה.
כשבוחנים את ה-Waterfall, אפשר לראות שכל הבקשות מנצלות את רוב הזמן בהמתנה עד שהבייט הראשון של התגובה יגיע.
הבעיה הזו היא הסיבה המקורית ליצירת HTTP/2 Push. מפתח האפליקציה יודע שמשאבים מסוימים נחוצים, והוא יכול לדחוף אותם למטה. עד שהלקוח מבין שהוא צריך לאחזר משאבים נוספים, הם כבר נמצאים במטמון הדפדפן. ה-HTTP/2 Push נראה שקשה מדי לבצע את הפעולה כראוי ונחשב ללא מומלץ. נבחן מחדש את הבעיה הזו במהלך התקנת התקן של HTTP/3. בינתיים, הפתרון הפשוט ביותר הוא להטמיע את כל המשאבים הקריטיים על חשבון היעילות של האחסון במטמון.
הקוד של ה-CSS הקריטי כבר מוטמע בקוד של הדף, הודות למודולים של CSS ולכלי העיבוד המקדים שלנו שמבוסס על Puppeteer. ב-JavaScript, צריך להטמיע בקוד את המודולים הקריטיים ואת יחסי התלות שלהם. רמת הקושי של המשימה הזו משתנה בהתאם ל-bundler שבו אתם משתמשים.
התמונה הזו חסכה שנייה אחת על ה-TTI שלנו. הגענו לנקודה שבה index.html
מכיל את כל מה שדרוש לעיבוד הראשוני ולהפיכת התמונה לאינטראקטיבית. ה-HTML יכול לעבור עיבוד בזמן ההורדה, וכך נוצר קובץ ה-FMP. ברגע שה-HTML מסתיים בניתוח ובביצוע, האפליקציה הופכת לאינטראקטיבית.
פיצול קוד אגרסיבי
כן, index.html
מכיל את כל מה שדרוש כדי להפוך לאינטראקטיבי. אבל אחרי בדיקה מעמיקה יותר, מתברר שהיא מכילה גם את כל שאר הדברים. הגודל של index.html
הוא כ-43KB. ננסה להבין את זה בהקשר של מה שהמשתמש יכול לבצע איתו אינטראקציה בהתחלה: יש לנו טופס להגדרת המשחק שמכיל כמה רכיבים, לחצן התחלה וכנראה קוד כלשהו לשמירה ולטעינה של הגדרות המשתמש. זה בערך הכול. 43KB נראה גבוה מדי.
כדי להבין מאיפה מגיע גודל החבילה, אפשר להשתמש בכלי לבדיקת מפת המקור או בכלי דומה כדי להציג פירוט של החבילה. כפי שנחזה, החבילה שלנו כוללת את לוגיקת המשחק, את מנוע הרינדור, את מסך הזכייה, את מסך ההפסדים ומספר רב של כלים. רק קבוצת משנה קטנה של המודולים האלה נדרשת לדף הנחיתה. העברת כל מה שלא נדרש באופן מוחלט לאינטראקטיביות למודול שנטען באיטיות תפחית את זמן הטעינה הראשוני באופן משמעותי.
מה שצריך לעשות הוא פיצול קוד. פיצול הקוד מפרק את החבילה המונוליתית לחלקים קטנים יותר שאפשר לטעון אותם בהדרגה לפי דרישה. חבילות פופולריות כמו 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 בלבד, האפליקציה היא אינטראקטיבית לחלוטין. כל שאר המודולים הפחות חיוניים נטענים ברקע.
עוד טריקים
אם תעיינו ברשימת המודולים הקריטיים שלמעלה, אפשר לראות שמנוע העיבוד הוא לא חלק מהמודולים הקריטיים. כמובן, המשחק לא יכול להתחיל עד שנקבל מנוע עיבוד גרפי שיעבד את המשחק. אנחנו יכולים להשבית את הלחצן 'התחלה' עד שמנוע הרינדור שלנו יהיה מוכן להפעיל את המשחק, אבל מניסיון שלנו, בדרך כלל המשתמש לוקח מספיק זמן להגדיר את הגדרות המשחק, כך שזה לא הכרחי. ברוב המקרים, תהליך הטעינה של מנוע העיבוד והמודולים הנותרים יסתיים עד שהמשתמש ילחץ על Start (התחלה). במקרים נדירים שבהם המשתמש מהיר יותר מהחיבור שלו לרשת, אנחנו מציגים מסך טעינה פשוט שממתין לסיום המודולים הנותרים.
סיכום
חשוב למדוד. כדי שלא תבזבזו זמן על בעיות לא אמיתיות, מומלץ תמיד למדוד לפני שמטמיעים אופטימיזציות. בנוסף, צריך לבצע מדידות במכשירים אמיתיים בחיבור 3G, או ב-WebPageTest אם לא נמצא מכשיר אמיתי.
פס ההמלצות יכול לספק תובנות לגבי התחושה של המשתמש בזמן טעינת האפליקציה. בעזרת מפל המשימות תוכלו לדעת אילו משאבים אחראים לזמני טעינה ארוכים. ריכזנו כאן רשימת משימות שתוכלו לבצע כדי לשפר את ביצועי הטעינה:
- כדאי להעביר כמה שיותר נכסים דרך חיבור אחד.
- טעינה מראש או אפילו משאבים מוטמעים שנדרשים לעיבוד הגרפי הראשון וליכולת האינטראקטיביות.
- עיבוד מראש של האפליקציה כדי לשפר את ביצועי הטעינה שנראים למשתמש.
- כדאי להשתמש בפיצול קוד אגרסיבי כדי לצמצם את כמות הקוד שנדרשת לאינטראקטיביות.
בחלק השני של הקורס נסביר איך לבצע אופטימיזציה של הביצועים בזמן הריצה במכשירים מוגבלים מדי.