現在的我們基本上都是使用 webpack 模式開發,修改了代碼之後,頁面會直接進行改變,但是很少有人想過,為什麼頁面不刷新就會直接改變了?
初識 HMR 的時候,覺得神奇的同時,腦海中一直有一些疑問:
- 一般來說, webpack 會將不同的模塊打包成不同 bundle 或 chunk 文件, 但是在使用 webpack 進行 dev 模式開發的時候,我並沒有在我的 dist 目錄中找到 webpack 打包好的文件,它們去哪了呢?
- 在查看 webpack-dev-server 的 package.json 文件中,我發現了 webpack-dev-middleware 這個依賴,這個 webpack-dev-middleware 又有什麼作用呢?
- 在研究 HMR 的過程中,通過 Chrome 的開發者工具,我知道瀏覽器是通過 websocket 和 webpack-dev-server 進行通信的,但是我在 websocket 中並沒有發現任何代碼塊。那麼新的代碼塊是怎麼跑到瀏覽器的呢?這個 websocket 又有什麼作用?為什麼不通過 websocket 進行新舊代碼塊的替換?
- 瀏覽器如何進行替換最新代碼?替換過程中如今處理依賴關係?替換失敗了會怎樣?
帶著這些疑惑,我去探究了一下 HMR 。
HMR 原理圖解
沒錯,這是一張 HMR 工作原理流程圖。
上圖顯示了我們從修改代碼開始觸發 webpack 打包,到瀏覽器端熱更新的一個流程,我已經通過小標識將步驟進行了標記。
- 在 webpack 的 dev 模式下, webpack 會 watch 文件系統的文件修改,一旦監聽到文件變化, webpack 就會對相關模塊進行重新打包,打包完之後會將代碼保存在內存中。
- webpack 和 webpack-dev-server 之間的交互,其中,主要是利用 webpack-dev-server 里的 webpack-dev-middleware 這個中間件調用 webpack 暴露給外部的 API 對代碼變化進行的監控。
- 第三步是 webpack-dev-server 對靜態文件變化的監控,這一步和第一步不同,並不是要監控代碼進行重新打包,而是監聽配置文件中靜態文件的變化,如果發生變化,則會通知瀏覽器需要重新加載,即 live reload (刷新),和 HMR 不一樣。具體配置為,在相關配置文件配置 devServer.watchContentBase 。
- 伺服器端的額 webpack-dev-server 利用 sockjs 在瀏覽器和伺服器之間建立一個 websocket 長連結,將 webpack 打包變化信息告訴瀏覽器端的 webpack-dev-server ,這其中也包括靜態文件的改變信息,當然,這裡面最重要的就是每次打包生成的不同 hash 值。
- 瀏覽器端的 webpack-dev-server 接收到伺服器端的請求,他自身並不會進行代碼的替換,他只是一個 中間商 ,當接收到的信息有變化時,他會通知 webpack/hot/dev-server , 這是 webpack 的功能模塊,他會根據瀏覽器端的 webpack-dev-server 傳遞的信息以及 dev-server 的配置,決定瀏覽器是執行刷新操作還是熱更新操作。
- 如果是刷新操作,則直接通知瀏覽器進行刷新。如果是熱更新操作,則會通知熱加載模塊 HotModuleReplacement.runtime ,這個 HotModuleReplacement.runtime 是瀏覽器端 HMR 的中樞系統,他負責接收上一步傳遞過來的 hash 值,然後通知並等待下一個模塊即 JsonpMainTemplate.runtime 向伺服器發送請求的結果。
- HotModuleReplacement.runtime 通知 JsonpMainTemplate.runtime 模塊要進行新的代碼請求,並等待其返回的代碼塊。
- JsonpMainTemplate.runtime 先向服務端發送請求,請求包含 hash 值得 json 文件。
- 獲取到所有要更新模塊的 hash 值之後,再次向服務端發送請求,通過 jsonp 的形式,獲取到最新的代碼塊,並將此代碼塊發送給 HotModulePlugin 。
- HotModulePlugin 將會對新舊模塊進行對比,決定是否需要更新,若需要更新,則會檢查其依賴關係,更新模塊的同時更新模塊間的引用。
HMR 用例詳解
在上一個部分,作者根據示意圖簡述了 HMR 的工作原理,當然,你可能覺得了解了一個大概,細節部分仍然是很模糊,對上面出現的英文單詞也感覺很陌生,沒關係,接下來,我會通過最純粹的一個小栗子,結合源碼來一步一步說明每個部分的內容。
我們從最簡單的例子開始說明,以下是這個 demo 的具體文件
--hello.js;
--index.js;
--index.html;
--package.json;
--webpack.config.js;
其中 hello.js 為以下代碼
const hello = () => 'hello world';
export default hello;
index.js 文件為以下代碼
import hello from './hello.js';
const div = document.createElemenet('div');
div.innerHTML = hello();
document.body.appendChild(div);
webpack.config.js 文件為以下代碼
const path = require('path');
const webpack = require('webpack');
module.exports = {
entry: './index.js',
output: {
filename: 'bundle.js',
path: path.join(__dirname, '/')
},
devServer: {
hot: true
}
};
我們使用 npm install 安裝完依賴,並啟動 devServer 伺服器。
接下來,我們進入關鍵環節,改變文件內容,觸發 HMR 。
// hello.js
- const hello = () => 'hello world'
+ const hello = () => 'hello nice'
這個時候頁面就從 hello world 渲染為 hello nice 。 我們從這個過程,一步一步詳細解析一下熱更新的過程及原理。
第一步,webpack 對文件進行監測並打包
webpack-dev-middleware 調用 webpack 中的 api 來監測文件系統的改變,當 hello.js 文件發生了改變, webpack 對其進行重新打包,將打包的內容保存到內存中去。
具體代碼如下:
// webpack-dev-middleware/lib/Shared.js
if (!options.lazy) {
var watching = compiler.watch(
options.watchOptions,
share.handleCompilerCallback
);
context.watching = watching;
}
那為什麼 webpack 沒有將文件直接打包到 output.path 呢?這些文件都沒有了系統是怎麼訪問的呢?
原來 webpack 將文件打包到內存中去了。
為什麼要將打包內容保存到內存中去呢
這樣做的理由就是能更快的訪問文件,減少代碼寫入的開銷。這就歸功於 memory-js 這個庫, webpack-dev-middleware 依賴此庫,同時將 webpack 中原來的 outputFileSystem 替換成了 MemoryFileSystem 實例,這樣代碼就輸出到內存中去了,其中一部分源碼如下:
// webpack-dev-middleware/lib/Shared.js
// 首先判斷當前 fileSystem 是否已經是 MemoryFileSystem 的實例
var isMemoryFs =
!compiler.compilers && compiler.outputFileSystem instanceof MemoryFileSystem;
if (isMemoryFs) {
fs = compiler.outputFileSystem;
} else {
// 如果不是,用 MemoryFileSystem 的實例替換 compiler 之前的 outputFileSystem
fs = compiler.outputFileSystem = new MemoryFileSystem();
}
第二步,devServer 通知瀏覽器端文件發生變化
這一階段,是利用 sockjs 來進行瀏覽器和伺服器之間的通訊。
在啟動 devServer 的時候, sockjs 在瀏覽器和伺服器之間建立了一個 websocket 長連結,以便能夠實時通知瀏覽器打包動向,這其中最關鍵的部分還是 webpack-dev-server 調用 webpack 的 api 來監聽 compile 的 done 事件,當編譯完成時, webpack-dev-server 通過 _sendStatus 方法將新模塊的 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 接受到 type 為 hash 消息後,會將 hash 暫存起來,當接收到 type 為 ok 的消息後,對應執行 reload 操作
在 reload 中, webpack-dev-server/client 會根據 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 接收到最新 hash 值驗證並請求模塊代碼
這一步,主要是 webpack 的三個模塊之間的配合:
- webpack/hot/dev-derver 監聽 webpack-dev-server/client 發送的 webpackHotUpdate ,並調用 HotModuleReplacement.runtime 中的 check 方法,監測是否有更新。
- 監測過程中會使用 JsonpMainTemplate.runtime 中的兩個方法 hotDownloadUpdateChunk , hotDownloadManifest ,第二個方法是調用 ajax 想伺服器發送請求是否有更新文件,如有,則將更新文件列表返回瀏覽器端,第一個方法是通過 jsonp 請求最新代碼塊,同時將代碼返回給 HotModuleReplacement 。
- HotModuleReplacement 根據拿到的代碼塊做處理。
如上圖所示,兩次請求都是使用的上一次 hash 值拼接而成的文件名,一個是對應的 hash 值,一個是對應的代碼塊。
為什麼不直接使用 websocket 來進行更新代碼呢
我個人覺得,大概是作者想把功能解耦,各個模塊干自己的事, websocket 在這裡的設計只是進行消息的傳遞。 在使用 webpack-hot-middleware 中有件有意思的事,它沒有使用 websocket ,而是使用的 EventSource ,感興趣的可以去了解下。
第五步,HotModuleReplacement.runtime 進行熱更新
這一步很關鍵,因為所有的熱更新操作都在這一步完成,主要過程就在 HotModuleReplacement.runtime 的 hotApply 這個方法中,下面我摘取了部分代碼片段:
// webpack/lib/HotModuleReplacement.runtime
function hotApply() {
// ...
var idx;
var queue = outdatedModules.slice();
while (queue.length > 0) {
moduleId = queue.pop();
module = installedModules[moduleId];
// ...
// 刪除過期的模塊
delete installedModules[moduleId];
// 刪除過期的依賴
delete outdatedDependencies[moduleId];
// 移除所有子節點
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);
}
}
}
// ...
// 插入新的代碼
for (moduleId in appliedUpdate) {
if (Object.prototype.hasOwnProperty.call(appliedUpdate, moduleId)) {
modules[moduleId] = appliedUpdate[moduleId];
}
}
// ...
}
上面的方法可以看出,這一共經歷了三個階段
- 找出過期模塊和過期依賴
- 刪除過期模塊和過期依賴
- 將新的模塊添加到 modules 中,當下次調用 __webpack_require__ 時,就是獲取新的模塊了。
當熱更新出錯了怎麼辦
如果在熱更新過程中出現錯誤,熱更新將回退到刷新瀏覽器,這部分代碼在 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();
}
});
最後更新頁面
當用新的模塊代碼替換老的模塊後,但是我們的業務代碼並不能知道代碼已經發生變化,也就是說,當 hello.js 文件修改後,我們需要在 index.js 文件中調用 HMR 的 accept 方法,添加模塊更新後的處理函數,及時將 hello 方法的返回值插入到頁面中。 以下是部分代碼:
// index.js
if (module.hot) {
module.hot.accept('./hello.js', function() {
div.innerHTML = hello();
});
}
這就是 HMR 的整個工作流程了。
總結
這篇文章沒有對 HMR 對過於詳盡的解析,很多細節方面也沒有說明,只是簡述了一下 HMR 的工作流程,希望這篇文章能幫助你更好的了解 webpack 。
文章來源: https://twgreatdaily.com/zh-hk/CrkRunEBnkjnB-0zcsPo.html