帶你看懂 HMR 熱更新原理

2020-04-27     sandag

現在的我們基本上都是使用 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 打包,到瀏覽器端熱更新的一個流程,我已經通過小標識將步驟進行了標記。

  1. 在 webpack 的 dev 模式下, webpack 會 watch 文件系統的文件修改,一旦監聽到文件變化, webpack 就會對相關模塊進行重新打包,打包完之後會將代碼保存在內存中。
  2. webpack 和 webpack-dev-server 之間的交互,其中,主要是利用 webpack-dev-server 里的 webpack-dev-middleware 這個中間件調用 webpack 暴露給外部的 API 對代碼變化進行的監控。
  3. 第三步是 webpack-dev-server 對靜態文件變化的監控,這一步和第一步不同,並不是要監控代碼進行重新打包,而是監聽配置文件中靜態文件的變化,如果發生變化,則會通知瀏覽器需要重新加載,即 live reload (刷新),和 HMR 不一樣。具體配置為,在相關配置文件配置 devServer.watchContentBase
  4. 伺服器端的額 webpack-dev-server 利用 sockjs 在瀏覽器和伺服器之間建立一個 websocket 長連結,將 webpack 打包變化信息告訴瀏覽器端的 webpack-dev-server ,這其中也包括靜態文件的改變信息,當然,這裡面最重要的就是每次打包生成的不同 hash 值。
  5. 瀏覽器端的 webpack-dev-server 接收到伺服器端的請求,他自身並不會進行代碼的替換,他只是一個 中間商 ,當接收到的信息有變化時,他會通知 webpack/hot/dev-server , 這是 webpack 的功能模塊,他會根據瀏覽器端的 webpack-dev-server 傳遞的信息以及 dev-server 的配置,決定瀏覽器是執行刷新操作還是熱更新操作。
  6. 如果是刷新操作,則直接通知瀏覽器進行刷新。如果是熱更新操作,則會通知熱加載模塊 HotModuleReplacement.runtime ,這個 HotModuleReplacement.runtime 是瀏覽器端 HMR 的中樞系統,他負責接收上一步傳遞過來的 hash 值,然後通知並等待下一個模塊即 JsonpMainTemplate.runtime 向伺服器發送請求的結果。
  7. HotModuleReplacement.runtime 通知 JsonpMainTemplate.runtime 模塊要進行新的代碼請求,並等待其返回的代碼塊。
  8. JsonpMainTemplate.runtime 先向服務端發送請求,請求包含 hash 值得 json 文件。
  9. 獲取到所有要更新模塊的 hash 值之後,再次向服務端發送請求,通過 jsonp 的形式,獲取到最新的代碼塊,並將此代碼塊發送給 HotModulePlugin 。
  10. 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 的三個模塊之間的配合:

  1. webpack/hot/dev-derver 監聽 webpack-dev-server/client 發送的 webpackHotUpdate ,並調用 HotModuleReplacement.runtime 中的 check 方法,監測是否有更新。
  2. 監測過程中會使用 JsonpMainTemplate.runtime 中的兩個方法 hotDownloadUpdateChunk , hotDownloadManifest ,第二個方法是調用 ajax 想伺服器發送請求是否有更新文件,如有,則將更新文件列表返回瀏覽器端,第一個方法是通過 jsonp 請求最新代碼塊,同時將代碼返回給 HotModuleReplacement 。
  3. 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];
}
}
// ...
}

上面的方法可以看出,這一共經歷了三個階段

  1. 找出過期模塊和過期依賴
  2. 刪除過期模塊和過期依賴
  3. 將新的模塊添加到 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-my/CrkRunEBnkjnB-0zcsPo.html