现在的我们基本上都是使用 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.jsif (!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.jscompiler.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.jshash: 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.runtimefunction 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.jsif (module.hot) { module.hot.accept('./hello.js', function() { div.innerHTML = hello(); });}
这就是 HMR 的整个工作流程了。
这篇文章没有对 HMR 对过于详尽的解析,很多细节方面也没有说明,只是简述了一下 HMR 的工作流程,希望这篇文章能帮助你更好的了解 webpack 。