webpack模塊熱更新原理
模塊熱替換(hot module replacement 或 HMR)是 webpack 提供的最有用的功能之一。它允許在運行時更新所有類型的模塊,而無需完全刷新。
下面我們運行一個例子來更直觀的感受什么是模塊熱更新。
視頻中,我修改了字體顏色,頁面會立即更新,但輸入框中的內(nèi)容依然保留著。HMR就是幫助我們實現(xiàn)了這樣一個效果,不然我們在每次修改代碼時,還需要手動刷新頁面,且頁面的內(nèi)容不會保留。模塊熱更新的好處顯而易見,它可以幫助我們節(jié)省開發(fā)時間,提升開發(fā)體驗。
細(xì)心的同學(xué)可能會發(fā)現(xiàn),webpack自動進(jìn)行重新編譯同時又多生成了兩個文件。

HMR 是怎樣實現(xiàn)自動編譯的? 模塊內(nèi)容的變更瀏覽器又是如何感知的? 以及新產(chǎn)生的兩個文件又是干嘛的? 局部更新又是如何做到的?
下面讓我們帶著這些疑問,一起來探索模塊熱更新的原理。
模塊熱更新的配置
在學(xué)習(xí)原理前,我們需要對模塊熱更新的配置有一個清晰的認(rèn)識。因為平時的工作中很少需要我們自己手動去配置,所以會導(dǎo)致我們忽略一些細(xì)節(jié)的問題?,F(xiàn)在我們來回顧一下配置流程,這樣更有助于對源碼的理解。
第一步:安裝webpack-dev-server
npm?install?--save-dev.?webpack-dev-server
第二步:在父模塊中注冊module.hot.accept事件
//src/index.js
let?div?=?document.createElement('div');
document.body.appendChild(div);
let?input?=?document.createElement('input');
document.body.appendChild(input);
let?render?=?()?=>?{
????let?title?=?require('./title.js')
????div.innerHTML?=?title;
}
render()
//添加如下內(nèi)容
+?if?(module.hot)?{
+?????module.hot.accept(['./title.js'],?render)
+?}
//?子模塊?src/title.js
module.exports?=?'Hello?webpack'
第三步:在webpack.config.js中配置hot:true
const?path?=?require('path');
const?HtmlWebpackPlugin?=?require('html-webpack-plugin')
module.exports?=?{
????mode:?'development',
????devtool:?'source-map',
????entry:?'./src/index.js',
????output:?{
????????filename:?'main.js',
????????path:?path.resolve(__dirname,?'dist')
????},
?+???devServer:?{
?+???????hot:?true
?+???},
????plugins:?[
????????new?HtmlWebpackPlugin(),
????],
}
現(xiàn)在你可能會有一些疑問,為什么平時修改代碼的時候不用監(jiān)聽module.hot.accept也能實現(xiàn)熱更新?那是因為我們使用的 loader 已經(jīng)在幕后幫我們實現(xiàn)了。
webpack-dev-server 提供了實時重加載的功能,但是不能局部刷新。必須配合后兩步的配置才能實現(xiàn)局部刷新,這兩步的背后其實是借助了HotModuleReplacementPlugin。
可以說HMR是webpack-dev-server和HotModuleReplacementPlugin 共同的功勞。
熱更新原理
下面就正式進(jìn)入我們今天的主題。先來介紹第一位主角:webpack-dev-server。
Webpack-dev-server
通過node_modules/webpack-dev-server下的package.json文件,根據(jù) bin 的值可以找到命令實際運行的文件。./node_modules/webpack-dev-server/bin/webpack-dev-server.js

下面我們就順著入口文件,來看一看webpack-dev-server都做了哪些事。為了減少篇幅,提高閱讀質(zhì)量,以下示例均為簡易版的實現(xiàn),感興趣的可以參照源碼一起來看。
1、開啟本地服務(wù)
首先通過webpack創(chuàng)建了一個compiler實例,然后通過創(chuàng)建自定義server實例,開啟了一個本地服務(wù)。
//?node_modules/webpack-dev-server/bin/webpack-dev-server.js
const?webpack?=?require('webpack');
const?config?=?require('../../webpack.config');
const?Server?=?require('../lib/Server')
const?compiler?=?webpack(config);
const?server?=?new?Server(compiler);
server.listen(8080,?'localhost',?()?=>?{})
這個自定義Server 不僅是創(chuàng)建了一個http服務(wù),它還基于http服務(wù)創(chuàng)建了一個websocket服務(wù),同時監(jiān)聽瀏覽器的接入,當(dāng)瀏覽器成功接入時向它發(fā)送hash值,從而實現(xiàn)服務(wù)端和瀏覽器間的雙向通信。
//?node_modules/webpack-dev-server/lib/Server.js
class?Server?{
????constructor()?{
????????this.setupApp();
????????this.createServer();
????}
????//創(chuàng)建http應(yīng)用
????setupApp()?{
????????this.app?=?express();
????}
????//創(chuàng)建http服務(wù)
????createServer()?{
????????this.server?=?http.createServer(this.app);
????}
????//監(jiān)聽端口號?
????listen(port,?host,?callback)?{
????????this.server.listen(port,?host,?callback)
????????this.createSocketServer();
????}
????//基于http服務(wù)創(chuàng)建websocket服務(wù),并注冊監(jiān)聽事件connection
????createSocketServer()?{
????????const?io?=?socketIO(this.server);
????????io.on('connection',?(socket)?=>?{
????????????this.clientSocketList.push(socket);
????????????socket.emit('hash',?this.currentHash);
????????????socket.emit('ok');
????????????socket.on('disconnect',?()?=>?{
????????????????let?index?=?this.clientSocketList.indexOf(socket);
????????????????this.clientSocketList.splice(index,?1)
????????????})
????????})
????}
}
module.exports?=?Server;
2、監(jiān)聽編譯完成
僅僅在建立websocket連接時,服務(wù)端向瀏覽器發(fā)送hash和拉取代碼的通知還不夠,我們還希望當(dāng)代碼改變時,瀏覽器也可以接到這樣的通知。于是,在開啟服務(wù)前,還需要對編譯完成事件進(jìn)行監(jiān)聽。
//監(jiān)聽編譯完成,當(dāng)編譯完成后通過websocket向瀏覽器發(fā)送廣播
setupHooks()?{
????let?{?compiler?}?=?this;
????compiler.hooks.done.tap('webpack-dev-server',?(stats)?=>?{
????????this.currentHash?=?stats.hash;
????????this.clientSocketList.forEach((socket)?=>?{
????????????socket.emit('hash',?this.currentHash);
????????????socket.emit('ok');
????????})
????})
}
3、監(jiān)聽文件修改
要想在代碼修改的時候,觸發(fā)重新編譯,那么就需要對代碼的變動進(jìn)行監(jiān)聽。這一步,源碼是通過webpackDevMiddleware庫實現(xiàn)的。庫中使用了compiler.watch對文件的修改進(jìn)行了監(jiān)聽,并且通過memory-fs實現(xiàn)了將編譯的產(chǎn)物存放到內(nèi)存中,這也是為什么我們在dist目錄下看不到變化的內(nèi)容,放到內(nèi)存的好處就是為了更快的讀寫從而提高開發(fā)效率。
//?node_modules/webpack-dev-middleware/index.js
const?MemoryFs?=?require('memory-fs')
compiler.watch({},?()?=>?{})
let?fs?=?new?MemoryFs();
this.fs?=?compiler.outputFileSystem?=?fs;
4、向瀏覽器中插入客戶端代碼
前面提到要想實現(xiàn)瀏覽器和本地服務(wù)的通信,那么就需要瀏覽器接入到本地開啟的websocket服務(wù),然而瀏覽器本身并不具備這樣的能力,這就需要我們自己提供這樣的客戶端代碼將它運行在瀏覽器。因此自定Server在開啟http服務(wù)之前,就調(diào)用了updateCompiler()方法,它修改了webpack配置中的entry,使得插入的兩個文件的代碼可以一同被打包到 main.js 中,運行在瀏覽器。
//node_modules/webpack-dev-server/lib/utils/updateCompiler.js
const?path?=?require('path');
function?updateCompiler(compiler)?{
????compiler.options.entry?=?{
????????main:?[
????????????path.resolve(__dirname,?'../../client/index.js'),
????????????path.resolve(__dirname,?'../../../webpack/hot/dev-server.js'),
????????????config.entry,
????????]
????}
}
module.exports?=?updateCompiler
node_modules /webpack-dev-server/client/index.js
這段代碼會放在瀏覽器作為客戶端代碼,它用來建立 websocket 連接,當(dāng)服務(wù)端發(fā)送hash廣播時就保存hash,當(dāng)服務(wù)端發(fā)送ok廣播時就調(diào)用reloadApp()。
let?currentHash;
let?hotEmitter?=?new?EventEmitter();
const?socket?=?window.io('/');
socket.on('hash',?(hash)?=>?{
????currentHash?=?hash;
})
socket.on('ok',?()?=>?{
????reloadApp();
})
function?reloadApp()?{
????hotEmitter.emit('webpackHotUpdate',?currentHash)
}
webpack/hot/dev-server.js
reloadApp()繼續(xù)調(diào)用module.hot.check(),當(dāng)然第一次加載頁面時是不會被調(diào)用的。至于這里為啥會分成兩個文件,個人理解是為了解藕,每個模塊負(fù)責(zé)不同的分工。
let?lastHash;
hotEmitter.on('webpackHotUpdate',?(currentHash)?=>?{
????if?(!lastHash)?{
????????lastHash?=?currentHash;
????????return;
????}
????module.hot.check();
})
module.hot.check()是哪來的?答案是HotModuleReplacementPlugin。我們可以在瀏覽器的sources下看到,main.js被插入很多代碼,這些代碼就是被HotModuleReplacementPlugin 插入進(jìn)來的。

它不僅在main.js中插入了代碼,前面提到過的編譯后生成的兩個補丁包也是它生成的 。
HotModuleReplacementPlugin
現(xiàn)在,我們來看一下今天的第二位主角HotModuleReplacementPlugin 在main.js都悄悄插了哪些代碼,從而實現(xiàn)的熱更新。
1、為模塊添加hot屬性
前面提到過,當(dāng)代碼發(fā)生改動時,服務(wù)端會向瀏覽器發(fā)送ok消息,瀏覽器會執(zhí)行module.hot.check進(jìn)行模塊熱檢查。check方法就是來源于這里了。
function?hotCreateModule()?{
????let?hot?=?{
????????_acceptedDependencies:?{},
????????accept(deps,?callback)?{
????????????deps.forEach(dep?=>?hot._acceptedDependencies[dep]?=?callback);
????????},
????????check:?hotCheck
????}
????return?hot
}
2、請求補丁文件
module.hot.check()就是調(diào)用hotCheck,此時瀏覽器會向服務(wù)端獲取兩個補丁文件。
function?hotCheck()?{
????hotDownloadManifest().then(update?=>?{
????????//{"h":"eb861ba9f6408c42f1fd","c":{"main":true}}
????????let?chunkIds?=?Object.keys(update.c)?//['main']
????????chunkIds.forEach(chunkId?=>?{
????????????hotDownloadUpdateChunk(chunkId)
????????})
????????lastHash?=?currentHash;
????}).catch(()?=>?{
????????window.location.reload();
????})
}
先看一眼這兩個文件長什么樣
d04feccfa446b174bc10.hot-update.json
告知瀏覽器新的hash值,并且是哪個chunk發(fā)生了改變

main.d04feccfa446b174bc10.hot-update.js
告知瀏覽器,main 代碼塊中的/src/title.js模塊變更的內(nèi)容

首先是通過XMLHttpRequest的方式,利用上一次保存的hash值請求hot-update.json文件。這個描述文件的作用就是提供了修改的文件所在的chunkId。
????function?hotDownloadManifest()?{
????????return?new?Promise(function?(resolve,?reject)?{
????????????let?xhr?=?new?XMLHttpRequest();
????????????let?url?=?`${lastHash}.hot-update.json`
????????????xhr.open('get',?url);
????????????xhr.responseType?=?'json'
????????????xhr.onload?=?function?()?{
????????????????resolve(xhr.response)
????????????}
????????????xhr.send()
????????})
????}
然后通過JSONP的方式,利用hot-update.json返回的chunkId 及 上一次保存的hash 拼接文件名進(jìn)而獲取文件內(nèi)容。
function?hotDownloadUpdateChunk(chunkId)?{
????let?script?=?document.createElement('script');
????script.src?=?`${chunkId}.${lastHash}.hot-update.js`;
????document.head.appendChild(script);
}
window.webpackHotUpdate?=?function?(chunkId,?moreModules)?{
????hotAddUpdateChunk(chunkId,?moreModules);
}
3、模塊內(nèi)容替換
當(dāng)hot-update.js文件加載好后,就會執(zhí)行window.webpackHotUpdate,進(jìn)而調(diào)用了hotApply。hotApply根據(jù)模塊ID找到舊模塊然后將它刪除,然后執(zhí)行父模塊中注冊的accept回調(diào),從而實現(xiàn)模塊內(nèi)容的局部更新。
????window.webpackHotUpdate?=?function?(chunkId,?moreModules)?{
????????hotAddUpdateChunk(chunkId,?moreModules);
????}
????let?hotUpdate?=?{}
????function?hotAddUpdateChunk(chunkId,?moreModules)?{
????????for?(let?moduleId?in?moreModules)?{
????????????modules[moduleId]?=?hotUpdate[moduleId]?=?moreModules[moduleId];
????????}
????????hotApply();
????}
????function?hotApply()?{
????????for?(let?moduleId?in?hotUpdate)?{
????????????let?oldModule?=?installedModules[moduleId]
????????????delete?installedModules[moduleId]
????????????oldModule.parents.forEach((parentModule)?=>?{
????????????????let?cb?=?parentModule.hot._acceptedDependencies[moduleId]
????????????????cb?&&?cb()
????????????})
????????}
????}
總結(jié)
模塊熱更新原理總結(jié):

在執(zhí)行npm run dev 后,首先會通過updateCompiler方法去修改compiler的entry,將兩個文件的代碼一起打包到main.js,這兩個文件一個是用來與服務(wù)端進(jìn)行通信的,一個是用來調(diào)用module.hot.check的。接著通過compiler.hooks.done.tap來監(jiān)聽編譯完成,通過compiler.watch 監(jiān)聽代碼的改動,通過createSocketServer()開啟http服務(wù)和websocekt服務(wù)。
當(dāng)用戶訪問http://localhost:8080時,瀏覽器會與服務(wù)端建立websocket連接。隨后服務(wù)端向瀏覽器發(fā)送hash 和 ok ,用來通知瀏覽器當(dāng)前最新編譯版本的hash值和告訴瀏覽器拉取代碼。同時服務(wù)端,會根據(jù)路由,將內(nèi)存中的文件返回,此時瀏覽器保存hash,頁面內(nèi)容出現(xiàn)。
當(dāng)修改本地代碼時,會觸發(fā)重新編譯,此時webpackDevMiddleWare會將編譯的產(chǎn)物保存到內(nèi)存中,這得益于內(nèi)置模塊memory-fs的功勞。同時HotModuleReplacementPlugin 會生成兩個補丁包,這兩個補丁包一個是用來告訴瀏覽器哪個chunk變更了,一個是用來告訴瀏覽器變更模塊及內(nèi)容。當(dāng)重新編譯完成,瀏覽器會保存當(dāng)前hash,然后通上一次的hash 值拼接出要請求的描述文件路徑,再根據(jù)描述文件返回的內(nèi)容,拼接出要另一個要請求的補丁包文件。請求成功就開始執(zhí)行webpckHotUdate了,會繼續(xù)調(diào)用 hotApply,實質(zhì)就是執(zhí)行了我們當(dāng)初在配置模塊熱更新第二步中的回調(diào)事件,從而實現(xiàn)了頁面內(nèi)容的局部刷新。
參考文檔:
模塊熱替換 | webpack 中文文檔[1]
輕松理解webpack熱更新原理 - 掘金[2]
參考資料
[1] 模塊熱替換 | webpack 中文文檔: https://webpack.docschina.org/guides/hot-module-replacement/
[2] 輕松理解webpack熱更新原理 - 掘金:https://juejin.cn/post/6844904008432222215