使用webpack-dev-server
實現的Hot Moudle Replacement(HMR)讓我們在開發時修改代碼并保存后,不必手動刷新瀏覽器,而是讓瀏覽器通過新的模塊替換老的模塊。這樣可以讓我們在保證當前頁面狀態的前提下,讓新的代碼生效,就如同在Chrome的控制臺修改CSS樣式一樣。
使用
安裝webpack-dev-server
npm install webpack-dev-server --save-dev
在webpack.config.js
中進行配置
devServer: {
contentBase: path.resolve(__dirname, 'dist'),
host: 'localhost',
compress: true,
port: 8080
}
其中:
-
contentBase
:服務器基本運行路徑 -
host
:服務器運行地址 -
compress
:服務器壓縮式,一般為true
-
port
:服務運行端口
在package.json
中定義相關命令:
"scripts": {
"dev": "webpack-dev-server --hot --open",
},
然后執行npm run dev
就可以開啟webpack的服務,并且實現模塊熱重載,并且自動打開瀏覽器。
增加--open
屬性可以自動打開瀏覽器。
原理解析
原來只是在各種Cli工具中使用了模塊熱重載,知道是利用了Webpack的HMR特性,但是它是怎么實現的卻不了解。今天在清理收藏夾攢的知識時看到了餓了么前端專欄的這篇文章Webpack HMR 原理解析,寫的非常好,簡單易懂,把道理也說的很明白。
上圖展示了從修改代碼到模塊熱更新完成的一個周期:
第一步:Webpack在watch
模式下打包更改的文件到內存中(對應圖中的①②③)
Webpack-dev-middleware調用Webpack的API對文件系統watch,監聽到文件變化時,根據配置文件對模塊重新編譯打包,將打包后的代碼以JavaScript對象的形式保存在內存中。
// webpack-dev-middleware/lib/Shared.js
if (!options.lazy) {
var watching = compiler.watch(options.watchOptions, share.handleCompilerCallback);
context.watching = watching;
}
Webpack會將打包的文件保存在內存中,而不是打包到output.path
目錄下,是因為訪問內存中的代碼比訪問文件系統中的代碼更快,也減少了寫入文件的開銷。這個過程利用了memory-fs這個庫,它提供了一個簡單的基于內存的文件系統,所有數據都保存在JavaScript對象中。
圖中的第③步也是對文件變化的監控,只不過這一步監聽的不是代碼,而是在配置文件制定的靜態文件目錄下的靜態文件的變化(當配置文件中配置了devServer.watchContentBase
為true
的時候),當靜態文件發生變化時通知瀏覽器對應用進行刷新(注意是瀏覽器刷新,而非HRM)
第二步:webpack-dev-Server通知瀏覽器端文件發生變化(對應④)
瀏覽器端和服務端之間是通過Websocket長連接進行通信的,利用的是sockjs建立的。通過Websocket長連接,webpack-dev-Server將編譯打包的各個階段狀態告知瀏覽器(包括第③步中監聽的靜態文件的變化)。
同時webpack-dev-Server調用Webpack的API監聽complie的done
事件,在編譯完成后,webpack-dev-Server通過_sendStatus
方法將編譯打包后的新模塊的hash值發送給瀏覽器,后面的步驟都會利用這個hash值來進行模塊熱替換。
// webpack-dev-server/lib/Server.js
compiler.plugin('done', (stats) => {
// stats.hash 是最新打包文件的 hash 值,發送給瀏覽器
this._sendStats(this.sockets, stats.toJson(clientStats));
this._stats = stats;
});
// ...
Server.prototype._sendStats = function (sockets, stats, force) {
if (!force && stats &&
(!stats.errors || stats.errors.length === 0) && stats.assets &&
stats.assets.every(asset => !asset.emitted)
) {
return this.sockWrite(sockets, 'still-ok');
}
// 調用 sockWrite 方法將 hash 值通過 websocket 發送到瀏覽器端
this.sockWrite(sockets, 'hash', stats.hash);
if (stats.errors.length > 0) {
this.sockWrite(sockets, 'errors', stats.errors);
}
else if (stats.warnings.length > 0) {
this.sockWrite(sockets, 'warnings', stats.warnings);
} else {
this.sockWrite(sockets, 'ok');
}
};
第三步:webpack-dev-server/client接收到服務端消息做出響應(對應⑤?)
webpack-dev-server/client端并不能夠請求更新的代碼,也不會執行熱更模塊操作,而是在接收到通過長連接收到的服務端的消息后,對信息進行處理,而具體的更新操作又交回給了Webpack。
webpack/hot/dev-server的工作就是根據webpack-dev-server/client傳給它的信息以及dev-server的配置決定是刷新瀏覽器呢還是進行模塊熱更新。當然如果僅僅是刷新瀏覽器,也就沒有后面那些步驟了。
我們并沒有在業務代碼里添加Websocket客戶端的代碼,也沒有在webpack.config.js
中的entry
屬性中添加新的入口文件,那么bundle.js
中的接受Websocket信息的代碼是從哪來的呢?答案是webpack-dev-server會自動修改Webpack配置中的entry
屬性,在里面添加了webpck-dev-client的代碼。
具體來看,webpack-dev-server/client接收到type
為hash
的消息后會將hash
保存起來,接收到type
為ok
的消息后會執行relooad
操作,在reload
操作中會根據hot
的配置是刷新瀏覽器還是執行熱更新(HMR):
// webpack-dev-server/client/index.js
hash: function msgHash(hash) {
currentHash = hash;
},
ok: function msgOk() {
// ...
reloadApp();
},
// ...
function reloadApp() {
// ...
if (hot) {
log.info('[WDS] App hot update...');
const hotEmitter = require('webpack/hot/emitter');
hotEmitter.emit('webpackHotUpdate', currentHash);
// ...
} else {
log.info('[WDS] App updated. Reloading...');
self.location.reload();
}
}
在上面的代碼中,webpack-dev-server/client首先將接收到的hash
值存儲到currentHash
變量中,當接收到ok
消息后調用reloadApp
方法,在其內部根據hot
配置,決定是調用webpack/hot/emitter
將最新的hash
值發送給Webpack執行熱更新,還是直接調用location.reload
刷新頁面。
第四步:Webpack接收新的hash
值并請求模塊代碼(對應⑥⑦⑧⑨)
首先webpack/hot/dev-server監聽上一步webpack-dev-server/client發送的webpackHotUpdate
消息,然后調用webpack/lib/HotModuleReplacement.runtime(簡稱HMR runtime),HMR runtime是客戶端HMR的中樞,它首先通過JsonpMainTemplate.runtime調用hotDownloadManifest
方法向server端發送JSONP請求,檢查是否有更新的文件,如果有的話服務端返回一個JSON響應,包含了所有要更新的模塊的hash值。
獲取到更新列表后,該模塊通過hotDownloadUpdateChunk
再次發送JSONP請求,獲取到最新的模塊代碼,并返回給HMR runtime。
上面為了獲取最新的Hash值和最新的代碼,HMR runtime向服務端發送了兩次Ajax請求,為什么不在第三步的Websocket長連接中發送給瀏覽器呢?可能的原因:
(1)包括了功能模塊的解耦,webpack-dev-server/client只負責消息的傳遞而不負責新模塊的拉取,HRM runtime來負責獲取新代碼
(2)可以使用webpack-hot-middleware來代替webpack-dev-server實現HMR,webpack-hot-middleware沒有使用Websocket,而是使用EventSource來實現客戶端與服務端通信。
第五步:HMR runtime對模塊進行熱更新(對應⑩)
HMR runtime會對新舊模塊進行對比,決定是否更新模塊,在決定更新模塊后,檢查模塊之間的依賴關系,更新模塊的同時更新模塊間的依賴引用。
這一切都發生在HMR runtime的hotApply
方法中:
// webpack/lib/HotModuleReplacement.runtime
function hotApply() {
// ...
var idx;
var queue = outdatedModules.slice();
while (queue.length > 0) {
moduleId = queue.pop();
module = installedModules[moduleId];
// ...
// remove module from cache
delete installedModules[moduleId];
// when disposing there is no need to call dispose handler
delete outdatedDependencies[moduleId];
// remove "parents" references from all children
for (j = 0; j < module.children.length; j++) {
var child = installedModules[module.children[j]];
if (!child) continue;
idx = child.parents.indexOf(moduleId);
if (idx >= 0) {
child.parents.splice(idx, 1);
}
}
}
// ...
// insert new code
for (moduleId in appliedUpdate) {
if (Object.prototype.hasOwnProperty.call(appliedUpdate, moduleId)) {
modules[moduleId] = appliedUpdate[moduleId];
}
}
// ...
}
hotApply
方法主要分為了三個階段:
- 找出陳舊的模塊
outdatedModules
和依賴outdatedDependencies
- 從緩存中刪除過期的模塊和依賴
- 將新的模塊和依賴添加到
moudles
中,當下次調用_webpack_require
方法時就獲取到新的代碼
如果HMR失敗后,回退到live reload
操作,也就是進行瀏覽器刷新來獲取最新打包代碼,相關的代碼在dev-server中:
module.hot.check(true).then(function(updatedModules) {
if (!updatedModules) {
return window.location.reload();
}
// ...
}).
catch (function(err) {
var status = module.hot.status();
if (["abort", "fail"].indexOf(status) >= 0) {
window.location.reload();
}
});
第六步:業務代碼改造
當新的模塊代替老的模塊后,舊的業務代碼并不能知道代碼發生變化,所以需要在業務代碼的入口調用HMR的accept
方法,添加模塊更新后的處理函數:
// index.js
if (module.hot) {
module.hot.accept('./hello.js', function() {
// 更新后的處理函數
})
}