הפחתת מטענים ייעודיים (payloads) של JavaScript בעזרת רעידות עצים

אפליקציות אינטרנט של היום יכולות להיות גדולות מאוד, במיוחד החלק של JavaScript. נכון לאמצע 2018, לפי נתוני HTTP Archive, גודל ההעברה החציוני של JavaScript במכשירים ניידים הוא בערך 350KB. וזה רק גודל ההעברה! לרוב, קובצי JavaScript עוברים דחיסה כששולחים אותם ברשת, כלומר הכמות האמיתית של JavaScript גדולה בהרבה אחרי שהדפדפן מבצע להם דה-קומפרסיה. חשוב לציין את זה, כי בכל מה שקשור לעיבוד של משאבים, דחיסה לא רלוונטית. ‫900KB של JavaScript לא דחוס הם עדיין 900KB עבור כלי הניתוח והקומפיילר, גם אם הם יכולים להיות בערך 300KB כשהם דחוסים.

דיאגרמה שממחישה את התהליך של הורדה, ביטול דחיסה, ניתוח, הידור וביצוע של JavaScript.
תהליך ההורדה וההפעלה של JavaScript. חשוב לשים לב: למרות שגודל ההעברה של הסקריפט הוא 300KB אחרי דחיסה, עדיין מדובר ב-900KB של JavaScript שצריך לנתח, לקמפל ולבצע.

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

תרשים השוואה בין זמן העיבוד של קובץ JavaScript בגודל 170KB לבין תמונה בפורמט JPEG בגודל דומה. המשאב של JavaScript דורש הרבה יותר משאבים לכל בייט בהשוואה ל-JPEG.
עלות העיבוד של ניתוח/קומפילציה של 170KB של JavaScript לעומת זמן הפענוח של קובץ JPEG בגודל דומה. (מקור).

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

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

הטכניקה הזו עובדת, אבל היא לא פותרת בעיה נפוצה באפליקציות שמתבססות על JavaScript, והיא הכללה של קוד שלא נעשה בו שימוש. השיטה Tree shaking מנסה לפתור את הבעיה הזו.

מה זה tree shaking?

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

המונח "tree shaking" (הסרת ענפים) מגיע מהמודל המנטלי של האפליקציה והתלות שלה כמבנה דמוי עץ. כל צומת בעץ מייצג תלות שמספקת פונקציונליות ייחודית לאפליקציה. באפליקציות מודרניות, התלויות האלה מובאות באמצעות הצהרות import סטטיות באופן הבא:

// Import all the array utilities!
import arrayUtils from "array-utils";

כשאפליקציה חדשה, יכול להיות שיש לה מעט תלויות. הוא גם משתמש ברוב התלויות שאתם מוסיפים, אם לא בכולן. עם זאת, ככל שהאפליקציה מתפתחת, יכול להיות שיתווספו לה עוד תלויות. בנוסף, תלויות ישנות יוצאות משימוש, אבל יכול להיות שהן לא יוסרו מבסיס הקוד. התוצאה הסופית היא שאפליקציה נשלחת עם הרבה JavaScript שלא נמצא בשימוש. השיטה Tree shaking (הסרת קוד שלא בשימוש) פותרת את הבעיה הזו על ידי ניצול האופן שבו הצהרות סטטיות של import מושכות חלקים ספציפיים ממודולים של ES6:

// Import only some of the utilities!
import { unique, implode, explode } from "array-utils";

ההבדל בין הדוגמה הזו, import, לבין הדוגמה הקודמת הוא שבמקום לייבא הכול מהמודול "array-utils" (שיכול להיות הרבה קוד), בדוגמה הזו מייבאים רק חלקים ספציפיים ממנו. בגרסאות פיתוח, זה לא משנה כלום, כי המודול כולו מיובא בכל מקרה. בגרסאות build של מוצרים, אפשר להגדיר את webpack כך שיסיר exports ממודולי ES6 שלא יובאו באופן מפורש, וכך להקטין את הגודל של גרסאות ה-build האלה. במדריך הזה נסביר איך עושים את זה.

איתור הזדמנויות לשינוי מהותי

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

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

צילום מסך של אפליקציה לדף יחיד לדוגמה לחיפוש במסד נתונים של פדלים לאפקטים של גיטרה.
צילום מסך של האפליקציה לדוגמה.

ההתנהגות שמניעה את האפליקציה הזו מופרדת לספק (כלומר, ‫Preact ו-Emotion) וחבילות קוד ספציפיות לאפליקציה (או 'chunks', כמו ש-webpack קוראת להן):

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

חבילות ה-JavaScript שמוצגות באיור שלמעלה הן גרסאות ייצור, כלומר הן עברו אופטימיזציה באמצעות הסרת רווחים וסימני פיסוק. ‫21.1KB עבור חבילה ספציפית לאפליקציה זה לא רע, אבל חשוב לציין שלא מתבצעת פעולת Tree Shaking כלל. בואו נבדוק את קוד האפליקציה ונראה מה אפשר לעשות כדי לפתור את הבעיה.

בכל אפליקציה, כדי למצוא הזדמנויות ל-tree shaking, צריך לחפש הצהרות סטטיות import. בחלק העליון של קובץ הרכיב הראשי, תופיע שורה כזו:

import * as utils from "../../utils/utils";

אפשר לייבא מודולים של ES6 במגוון דרכים, אבל מודולים כמו זה צריכים למשוך את תשומת הלב שלכם. השורה הספציפית הזו אומרת "import everything from the utils module, and put it in a namespace called utils." השאלה הגדולה שצריך לשאול כאן היא "just how much stuff is in that module?"

אם תעיינו בקוד המקור של מודול utils, תראו שיש בו כ-1,300 שורות קוד.

האם צריך את כל הדברים האלה? כדי לוודא, נחפש בקובץ הרכיב הראשי שמייבא את המודול utils כדי לראות כמה מופעים של מרחב השמות הזה מופיעים.

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

מסתבר שמרחב השמות utils מופיע רק בשלושה מקומות באפליקציה שלנו – אבל באילו פונקציות? אם תסתכלו שוב על קובץ הרכיב הראשי, תראו שזו רק פונקציה אחת, utils.simpleSort, שמשמשת למיון רשימת תוצאות החיפוש לפי מספר קריטריונים כשמשנים את התפריטים הנפתחים של המיון:

if (this.state.sortBy === "model") {
  // `simpleSort` gets used here...
  json = utils.simpleSort(json, "model", this.state.sortOrder);
} else if (this.state.sortBy === "type") {
  // ..and here...
  json = utils.simpleSort(json, "type", this.state.sortOrder);
} else {
  // ..and here.
  json = utils.simpleSort(json, "manufacturer", this.state.sortOrder);
}

מתוך קובץ עם 1,300 שורות עם הרבה ייצואים, רק אחד מהם נמצא בשימוש. כתוצאה מכך, נשלח הרבה קוד JavaScript שלא נמצא בשימוש.

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

מניעת המרת מודולים של ES6 למודולים של CommonJS ב-Babel

Babel הוא כלי חיוני, אבל הוא עלול להקשות על הבחנה בהשפעות של tree shaking. אם אתם משתמשים ב-@babel/preset-env, ‏ Babel עשוי להמיר מודולים של ES6 למודולים של CommonJS שתואמים למגוון רחב יותר של דפדפנים – כלומר, מודולים שאתם require במקום import.

קשה יותר לבצע tree shaking במודולים של CommonJS, ולכן אם תבחרו להשתמש בהם, מערכת webpack לא תדע מה למחוק מהחבילות. הפתרון הוא להגדיר את @babel/preset-env כך שישאיר את מודולי ES6 כמו שהם. בכל מקום שבו מגדירים את Babel – ב-babel.config.js או ב-package.json – צריך להוסיף עוד משהו:

// babel.config.js
export default {
  presets: [
    [
      "@babel/preset-env", {
        modules: false
      }
    ]
  ]
}

ציון modules: false בהגדרות של @babel/preset-env גורם ל-Babel להתנהג כמו שרוצים, וכך מאפשר ל-webpack לנתח את עץ התלות ולסלק תלויות שלא נעשה בהן שימוש.

חשוב לזכור את תופעות הלוואי

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

let fruits = ["apple", "orange", "pear"];

console.log(fruits); // (3) ["apple", "orange", "pear"]

const addFruit = function(fruit) {
  fruits.push(fruit);
};

addFruit("kiwi");

console.log(fruits); // (4) ["apple", "orange", "pear", "kiwi"]

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

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

ב-webpack, אפשר להשתמש ברמז כדי לציין שחבילה והתלות שלה לא כוללות תופעות לוואי. לשם כך, מציינים "sideEffects": false בקובץ package.json של הפרויקט:

{
  "name": "webpack-tree-shaking-example",
  "version": "1.0.0",
  "sideEffects": false
}

לחלופין, אפשר לציין ב-webpack אילו קבצים ספציפיים לא חופשיים מתופעות לוואי:

{
  "name": "webpack-tree-shaking-example",
  "version": "1.0.0",
  "sideEffects": [
    "./src/utils/utils.js"
  ]
}

בדוגמה השנייה, כל קובץ שלא צוין ייחשב כקובץ שלא כולל תופעות לוואי. אם אתם לא רוצים להוסיף את זה לקובץ package.json, אפשר גם לציין את הדגל הזה בהגדרות של webpack באמצעות module.rules.

ייבוא רק של מה שצריך

אחרי שמנחים את Babel לא לגעת במודולים של ES6, צריך לבצע שינוי קל בתחביר של import כדי להשתמש רק בפונקציות הנדרשות מהמודול utils. בדוגמה שבמדריך הזה, כל מה שצריך הוא הפונקציה simpleSort:

import { simpleSort } from "../../utils/utils";

מכיוון שמייבאים רק את simpleSort ולא את המודול utils כולו, צריך לשנות כל מופע של utils.simpleSort ל-simpleSort:

if (this.state.sortBy === "model") {
  json = simpleSort(json, "model", this.state.sortOrder);
} else if (this.state.sortBy === "type") {
  json = simpleSort(json, "type", this.state.sortOrder);
} else {
  json = simpleSort(json, "manufacturer", this.state.sortOrder);
}

אלה כל הפעולות שצריך לבצע כדי שהטכניקה של tree shaking תפעל בדוגמה הזו. זה הפלט של webpack לפני הסרת ענפים לא נחוצים מעץ התלות:

                 Asset      Size  Chunks             Chunk Names
js/vendors.16262743.js  37.1 KiB       0  [emitted]  vendors
   js/main.797ebb8b.js  20.8 KiB       1  [emitted]  main

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

                 Asset      Size  Chunks             Chunk Names
js/vendors.45ce9b64.js  36.9 KiB       0  [emitted]  vendors
   js/main.559652be.js  8.46 KiB       1  [emitted]  main

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

צאו לנער כמה עצים!

המידה שבה תהנו מ-tree shaking תלויה באפליקציה, בתלויות ובארכיטקטורה שלה. רוצה לנסות? אם אתם יודעים בוודאות שלא הגדרתם את כלי ה-bundling של המודולים לביצוע האופטימיזציה הזו, כדאי לנסות ולראות איך היא משפרת את האפליקציה.

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

תודה מיוחדת ל-Kristofer Baxter,‏ Jason Miller,‏ Addy Osmani,‏ Jeff Posnick,‏ Sam Saccone ו-Philip Walton על המשוב החשוב שלהם, ששיפר משמעותית את איכות המאמר הזה.