從頭建立一套 web-based 服務架構 (6) Webpack HMR

Webpack 內建名為 Hot Module Replacement (HMR) 的機制,和 LiveReload 等工具類似,能夠在開發時動態重新載入更改的程式碼。 和 LiveReload 不同的是,Webpack 以 module 為單位進行重載,能夠在不重新整理頁面的情況下執行新的 module 並覆蓋舊的結果。 對 React.js 的開發而言,HMR 能夠自動重載 component 內容以大幅增加開發速度。

啟用 HMR 機制

動態重載的原理是另啟一個 server 去監聽本地端的檔案系統變化,並即時通知前端頁面來實行變動。 此專案使用 webpack-dev-middlewarewebpack-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.OccurrenceOrderPluginwebpack.HotModuleReplacementPluginwebpack.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`;

加入 HMR 程式碼

只靠 hot server 並沒有真正達到動態重新載入的效果;實際上,HMR 機制還需要仰賴程式碼本身的設定才能運作。 道理很簡單,因為 HMR 不會預先假設開發者撰寫的程式碼該如何去處理 module 重載的行為,這必須由開發者自行設定。

在 Webpack 載入 JS code 時,會對程式碼內用到的 requiremodule 變數做靜態轉換,建立 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 的變動事件。

真的能夠動態重載任何 module 嗎?

若是 HMR 設定成功,Webpack 會在 browser console 輸出相關的 debug 訊息,例如和 hot server 連線成功的 [HMR] connected、module 重新載入完成的 [HMR] bundle rebuilt in OOOms 等。 當發生 HMR 事件時,Webpack 也會將變動的 module 依照 dependency graph 一路印出到 root 的結果:

Module Tree in Console

反過來,若是 Webpack 偵測到無法被 hot replace 的 module 變動,也會以 console.warn 的形式輸出到 browser console:

HMR failed

這個警告訊息代表此 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();
    });
}

使用 HMR 的幾個建議

  1. 在程式碼內儘量越少出現 HMR 相關的程式碼越好,以將程式本身和 Webpack 之間的耦合度限制在最小範圍(例如只撰寫於 Webpack 的 entry file 內)
  2. 不用擔心 HMR 造成的 build bundle size 增加問題或是讓 production bundle 增加無謂的程式碼,由於 module.hot 在沒有使用 HMR 時會是空值,這些 if 區塊會整個被 UglifyJS 拔掉(UglifyJS 的 unreachable code removal 功能)
  3. 有些 loader 會偷塞 HMR 機制的程式碼(例如 style-loader),除了方便開發外,也可以研究一下不同套件對 HMR 的寫法。

註1:雖然「由 entry file 去 require 其他所有的 module」這一行為用 tree 會是比較好的形容方式,但考量到 module 是允許複數 parent (被不同 module 所 require)的狀況下,作者個人認為它更像是一個 directed graph,因此文內均以 dependency graph 一詞替代 Webpack 內部使用的 module tree 一詞。

results matching ""

    No results matching ""