בשידור החי האחרון שלנו בנושא שיפור הביצועים הטמענו פיצול קוד וקיצוץ לפי מסלולים. עם HTTP/2 ומודולים מקומיים של ES6, השיטות האלה יהיו חיוניות כדי לאפשר טעינת משאבי סקריפט יעילה ושמירתם במטמון.
טיפים וטריקים נוספים בפרק הזה
asyncFunction().catch()
עםerror.stack
: 9:55- מודולים ומאפיין
nomodule
בתגים<script>
: 7:30 promisify()
בצומת 8: 17:20
אמ;לק
איך מבצעים פיצול קוד באמצעות חלוקה למקטעים שמבוססת על מסלולים:
- מקבלים רשימה של נקודות הכניסה.
- חילוץ יחסי התלות של המודולים בכל נקודות הכניסה האלה.
- מציאת יחסי תלות משותפים בין כל נקודות הכניסה.
- מקבצים את יחסי התלות המשותפים.
- כותבים מחדש את נקודות הכניסה.
פיצול קוד לעומת קיבוץ לפי נתיב
חלוקת קוד וקיצוץ לפי מסלולים קשורים מאוד זה לזה, ולעיתים קרובות משתמשים בהם באופן חלופי. זה גרם לבלבול מסוים. ננסה להבהיר את הנושא:
- פיצול קוד: פיצול קוד הוא תהליך של פיצול הקוד למספר חבילות. אם לא שולחים לקוח חבילת JavaScript אחת גדולה עם כל הקוד, מבצעים פיצול קוד. אחת מהדרכים הספציפיות לפצל את הקוד היא להשתמש בחלוקה למקטעים שמבוססת על מסלולים.
- חלוקה למקטעים מבוססי-נתיב: חלוקה למקטעים מבוססי-נתיב יוצרת חבילות שקשורות לנתיבי האפליקציה. על סמך ניתוח המסלולים ויחסי התלות שלהם, אנחנו יכולים לשנות את המודולים שייכללו בחבילה.
למה כדאי לפצל את הקוד?
מודולים רופפים
במודולים מקומיים של ES6, כל מודול JavaScript יכול לייבא את יחסי התלות שלו. כשהדפדפן מקבל מודול, כל הצהרות ה-import
יפעילו אחזור נוסף כדי לקבל את המודולים הנדרשים להרצת הקוד. עם זאת, לכל המודולים האלה יכולות להיות יחסי תלות משלהם. הסכנה היא שהדפדפן יסתיים עם מפל של אחזורים שנמשכים כמה סבבים לפני שהקוד יוכל להתבצע סוף סוף.
קיבוץ
אריזה (bundling) היא הטמעה של כל המודולים בתוך חבילת קוד אחת. כך תוכלו לוודא שלדפדפן יש את כל הקוד הדרוש אחרי סבב אחד, והוא יוכל להתחיל להריץ את הקוד מהר יותר. עם זאת, הפעולה הזו מאלצת את המשתמש להוריד הרבה קוד שלא נדרש, כך שרווח זמן ורוחב פס נאבדים. בנוסף, כל שינוי באחד מהמודולים המקוריים שלנו יוביל לשינוי בחבילה, ויגרום לביטול של כל גרסה של החבילה שנשמרה במטמון. המשתמשים יצטרכו להוריד מחדש את כל התוכן.
פיצול קוד
חלוקת הקוד היא דרך ביניים. אנחנו מוכנים להשקיע עוד נסיעות הלוך ושוב כדי לשפר את יעילות הרשת על ידי הורדה של מה שאנחנו צריכים בלבד, ולשפר את יעילות השמירה במטמון על ידי צמצום מספר המודולים בכל חבילה. אם הקיפול יבוצע בצורה נכונה, מספר הנסיעות הכולל יהיה נמוך בהרבה מאשר במודולים רופפים. לבסוף, אם צריך, אפשר להשתמש במנגנוני טעינה מראש כמו link[rel=preload]
כדי לחסוך זמן נוסף של סבב שלוש.
שלב 1: קבלת רשימה של נקודות הכניסה
זו רק אחת מהגישות הרבות, אבל בפרק ניתחנו את sitemap.xml
של האתר כדי לקבל את נקודות הכניסה לאתר שלנו. בדרך כלל משתמשים בקובץ JSON ייעודי שמפרט את כל נקודות הכניסה.
שימוש ב-babel לעיבוד JavaScript
בדרך כלל משתמשים ב-Babel ל'טרנספילינג': שימוש בקוד JavaScript מתקדם והפיכתו לגרסה ישנה יותר של JavaScript, כדי שיותר דפדפנים יוכלו להריץ את הקוד. השלב הראשון הוא לנתח את ה-JavaScript החדש באמצעות מנתח (Babel משתמש ב-babylon) שממיר את הקוד למה שנקרא 'עץ תחביר מופשט' (AST). אחרי יצירת ה-AST, סדרה של פלאגינים מנתחת ומעבדת את ה-AST.
אנחנו נשתמש הרבה ב-babel כדי לזהות (ולבצע בהמשך מניפולציה) את הייבוא של מודול JavaScript. יכול להיות שתתפתתו להשתמש בביטויים רגולריים, אבל ביטויים רגולריים לא חזקים מספיק כדי לנתח שפה בצורה נכונה, וקשה לתחזק אותם. שימוש בכלים מוכרים כמו Babel יחסוך לכם הרבה כאבי ראש.
דוגמה פשוטה להרצת Babel עם פלאגין בהתאמה אישית:
const plugin = {
visitor: {
ImportDeclaration(decl) {
/* ... */
}
}
}
const {code} = babel.transform(inputCode, {plugins: [plugin]});
פלאגין יכול לספק אובייקט visitor
. המבקר מכיל פונקציה לכל סוג צומת שהפלאגין רוצה לטפל בו. כשנתקלים בצומת מהסוג הזה במהלך הניווט ב-AST, הפונקציה התואמת באובייקט visitor
תופעל עם הצומת הזה כפרמטר. בדוגמה שלמעלה, השיטה ImportDeclaration()
תופעל לכל הצהרת import
בקובץ. כדי להבין טוב יותר את סוגי הצמתים ואת ה-AST, כדאי להיכנס לאתר astexplorer.net.
שלב 2: חילוץ יחסי התלות של המודול
כדי ליצור את עץ התלות של מודול, ננתח את המודול הזה ונוצר רשימה של כל המודולים שהוא מייבא. אנחנו צריכים גם לנתח את יחסי התלות האלה, כי יכול להיות שלהם יש יחסי תלות משלהם. דוגמה קלאסית לחזרה חוזרת (recursion)!
async function buildDependencyTree(file) {
let code = await readFile(file);
code = code.toString('utf-8');
// `dep` will collect all dependencies of `file`
let dep = [];
const plugin = {
visitor: {
ImportDeclaration(decl) {
const importedFile = decl.node.source.value;
// Recursion: Push an array of the dependency’s dependencies onto the list
dep.push((async function() {
return await buildDependencyTree(`./app/${importedFile}`);
})());
// Push the dependency itself onto the list
dep.push(importedFile);
}
}
}
// Run the plugin
babel.transform(code, {plugins: [plugin]});
// Wait for all promises to resolve and then flatten the array
return flatten(await Promise.all(dep));
}
שלב 3: מוצאים יחסי תלות משותפים בין כל נקודות הכניסה
מכיוון שיש לנו קבוצה של עצי תלות – יער תלות, אם תרצו – אנחנו יכולים למצוא את יחסי התלות המשותפים על ידי חיפוש צמתים שמופיעים בכל עץ. אנחנו נסיר את הכפילויות ונשטח את היער, ונבצע סינון כדי לשמור רק את האלמנטים שמופיעים בכל העצים.
function findCommonDeps(depTrees) {
const depSet = new Set();
// Flatten
depTrees.forEach(depTree => {
depTree.forEach(dep => depSet.add(dep));
});
// Filter
return Array.from(depSet)
.filter(dep => depTrees.every(depTree => depTree.includes(dep)));
}
שלב 4: ארגון יחסי התלות המשותפים בחבילה
כדי לארוז את קבוצת יחסי התלות המשותפים, אפשר פשוט לשרשר את כל קובצי המודולים. כשמשתמשים בגישה הזו, נוצרות שתי בעיות: הבעיה הראשונה היא שהחבילה עדיין תכלול הצהרות import
שיגרמו לדפדפן לנסות לאחזר משאבים. הבעיה השנייה היא שקשרי התלות של יחסי התלות לא נארזו. מכיוון שכבר עשינו זאת בעבר, נכתוב פלאגין נוסף של Babel.
הקוד דומה למדי לזה של הפלאגין הראשון שלנו, אבל במקום רק לחלץ את הייבוא, נצטרך גם להסיר אותו ולהוסיף גרסה מקובצת של הקובץ שיובא:
async function bundle(oldCode) {
// `newCode` will be filled with code fragments that eventually form the bundle.
let newCode = [];
const plugin = {
visitor: {
ImportDeclaration(decl) {
const importedFile = decl.node.source.value;
newCode.push((async function() {
// Bundle the imported file and add it to the output.
return await bundle(await readFile(`./app/${importedFile}`));
})());
// Remove the import declaration from the AST.
decl.remove();
}
}
};
// Save the stringified, transformed AST. This code is the same as `oldCode`
// but without any import statements.
const {code} = babel.transform(oldCode, {plugins: [plugin]});
newCode.push(code);
// `newCode` contains all the bundled dependencies as well as the
// import-less version of the code itself. Concatenate to generate the code
// for the bundle.
return flatten(await Promise.all(newCode)).join('\n');
}
שלב 5: כתיבה מחדש של נקודות הכניסה
בשלב האחרון נכתוב עוד פלאגין של Babel. התפקיד שלו הוא להסיר את כל הייבוא של המודולים שנמצאים בחבילה המשותפת.
async function rewrite(section, sharedBundle) {
let oldCode = await readFile(`./app/static/${section}.js`);
oldCode = oldCode.toString('utf-8');
const plugin = {
visitor: {
ImportDeclaration(decl) {
const importedFile = decl.node.source.value;
// If this import statement imports a file that is in the shared bundle, remove it.
if(sharedBundle.includes(importedFile))
decl.remove();
}
}
};
let {code} = babel.transform(oldCode, {plugins: [plugin]});
// Prepend an import statement for the shared bundle.
code = `import '/static/_shared.js';\n${code}`;
await writeFile(`./app/static/_${section}.js`, code);
}
סיום
זה היה נסיעה מעניינת, לא? חשוב לזכור שהמטרה של הפרק הזה הייתה להסביר ולפשט את חלוקת הקוד. התוצאה עובדת – אבל היא ספציפית לאתר הדגמה שלנו ותיכשל בצורה חמורה במקרה הכללי. בסביבת הייצור, מומלץ להסתמך על כלים מוכרים כמו WebPack, RollUp וכו'.
הקוד שלנו זמין במאגר GitHub.
נתראה בפעם הבאה!