הצגת קוד מודרני בדפדפנים מודרניים לטעינת דפים מהירה יותר

בקודלאב הזה תלמדו לשפר את הביצועים של האפליקציה הפשוטה הזו, שמאפשרת למשתמשים לדרג חתולים אקראיים. איך מבצעים אופטימיזציה של חבילת ה-JavaScript על ידי צמצום כמות הקוד שמומר?

צילום מסך של אפליקציה

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

מדידה

תמיד מומלץ להתחיל בבדיקה של האתר לפני שמוסיפים אופטימיזציות:

  1. כדי לראות תצוגה מקדימה של האתר, מקישים על הצגת האפליקציה. לאחר מכן מקישים על מסך מלא מסך מלא.
  2. מקישים על Control+Shift+J (או על Command+Option+J ב-Mac) כדי לפתוח את DevTools.
  3. לוחצים על הכרטיסייה רשתות.
  4. מסמנים את התיבה Disable cache (השבתת המטמון).
  5. טוענים מחדש את האפליקציה.

הבקשה המקורית לגבי גודל החבילה

האפליקציה הזו משתמשת ביותר מ-80KB! עכשיו הגיע הזמן לבדוק אם יש חלקים בחבילה שלא בשימוש:

  1. מקישים על Control+Shift+P (או על Command+Shift+P ב-Mac) כדי לפתוח את התפריט Command. תפריט הפקודות

  2. מזינים Show Coverage ומקישים על Enter כדי להציג את הכרטיסייה Coverage.

  3. בכרטיסייה Coverage, לוחצים על Reload כדי לטעון מחדש את האפליקציה בזמן תיעוד הכיסוי.

    טעינה מחדש של האפליקציה עם כיסוי קוד

  4. כדאי לבדוק כמה קוד נעשה בו שימוש לעומת כמה קוד נטען בחבילה הראשית:

    רמת הכיסוי של הקוד בחבילה

יותר ממחצית החבילה (44KB) אפילו לא נמצאת בשימוש. הסיבה לכך היא שחלק גדול מהקוד מורכב מ-polyfills כדי להבטיח שהאפליקציה תפעל בדפדפנים ישנים יותר.

שימוש ב-‎ @babel/preset-env

התחביר של שפת JavaScript תואם לתקן שנקרא ECMAScript או ECMA-262. גרסאות חדשות של המפרט מתפרסמות מדי שנה, והן כוללות תכונות חדשות שעברו את תהליך ההצעה. בכל דפדפן עיקרי יש תמיד שלב שונה של תמיכה בתכונות האלה.

האפליקציה משתמשת בתכונות הבאות של ES2015:

בנוסף, נעשה שימוש בתכונה הבאה של ES2017:

מומלץ לעיין בקוד המקור ב-src/index.js כדי לראות איך משתמשים בכל זה.

כל התכונות האלה נתמכות בגרסה האחרונה של Chrome, אבל מה קורה בדפדפנים אחרים שלא תומכים בהן? Babel, שכלולה באפליקציה, היא הספרייה הפופולרית ביותר שמשמשת לקמפלור של קוד שמכיל תחביר חדש יותר לקוד שסביבות וגם דפדפנים ישנים יותר יכולים להבין. הוא עושה זאת בשתי דרכים:

  • Polyfills כלולים כדי לדמות פונקציות חדשות יותר מ-ES2015, כך שאפשר להשתמש בממשקי ה-API שלהן גם אם הדפדפן לא תומך בהן. הנה דוגמה לpolyfill של השיטה Array.includes.
  • פלאגינים משמשים להמרת קוד ES2015 (או גרסה מתקדמת יותר) לתחביר ES5 ישן יותר. מכיוון שמדובר בשינויים שקשורים לתחביר (כמו פונקציות חץ), אי אפשר לדמות אותם באמצעות polyfills.

אפשר לעיין ב-package.json כדי לראות אילו ספריות של Babel נכללות:

"dependencies": {
  "@babel/polyfill": "^7.0.0"
},
"devDependencies": {
  //...
  "babel-loader": "^8.0.2",
  "@babel/core": "^7.1.0",
  "@babel/preset-env": "^7.1.0",
  //...
}
  • @babel/core הוא הליבה של מהדר Babel. כך כל הגדרות Babel מוגדרות בקובץ .babelrc ברמה הבסיסית של הפרויקט.
  • babel-loader כולל את Babel בתהליך ה-build של webpack.

עכשיו נסתכל על webpack.config.js כדי לראות איך babel-loader נכלל ככלל:

module: {
  rules: [
    //...
    {
      test: /\.js$/,
      exclude: /node_modules/,
      loader: "babel-loader"
    }
  ]
},
  • @babel/polyfill מספק את כל ה-polyfills הנדרשים לכל התכונות החדשות יותר של ECMAScript, כדי שיוכלו לפעול בסביבות שלא תומכות בהן. הוא כבר יובא בחלק העליון של src/index.js.
import "./style.css";
import "@babel/polyfill";
  • @babel/preset-env מזהה אילו טרנספורמציות ו-polyfills נדרשים לכל הדפדפנים או הסביבות שנבחרו כיעדים.

אפשר לעיין בקובץ ההגדרות של Babel, .babelrc, כדי לראות איך הוא נכלל:

{
  "presets": [
    [
      "@babel/preset-env",
      {
        "targets": "last 2 versions"
      }
    ]
  ]
}

זוהי הגדרה של Babel ו-webpack. כך כוללים את Babel באפליקציה אם אתם משתמשים ב-webpack ולא ב-Webpack Bundler.

המאפיין targets ב-.babelrc מזהה את הדפדפנים שאליהם מוצגת הטירגוט. @babel/preset-env משתלב עם browserslist, כך שאפשר למצוא רשימה מלאה של שאילתות תואמות שאפשר להשתמש בהן בשדה הזה במסמכי התיעוד של browserslist.

הערך "last 2 versions" מעביר את הקוד באפליקציה לשתי הגרסאות האחרונות של כל דפדפן.

ניפוי באגים

כדי לקבל תמונה מלאה של כל היעדים של Babel בדפדפן, וגם של כל הטרנספורמציות והפוליפילים הכלולים, מוסיפים שדה debug ל-.babelrc:

{
  "presets": [
    [
      "@babel/preset-env",
      {
        "targets": "last 2 versions",
        "debug": true
      }
    ]
  ]
}
  • לוחצים על כלים.
  • לוחצים על יומנים.

צריך לטעון מחדש את האפליקציה ולבדוק את יומני הסטטוס של Glitch בתחתית העורכת.

דפדפנים מטורגטים

Babel מתעד במסוף מספר פרטים על תהליך הידור הקוד, כולל כל סביבות היעד שהקוד עבר בהן הידור.

דפדפנים מטורגטים

שימו לב שדפדפנים שהוצאו משימוש, כמו Internet Explorer, כלולים ברשימה הזו. זו בעיה כי לא יתווספו תכונות חדשות לדפדפנים שלא נתמכים, ו-Babel ימשיך להמיר עבורם תחביר ספציפי. הפעולה הזו מגדילה את גודל החבילה ללא צורך, אם המשתמשים לא משתמשים בדפדפן הזה כדי לגשת לאתר.

ב-Babel מתועדת גם רשימה של יישומי הפלאגין לטרנספורמציה שבהם נעשה שימוש:

רשימת יישומי הפלאגין שבהם נעשה שימוש

זו רשימה ארוכה למדי! אלה כל הפלאגינים שבהם Babel צריך להשתמש כדי להמיר כל תחביר מ-ES2015 ואילך לתחביר ישן יותר בכל הדפדפנים המטורגטים.

עם זאת, Babel לא מציג polyfills ספציפיים שבהם נעשה שימוש:

לא נוספו polyfills

הסיבה לכך היא שכל @babel/polyfill מיובא ישירות.

טעינת פוליפולים בנפרד

כברירת מחדל, Babel כולל את כל ה-polyfills הנדרשים לסביבה מלאה של ES2015 ואילך כש@babel/polyfill מיובאים לקובץ. כדי לייבא פוליפולים ספציפיים שנדרשים לדפדפני היעד, מוסיפים useBuiltIns: 'entry' לתצורה.

{
  "presets": [
    [
      "@babel/preset-env",
      {
        "targets": "last 2 versions",
        "debug": true
        "useBuiltIns": "entry"
      }
    ]
  ]
}

טוענים מחדש את האפליקציה. עכשיו אפשר לראות את כל הפוליפילים הספציפיים שכלולים:

רשימת ה-polyfills שיובאו

עכשיו נכללים רק פוליפולים נחוצים ל-"last 2 versions", אבל עדיין מדובר ברשימה ארוכה מאוד. הסיבה לכך היא שעדיין נכללים פוליפילים שנדרשים לדפדפני היעד עבור כל התכונות החדשות יותר. משנים את הערך של המאפיין ל-usage כדי לכלול רק את אלה שנדרשים לתכונות שבהן נעשה שימוש בקוד.

{
  "presets": [
    [
      "@babel/preset-env",
      {
        "targets": "last 2 versions",
        "debug": true,
        "useBuiltIns": "entry"
        "useBuiltIns": "usage"
      }
    ]
  ]
}

כך, ה-polyfills נכללים באופן אוטומטי במקומות הנדרשים. כלומר, אפשר להסיר את הייבוא של @babel/polyfill ב-src/index.js.

import "./style.css";
import "@babel/polyfill";

עכשיו נכללים רק ה-polyfills הנדרשים לאפליקציה.

רשימת ה-polyfills שכלולים באופן אוטומטי

גודל חבילת האפליקציות מצטמצם באופן משמעותי.

גודל החבילה צומצם ל-30.1KB

צמצום רשימת הדפדפנים הנתמכים

מספר יעדי הדפדפנים שכלולים עדיין גדול למדי, ומספר המשתמשים שמשתמשים בדפדפנים שהוצאו משימוש, כמו Internet Explorer, הוא קטן. מעדכנים את ההגדרות כך:

{
  "presets": [
    [
      "@babel/preset-env",
      {
        "targets": "last 2 versions",
        "targets": [">0.25%", "not ie 11"],
        "debug": true,
        "useBuiltIns": "usage",
      }
    ]
  ]
}

בודקים את הפרטים של החבילה שאוחזרה.

גודל החבילה הוא 30.0KB

מכיוון שהאפליקציה קטנה מאוד, אין הבדל משמעותי בין השינויים האלה. עם זאת, הגישה המומלצת היא להשתמש באחוז נתח השוק של הדפדפן (כמו ">0.25%") יחד עם החרגה של דפדפנים ספציפיים שאתם בטוחים שהמשתמשים שלכם לא משתמשים בהם. למידע נוסף, כדאי לקרוא את המאמר 'Last 2 versions' considered harmful של James Kyle.

שימוש בתג <script type="module">

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

מודולים של JavaScript הם תכונה חדשה יחסית שנתמכת בכל הדפדפנים העיקריים. אפשר ליצור מודולים באמצעות מאפיין type="module" כדי להגדיר סקריפטים שייבאו וייצאו ממודולים אחרים. לדוגמה:

// math.mjs
export const add = (x, y) => x + y;

<!-- index.html -->
<script type="module">
  import { add } from './math.mjs';

  add(5, 2); // 7
</script>

כבר יש תמיכה בתכונות ECMAScript חדשות רבות בסביבות שתומכות במודולים של JavaScript (במקום צורך ב-Babel). המשמעות היא שאפשר לשנות את קובץ התצורה של Babel כדי לשלוח לדפדפן שתי גרסאות שונות של האפליקציה:

  • גרסה שתעבוד בדפדפנים חדשים יותר שתומכים במודולים, וכוללת מודול שלא עבר טרנספיילציה במידה רבה אבל בגודל קובץ קטן יותר
  • גרסה שכוללת סקריפט גדול יותר שעובר תרגום (transpilation) ויכול לפעול בכל דפדפן מדור קודם

שימוש במודולים של ES עם Babel

כדי להגדיר הגדרות @babel/preset-env נפרדות לשתי הגרסאות של האפליקציה, מסירים את הקובץ .babelrc. כדי להוסיף הגדרות של Babel להגדרות של webpack, מציינים שני פורמטים שונים של הידור לכל גרסה של האפליקציה.

מתחילים בהוספת הגדרה לסקריפט הקודם אל webpack.config.js:

const legacyConfig = {
  entry,
  output: {
    path: path.resolve(__dirname, "public"),
    filename: "[name].bundle.js"
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        loader: "babel-loader",
        options: {
          presets: [
            ["@babel/preset-env", {
              useBuiltIns: "usage",
              targets: {
                esmodules: false
              }
            }]
          ]
        }
      },
      cssRule
    ]
  },
  plugins
}

שימו לב שבמקום להשתמש בערך targets עבור "@babel/preset-env", המערכת משתמשת ב-esmodules עם הערך false. המשמעות היא ש-Babel כולל את כל הטרנספורמציות והפוליפילים הנדרשים כדי לטרגט כל דפדפן שעדיין לא תומך במודולים של ES.

מוסיפים את האובייקטים entry, cssRule ו-corePlugins לתחילת הקובץ webpack.config.js. כל אלה משותפים בין המודול לבין הסקריפטים הקודמים שמוצגים בדפדפן.

const entry = {
  main: "./src"
};

const cssRule = {
  test: /\.css$/,
  use: ExtractTextPlugin.extract({
    fallback: "style-loader",
    use: "css-loader"
  })
};

const plugins = [
  new ExtractTextPlugin({filename: "[name].css", allChunks: true}),
  new HtmlWebpackPlugin({template: "./src/index.html"})
];

באופן דומה, יוצרים אובייקט תצורה לסקריפט המודול הבא, שבו מוגדר legacyConfig:

const moduleConfig = {
  entry,
  output: {
    path: path.resolve(__dirname, "public"),
    filename: "[name].mjs"
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        loader: "babel-loader",
        options: {
          presets: [
            ["@babel/preset-env", {
              useBuiltIns: "usage",
              targets: {
                esmodules: true
              }
            }]
          ]
        }
      },
      cssRule
    ]
  },
  plugins
}

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

בסוף הקובץ, מייצאים את שתי ההגדרות במערך אחד.

module.exports = [
  legacyConfig, moduleConfig
];

עכשיו המערכת יוצרת מודול קטן יותר לדפדפנים שתומכים בו, וסקריפט גדול יותר שעובר תרגום לדפדפנים ישנים יותר.

בדפדפנים שתומכים במודולים, סקריפטים עם המאפיין nomodule מתעלמים. לעומת זאת, דפדפנים שלא תומכים במודולים מתעלמים מרכיבי סקריפט עם הערך type="module". כלומר, אפשר לכלול מודול וגם חלופה מתומצתת. באופן אידיאלי, שתי הגרסאות של האפליקציה צריכות להיות ב-index.html ככה:

<script type="module" src="main.mjs"></script>
<script nomodule src="main.bundle.js"></script>

בדפדפנים שתומכים במודולים, מתבצע אחזור והפעלה של main.mjs והתעלמות מ-main.bundle.js.. בדפדפנים שלא תומכים במודולים, מתבצע הפוך.

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

<script type="module" src="main.mjs"></script>
<script nomodule src="main.bundle.js" defer></script>

השלב האחרון הוא להוסיף את המאפיינים module ו-nomodule למודול ולסקריפט הקודם, בהתאמה, ולייבא את ScriptExtHtmlWebpackPlugin בחלק העליון של webpack.config.js:

const path = require("path");

const webpack = require("webpack");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const ScriptExtHtmlWebpackPlugin = require("script-ext-html-webpack-plugin");

עכשיו מעדכנים את המערך plugins בהגדרות כך שיכלול את הפלאגין הזה:

const plugins = [
  new ExtractTextPlugin({filename: "[name].css", allChunks: true}),
  new HtmlWebpackPlugin({template: "./src/index.html"}),
  new ScriptExtHtmlWebpackPlugin({
    module: /\.mjs$/,
    custom: [
      {
        test: /\.js$/,
        attribute: 'nomodule',
        value: ''
    },
    ]
  })
];

הגדרות הפלאגין האלה מוסיפות מאפיין type="module" לכל רכיבי הסקריפטים מסוג .mjs, וגם מאפיין nomodule לכל המודולים של הסקריפטים מסוג .js.

הצגת מודולים במסמך ה-HTML

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

מוסיפים את הטקסט הבא לקובץ src/index.js בסוף הקובץ:

    ...
    </form>
    <script type="module" src="main.mjs"></script>
  </body>
</html>

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

מודול של 5.2KB שאוחזר דרך הרשת לדפדפנים חדשים יותר

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

אם תטעינו את האפליקציה בדפדפן ישן יותר, יוחזרו רק הסקריפט הגדול יותר שעבר טרנספיילציה עם כל הפונקציות החסרות (polyfills) והטרנספורמציות הנדרשות. זהו צילומי מסך של כל הבקשות שנשלחו בגרסה ישנה יותר של Chrome (גרסה 38).

סקריפט של 30KB שאוחזר לדפדפנים ישנים יותר

סיכום

עכשיו ברור לכם איך להשתמש ב-@babel/preset-env כדי לספק רק את ה-polyfills הנדרשים לדפדפנים המטורגטים. אתם גם יודעים איך מודולים של JavaScript יכולים לשפר את הביצועים עוד יותר על ידי שליחת שתי גרסאות שונות של אפליקציה שעברו טרנספיילציה. עכשיו, אחרי שהבנתם איך שתי השיטות האלה יכולות להקטין באופן משמעותי את נפח החבילה, תוכלו להתחיל לבצע אופטימיזציה.