現在的我們基本上都是使用 webpack 模式開發,修改了代碼之後,頁面會直接進行改變,但是很少有人想過,為什麼頁面不刷新就會直接改變了?
初識 HMR 的時候,覺得神奇的同時,腦海中一直有一些疑問:
帶著這些疑惑,我去探究了一下 HMR 。
沒錯,這是一張 HMR 工作原理流程圖。
上圖顯示了我們從修改代碼開始觸發 webpack 打包,到瀏覽器端熱更新的一個流程,我已經通過小標識將步驟進行了標記。
在上一個部分,作者根據示意圖簡述了 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-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();
}
這一階段,是利用 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 接受到 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 值拼接而成的文件名,一個是對應的 hash 值,一個是對應的代碼塊。
我個人覺得,大概是作者想把功能解耦,各個模塊干自己的事, websocket 在這裡的設計只是進行消息的傳遞。 在使用 webpack-hot-middleware 中有件有意思的事,它沒有使用 websocket ,而是使用的 EventSource ,感興趣的可以去了解下。
這一步很關鍵,因為所有的熱更新操作都在這一步完成,主要過程就在 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];
}
}
// ...
}
上面的方法可以看出,這一共經歷了三個階段
如果在熱更新過程中出現錯誤,熱更新將回退到刷新瀏覽器,這部分代碼在 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 。