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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

קדימה, עודדו כמה עצים!

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

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

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