什么是 HMR
Hot Module Replacement(以下简称 HMR)是 webpack 发展至今引入的最令人兴奋的特性之一 ,当你对代码进行修改并保存后,webpack 将对代码重新打包,并将新的模块发送到浏览器端,浏览器通过新的模块替换老的模块,这样在不刷新浏览器的前提下就能够对应用进行更新。例如,在开发 Web 页面过程中,当你点击按钮,出现一个弹窗的时候,发现弹窗标题没有对齐,这时候你修改 CSS 样式,然后保存,在浏览器没有刷新的前提下,标题样式发生了改变。感觉就像在 Chrome 的开发者工具中直接修改元素样式一样。
配置
- 安装 webpack-dev-server
npm install --save-dev webpack-dev-server
- 配置 devServer
devServer: {
contentBase: './dist'
}
- 配置开启入口
"start": "webpack-dev-server --open -hot"
一些疑问
- webpack 打包后的文件去哪里了
- webpack-dev-middleware 在 HMR 中扮演什么样的角色
- 在 HMR 的过程中,通过 Chrome 开发工具我们可以知道浏览器是通过 websocket 进行通信的,但是 websocket 的 message 中并没有发现新模块的代码。打包后的新模块又是通过什么方式发送到浏览器端的呢?
- 浏览器拿到新的模块代码,HMR 又是怎么将老的模块替换成新的模块的?
- 在模块的热替换过程中,如果替换模块失败,有什么回退机制嘛?
带着上面的问题,我们继续探索 HMR 的奥秘
webpack 的编译构建过程
项目启动后,进行构建打包,控制台会输出构建过程,我们可以观察到生成了一个 Hash值:a93fd735d02d98633356
然后,在我们每次修改代码保存后,控制台都会出现 Compiling…字样,触发新的编译,可以在控制台中观察到:
- 新的Hash值:a61bdd6e82294ed06fa3
- 新的json文件: a93fd735d02d98633356.hot-update.json
- 新的js文件:index.a93fd735d02d98633356.hot-update.js
首先,我们知道Hash值代表每一次编译的标识。其次,根据新生成文件名可以发现,上次输出的Hash值会作为本次编译新生成的文件标识。依次类推,本次输出的Hash值会被作为下次热更新的标识。
然后看一下,新生成的文件是什么?每次修改代码,紧接着触发重新编译,然后浏览器就会发出 2 次请求。请求的便是本次新生成的 2 个文件。如下:
首先看json文件,返回的结果中,h代表本次新生成的Hash值,用于下次文件热更新请求的前缀。c表示当前要热更新的文件对应的是index模块。
再看下生成的js文件,那就是本次修改的代码,重新编译打包后的。
热更新实现原理
webpack-dev-server启动本地服务
我们根据webpack-dev-server的package.json中的bin命令,可以找到命令的入口文件 bin/webpack-dev-server.js。
// node_modules/webpack-dev-server/bin/webpack-dev-server.js
// 生成webpack编译主引擎 compiler
let compiler = webpack(config);
// 启动本地服务
let server = new Server(compiler, options, log);
server.listen(options.port, options.host, (err) => {
if (err) {throw err};
});
本地服务代码:
// node_modules/webpack-dev-server/lib/Server.js
class Server {
constructor() {
this.setupApp();
this.createServer();
}
setupApp() {
// 依赖了express
this.app = new express();
}
createServer() {
this.listeningApp = http.createServer(this.app);
}
listen(port, hostname, fn) {
return this.listeningApp.listen(port, hostname, (err) => {
// 启动express服务后,启动websocket服务
this.createSocketServer();
}
}
}
这一小节代码主要做了三件事:
- 启动webpack,生成compiler实例。compiler上有很多方法,比如可以启动 webpack 所有编译工作,以及监听本地文件的变化。
- 使用express框架启动本地server,让浏览器可以请求本地的静态资源。
- 本地server启动之后,再去启动websocket服务。通过websocket,可以建立本地服务和浏览器的双向通信。这样就可以实现当本地文件发生变化,立马告知浏览器可以热更新代码啦!
上述代码主要干了三件事,但是源码在启动服务前又做了很多事,接下来便看看webpack-dev-server/lib/Server.js还做了哪些事?
修改webpack.config.js的entry配置
启动本地服务前,调用了updateCompiler(this.compiler)方法。这个方法中有 2 段关键性代码。一个是获取websocket客户端代码路径,另一个是根据配置获取webpack热更新代码路径。
// 获取websocket客户端代码
const clientEntry = `${require.resolve(
'../../client/'
)}?${domain}${sockHost}${sockPath}${sockPort}`;
// 根据配置获取热更新代码
let hotEntry;
if (options.hot) {
hotEntry = require.resolve('webpack/hot/dev-server');
}
修改后的webpack入口配置如下:
// 修改后的entry入口
{ entry:
{ index:
[
// 上面获取的clientEntry
'xxx/node_modules/webpack-dev-server/client/index.js?http://localhost:8080',
// 上面获取的hotEntry
'xxx/node_modules/webpack/hot/dev-server.js',
// 开发配置的入口
'./src/index.js'
],
},
}
在入口默默增加了 2 个文件,那就意味会一同打包到bundle文件中去,那么为什么要新增 2 个文件呢?
- webpack-dev-server/client/index.js
这个文件用于websocket的,因为websoket是双向通信,在第 1 步 webpack-dev-server初始化 的过程中,启动的是本地服务端的websocket。那客户端也就是我们的浏览器,浏览器还没有和服务端通信的代码呢?因此我们需要把websocket客户端通信代码偷偷塞到我们的代码中。
- webpack/hot/dev-server.js
这个文件主要是用于检查更新逻辑的,后面会在细讲。
监听webpack编译结束
修改好入口配置后,又调用了setupHooks方法。这个方法是用来注册监听事件的,监听每次 webpack 编译完成。
// node_modules/webpack-dev-server/lib/Server.js
// 绑定监听事件
setupHooks() {
const {done} = compiler.hooks;
// 监听webpack的done钩子,tapable提供的监听方法
done.tap('webpack-dev-server', (stats) => {
this._sendStats(this.sockets, this.getStats(stats));
this._stats = stats;
});
};
当监听到一次webpack编译结束,就会调用_sendStats方法通过websocket给浏览器发送通知,ok和hash事件,这样浏览器就可以拿到最新的hash值了,做检查更新逻辑。
// 通过websoket给客户端发消息
_sendStats() {
this.sockWrite(sockets, 'hash', stats.hash);
this.sockWrite(sockets, 'ok');
}
webpack监听文件变化
每次修改代码,就会触发编译。说明我们还需要监听本地代码的变化,主要是通过 setupDevMiddleware 方法实现的。
这个方法主要执行了 webpack-dev-middleware 库。很多人分不清 webpack-dev-middleware 和 webpack-dev-server 的区别。其实就是因为 webpack-dev-server 只负责启动服务和前置准备工作,所有文件相关的操作都抽离到 webpack-dev-middleware 库了,主要是本地文件的编译和输出以及监听,职责的划分更加清晰。
那我们来看下webpack-dev-middleware源码里做了什么事:
// node_modules/webpack-dev-middleware/index.js
compiler.watch(options.watchOptions, (err) => {
if (err) { /*错误处理*/ }
});
// 通过“memory-fs”库将打包后的文件写入内存
setFs(context, compiler);
1. 调用了compiler.watch 方法, 这个方法主要就做了 2 件事:
- 首先对本地文件代码进行编译打包,也就是webpack的一系列编译流程。
- 其次编译结束后,开启对本地文件的监听,当文件发生变化,重新编译,编译完成之后继续监听。
2. 执行setFs方法,这个方法主要目的就是将编译后的文件打包到内存。
这就是为什么在开发的过程中,你会发现dist目录没有打包后的代码,因为都在内存中。原因就在于访问内存中的代码比访问文件系统中的文件更快,而且也减少了代码写入文件的开销,这一切都归功于memory-fs。
浏览器接收到热更新的通知
我们已经可以监听到文件的变化了,当文件发生变化,就触发重新编译。
同时还监听了每次编译结束的事件。当监听到一次webpack编译结束,_sendStats 方法就通过 websoket 给浏览器发送通知,检查下是否需要热更新。
下面重点讲的就是 _sendStats 方法中的 ok 和 hash 事件都做了什么。
那浏览器是如何接收到websocket的消息呢?回忆下第 2 步骤增加的入口文件,也就是websocket客户端代码。
'xxx/node_modules/webpack-dev-server/client/index.js?http://localhost:8080'
这个文件的代码会被打包到bundle.js中,运行在浏览器中。来看下这个文件的核心代码:
// webpack-dev-server/client/index.js
var socket = require('./socket');
var onSocketMessage = {
hash: function hash(_hash) {
// 更新currentHash值
status.currentHash = _hash;
},
ok: function ok() {
sendMessage('Ok');
// 进行更新检查等操作
reloadApp(options, status);
},
};
// 连接服务地址socketUrl,?http://localhost:8080,本地服务地址
socket(socketUrl, onSocketMessage);
function reloadApp() {
if (hot) {
log.info('[WDS] App hot update...');
// hotEmitter其实就是EventEmitter的实例
var hotEmitter = require('webpack/hot/emitter');
hotEmitter.emit('webpackHotUpdate', currentHash);
}
}
socket方法建立了 websocket 和服务端的连接,并注册了 2 个监听事件。
- hash事件,更新最新一次打包后的hash值。
- ok事件,进行热更新检查。
热更新检查事件是调用 reloadApp 方法。这个方法又利用node.js的EventEmitter,发出 webpackHotUpdate消息。为什么不直接进行检查更新呢? 个人理解就是为了更好的维护代码,以及职责划分的更明确。websocket仅仅用于客户端(浏览器)和服务端进行通信。而真正做事情的活还是交回给了webpack。
那webpack怎么做的呢?再来回忆下第 2 步。入口文件还有一个文件没有讲到,就是:
'xxx/node_modules/webpack/hot/dev-server.js'
这个文件的代码同样会被打包到bundle.js中,运行在浏览器中。我们来看下这个文件做了什么:
// node_modules/webpack/hot/dev-server.js
var check = function check() {
module.hot.check(true)
.then(function(updatedModules) {
// 容错,直接刷新页面
if (!updatedModules) {
window.location.reload();
return;
}
// 热更新结束,打印信息
if (upToDate()) {
log("info", "[HMR] App is up to date.");
}
})
.catch(function(err) {
window.location.reload();
});
};
var hotEmitter = require("./emitter");
hotEmitter.on("webpackHotUpdate", function(currentHash) {
lastHash = currentHash;
check();
});
这里webpack监听到了webpackHotUpdate事件,并获取最新了最新的hash值,然后终于进行检查更新了。
检查更新呢调用的是module.hot.check方法。那么问题又来了,module.hot.check又是哪里冒出来了的!答案是HotModuleReplacementPlugin, 这里留个疑问,然后我们继续往下看。
HotModuleReplacementPlugin
前面好像一直是webpack-dev-server做的事,那HotModuleReplacementPlugin在热更新过程中又做了什么伟大的事业呢?
首先你可以对比下,配置热更新和不配置时bundle.js的区别。
-
(1)没有配置的。
-
(2)配置了HotModuleReplacementPlugin或--hot的。
我们发现moudle新增了一个属性为hot,再看hotCreateModule方法。 这不就找到module.hot.check是哪里冒出来的。
经过对比打包后的文件, webpack_require 中的 moudle 以及代码行数的不同。我们都可以发现HotModuleReplacementPlugin 原来也是默默的塞了很多代码到bundle.js中。这和第 2 步骤很是相似,为什么要塞代码呢,因为检查更新是在浏览器中操作,这些代码必须在客户端的环境。
moudle.hot.check 开始热更新
通过上一步,我们知道 moudle.hot.check 方法是如何来的啦。那都做了什么?
- 利用上一次保存的hash值,调用 hotDownloadManifest 发送 xxx/hash.hot-update.json 的 ajax 请求; 请求结果获取热更新模块,以及下次热更新的 Hash 标识,并进入热更新准备阶段。
hotAvailableFilesMap = update.c; // 需要更新的文件
hotUpdateNewHash = update.h; // 更新下次热更新hash值
hotSetStatus("prepare"); // 进入热更新准备状态
- 调用 hotDownloadUpdateChunk 发送 xxx/hash.hot-update.js 请求,通过 JSONP 方式。
function hotDownloadUpdateChunk(chunkId) {
var script = document.createElement("script");
script.charset = "utf-8";
script.src = __webpack_require__.p + "" + chunkId + "." + hotCurrentHash + ".hot-update.js";
if (null) script.crossOrigin = null;
document.head.appendChild(script);
}
可以发现,新编译后的代码是在一个 webpackHotUpdate 函数体内部的。
再看下webpackHotUpdate这个方法。
window["webpackHotUpdate"] = function (chunkId, moreModules) {
hotAddUpdateChunk(chunkId, moreModules);
} ;
这个方法做了两件事情:
- hotAddUpdateChunk方法会把更新的模块moreModules赋值给全局全量hotUpdate。
- hotUpdateDownloaded方法会调用hotApply进行代码的替换。
function hotAddUpdateChunk(chunkId, moreModules) {
// 更新的模块moreModules赋值给全局全量hotUpdate
for (var moduleId in moreModules) {
if (Object.prototype.hasOwnProperty.call(moreModules, moduleId)) {
hotUpdate[moduleId] = moreModules[moduleId];
}
}
// 调用hotApply进行模块的替换
hotUpdateDownloaded();
}
hotApply 热更新模块替换
热更新的核心逻辑就在hotApply方法了
1. 删除过期的模块,就是需要替换的模块:通过hotUpdate可以找到旧模块
var queue = outdatedModules.slice();
while (queue.length > 0) {
moduleId = queue.pop();
// 从缓存中删除过期的模块
module = installedModules[moduleId];
// 删除过期的依赖
delete outdatedDependencies[moduleId];
// 存储了被删掉的模块id,便于更新代码
outdatedSelfAcceptedModules.push({
module: moduleId
});
}
2. 将新的模块添加到 modules 中
appliedUpdate[moduleId] = hotUpdate[moduleId];
for (moduleId in appliedUpdate) {
if (Object.prototype.hasOwnProperty.call(appliedUpdate, moduleId)) {
modules[moduleId] = appliedUpdate[moduleId];
}
}
3. 通过__webpack_require__执行相关模块的代码
for (i = 0; i < outdatedSelfAcceptedModules.length; i++) {
var item = outdatedSelfAcceptedModules[i];
moduleId = item.module;
try {
// 执行最新的代码
__webpack_require__(moduleId);
} catch (err) {
// ...容错处理
}
}