引言
使用webpack有一段時間了,對其中的熱更新的大概理解是:對某個模塊做了修改,頁面只做局部更新而不需要刷新整個頁面來進行更新。這樣就能節省因為整個頁面刷新所產生開銷的時間,模塊熱加載加快了開發的速度。
熱加載的基礎是模塊熱替換(HMR,Hot Module Replacement)。
具體的是:webpack可以監控文件的改動,在模塊文件代碼發生改動時,並發送 HMR 更新消息(HMR update)給HMR 運行時(HMR runtime)環境,它決定模塊的替換,具體可以參考下圖:
HMR實現的具體效果可以先看下下圖的效果:
可是最近,親自搭建一個webpack應用項目時,在實現開發環境的模塊熱更新時,遇到這樣那樣的問題。由於之前都是使用第三方插件來實現應用的熱更新,它們都封裝了實現熱更新的一些細節,導致在不用第三方插件實現模塊熱更新時出現問題,其實還是理解的不夠深入。於是在搞明白之后寫下此文與大家分享。
Hot Module Replacement(HMR)
webpack的自帶的HMR插件HotModuleReplacementPlugin是使用webpack熱更新功能的基礎。其他的第三方插件如webpack-hot-middleware、react-hot-loader、babel-plugin-dva-hmr等等都是要配合webpack自帶的HotModuleReplacementPlugin插件提供的api來實現代碼的熱更新。例如下面在某個模塊中使用HMR代碼一個例子:
if (module.hot) {
module.hot.accept('./containers/rootContainer.js', () => {
const NextRootContainer = require('./containers/rootContainer.js').default;
render(<NextRootContainer />, document.getElementById('react-root'));
}
}
當然HotModuleReplacementPlugin為可以使用HMR的模塊提供了module.hot,它為一個對象,其含有很多api,具體可以參考這里。這樣利用插件提供的這些api可以為模塊實現自定義的熱更新邏輯。
但是,在開發過程中,你們可能也發現了,我們並沒有為項目中的每個模塊提供這種多余的HMR代碼,盡管所有代碼都有可能變化。那么當這些代碼沒有HMR代碼的模塊發生變化時,他是如何實現熱更新的呢?這就要說到webpack HMR更新的冒泡(bubble)機制。具體可以看下圖所展示的冒泡機制:

從圖中可以看出:
-
模塊C發生了變化,但是模塊C沒有用HMR代碼捕獲變化,則模塊C的變化消息將冒泡到依賴C模塊的其他模塊A和B中。
-
模塊B由於使用了HMR代碼進行捕獲變化,那么應用的變化就按照代碼進行了更新。並且不會再冒泡了。
-
模塊A由於同樣沒有HMR代碼捕獲變化,同樣將變更消息冒泡到依賴A模塊的模塊entry中。
-
入口entry模塊沒有HMR代碼捕獲變化的話:
-
1、 若項目使用webpack-dev-server的
webpack/hot/dev-server,則頁面會刷新整個頁面來加載變化;若使用webapck/hot/only-dev-server的話,不會刷新頁面,會在控制台展示一些有用的信息供開發者參考。具體可以參考這里。 -
2、若為
webpack-hot-middleware配置了reload:true,那么頁面就會整個刷新來加載加載變化,這就變成liveroad模式;否則webpack就不知道如何加載變化模塊,控制台也會有對應的提示。
-
例如,在本人的實例中,修改了searchForm.jsx模塊,可以在控制台清晰的看到,它一直冒泡到入口模塊index.js。如下圖:

開發過程中遇到的問題
在用webpack構建的項目中,在開發階段我們為了實現開發過程代碼的熱更新,如果對使用HMR不熟悉,可能會遇到這樣或者那樣的問題。下面就在本人開發過程中遇到過:
1、在cli中使用帶--hot選項的webpack-dev-server命令時,不要在webpack的配置文件在配置HMR插件。
--hot選項的webpack-dev-server命令時,不要在webpack的配置文件在配置HMR插件。否則會報下面的錯誤,具體可參考這里。

注意:
webpack-dev-server的node api模式下配置
hot: true仍然需要在webpack配置文件中配置該插件
重要更新:
\(\color{#FF0000}{該規則已不是問題,目前的webpack4已做了處理,即若webpack的配置項配置過HMR插件就不做處理,沒有配置則會主動幫我們添加。}\)
其中源碼如下:
[].concat(config).forEach((config) => {
config.entry = prependEntry(config.entry || './src');
if (options.hot || options.hotOnly) {
config.plugins = config.plugins || [];
if (
!config.plugins.find(
(plugin) =>
plugin.constructor === webpack.HotModuleReplacementPlugin
)
) {
config.plugins.push(new webpack.HotModuleReplacementPlugin());
}
}
});
2、在不使用第三方HMR庫,純搭建自己的本地node server時,一定要在項目的入口模塊添加module.hot.accept代碼來接受更新消息以實現熱更新。
在本人另一個項目中,使用dora插件系列的dora-plugin-webpack-hmr插件來實現熱更新,由於沒有在入口模塊添加HMR代碼來接受變更,導致模塊一產生變化就刷新整個頁面。
具體是因為:dora-plugin-webpack-hmr使用webpack-hot-middleware時,默認配置了其reload:true(參考這里),所以每次修改都會刷新整個頁面。
第三方HMR插件/庫的實現細節
前面說到,要想實現webpack的HMR功能,需要兩點:webpack配置HMR和入口文件添加HMR代碼。二者缺一不可,否則模塊熱更新就會失敗。
但是,在開發過程中,我們可能根本沒有配置過上面所說的兩點;這主要是因為我們在項目中使用第三方HMR插件或者庫,它們自動替我們完成這些;要么是二者都會給配置掉,要么就配置其中之一。 比方在本人項目中使用過的dora-plugin-webpack-hmr和babel-plugin-dva-hmr,以及Gaearon大神的react-hot-loader;下面就來說說他們的他們為我們做了什么隱蔽的事。
dora-plugin-webpack-hmr
該插件是為dora系列的插件,主要用在基於dora的項目中。該插件是基於webpack-hot-middleware庫來實現熱加載的,它主要為我們做了兩件事:
-
代碼更新沒有捕獲時會刷新整個頁面來加載更新。 也就是為
webpack-hot-middleware的reload屬性默認配置true,可看源碼1 -
自動為webpack配置項添加HMR插件配置。具體看源碼2這樣,我們使用該插件就不需要在webpack中配置HMR,否則會遇到常見問題1中的情況。
所以:
使用dora-plugin-webpack-hmr插件還是需要在入口模塊添加
module.hot.accept來接受更新,否則達不到熱更新效果。
babel-plugin-dva-hmr
該插件是與dva配套的,用在使用dva框架下的代碼熱更新插件。該插件自動替我們在入口模塊添加HMR代碼,具體可看源碼3,開發環境下入口模塊添加的代碼如下圖:
由此該插件只幫我們在入口模塊添加HMR代碼接受變更,但是它沒有幫我們在webpack中配置HMR,這樣HMR的api是不能用的。所以:
使用babel-plugin-dva-hmr插件還需要在webpack配置項中配置HMR。
react-hot-loader@❤️.0.0
該loader的目的是:保持組件狀態的熱更新。即不僅達到模塊的熱更新,還要保持各個模塊的狀態不會丟失,具體可參考Gaearon大神的Hot Reloading in React。它如何保持狀態不在本文范圍,可自行查詢。
在該loader的3.0.0版本前,與babel-plugin-dva-hmr插件類似,它也是自動為我們在模塊中注入接受更新的HMR代碼而沒有在webpack配置項自動添加HMR配置,具體可參考源碼4。但是它與前者不同是:它為每個啟用該loader的js文件都注入接受更新的HMR代碼。
例如,在webpack.config.js中為js文件配置該插件:
//這樣src目錄下的所有.js文件都將被自動添加HMR熱更新代碼
loaders: [{
test: /\.js$/,
loaders: ['react-hot', 'babel'],
include: path.join(__dirname, 'src')
}]
自動添加的有關HMR代碼如下,只截取部分代碼:

但是同樣的,
我們需要在webpack配置項中添加HMR插件配置。
注意:
react-hot-loader在3.0.0版本之后就廢棄掉該方式,不會自動添加HMR熱更新代碼,需要開發者在項目入口模塊手動添加HMR代碼,參考這里
搭建帶HMR的本地開發node sever
之前,與webpack配合的webpack-dev-server服務,通過配置就可以實現代碼熱更新,但是隱藏了實現細節。下面我們手動搭建一個自帶HMR功能的本地開發node sever。
1、使用webpack-dev-middleware搭建本地服務
webpack-dev-server就是基於webpack-dev-middleware來搭建內部node server。我們搭建自己的開發環境就用它來直接搭建。
2、使用webpack-hot-middleware來實現客戶端與服務端的通信以接受更新
該模塊只是負責客戶端與服務器通信及接受變化,但是如何實現根據熱加載來完成應用的無縫變化銜接就超出了該模塊的范圍,正如其官網所描述:
This module is only concerned with the mechanisms to connect a browser client to a webpack server & receive updates. It will subscribe to changes from the server and execute those changes using webpack's HMR API. Actually making your application capable of using hot reloading to make seamless changes is out of scope, and usually handled by another library.
這句話的意思是:
What this means in practice, is you either need to add some code which calls module.hot.accept(), or use a plugin which can automatically add this code to your modules - otherwise webpack doesn't know how to apply the hot update.
也就是, 要么你在模塊中增加調用module.hot.accept()的代碼,要么使用第三方插件自動的為你模塊添加這些代碼;否則webpack不知道怎么更新這些模塊。具體可以參考這里。
另外,要使用HMR功能,需要在webpack的配置項的每個入口項數組中添加webpack-hot-middleware/client,即:
entry: {
index: ['./src/index','webpack-hot-middleware/client']
}
3、配置HMR
正如上文所描述的,它分為兩步:
- 首先,要在webpack的配置項plugins需要配置HMR插件即
plugins: [ new webpack.HotModuleReplacementPlugin()]
- 其次,需要在項目的入口模塊中添加HMR代碼捕獲變化以做熱更新。例如下面:
if(module.hot){
module.hot.accpet() //接受模塊更新的事件,同時阻止這個事件繼續冒泡
}
若為每個模塊添加HMR代碼來熱更新對應的模塊機制是不可取的,這會產生大量冗余代碼,極不推薦這種做法,除非像第三方插件那樣自動幫我們完成。
一般在入口模塊添加module.hot的相關api來更新具體變化,入口模塊沒有添加的話就不會達到熱更新的效果,瀏覽器控制台也會出現如下警告(前提是webpack-hot-middleware的reload配置為false):

在瀏覽器控制台中出現這樣一句提示:
This is usually because the modules which have changed (and their parents) do not know how to hot reload themselves.
正如提示所說的,修改某個子模塊時,若不在模塊本身或者頂級的入口模塊添加熱更新接受機制,那么產生變化的模塊及其父模塊不知道怎么加載他們。
最終,用戶自定義的開發環境node server具體的核心開發代碼如下:
//dev-server.js 文件
var webpackDevMiddleware = require('webpack-dev-middleware');
var webpackHotMiddleware = require('webpack-hot-middleware');
Object.keys(webpackConfig.entry).forEach(function(name){
webpackConfig.entry[name] = ['webpack-hot-middleware/client'].concat(webpackConfig.entry[name]);
})
var compiler = webpack(webpackConfig);
var devMiddleware = webpackDevMiddleware(compiler, {
publicPath: webpackConfig.output.publicPath,
hot: true,
noInfo: true,
stats: {
colors: true
}
});
var hotMiddleware = webpackHotMiddleware(compiler);
app.use(devMiddleware);
app.use(hotMiddleware);
app.listen(port, function(err){
if(err){
console.log(err);
}else {
var url = 'http://localhost:' + port;
console.log("listening on port %s", port);
}
})
另外,我們可能會想到,在使用redux的react項目中,這種熱更新會導致應用的state丟失,為了防止state隨熱更新而丟失,一般需要在針對reducer的修改來實現進行state的保存,最常用的做法是在store模塊下添加如下reducer熱更新代碼:
if(module.hot){
module.hot.accept('../reducers/index.js', ()=>{
const nextReducer = require('../reducers/index.js');
store.replaceReducer(nextReducer || nextReducer.default);
})
}
至此,一個帶HMR代碼熱更新功能的本地開發node server就搭建成功了。
其他文件熱更新的實現
上面的帶HMR熱更新功能的node server雖已搭建,但是就能滿足我們的開發需求了么?我想答案是否定的。上面的熱更新其實是針對js文件的熱更新,也就是說對js文件的變更做熱更新。在實際項目中,我們修改的可不僅僅是js文件,還有css文件、html文件等等,這些都需要考慮熱更新。
1、html文件的熱更新
在項目中,我們使用html-webpack-plugin來生成webpack spa頁面。由於該插件不支持HMR,為了支持html的HMR,我們需要利用webpack-hot-middleware提供對外接口來實現。具體需要三步:
- 首先,在上面的dev-server.js中為html-webpack-plugin鈎子
html-webpack-plugin-after-emit增加回調,釋放一個信號表示html頁面已經構建完成。
// dev-server.js
compiler.plugin('compilation', function (compilation) {//webpack編譯完成
//在這個插件合成出頁面之后,添加一個回調,調用中間件emit一個action為reload的事件,對應另一邊client訂閱的事件,實現瀏覽器的刷新
compilation.plugin('html-webpack-plugin-after-emit', function (data, cb) {
hotMiddleware.publish({action: 'reload'})
cb()
})
});
- 其次,為html頁面構建完成后添加回調,用於實現熱更新邏輯
// 新建一個build/dev-client.js文件
var hotClient = require('webpack-hot-middleware/client');
// 添加一個訂閱事件,當監聽到 event.action === 'reload' 時執行頁面刷新
hotClient.subscribe(function (event) {
if (event.action === 'reload') {
window.location.reload()
}
})
- 最后,修改webpack的entry,為其添加前綴,即第二步創建的文件
build/dev-client
// 在webpack配置中設置
Object.keys(config.entry).forEach(function (name, i) {
config.entry[name] = ['./build/dev-client'].concat(config.entry[name])
})
至此,html文件的熱更新就完成了,不過這里不是真正意義上的熱更新,而是刷新整個頁面。
2、css文件的熱更新
一般情況下,webpack項目中的css處理都是通過 extract-text-webpack-plugin 插件把css抽離到單獨css文件中,但令人遺憾的是該插件是不支持熱加載的,具體可以參考issue。
但是,可喜的是webpack的style-loader是支持css熱加載的。 該插件通過js創建一個 style 標簽,然后注入內聯的css。
所以,按照上面描述,要想實現css的熱加載,只需要: 開發環境不要用extract-text-webpack-plugin插件,而是用style-loader代替。 但是,這種做法被開發者狠狠的吐槽了,並且還列出的原因:
-
用隔離的css文件能更好的調試
-
開發和生產環境的盡可能的一致,可以保證盡可能少的bug
吐槽歸吐槽,官方還是沒有提供熱加載支持,但是社區出現了extract-text-webpack-plugin支持熱加載的各種實現方式,雖然有些是hack,但是能工作的很好啊,例如下面的列舉的實現:
-
類似於html文件熱更新,采用事件通知機制來實現,可以參加這里
-
將引入js模塊中的css模塊文件,如
require('<path to css file>')這行代碼抽取成一個單獨的js文件,並在該js文件實現模塊更新接收,可以參考這里。 -
用一個babel插件css-hot-loader來實現。
該插件的實現原理:
每次熱加載都是一個 js 文件的修改,每個 css 文件在 webpack 中也是一個 js 模塊,那么只需要在這個 css 文件對應的模塊里面加一段代碼就可以實現 css 文件的更新了(具體的是更新外鏈link的地址url,為其添加時間戳),它會自動在每個css文件中添加如下代碼:
if(module.hot) {
// ${Date.now()}
const cssReload = require(${loaderUtils.stringifyRequest(this, require.resolve('./hotModuleReplacement'))})(${JSON.stringify(options)});
module.hot.dispose(cssReload);
module.hot.accept(undefined, cssReload);
}
最終對應的CSS文件編譯生成的代碼可能是這樣子:
// removed by extract-text-webpack-plugin
if(module.hot) {
// 1498744720173
const cssReload = require("../../../node_modules/css-hot-loader/hotModuleReplacement.js")({"fileMap":"{fileName}"});
module.hot.dispose(cssReload);
module.hot.accept(undefined, cssReload);
}
/*****************
** WEBPACK FOOTER
** ./src/routes/main.less
** module id = 636
** module chunks = 1
**/
3、其他配置文件變動的更新
這里不說代碼熱更新,而是提供一種代碼變動更新機制。
在項目中,我們可以很容易實現js、css和html文件的熱更新;但是,我們有沒有想到過,在我們項目中其他文件變更時也要加載變化后的文件,例如項目中package.json或者webpack.config.js配置文件發生了變化,我們也想瀏覽器有所反應而不是無動於衷,那么我們可以監控這些文件的變化來實現。具體:
- 在上述的
dev-server.js文件中用chokidar添加對指定文件的監控,比如webpack.config.js
//dev-server.js
var chokidar = require('chokidar');
chokidar.watch(path.resolve(process.cwd(), 'webpack.dev.conf.js')).on('change', function(){
process.send('restart'); //向父進程傳遞消息信號
})
- 創建本地node server主入口文件,用於創建
dev-server.js對應的子進程。
//dev-server-main.js
var cp = require('child_process');
function start(){
const p = cp.fork(__dirname + '/dev-server.js');
p.on('message', function(data){
if(data === 'restart'){
p.kill('SIGINT');
start();
}
})
}
if(!process.send){
start();
}
- 最后用
node dev-server-main.js開啟服務
這樣,就可以實現修改webpack.config.js達到重新加載配置的目的。不過它的做法是webpack重新對項目編譯。
