התחלת עבודה עם Webpack




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

רקע

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

על איזה צורך ספריית webpack באה לענות?

ספריית webpack באה לענות על מספר צרכים:
  1. concatenation (שרשור קבצים) - אתר אינטרנט פשוט מכיל מספר רב של קבצי html, JavaScript ו- css. במקום לשלוח כל קובץ בנפרד מהשרת ללקוח, ע"י ספריית webpack מאחדים את כל קבצי ה-JS וה-CSS לקובץ אחד גדול וכך ניתן לשלוח מהשרת ללקוח את הכל בפעם אחת או לפחות להקטין בצורה משמעותית את הפניות לשרת.
  2. Minification -  פרויקטים של אתרי אינטרנט יכולים להגיע לגודל של מאות MBytes. ניתן להקטין את גודל הקבצים ע"י מחיקה של כל הרווחים וכל התווים שלא הכרחיים למחשב (רווחים, טאבים, ירידת שורה וכו'). בנוסף, webpack משנה את הקוד כך שהוא יהיה קצר יותר אבל ללא שינוי האלגוריתם. כל השינויים האלו מקטינים באופן משמעותי את גודל הקבצים.
  3. סדר טעינת הקבצים - לפעמים יש צורך בטעינה של קבצי JS לפי סדר מסוים כיון שיש תלות של אחד בשני. כשיש מעט קבצים זה קל, אבל כשיש הרבה קבצים זה הופך להיות מסובך מאוד. ע"י שימוש ב-module system ניתן להגדיר לכל קובץ באיזה קבצים הוא תלוי וכך אין צורך לדאוג לסדר טעינת הקבצים בעצמנו. ה-webpack מסייע לנו במשימה זו תוך שימוש ב-module system שנבחר.
  4. Transpilation - לפעמים רוצים להשתמש ביכולות חדשות בשפת JavaScript והבעיה היא שה-browsers עדיין לא תומכים בזה. ע"י שימוש ב-transpiler ניתן להשתמש ביכולות החדשות וה-transpiler ימיר את הקוד למשהו שה-browser תומך בו. webpack מסייע לנו להשתמש ב-transpiler.
  5. Linting - כאשר עובדים בפרויקט גדול עם הרבה מתכנתים, עוזר מאוד להגדיר איזשהו code standard כדי שהקוד יהיה יותר קריא ולא יראה כמו פאזל שמורכב מסגנונות כתיבה שונות. בעזרת כלי linting ניתן לקבוע כללים כאלו וכל פעם שתהיה חריגה מהכללים הכלי יתריע על כך. webpack מסייע לנו להשתמש בכלי ה-linting.

תחילת עבודה

כדי להתחיל לעבוד עם webpack צריך להתקין אותו:

npm install webpack -g

נניח שיש לנו פרויקט קטן עם 2 קבצים: index.html, app.js. 

index.html
<!DOCTYPE html>
<html>

  <head>
    <script src="bundle.js"></script>
  </head>

  <body>
    
  </body>
</html>


app.js
document.write("playing with webpack");
console.log("app.js loaded");

נכתוב בטרמינל:

webpack app.js bundle.js

webpack ייקח את הקובץ app.js ויהפוך אותו ל-bundle.js (ב-webpack מקובל לקרוא לקובץ המתקבל בשם bundle). בתוך הקובץ index.html נוסיף הכללה של הקובץ bundle.js כפי שרואים לעיל.

קובץ קונפיגורציה

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

webpack.config.js
module.exports = {
    entry: "./app.js",
    output: {
        filename: "bundle.js"
    }
}

השדה entry מציין מה הם הקבצים שאנו רוצים לכלול ב-build שלנו.
השדה output הוא אובייקט שמציין פרמטרים לגבי התוצר שנקבל. במקרה שלנו ציינו רק את שם הקובץ שנקבל.
במצב הנוכחי, קובץ הקונפיגורציה עושה בדיוק מה שעשינו בפקודה הישירה בטרמינל webpack app.js bundle.js.

watch mode

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

webpack.config.js
module.exports = {
    entry: "./app.js",
    output: {
        filename: "bundle.js"
    },
    watch: true
}

webpack dev server

כדי שנוכל לעבוד בצורה יותר מעשית עם קריאות http, אנחנו רוצים לעבוד מול שרת ולא כל פעם להריץ את קובץ ה-index.html. גם לדבר הזה יש פתרון ע"י webpack. נתקין את השרת של webpack ע"י הפקודה:

npm install webpack-dev-server -g

כדי להריץ את השרת נכתוב בטרמינל:

webpack-dev-server

השרת מריץ את ה-webpack שלנו (כפי שמוגדר בקובץ הקונפיגורציה שלנו) ונותן לנו כתובת URL שבה נוכל לראות את קובץ ה-bundle שלנו פועל. בטרמינל נראה משהו כזה:


במקרה הזה ניתן לראות שה-URL הוא: 
http://localhost:8080/

עכשיו נעשה שינוי קטן בקובץ app.js ונשמור אותו. ונוכל לראות שהכל מתבצע אוטומטית. ה-webpack יוצר לנו את קובץ ה-bundle, משתמש בו בשרת, ומרענן לנו את הדף. כך שכל פעם שנשמור שינוי נוכל ישר לראות אותו ב-browser בלי לעשות שום דבר נוסף. מגניב, לא?
ואם כל זה לא מספיק, אז מקובל לכתוב סקריפט שיריץ את ה-webpack-dev-sever בצורה עוד יותר פשוטה בעזרת node. פשוט פותחים את קובץ package.json ושם בשדה של scripts יש באופן דיפולטיבי שדה בשם test. נמחק את כל השורה הזו ובמקומה נכתוב את הסקריפט שלנו:
"scripts": {
"start": "webpack-dev-server"
},
עכשיו בכל פעם שנרצה להריץ את ה-webpack-dev-server פשוט נכתוב npm start.

בניה של מספר קבצים

בד"כ יהיה לנו יותר מקובץ js אחד. נתייחס לשני מקרים, קובץ לוקאלי שהוא חלק מהפרויקט. וקובץ של ספרייה חיצונית כמו jquery שאנחנו רוצים להשתמש בו בפרויקט שלנו.

1. קובץ לוקאלי - במקרה של קובץ רגיל שהוא חלק מהפרויקט נצרף אותו ע"י פקודת require של commonjs בתוך הקובץ שבו הקובץ הזה נצרך. למשל אם יש לנו קובץ page1.js והוא נצרך ב-app.js אז נכתוב בתחילת app.js:
require('./page1');

2. ספריה חיצונית - במקרה שנרצה להוסיף ספרייה חיצונית שצריך שתהיה זמינה בכל מיני מקומות בפרויקט נוסיף אותה לשדה ה-entry בקובץ webpack.config.js. למשל אם לספריה החיצונית קוראים page2.js אז נכתוב:
webpack.config.js
module.exports = {
    entry: ["page2.js", "./app.js"],
    output: {
        filename: "bundle.js"
    },
    watch: true
}

כדי להשתמש בשינויים שעשינו בקובץ הקונפיגורציה נצטרך להריץ מחדש את ה-webpack-dev-server.

שימוש ב-Loaders

בעיקרון, כל מה ש-webpack יודע לעשות זה concatenation ו-minification. כדי שיהיה ניתן לעשות איתו עוד דברים צריך להשתמש ב-loaders.
לדוגמה:

שימוש ב-babel כ-transpiler מ-es6 ל-es5


אם אנחנו רוצים להשתמש בקוד חדש שנתמך ב-es6 אבל לא נתמך ב-es5 נצטרך להשתמש ב-transpiler שימיר לנו את הקוד מ-es6 ל-es5. לצורך כך נשתמש ב-babel.
קודם כל נתקין 3 ספרות של babel:

npm install babel-core babel-loader babel-preset-es2015 --save-dev

בנוסף ניצור קובץ בשם babelrc. שבו נגדיר ל-babel לאיזה פורמט להמיר את הקוד.


.babelrc
{
  "presets": ["es2015"]
}

ניצור קובץ בשם login.es6 ונכתוב שם קוד של es6:

login.es6
let login = (username, password) => {
if(username !== 'admin' || password !== 'abcd'){
console.log("incorrect login try");
}
};
login('admin', 'aaaa');

  ונשתמש בו בתוך app.js:

login.es6
require('./login');
document.write("playing with webpack! yes");
console.log("app.js loaded");

עכשיו נקנפג את webpack להשתמש ה-babel. נערוך את קובץ הקונפיגורציה של webpack בצורה הבאה:

.webpack.config.js
module.exports = {
entry: ["./page2.js", "./app.js"],
output: {
filename: "bundle.js"
},
watch: true,
module:{
rules:[
{
test: /\.es6$/,
exclude: /node_modules/,
loader: "babel-loader"
}
]
},
resolve:{
extensions: ['.js', '.es6']
}
}

החלקים החדשים מודגשים בצהוב.
בשדה module נקנפג את השימוש שלנו ב-loaders. אז כרגע הוספנו loader אחד (כל loader הוא אובייקט) וכדי להגדיר אותו צריך 3 שדות.
  • test - בשדה הזה נכניס ביטוי regex שקובע באילו סוגי קבצים יטפל ה-loader הזה. במקרה שלנו רק קבצי es6.
  • exclude - בשדה הזה נכניס ביטוי regex שקובע באילו סוגי קבצים לא יטפל ה-loader הזה. אפילו אם הם עונים לביטוי ה-regex שבשדה test.
  • loader - שם ה-loader.
בנוסף הוספנו שדה של resolve. בשדה הזה נגדיר ל-webpack את סוגי הקבצים שהוא צריך לעבוד עליהם גם אם לא ציינו במפורש את סיומת הקובץ. במקרה שלנו הגדרנו קבצי js (שזה ה-default) וקבצי es6. בצורה זו נוכל להכליל את קובץ ה-login.es6 ע"י פקודת:
require('./login');
גם בלי לציין את סיומת הקובץ, וה-webpack ידע למצוא אותו. כמו שעשינו ב-app.js.

עכשיו ניתן להריץ מחדש את השרת שלנו:
webpack-dev-server
ולראות את ההדפסה שמוכיחה שנעשה שימוש בקובץ login.es6:
incorrect login try

שימוש ב-preloaders

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

שימוש ב-jshint לצורך static code analysis



jshint זה כלי שעושה static code analysis ל-js ויכול לזהות בעיות פוטנציאליות בקוד. כמו כן ניתן לקנפג אותו שיתריע על כל מיני דברים כפי שנדרוש ממנו.
קודם כל נתקין אותו:



npm install jshint jshint-loader --save-dev

צורת השימוש ב-preloader דומה לשימוש ב-loader. אנחנו צריכים קובץ שבו נגדיר מה אנחנו רוצים ש-jshint יעשה. הקובץ הזה נקרא jshintrc.


.jshintrc
{
"undef": true,
"unused": true
}

פה למשל הגדרנו ל-jshint להודיע לנו על משתנים שלא מוגדרים או לא בשימוש. וצריך להגדיר בקובץ webpack.config.js להשתמש ב-jshint כ-preloader:


webpack.config.js
module.exports = {
entry: ["./page2.js", "./app.js"],
output: {
filename: "bundle.js"
},
watch: true,
module:{
rules:[
{
test: /\.js$/,
enforce: "pre",
exclude: /node_modules/,
loader: "jshint-loader"
},
{
test: /\.es6$/,
exclude: /node_modules/,
loader: "babel-loader"
}
]
},
resolve:{
extensions: ['.js', '.es6']
}
}

כפי שרואים לעיל צורת ההגדרה של ה-preloaders ב-webpack.config.js דומה מאוד ל-loaders, בתוספת שדה אחד שנקרא enforce ובו הגדרנו שמדובר ב-pre. הכוונה ל-loader שרץ לפני תהליך ה-build.
עכשיו אם נתחיל מחדש את ה-webpack-dev-server נראה כל מיני warnings ש-jshint כותב לנו לגבי page2.js ו-app.js.

הכנת build ל-production

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

minification

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



webpack -p

עכשיו אם נסתכל על קובץ ה-bundle.js שנוצר נראה שכל הקוד כתוב בשורה אחת.

ביטול חלק מהקוד

בנוסף, אנחנו בד"כ נרצה לבטל את כל ההדפסות שנועדו לצורך debug ועוד דברים שלא נצרכים ב-production. לצורך כך נשתמש ב-Loader שנקרא strip-loader. בהתחלה נתקין אותו:



npm i strip-loader --save-dev

למי שלא מכיר, ניתן לקצר ולכתוב "npm i" במקום "npm install".
עכשיו אנחנו ליצור קובץ קונפיגורציה חדש ל-webpack שישמש רק ל-production. נקרא לו webpack-production.config.js. 

ונכתוב אותו בצורה הבאה:


webpack-production.config.js
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
var WebpackStrip = require('strip-loader');
var devConfig = require('./webpack.config');
var stripLoader = {
    test: [/\.js$/, /\.es6$/],
    exclude: /node_modules/,
    loader: WebpackStrip.loader('console.log')
}

devConfig.module.rules.push(stripLoader);
module.exports = devConfig;

כיון שאנחנו לא רוצים לכתוב את כל הקונפיגורציה מחדש אלא רק לשנות חלק מהקונפיגורציה קיימת, בשורה 2 לקחנו את הקונפיגורציה הקיימת ושמנו אותה בתוך המשתנה devConfig. לאחר מכן משורה 3 בנינו את ה-loader החדש שלנו והגדרנו לו לעבוד על כל קבצי ה-js וה-es6. והפקודה שהוא יריץ על הקבצים האלו היא:
WebpackStrip.loader('console.log')
שזה בעצם אומר למחוק את כל פקודות ה-console.log. ניתן להוסיף כאן עוד פקודות שאנחנו לא רוצים ב-production שלנו, פשוט ע"י פסיק וציון של שם הפונקציה שנרצה למחוק בתוך גרשיים.
בשורה 9 הוספנו את ההגדרה של ה-loader החדש לתוך מערך ה-rules שלנו. למי שלא זוכר מערך ה-rules הוא המערך שבו הגדרנו את כל ה-loaders שלנו בקובץ webpack.config.js. אז בעצם בשורה 9 הוספנו לו עוד loader אחד. 
בשורה 10 ייצאנו את הקונפיגורציה החדשה כדי שתהיה זמינה לשימוש.

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

webpack --config webpack-production.config.js -p

האפשרות "config--" אומרת ל-webpack באיזה קובץ קונפיגורציה להשתמש. בלי האפשרות הזו הוא ישתמש בקובץ הדיפולטיבי שהוא webpack.config.js. לכן השתמשנו באפשרות הזו כדי להשתמש בקובץ הקונפיגורציה החדש שיצרנו ל-production. וכמובן שהשתמשנו גם ב-p כדי לעשות מיניפיקציה.

עכשיו כדי לראות את התוצאה נשתמש ב-http-server של node (לא נשתמש ב-webpack-dev-server הרגיל שלנו כיום שאז הוא ישתמש בקובץ הקונפיגורציה הדיפולטיבי שהוא webpack.config.js ואת זה אנחנו לא רוצים עכשיו). אז נתקין את ה-http-server:

npm i http-server -g

ונריץ אותו פשוט ע"י הפקודה:

http-server

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











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


סידור נכון של הפרויקט

עד עכשיו עבדנו עם כל הקבצים תחת אותה ספריה. בפרויקט אמיתי כדאי לסדר את הקבצים בצורה שונה לפי ספריות.
בתור התחלה נעביר את קבצי ה-js וה-es6 שלנו לתוך ספריית js. את הקובץ index.html נעביר לספריית public. עכשיו הפרויקט שלנו יראה כך:

עכשיו צריך לשנות קצת את קובץ ה-index.html כיון שכרגע הוא מנסה להשתמש בקובץ bundle.js אבל הכותבת שלו לא נכונה. לכן נשנה את הקובץ לצורה הבאה:

<!DOCTYPE html>
<html>

  <head>
    <script src="/public/assets/js/bundle.js"></script>
  </head>

  <body>
    
  </body>
</html>

זה הכתובת שממנה אני רוצה להשתמש בקובץ ה-bundle.js אבל זו לא הכתובת שאליה אני רוצה לבנות את הקובץ bundle.js. 
כדי לשנות את הכתובת שאליה נבנה קובץ ה-bundle.js נחזור לקובץ הקונפיגורציה webpack.config.js ונשנה אותו לצורה הבאה:


webpack.config.js

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
var path = require('path');

module.exports = {
    context: path.resolve('js'),
    entry: ["./page2.js", "./app.js"],
    output: {
        path: path.resolve('build/js/'),
        publicPath: '/public/assets/js/',
        filename: "bundle.js"
    },

    devServer:{
        contentBase: 'public'
    },

    watch: true,
    
    module:{
        rules:[
            {
                test: /\.js$/,
                enforce: "pre",
                exclude: /node_modules/,
                loader: "jshint-loader"
            },
            {
                test: /\.es6$/,
                exclude: /node_modules/,
                loader: "babel-loader"
            }
        ]
    },
    resolve:{
        extensions: ['.js', '.es6']
    }
}

בשורה הראשונה אנחנו מייבאים מודול של node שנקרא path. המודול הזה יעזור לנו בקביעת הכתובות של הקבצים שנייצר ושנשתמש.
בשורה 3 הוספנו שדה שנקרא context. בשדה זה אנחנו קובעים כתובת יחסית בשביל שדה ה-entry. כך ש-webpack יחפש את הקבצים page2.js ו-app.js בתוך ספרייה בשם js.
בשורה 7 הוספנו שדה שנקרא path ובו קבענו שהקובץ bundle.js שנוצר יכנס לתוך ספריית ה-build/js. 
בשורה 8 הוספנו שדה שנקרא publicPath ובו קבענו ל-webpack מה הכתובת שממנו ילקח bundle.js כשמייביאים אותו מה-web server. במקרה שלנו הוא יהיה בכתובת public/assets/js וזה תואם למה שכתוב בקובץ index.html. 
בשורות 12-14 הוספנו שדה devServer שמכיל שדה שנקרא contentBase. בשדה זה אנחנו בעצם מגדירים ל-web server שלנו את ה-root directory כך שאם ה-browser יבקש את ה-index.html אז הוא ילקח מספריית ה-root שנקראת public בשרת.
לסיכום, בקשות לתוכן מהכתובת public/assets/js יגיעו בשרת מהכתובת build/js. ובקשות לתוכן מה-root directory יגיעו מהשרת בכתובת public.

הוספת Source Maps

לעיל הסברנו ש-webpack משנה את הקוד שלנו (מוריד רווחים, מכניס את כל הקוד לשורה אחת וכו'), וזה מה שבפועל רץ ב-browser. 
בנוסף הסברנו איך ממירים קבצי es6 ל-js בעזרת babel. וגם כאן מה שירוץ בפועל יהיה קובץ ה-js.
אבל אם נרצה לדבאג את הקוד שלנו נרצה לראות את הקבצים המקוריים שלנו (קבצי ה-js וה-es6 שכתבנו) ולא את קבצי ה-js שנוצרו ע"י babel ו-webpack. 
לצורך כך נועדו קבצי ה-source maps. קבצים אלו ממפים את קוד ה-js חזרה לקוד המקורי וכך ניתן לראות ולדבאג ישירות את הקוד שכתבנו למרות שמה שרץ באמת זה הקוד שב-bundle.js.

כדי ש-webpack יצור קבצי source maps כל מה שצריך להוסיף זה d-. למשל:
webpack -d
או
webpack-dev-server -d
וכך כשנדבאג את הקוד שלנו נראה את הקבצים המקוריים שלנו ולא את הקובץ bundle.js.

---------------------------------------------------------------------------------
מפה והלאה זה עדיין בכתיבה אז מומלץ לא להתייחס...
---------------------------------------------------------------------------------

ואם רוצים ליצור יותר מקובץ אחד

לפעמים, מכל מיני סיבות יש צורך ש-webpack יארוז את הקוד שלנו במספר קבצים ולא רק בקובץ אחד (bundle.js). למשל כשרוצים לממש lazy loading יש צורך בדבר הזה. כמובן שגם את זה ניתן לעשות ב-webpack. 
ניקח לדוגמה פרויקט שיש בו שלושה קבצי html ובמקביל שלושה קבצי js:
index.html, page1.html, page2.html
index.js,     page1.js,     page2.js
כל קובץ html יכיל הפניה לקובץ ה-js שלו ובנוסף הפניה לקובץ שנקרא shared.js. אנחנו נקנפג את webpack כך שיצור קובץ שנקרא shared.js שבו יהיה הקוד של webpack שניצרך לכל הקבצים כדי ש-webpack לא ישכפל אותו בכל js בנפרד.
לכן למשל בקובץ page1.html נכתוב את 2 השורות  הבאות:


1
2
<script src="/public/assets/js/shared.js"></script>
<script src="/public/assets/js/page1.js"></script>

בקובץ page2.html נכלול במקום page1.js את page2.js ואילו ב-index.html נכלול את index.js.
עכשיו נטפל בקובץ הקונפיגורציה בצורה הבא: