باید قبول کنیم Webpack ابزار پیچیدهایست. شاید یکی از دلایل اصلی، گستردگیِ آن در مدیریت فایلهای پروژه باشد. Webpack فراتر از ابزارهایی مانند Gulp و Grunt کمک میکند مدیریت بهتری روی assetهای استاتیک داشته باشیم و از آنها به طور مستقیم در کد استفاده کنیم. کاری که در نهایت یک «گراف وابستگی» یا Dependency Graph ایجاد میکند.
در این مقاله میخواهم به طور اختصاصی در مورد فعال کردن HMR یا Hot Module Replacement در یک پروژهی ساده توضیح بدهم. چرا یک پروژه ساده؟ اصلا منظور از پروژه ساده چیست؟
چون در بیشتر مقالههای فنی به بررسی HMR در یک پروژه Reactی پرداختهاند که خب ما به آن حد از پیچیدگی نیازی نداریم. برای درک یک موضوع هر چه داستان سبکتر بهتر.
پروژه ساده از تعدادی ماژول (همان فایل خودمان) Javascript تشکیل شده به همراه یک فایل CSS و یک فایل index.html. میخواهیم فایلهای js را یکی کنیم و در عین حال با تغییر آنها یا استایلها بدون ریفرش شدن بروزر تغییرات جدید را مشاهده کنیم. همین! :)
برای این مقاله پروژهای را به عنوان نمونه آماده کردم. سورس آن را میتوانید در گیتهاب پیدا کنید.
ساختار پروژه به این شکل است:
project
|_ src
| |_ css
| | |__ style.css
| |_ js
| |__ btn.js
| |__ foo.js
| |__ index.js
|__ index.html
|__ package.json
|__ webpack.common.js
|__ webpack.dev.js
|__ webpack.prod.js
تعریف Hot Module Replacement
ابتدا ببینیم تعریف Webpack از HMR چیست:
وقتی یک اپلیکیشن در حال کار کردن است HMR وظیفهی عوضکردن (Exchange) و اضافه/کم کردن ماژولها را بدون ریفرش کامل صفحه بر عهده دارد.
خب پس یعنی ما فایلهای source پروژه را ادیت میکنیم و تغییرات بدون ریفرش شدن صفحه اعمال میشوند. این قابلیت باعث میشود هم state کل پروژه از بین نرود و هم سرعت توسعه افزایش پیدا کند. پس همینجا مشخص شد HMR فقط برای محیط توسعه یا development به کارمان میآید.
یک نکته دیگر اینکه برای فعال کردن HMR نیاز به Webpack Dev Server یا Webpack Hot Middleware داریم. چرا؟ توضیح ساده اینکه باید به طریقی بتوان تغییرات جدید را به اپلیکیشن منتقل کرد. پل ارتباطیِ همیشه باز بین کد اپلیکیشن ما و کامپایلر Webpack ، یک سرور خواهد بود. تغییرات نیز به فرمت json به اپلیکیشن منتقل میشوند. در واقع وقتی HMR فعال است، کدی به عنوان HMR Runtime به خروجی نهایی اضافه میشود. این کد با کد اصلی اپلیکیشن ما در ارتباط است و تغییرات را از HMR Server که در Webpack Dev Server قرار دارد دریافت کرده و به کد ما منتقل میکند.
برای درک بهتر پیشنهاد میکنم این مقاله را مطالعه کنید.
همچنین قسمت concept مستندات webpack HMR هم میتواند مفید باشد.
فعالسازی HMR
در کانفیگ webpack کلید hot:true
را به devServer
اضافه میکنیم. همچنین باید پلاگین HotModuleReplacementPlugin
را به آرایهی پلاگینها اضافه کنیم:
const webpack = require('webpack');
const config = {
mode: 'development',
devtool: 'source-map',
module: {
rules: [] /* list of rules */
},
devServer: {
contentBase: '/',
port: 3000,
open: true,
hot: true,
},
plugins: [
new webpack.HotModuleReplacementPlugin(),
]
};
با این دو حرکت HMR فعال میشود و در اپلیکیشن به module.hot و توابع آن دسترسی خواهیم داشت.
HMR با Javascript
حال باید فایل index.js پروژه را هم آپدیت کنیم تا هر زمان تغییری رخ داد به webpack بگوییم ماژول آپدیت شده را قبول (accept) کند. برای این کار از متد accept
استفاده میکنیم. این متد دو آرگومان دریافت میکند. اولی dependencyها (به شکل یک رشته یا آرایه) و دومی یک تابع callback. کاری که انجام میدهد این است که تغییرات را برای dependencyها دریافت کرده و در مقابل تابع callback را فراخوانی میکند.
در این مثال یک ماژول به نام foo
خواهیم داشت که در فایل اصلی (index.js) import
شده و استفاده میشود. میخواهیم هر زمان این ماژول تغییر کرد، این تغییرات در خروجی نهایی نیز اعمال شود.
میتوانیم فعلا برای مشاهده سیر تغییرات در خروجی، متنی را در callback کنسول کنیم:
index.js
import foo from './foo.js';
foo();
if(module.hot){
module.hot.accept('./foo.js', function() {
console.log('updating foo module ...');
foo();
});
}
foo.js
export default () => {
console.log('this is foo module');
}
پروژه را با دستور npm run dev
اجرا میکنیم. در کنسول بروزر چنین داریم:
[HMR] Waiting for update signal from WDS...
this is foo module
[WDS] Hot Module Replacement enabled.
حالا متنی که در فایل foo.js کنسول کردیم را تغییر میدهیم و بلافاصله میتوانیم تغییرات را در کنسول ببینیم:
export default () => {
console.log('this is foo module UPDATE: some text');
}
[WDS] App updated. Recompiling...
[WDS] App hot update...
[HMR] Checking for updates on the server...
updating foo module ...
this is foo module UPDATE: some text
[HMR] Updated modules:
[HMR] - ./src/js/foo.js
[HMR] App is up to date.
همانطور که میبینید در کنسول بعد از لاگی که در callback نوشته بودیم، مقدار جدید لاگ در فایل foo.js درج شده است. کل این پروسه بدون ریفرش شدن بروزر انجام میشود. بسیار هم جذاب و هیجانانگیز!
در این مثال از ESM یا ECMAScript Modules استفاده کردیم. همانجا که ماژول foo را export
و بعدا در فایل اصلی import
کردیم در واقع مدیریت ماژول ES6 یا ES2015 را به کار بردهایم. اگر بخواهیم از CommonJS استفاده کنیم داستان کمی فرق خواهد کرد. باید dependencyها را به شکل دستی در callback دوباره require
کنیم:
let foo = require('./foo.js');
foo();
if(module.hot){
module.hot.accept('./foo.js', function() {
console.log('updating foo module ...');
foo = require('./foo.js');
foo();
});
}
چرا؟ چون لود شدن ماژولهای ES به شکل آسنکرون اما CommonJS به شکل سنکرون انجام میشود. برای مطالعه بیشتر این مقاله را پیشنهاد میکنم.
نکته مهم:
اگر از babel-loader استفاده میکنید، باید جلوی transpile کردن ماژولهای ES را گرفته و اینکار را به webpack بسپارید. در غیر این صورت مثالی که با هم بررسی کردیم جواب نخواهد داد. مطالعهی این issue را پیشنهاد میکنم.
HMR با CSS
HMR با CSS بسیار ساده است. کافیست style-loader را به ماژولهای webpack اضافه کنیم. این لودر در پسزمینه از module.hot.accept
استفاده میکند. بعد از آن، فایل css را در index.js import
میکنیم. در نتیجه هر تغییری در استایلها بدون ریفرش شدن به صفحه تزریق میشود.
webpack.dev.js
const webpack = require('webpack');
// css rule
const css = {
test: /\.css$/,
use: ['style-loader', 'css-loader'],
};
const config = {
module: {
rules: [css]
},
// other configs
};
گیرهای HMR
نوع منطق کدی که مخاطبِ HMR است ممکن است متفاوت باشد. همانطور که میدانیم ماژولها همیشه به سادگی foo نیستند. به همین علت ممکن است گاهی خروجی HMR ما را شگفتزده کند!
تسکهایی که در نتیجهی یک event اتفاق میافتند از این دست کدها هستند. فرض کنیم ماژولی داریم که در آن با کلیک روی یک باتن میخواهیم پیامی را کنسول کنیم. نام این ماژول را بدون هیچ خلاقیتی btn میگذاریم:
btn.js
function init() {
const btn = document.getElementById('btn');
btn.addEventListener('click', onClick);
}
function onClick(){
console.log('button clicked');
}
init();
حالا فایل index.js را به این شکل تغییر میدهیم:
import 'css/style.css';
import foo from './foo.js';
import './btn';
foo();
if(module.hot){
module.hot.accept();
}
قبل از توضیح مثال اگر دقت کرده باشید تابع accept
را بدون هیچ آرگومان فراخوانی کردیم. در این حالت تغییرات خود فایل دیده میشود. یک روش معمول این است که module.hot.accept
را یک بار در بالاترین فایل اپلیکیشن که در اینجا index.js است فراخوانی میکنند. در این صورت تغییرات بچه ماژولها (!) هم به پدر گزارش میشود.
خب برگردیم به مثال. با کلیک روی باتن، متن تابع onClick در btn.js کنسول میشود. بیایید این متن را عوض کنیم. ببینیم آیا HMR درست کار میکند یا نه. بعد از عوض کردن متن و کلیک روی باتن متوجه میشویم که متن آپدیت شده است اما متن قبلی نیز دوباره کنسول میشود! یعنی با هر بار کلیک دو متن در کنسول داریم.
متن را دوباره تغییر داده و صبر کنید HMR کارش تمام شود. حالا روی باتن کلیک کنید. این دفعه سه متن متفاوت کنسول میشود.
خب بدون شک این یک فاجعه است! داستان از آنجا آب میخورد که event handler باتن بدون unbind شدن هر دفعه به شکل تازهای به باتن bind میشود. در واقع تمام منطقهای قبل هنوز به باتن متصل هستند و با کلیک همه با هم اجرا میشوند.
در چنین سناریوهایی میتوانیم از متد module.hot.dispose
استفاده کنیم. این متد زمانی که کد ماژول عوض شده است اجرا میشود. اینجا دقیقا جاییست که میتوانیم eventهایمان را unbind کنیم. کد ماژول btn به این شکل تغییر پیدا میکند:
function init() {
const btn = document.getElementById('btn');
btn.addEventListener('click', onClick);
if(module.hot){
module.hot.accept(() => btn.removeEventListener('click', onClick));
}
}
function onClick(){
console.log('button clicked');
}
init();
حالا با عوض کردن متن onClick و کلیک روی باتن، متن جدید بدون ریفرش شدن بروزر کنسول میشود. :)
مرور کنیم
پس فهمیدیم در یک پروژه ساده چطور از HMR برای کدهای Javascript و CSS استفاده کنیم. اما هنوز برای یک پروژه بزرگ سوالهای اساسی داریم. مثلا اینکه باید حواسمان به eventها و handlerهایشان باشد واقعا دردی در جان است (ترجمه سنگین و شرقی از pain in the a.s.s).
اولا فراموش نکنیم این یک ابزار توسعه است. ابزارها قرار بود به کمکمان بیایند تا کارها را آسانتر پیش ببریم. پس اگر این روال برایتان دست و پاگیر میشود اصلا از آن استفاده نکنید! به این فکر کنید که تا قبل از HMR برای فایلهای Javascript چه کار میکردید.
دوما به احتمال بسیار زیاد یک پروژه بزرگ نیازمند فریمورک خواهد بود و کسی از بیخ و بن شروع به نوشتن کد Javascript خام نخواهد کرد. اگر این فرض را بپذیریم برای فریمورکها ابزارهایی وجود دارند که این مسیر را سادهتر میکنند برای مثال React Hot Loader، ٰVue Loader، Angular HMR و ...
پس نگران این موضوع نباشید :)