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

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

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

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

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

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

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

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

מהי רעידת עצים?

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

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

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

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

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

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

איתור הזדמנויות לרעידת עץ

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

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

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

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

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

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

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

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

ניתן לייבא מודולים של ES6 במגוון דרכים, אבל מודולים כאלה צריכים למשוך את תשומת ליבכם. בשורה הספציפית הזו כתוב "import כל מה מהמודול utils, ומציבים אותו במרחב שמות שנקרא utils." השאלה הגדולה שאפשר לשאול היא "כמה דברים מופיעים במודול הזה?"

אם תסתכלו בקוד המקור של המודול 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 שאינם בשימוש.

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

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

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

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

כשמדובר ב-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);
}

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

קדימה, מנערים כמה עצים!

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

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

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