אפליקציות אינטרנט יכולות להיות גדולות מאוד, במיוחד החלק שלהן ב-JavaScript. נכון לאמצע 2018, גודל ההעברה החציוני של JavaScript במכשירים ניידים הוא כ-350KB. וזה רק גודל ההעברה! בדרך כלל, קוד JavaScript נדחס כשהוא נשלח ברשת, כלומר הכמות האמיתית של קוד JavaScript גדולה בהרבה אחרי שהדפדפן מבצע את הפירוק שלו. חשוב לציין זאת, כי מבחינת עיבוד המשאבים, הדחיסה לא רלוונטית. 900KB של JavaScript ללא דחיסה עדיין הם 900KB למנתח ולמקראם, גם אם הם עשויים להיות בערך 300KB לאחר דחיסה.
עיבוד JavaScript הוא תהליך יקר. בניגוד לתמונות, שבהן זמן הפענוח הוא קל יחסית אחרי ההורדה, צריך לנתח, לקמפל ולבסוף להריץ את JavaScript. כך, בייט אחרי בייט, JavaScript יקר יותר מסוגים אחרים של משאבים.
אנחנו כל הזמן מבצעים שיפורים כדי לשפר את היעילות של מנועי JavaScript, אבל שיפור הביצועים של JavaScript הוא, כמו תמיד, משימה של המפתחים.
לשם כך, יש שיטות לשיפור הביצועים של JavaScript. פיצול קוד הוא אחת מהשיטות האלה לשיפור הביצועים. בשיטה הזו, קוד ה-JavaScript של האפליקציה מחולק למקטעים, והמקטעים האלה מוצגים רק למסלולים של האפליקציה שזקוקים להם.
השיטה הזו עובדת, אבל היא לא פותרת בעיה נפוצה באפליקציות עם הרבה JavaScript, והיא הכללת קוד שלא נעשה בו שימוש אף פעם. רעידת העץ מנסה לפתור את הבעיה הזו.
מהו tree shaking?
Tree shaking הוא סוג של הסרה של קוד לא פעיל. המונח פורסם על ידי Rollup, אבל הרעיון של הסרת קוד לא פעיל קיים כבר זמן מה. הקונספט הזה נמצא גם ב-webpack, שבו נעשה שימוש במאמר הזה באמצעות אפליקציה לדוגמה.
המונח 'ניעור עצים' נגזר מהמודל המנטלי של האפליקציה והיחסים שלה כמבנה דמוי עץ. כל צומת בעץ מייצג תלות שמספקת פונקציונליות ייחודית לאפליקציה שלכם. באפליקציות מודרניות, יחסי התלות האלה מגיעים באמצעות הצהרות import
סטטיות, למשל:
// Import all the array utilities!
import arrayUtils from "array-utils";
כשאפליקציה צעירה – שתילה, אם תרצו – יכול להיות שיש לה מעט יחסי תלות. הוא גם משתמש ברוב יחסי התלות שאתם מוסיפים, אם לא בכולם. עם זאת, ככל שהאפליקציה מתפתחת, יכול להיות שיתווספו עוד יחסי תלות. נוסף על כך, יחסי תלות ישנים כבר לא בשימוש, אבל יכול להיות שהם לא יוסרו מקוד הבסיס. התוצאה הסופית היא שהאפליקציה תכלול הרבה קוד JavaScript שלא בשימוש. רעידות עצים מטפלות בבעיה הזו באמצעות ניצול של האופן שבו הצהרות 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 מכנה אותם):
חבילות ה-JavaScript שמוצגות בתרשים שלמעלה הן גרסאות build לסביבת הייצור, כלומר הן עוברות אופטימיזציה באמצעות uglification. 21.1KB לחבילה ספציפית לאפליקציה אינו רע, אך חשוב לציין שאף פעם לא מתרחשת רעידת עצים. נבדוק את קוד האפליקציה ונראה מה אפשר לעשות כדי לפתור את הבעיה.
בכל אפליקציה, כדי למצוא הזדמנויות ל-tree shaking צריך לחפש הצהרות import
סטטיות. סמוך לחלק העליון של קובץ הרכיב הראשי, תופיע שורה כזו:
import * as utils from "../../utils/utils";
ניתן לייבא מודולים של ES6 במגוון דרכים, אבל מודולים כאלה צריכים למשוך את תשומת הלב שלכם. השורה הספציפית הזו אומרת "import
הכול מהמודול utils
, ומכניסים אותו למרחב שמות שנקרא utils
". השאלה הגדולה שצריך לשאול כאן היא "כמה דברים יש במודול הזה?"
אם תעיינו בקוד המקור של מודול utils
, תגלו שיש בו כ-1,300 שורות קוד.
האם צריך את כל הדברים האלה? בודקים שוב על ידי חיפוש בקובץ הרכיב הראשי שמייבא את המודול 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%. האפשרות הזו מקצרת את זמן העיבוד של הסקריפט, אלא גם את זמן העיבוד שלו.
קדימה, אפשר לזוז קצת!
התוצאות של 'ניפוי עצים' תלויות באפליקציה, ביחסי התלות ובארכיטקטורה שלה. נסה בעצמך! אם ידוע לך שעדיין לא הגדרת את חבילת ה-מודולים לביצוע האופטימיזציה הזו, אין טעם לנסות ולראות איך הוא מועיל לאפליקציה שלך.
יכול להיות שתבחינו בשיפור משמעותי בביצועים בעקבות 'רעידת העץ', או שלא תבחינו בשיפור משמעותי בכלל. עם זאת, הגדרת מערכת ה-build שלך לנצל את האופטימיזציה הזו בפיתוח גרסאות build ולייבא באופן סלקטיבי רק את מה שהאפליקציה שלך צריכה, כך שחבילות האפליקציות שלך יהיו קטנות ככל האפשר באופן יזום.
תודה מיוחדת ל-Kristofer Baxter, ל-Jason Miller, ל-Addy Osmani, ל-Jeff Posnick, ל-Sam Saccone ול-Philip Walton על המשוב החשוב שלהם, ששיפר באופן משמעותי את האיכות של המאמר הזה.