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

בשידור החי האחרון שלנו ב-Supercharged Livestream, הטמענו פיצול קוד וחלוקה מבוססת-נתיבים. באמצעות מודולים של 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 תופעל עם הצומת הזה כפרמטר. בדוגמה שלמעלה, תתבצע קריאה ל-method ImportDeclaration() לכל הצהרת import בקובץ. כדי להכיר טוב יותר את סוגי הצמתים ואת AST, תוכלו לעיין ב-astexplorer.net.

שלב 2: מחלצים את יחסי התלות של המודולים

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

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);
}

End

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

הקוד שלנו מופיע במאגר של GitHub.

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