בלוג חזק בשידור חי – פיצול קוד

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

טיפים וטריקים נוספים בפרק הזה

  • asyncFunction().catch() עם error.stack: 9:55
  • מודולים ומאפיין nomodule בתגים <script>: 7:30
  • promisify() בצומת 8: 17:20

אמ;לק

איך מבצעים פיצול קוד באמצעות חלוקה למקטעים שמבוססת על מסלולים:

  1. מקבלים רשימה של נקודות הכניסה.
  2. חילוץ יחסי התלות של המודולים בכל נקודות הכניסה האלה.
  3. לחפש יחסי תלות משותפים בין כל נקודות הכניסה.
  4. מקבצים את יחסי התלות המשותפים.
  5. כותבים מחדש את נקודות הכניסה.

פיצול קוד לעומת חלוקה מבוססת-מסלולים

פיצול קוד וקיבוץ מבוסס מסלול הם קשורים זה לזה, ולעיתים קרובות נעשה בהם שימוש משולב. זה גרם לבלבול מסוים. ננסה להבהיר את הנושא:

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

למה צריך לפצל קוד?

מודולים רופפים

במודולים מקומיים של ES6, כל מודול JavaScript יכול לייבא את יחסי התלות שלו. כשהדפדפן מקבל מודול, כל הצהרות ה-import יפעילו אחזור נוסף כדי לקבל את המודולים הנדרשים להרצת הקוד. עם זאת, לכל המודולים האלה יכולות להיות יחסי תלות משלהם. הסכנה היא שהדפדפן יסתיים עם מפל של אחזורים שנמשכים כמה סבבים לפני שהקוד יוכל להתבצע סוף סוף.

קיבוץ

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

פיצול קוד

חלוקת הקוד היא פתרון ביניים. אנחנו מוכנים להשקיע עוד נסיעות הלוך ושוב כדי לשפר את יעילות הרשת על ידי הורדה של מה שאנחנו צריכים בלבד, ולשפר את יעילות השמירה במטמון על ידי צמצום מספר המודולים בכל חבילה. אם הקיפול יבוצע בצורה נכונה, מספר הנסיעות הכולל יהיה נמוך בהרבה מאשר במודולים רופפים. לבסוף, אפשר להשתמש במנגנוני טעינה מראש כמו 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.

נתראה בפעם הבאה!