Webpack 內建名為 Hot Module Replacement (HMR) 的機制,和 LiveReload 等工具類似,能夠在開發時動態重新載入更改的程式碼。和 LiveReload 不同的是,Webpack 以 module 為單位進行重載,能夠在不重新整理頁面的情況下執行新的 module 並覆蓋舊的結果。對 React.js 的開發而言,HMR 能夠自動重載 component 內容以大幅增加開發速度。
動態重載的原理是另啟一個 server 去監聽本地端的檔案系統變化,並即時通知前端頁面來實行變動。此專案使用 webpack-dev-middleware
和 webpack-hot-middleware
來打造這台 server,並由 gulp server-hot
這個 Gulp task 啟用。gulp server-hot
會啟動一台 Express.js server:
import express from 'express';
import webpack from 'webpack';
import webpackDev from 'webpack-dev-middleware';
import webpackHot from 'webpack-hot-middleware';
import makeWebpackConfig from '../config.webpack';
const app = express();
const webpackConfig = makeWebpackConfig(process.env);
const compiler = webpack(webpackConfig);
app.use(webpackDev(compiler, {
headers: { 'Access-Control-Allow-Origin': '*' },
noInfo: true,
publicPath: webpackConfig.output.publicPath
}));
app.use(webpackHot(compiler));
app.listen(8080);
以上的兩個 Express middleware 到底實際上做了什麼事情?首先,webpack-dev-middleware
將 Webpack 的 compile 結果 host 在 server 中,而非寫入 output
所指定的檔案路徑。由於 bundling 的結果存放在記憶體,dev middleware 在執行 compile 的速度比起直接 build Webpack 快上不少。
webpack-hot-middleware
會將 dev middleware 的產出提供給前端存取。具體的作法是在 Webpack 的設定中,加入 webpack.optimize.OccurrenceOrderPlugin
、webpack.HotModuleReplacementPlugin
和 webpack.NoErrorsPlugin
三個 plugin,以及在 entry
的 hot server endpoint:
entry: {
app: [
`webpack-hot-middleware/client?path=http://${ip.address()}:8080/__webpack_hmr`,
'./src/client/main.js'
]
},
以及在 render HTML 內容時,將 JS bundle URL 指定到 hot server 提供的 endpoint:
const scriptSrc = isProduction ? '/dist/app.js' : `//${ip.address()}:8080/dist/app.js`;
只靠 hot server 並沒有真正達到動態重新載入的效果;實際上,HMR 機制還需要仰賴程式碼本身的設定才能運作。道理很簡單,因為 HMR 不會預先假設開發者撰寫的程式碼該如何去處理 module 重載的行為,這必須由開發者自行設定。
在 Webpack 載入 JS code 時,會對程式碼內用到的 require
和 module
變數做靜態轉換,建立 dependency graph(Webpack 的術語稱為 module tree)(註1);而開發者需要使用 Wepback 提供給 module
的 HMR API 來動態接受新的 module。HMR 機制可以藉由 module.hot.accept
設定:
if (module.hot && typeof module.hot.accept === 'function') {
module.hot.accept('./root.view', renderRoot);
}
function renderRoot() {
const Root = require('./root.view').default;
ReactDOM.render(<Root />, document.getElementById('app'));
}
以這段程式為例,當 root.view.js
內容更動時,module.hot.accept
會接收此事件並呼叫開發者設定的 renderRoot
callback。renderRoot
的內容就是單純重新 require 新版的 Root
component 並重新執行 ReactDOM.render
。而 HMR 真正的強大之處,在於它會監聽 dependency graph 內的任何 module 變動,並將此變動事件沿著 dependency graph 的 root 方向 bubble 出去,直到被 module.hot.accept
攔截為止。因此,我們不需要在每個 module 都加上 HMR 相關的程式碼,只需要在接近根部的位置(以此例而言,root.view.js
)設定好 HMR 即可一次監聽所有 child nodes 的變動事件。
若是 HMR 設定成功,Webpack 會在 browser console 輸出相關的 debug 訊息,例如和 hot server 連線成功的 [HMR] connected
、module 重新載入完成的 [HMR] bundle rebuilt in OOOms
等。當發生 HMR 事件時,Webpack 也會將變動的 module 依照 dependency graph 一路印出到 root 的結果:
反過來,若是 Webpack 偵測到無法被 hot replace 的 module 變動,也會以 console.warn
的形式輸出到 browser console:
這個警告訊息代表此 module 的變動事件在 bubbling 過程沒有被任何 module.hot.accept
攔截到。另外,由於一個 module 往往會被複數的 module 所 require,當有任何一條往 root 的 bubbling 的路線沒能被 module.hot.accept
攔截時,仍會出現這個 hot reload 失敗的訊息。若是想設定一個最上層的 module.hot.accept
攔截行為的話,以下模仿 LiveReload 行為的最簡易版本可以提供參考:它會攔截所有 module 的變動事件,然後直接重新整理頁面。
if (module.hot) {
module.hot.accept();
module.hot.dispose(() => {
window.location.reload();
});
}
module.hot
在沒有使用 HMR 時會是空值,這些 if 區塊會整個被 UglifyJS 拔掉(UglifyJS 的 unreachable code removal 功能)style-loader
),除了方便開發外,也可以研究一下不同套件對 HMR 的寫法。註1:雖然「由 entry file 去 require 其他所有的 module」這一行為用 tree 會是比較好的形容方式,但考量到 module 是允許複數 parent (被不同 module 所 require)的狀況下,作者個人認為它更像是一個 directed graph,因此文內均以 dependency graph 一詞替代 Webpack 內部使用的 module tree 一詞。