带你看懂 HMR 热更新原理
带你看懂 HMR 热更新原理
Hot Module Replacement
(以下简称 HMR)是webpack
发展至今引入的最令人兴奋的特性之一 ,当你对代码进行修改并保存后,webpack
将对代码重新打包,并将新的模块发送到浏览器端,浏览器通过新的模块替换老的模块,这样在不刷新浏览器的前提下就能够对应用进行更新。例如,我们在开发网站的时候,发现某个元素样式的需要调整,这个时候你只需要在代码里修改对应的样式,然后保存,在浏览器没有刷新的前提下,这个元素的样式已经进行了改变,这种开发体验就好像直接在Chrome
里面直接对元素进行了改变一样,十分舒服。
HMR 的疑惑
现在的我们基本上都是使用 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
。
最新 HMR 的改变
P.S 新版的
HMR
已经做出了改变,使用websocket
进行代码替换,不再是通过jsonp
插入代码块,但仍然是通过jsonp
获取热更新的json
文件来确定hash
值。