webpack模塊熱更新原理
以下文章來源于ELab團(tuán)隊(duì) ,作者ELab.tanyueying
什么是模塊熱更新?
模塊熱替換(hot module replacement 或 HMR)是 webpack 提供的最有用的功能之一。它允許在運(yùn)行時(shí)更新所有類型的模塊,而無需完全刷新。
下面我們運(yùn)行一個(gè)例子來更直觀的感受什么是模塊熱更新。
視頻中,我修改了字體顏色,頁(yè)面會(huì)立即更新,但輸入框中的內(nèi)容依然保留著。HMR就是幫助我們實(shí)現(xiàn)了這樣一個(gè)效果,不然我們?cè)诿看涡薷拇a時(shí),還需要手動(dòng)刷新頁(yè)面,且頁(yè)面的內(nèi)容不會(huì)保留。模塊熱更新的好處顯而易見,它可以幫助我們節(jié)省開發(fā)時(shí)間,提升開發(fā)體驗(yàn)。
細(xì)心的同學(xué)可能會(huì)發(fā)現(xiàn),webpack自動(dòng)進(jìn)行重新編譯同時(shí)又多生成了兩個(gè)文件。
HMR 是怎樣實(shí)現(xiàn)自動(dòng)編譯的?
模塊內(nèi)容的變更瀏覽器又是如何感知的?
以及新產(chǎn)生的兩個(gè)文件又是干嘛的?
局部更新又是如何做到的?
下面讓我們帶著這些疑問,一起來探索模塊熱更新的原理。
模塊熱更新的配置
在學(xué)習(xí)原理前,我們需要對(duì)模塊熱更新的配置有一個(gè)清晰的認(rèn)識(shí)。因?yàn)槠綍r(shí)的工作中很少需要我們自己手動(dòng)去配置,所以會(huì)導(dǎo)致我們忽略一些細(xì)節(jié)的問題?,F(xiàn)在我們來回顧一下配置流程,這樣更有助于對(duì)源碼的理解。
第一步:安裝webpack-dev-server
npm install --save-dev. webpack-dev-server
第二步:在父模塊中注冊(cè)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)在你可能會(huì)有一些疑問,為什么平時(shí)修改代碼的時(shí)候不用監(jiān)聽module.hot.accept也能實(shí)現(xiàn)熱更新?那是因?yàn)槲覀兪褂玫?loader 已經(jīng)在幕后幫我們實(shí)現(xiàn)了。
webpack-dev-server 提供了實(shí)時(shí)重加載的功能,但是不能局部刷新。必須配合后兩步的配置才能實(shí)現(xiàn)局部刷新,這兩步的背后其實(shí)是借助了HotModuleReplacementPlugin。
可以說HMR是webpack-dev-server和HotModuleReplacementPlugin 共同的功勞。
熱更新原理
下面就正式進(jìn)入我們今天的主題。先來介紹第一位主角:webpack-dev-server。
Webpack-dev-server
通過node_modules/webpack-dev-server下的package.json文件,根據(jù) bin 的值可以找到命令實(shí)際運(yùn)行的文件。./node_modules/webpack-dev-server/bin/webpack-dev-server.js
下面我們就順著入口文件,來看一看webpack-dev-server都做了哪些事。為了減少篇幅,提高閱讀質(zhì)量,以下示例均為簡(jiǎn)易版的實(shí)現(xiàn),感興趣的可以參照源碼一起來看。
1、開啟本地服務(wù)
首先通過webpack創(chuàng)建了一個(gè)compiler實(shí)例,然后通過創(chuàng)建自定義server實(shí)例,開啟了一個(gè)本地服務(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', () => {})
這個(gè)自定義Server 不僅是創(chuàng)建了一個(gè)http服務(wù),它還基于http服務(wù)創(chuàng)建了一個(gè)websocket服務(wù),同時(shí)監(jiān)聽瀏覽器的接入,當(dāng)瀏覽器成功接入時(shí)向它發(fā)送hash值,從而實(shí)現(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)聽端口號(hào)
listen(port, host, callback) {
this.server.listen(port, host, callback)
this.createSocketServer();
}
//基于http服務(wù)創(chuàng)建websocket服務(wù),并注冊(cè)監(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連接時(shí),服務(wù)端向?yàn)g覽器發(fā)送hash和拉取代碼的通知還不夠,我們還希望當(dāng)代碼改變時(shí),瀏覽器也可以接到這樣的通知。于是,在開啟服務(wù)前,還需要對(duì)編譯完成事件進(jìn)行監(jiān)聽。
//監(jiān)聽編譯完成,當(dāng)編譯完成后通過websocket向?yàn)g覽器發(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)聽文件修改
要想在代碼修改的時(shí)候,觸發(fā)重新編譯,那么就需要對(duì)代碼的變動(dòng)進(jìn)行監(jiān)聽。這一步,源碼是通過webpackDevMiddleware庫(kù)實(shí)現(xiàn)的。庫(kù)中使用了compiler.watch對(duì)文件的修改進(jìn)行了監(jiān)聽,并且通過memory-fs實(shí)現(xiàn)了將編譯的產(chǎn)物存放到內(nèi)存中,這也是為什么我們?cè)赿ist目錄下看不到變化的內(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、向?yàn)g覽器中插入客戶端代碼
前面提到要想實(shí)現(xiàn)瀏覽器和本地服務(wù)的通信,那么就需要瀏覽器接入到本地開啟的websocket服務(wù),然而瀏覽器本身并不具備這樣的能力,這就需要我們自己提供這樣的客戶端代碼將它運(yùn)行在瀏覽器。因此自定Server在開啟http服務(wù)之前,就調(diào)用了updateCompiler()方法,它修改了webpack配置中的entry,使得插入的兩個(gè)文件的代碼可以一同被打包到 main.js 中,運(yùn)行在瀏覽器。
//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
這段代碼會(huì)放在瀏覽器作為客戶端代碼,它用來建立 websocket 連接,當(dāng)服務(wù)端發(fā)送hash廣播時(shí)就保存hash,當(dāng)服務(wù)端發(fā)送ok廣播時(shí)就調(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)然第一次加載頁(yè)面時(shí)是不會(huì)被調(diào)用的。至于這里為啥會(huì)分成兩個(gè)文件,個(gè)人理解是為了解藕,每個(gè)模塊負(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中插入了代碼,前面提到過的編譯后生成的兩個(gè)補(bǔ)丁包也是它生成的 。
HotModuleReplacementPlugin
現(xiàn)在,我們來看一下今天的第二位主角HotModuleReplacementPlugin 在main.js都悄悄插了哪些代碼,從而實(shí)現(xiàn)的熱更新。
1、為模塊添加hot屬性
前面提到過,當(dāng)代碼發(fā)生改動(dòng)時(shí),服務(wù)端會(huì)向?yàn)g覽器發(fā)送ok消息,瀏覽器會(huì)執(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、請(qǐng)求補(bǔ)丁文件
module.hot.check()就是調(diào)用hotCheck,此時(shí)瀏覽器會(huì)向服務(wù)端獲取兩個(gè)補(bǔ)丁文件。
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();
})
}
先看一眼這兩個(gè)文件長(zhǎng)什么樣
d04feccfa446b174bc10.hot-update.json
告知瀏覽器新的hash值,并且是哪個(gè)chunk發(fā)生了改變
main.d04feccfa446b174bc10.hot-update.js
告知瀏覽器,main 代碼塊中的/src/title.js模塊變更的內(nèi)容
首先是通過XMLHttpRequest的方式,利用上一次保存的hash值請(qǐng)求hot-update.json文件。這個(gè)描述文件的作用就是提供了修改的文件所在的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文件加載好后,就會(huì)執(zhí)行window.webpackHotUpdate,進(jìn)而調(diào)用了hotApply。hotApply根據(jù)模塊ID找到舊模塊然后將它刪除,然后執(zhí)行父模塊中注冊(cè)的accept回調(diào),從而實(shí)現(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 后,首先會(huì)通過updateCompiler方法去修改compiler的entry,將兩個(gè)文件的代碼一起打包到main.js,這兩個(gè)文件一個(gè)是用來與服務(wù)端進(jìn)行通信的,一個(gè)是用來調(diào)用module.hot.check的。接著通過compiler.hooks.done.tap來監(jiān)聽編譯完成,通過compiler.watch 監(jiān)聽代碼的改動(dòng),通過createSocketServer()開啟http服務(wù)和websocekt服務(wù)。
當(dāng)用戶訪問http://localhost:8080時(shí),瀏覽器會(huì)與服務(wù)端建立websocket連接。隨后服務(wù)端向?yàn)g覽器發(fā)送hash 和 ok ,用來通知瀏覽器當(dāng)前最新編譯版本的hash值和告訴瀏覽器拉取代碼。同時(shí)服務(wù)端,會(huì)根據(jù)路由,將內(nèi)存中的文件返回,此時(shí)瀏覽器保存hash,頁(yè)面內(nèi)容出現(xiàn)。
當(dāng)修改本地代碼時(shí),會(huì)觸發(fā)重新編譯,此時(shí)webpackDevMiddleWare會(huì)將編譯的產(chǎn)物保存到內(nèi)存中,這得益于內(nèi)置模塊memory-fs的功勞。同時(shí)HotModuleReplacementPlugin 會(huì)生成兩個(gè)補(bǔ)丁包,這兩個(gè)補(bǔ)丁包一個(gè)是用來告訴瀏覽器哪個(gè)chunk變更了,一個(gè)是用來告訴瀏覽器變更模塊及內(nèi)容。當(dāng)重新編譯完成,瀏覽器會(huì)保存當(dāng)前hash,然后通上一次的hash 值拼接出要請(qǐng)求的描述文件路徑,再根據(jù)描述文件返回的內(nèi)容,拼接出要另一個(gè)要請(qǐng)求的補(bǔ)丁包文件。請(qǐng)求成功就開始執(zhí)行webpckHotUdate了,會(huì)繼續(xù)調(diào)用 hotApply,實(shí)質(zhì)就是執(zhí)行了我們當(dāng)初在配置模塊熱更新第二步中的回調(diào)事件,從而實(shí)現(xiàn)了頁(yè)面內(nèi)容的局部刷新。
參考文檔:
模塊熱替換 | webpack 中文文檔[1]
輕松理解webpack熱更新原理 - 掘金[2]
參考資料
[1] 模塊熱替換 | webpack 中文文檔: https://webpack.docschina.org/guides/hot-module-replacement/
[2] 輕松理解webpack熱更新原理 - 掘金:https://juejin.cn/post/6844904008432222215
作者:ELab.tanyueying
歡迎關(guān)注微信公眾號(hào) :前端晚間課
更多文章,收錄于小程序-互聯(lián)網(wǎng)小兵