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

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

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

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

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

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

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

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

מהו tree shaking?

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

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

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

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

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

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

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

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

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

בכל אפליקציה, כדי למצוא הזדמנויות ל-tree shaking צריך לחפש הצהרות 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 שלא מנוצל.

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

איך למנוע מ-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);
}

זה אמור להיות כל מה שנדרש כדי ש-tree shaking יפעל בדוגמה הזו. זהו הפלט של webpack לפני שנעזרים ב-shake כדי לערער את עץ התלות:

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

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