在本文中,我们将给您介绍关于WebpackHMR原理解析的详细内容,并且为您解答webpack_hmr的相关问题,此外,我们还将为您提供关于120行代码帮你了解Webpack下的HMR机制、Vue(基
在本文中,我们将给您介绍关于Webpack HMR 原理解析的详细内容,并且为您解答webpack_hmr的相关问题,此外,我们还将为您提供关于120 行代码帮你了解 Webpack 下的 HMR 机制、Vue(基础七)_webpack(webpack异步加载原理)、webpack - hmr热更新、webpack -HMR-非常实用(6)的知识。
本文目录一览:- Webpack HMR 原理解析(webpack_hmr)
- 120 行代码帮你了解 Webpack 下的 HMR 机制
- Vue(基础七)_webpack(webpack异步加载原理)
- webpack - hmr热更新
- webpack -HMR-非常实用(6)
Webpack HMR 原理解析(webpack_hmr)
Hot Module Replacement(以下简称 HMR)是 webpack 发展至今引入的最令人兴奋的特性之一 ,当你对代码进行修改并保存后,webpack 将对代码重新打包,并将新的模块发送到浏览器端,浏览器通过新的模块替换老的模块,这样在不刷新浏览器的前提下就能够对应用进行更新。例如,在开发 Web 页面过程中,当你点击按钮,出现一个弹窗的时候,发现弹窗标题没有对齐,这时候你修改 CSS 样式,然后保存,在浏览器没有刷新的前提下,标题样式发生了改变。感觉就像在 Chrome 的开发者工具中直接修改元素样式一样。
本篇文章不是告诉你怎么使用 HMR,如果你对 HMR 依然感觉到陌生,建议先阅读官网 HMR 指南,上面有 HMR 最简单的用例,我会等着你回来的。
为什么需要 HMR
在 webpack HMR 功能之前,已经有很多 live reload 的工具或库,比如 live-server,这些库监控文件的变化,然后通知浏览器端刷新页面,那么我们为什么还需要 HMR 呢?答案其实在上文中已经提及一些。
- live reload 工具并不能够保存应用的状态(states),当刷新页面后,应用之前状态丢失,还是上文中的例子,点击按钮出现弹窗,当浏览器刷新后,弹窗也随即消失,要恢复到之前状态,还需再次点击按钮。而 webapck HMR 则不会刷新浏览器,而是运行时对模块进行热替换,保证了应用状态不会丢失,提升了开发效率。
- 在古老的开发流程中,我们可能需要手动运行命令对代码进行打包,并且打包后再手动刷新浏览器页面,而这一系列重复的工作都可以通过 HMR 工作流来自动化完成,让更多的精力投入到业务中,而不是把时间浪费在重复的工作上。
- HMR 兼容市面上大多前端框架或库,比如 React Hot Loader,Vue-loader,能够监听 React 或者 Vue 组件的变化,实时将最新的组件更新到浏览器端。Elm Hot Loader 支持通过 webpack 对 Elm 语言代码进行转译并打包,当然它也实现了 HMR 功能。
HMR 的工作原理图解
初识 HMR 的时候觉得其很神奇,一直有一些疑问萦绕在脑海。
- webpack 可以将不同的模块打包成 bundle 文件或者几个 chunk 文件,但是当我通过 webpack HMR 进行开发的过程中,我并没有在我的 dist 目录中找到 webpack 打包好的文件,它们去哪呢?
- 通过查看 webpack-dev-server 的 package.json 文件,我们知道其依赖于 webpack-dev-middleware 库,那么 webpack-dev-middleware 在 HMR 过程中扮演什么角色?
- 使用 HMR 的过程中,通过 Chrome 开发者工具我知道浏览器是通过 websocket 和 webpack-dev-server 进行通信的,但是 websocket 的 message 中并没有发现新模块代码。打包后的新模块又是通过什么方式发送到浏览器端的呢?为什么新的模块不通过 websocket 随消息一起发送到浏览器端呢?
- 浏览器拿到最新的模块代码,HMR 又是怎么将老的模块替换成新的模块,在替换的过程中怎样处理模块之间的依赖关系?
- 当模块的热替换过程中,如果替换模块失败,有什么回退机制吗?
带着上面的问题,于是决定深入到 webpack 源码,寻找 HMR 底层的奥秘。
图一:HMR 工作流程图解
上图是webpack 配合 webpack-dev-server 进行应用开发的模块热更新流程图。
- 上图底部红色框内是服务端,而上面的橙色框是浏览器端。
- 绿色的方框是 webpack 代码控制的区域。蓝色方框是 webpack-dev-server 代码控制的区域,洋红色的方框是文件系统,文件修改后的变化就发生在这,而青色的方框是应用本身。
上图显示了我们修改代码到模块热更新完成的一个周期,通过深绿色的阿拉伯数字符号已经将 HMR 的整个过程标识了出来。
- 第一步,在 webpack 的 watch 模式下,文件系统中某一个文件发生修改,webpack 监听到文件变化,根据配置文件对模块重新编译打包,并将打包后的代码通过简单的 JavaScript 对象保存在内存中。
- 第二步是 webpack-dev-server 和 webpack 之间的接口交互,而在这一步,主要是 dev-server 的中间件 webpack-dev-middleware 和 webpack 之间的交互,webpack-dev-middleware 调用 webpack 暴露的 API对代码变化进行监控,并且告诉 webpack,将代码打包到内存中。
- 第三步是 webpack-dev-server 对文件变化的一个监控,这一步不同于第一步,并不是监控代码变化重新打包。当我们在配置文件中配置了devServer.watchContentBase 为 true 的时候,Server 会监听这些配置文件夹中静态文件的变化,变化后会通知浏览器端对应用进行 live reload。注意,这儿是浏览器刷新,和 HMR 是两个概念。
- 第四步也是 webpack-dev-server 代码的工作,该步骤主要是通过 sockjs(webpack-dev-server 的依赖)在浏览器端和服务端之间建立一个 websocket 长连接,将 webpack 编译打包的各个阶段的状态信息告知浏览器端,同时也包括第三步中 Server 监听静态文件变化的信息。浏览器端根据这些 socket 消息进行不同的操作。当然服务端传递的最主要信息还是新模块的 hash 值,后面的步骤根据这一 hash 值来进行模块热替换。
- webpack-dev-server/client 端并不能够请求更新的代码,也不会执行热更模块操作,而把这些工作又交回给了 webpack,webpack/hot/dev-server 的工作就是根据 webpack-dev-server/client 传给它的信息以及 dev-server 的配置决定是刷新浏览器呢还是进行模块热更新。当然如果仅仅是刷新浏览器,也就没有后面那些步骤了。
- HotModuleReplacement.runtime 是客户端 HMR 的中枢,它接收到上一步传递给他的新模块的 hash 值,它通过 JsonpMainTemplate.runtime 向 server 端发送 Ajax 请求,服务端返回一个 json,该 json 包含了所有要更新的模块的 hash 值,获取到更新列表后,该模块再次通过 jsonp 请求,获取到最新的模块代码。这就是上图中 7、8、9 步骤。
- 而第 10 步是决定 HMR 成功与否的关键步骤,在该步骤中,HotModulePlugin 将会对新旧模块进行对比,决定是否更新模块,在决定更新模块后,检查模块之间的依赖关系,更新模块的同时更新模块间的依赖引用。
- 最后一步,当 HMR 失败后,回退到 live reload 操作,也就是进行浏览器刷新来获取最新打包代码。
运用 HMR 的简单例子
在上一个部分,通过一张 HMR 流程图,简要的说明了 HMR 进行模块热更新的过程。当然你可能感觉还是很迷糊,对上面出现的一些英文名词也可能比较陌生(上面这些英文名词代表着代码仓库或者仓库中的文件模块),没关系,在这一部分,我将通过一个最简单最纯粹的例子,通过分析 wepack及 webpack-dev-server 源码详细说明各个库在 HMR 过程中的具体职责。
在开始这个例子之前简单对这个仓库文件进行下说明,仓库中包含文件如下:
--hello.js
--index.js
--index.html
--package.json
--webpack.config.js
项目中包含两个 js 文件,项目入口文件是 index.js 文件,hello.js 文件是 index.js 文件的一个依赖,js 代码如你所见(点击上面例子链接可以查看源码),将在 body 元素中添加一个包含「hello world」的 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
}
}
值得一提的是,在上面的配置中并没有配置 HotModuleReplacementPlugin,原因在于当我们设置 devServer.hot 为 true 后,并且在package.json 文件中添加如下的 script 脚本:
“start”: “webpack-dev-server --hot --open”
添加 —hot 配置项后,devServer 会告诉 webpack 自动引入 HotModuleReplacementPlugin 插件,而不用我们再手动引入了。
进入到仓库目录,npm install 安装依赖后,运行 npm start 就启动了 devServer 服务,访问 http://127.0.0.1:8080 就可以看到我们的页面了。
下面将进入到关键环节,在简单例子中,我将修改 hello.js 文件中的代码,在源码层面上来分析 HMR 的具体运行流程,当然我还是将按照上面图解来分析。修改代码如下:(以下所有代码块首行就是该文件的路径)
// hello.js
- const hello = () => 'hello world' // 将 hello world 字符串修改为 hello eleme
+ const hello = () => 'hello eleme'
页面中 hello world 文本随即变成 hello eleme。
第一步:webpack 对文件系统进行 watch 打包到内存中
webpack-dev-middleware 调用 webpack 的 api 对文件系统 watch,当 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 将 bundle.js 文件打包到了内存中,不生成文件的原因就在于访问内存中的代码比访问文件系统中的文件更快,而且也减少了代码写入文件的开销,这一切都归功于memory-fs,memory-fs 是 webpack-dev-middleware 的一个依赖库,webpack-dev-middleware 将 webpack 原本的 outputFileSystem 替换成了MemoryFileSystem 实例,这样代码就将输出到内存中。webpack-dev-middleware 中该部分源码如下:
// webpack-dev-middleware/lib/Shared.js
var isMemoryFs = !compiler.compilers && compiler.outputFileSystem instanceof MemoryFileSystem;
if(isMemoryFs) {
fs = compiler.outputFileSystem;
} else {
fs = compiler.outputFileSystem = new MemoryFileSystem();
}
首先判断当前 fileSystem 是否已经是 MemoryFileSystem 的实例,如果不是,用 MemoryFileSystem 的实例替换 compiler 之前的 outputFileSystem。这样 bundle.js 文件代码就作为一个简单 javascript 对象保存在了内存中,当浏览器请求 bundle.js 文件时,devServer就直接去内存中找到上面保存的 javascript 对象返回给浏览器端。
第二步:devServer 通知浏览器端文件发生改变
在这一阶段,sockjs 是服务端和浏览器端之间的桥梁,在启动 devServer 的时候,sockjs 在服务端和浏览器端建立了一个 webSocket 长连接,以便将 webpack 编译和打包的各个阶段状态告知浏览器,最关键的步骤还是 webpack-dev-server 调用 webpack api 监听 compile的 done
事件,当compile 完成后,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 接收到服务端消息做出响应
可能你又会有疑问,我并没有在业务代码里面添加接收 websocket 消息的代码,也没有在 webpack.config.js 中的 entry 属性中添加新的入口文件,那么 bundle.js 中接收 websocket 消息的代码从哪来的呢?原来是 webpack-dev-server 修改了webpack 配置中的 entry 属性,在里面添加了 webpack-dev-client 的代码,这样在最后的 bundle.js 文件中就会有接收 websocket 消息的代码了。
webpack-dev-server/client 当接收到 type 为 hash 消息后会将 hash 值暂存起来,当接收到 type 为 ok 的消息后对应用执行 reload 操作,如下图所示,hash 消息是在 ok 消息之前。
图二:websocket 接收 dev-server 通过 sockjs 发送到浏览器端的消息列表
在 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();
}
}
如上面代码所示,首先将 hash 值暂存到 currentHash 变量,当接收到 ok 消息后,对 App 进行 reload。如果配置了模块热更新,就调用 webpack/hot/emitter 将最新 hash 值发送给 webpack,然后将控制权交给 webpack 客户端代码。如果没有配置模块热更新,就直接调用 location.reload 方法刷新页面。
第四步:webpack 接收到最新 hash 值验证并请求模块代码
在这一步,其实是 webpack 中三个模块(三个文件,后面英文名对应文件路径)之间配合的结果,首先是 webpack/hot/dev-server(以下简称 dev-server) 监听第三步 webpack-dev-server/client 发送的 webpackHotUpdate
消息,调用 webpack/lib/HotModuleReplacement.runtime(简称 HMR runtime)中的 check 方法,检测是否有新的更新,在 check 过程中会利用 webpack/lib/JsonpMainTemplate.runtime(简称 jsonp runtime)中的两个方法 hotDownloadUpdateChunk
和 hotDownloadManifest
, 第二个方法是调用 AJAX 向服务端请求是否有更新的文件,如果有将发更新的文件列表返回浏览器端,而第一个方法是通过 jsonp 请求最新的模块代码,然后将代码返回给 HMR runtime,HMR runtime 会根据返回的新模块代码做进一步处理,可能是刷新页面,也可能是对模块进行热更新。
图三:hotDownloadManifest方法获取更新文件列表
图四:hotDownloadUpdateChunk获取到更新的新模块代码
如上两图所示,值得注意的是,两次请求的都是使用上一次的 hash 值拼接的请求文件名,hotDownloadManifest 方法返回的是最新的 hash 值,hotDownloadUpdateChunk 方法返回的就是最新 hash 值对应的代码块。然后将新的代码块返回给 HMR runtime,进行模块热更新。
还记得 HMR 的工作原理图解 中的问题 3 吗?为什么更新模块的代码不直接在第三步通过 websocket 发送到浏览器端,而是通过 jsonp 来获取呢?我的理解是,功能块的解耦,各个模块各司其职,dev-server/client 只负责消息的传递而不负责新模块的获取,而这些工作应该有 HMR runtime 来完成,HMR runtime 才应该是获取新代码的地方。再就是因为不使用 webpack-dev-server 的前提,使用 webpack-hot-middleware 和 webpack 配合也可以完成模块热更新流程,在使用 webpack-hot-middleware 中有件有意思的事,它没有使用 websocket,而是使用的 EventSource。综上所述,HMR 的工作流中,不应该把新模块代码放在 websocket 消息中。
第五步:HotModuleReplacement.runtime 对模块进行热更新
这一步是整个模块热更新(HMR)的关键步骤,而且模块热更新都是发生在HMR runtime 中的 hotApply 方法中,这儿我不打算把 hotApply 方法整个源码贴出来了,因为这个方法包含 300 多行代码,我将只摘取关键代码片段。
// webpack/lib/HotModuleReplacement.runtime
function hotApply() {
// ...
var idx;
var queue = outdatedModules.slice();
while(queue.length > 0) {
moduleId = queue.pop();
module = installedModules[moduleId];
// ...
// remove module from cache
delete installedModules[moduleId];
// when disposing there is no need to call dispose handler
delete outdatedDependencies[moduleId];
// remove "parents" references from all children
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);
}
}
}
// ...
// insert new code
for(moduleId in appliedUpdate) {
if(Object.prototype.hasOwnProperty.call(appliedUpdate, moduleId)) {
modules[moduleId] = appliedUpdate[moduleId];
}
}
// ...
}
从上面 hotApply 方法可以看出,模块热替换主要分三个阶段,第一个阶段是找出 outdatedModules 和 outdatedDependencies,这儿我没有贴这部分代码,有兴趣可以自己阅读源码。第二个阶段从缓存中删除过期的模块和依赖,如下:
delete installedModules[moduleId];
delete outdatedDependencies[moduleId];
第三个阶段是将新的模块添加到 modules 中,当下次调用 webpack_require (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();
}
});
dev-server 先验证是否有更新,没有代码更新的话,重载浏览器。如果在 hotApply 的过程中出现 abort 或者 fail 错误,也进行重载浏览器。
第六步:业务代码需要做些什么?
当用新的模块代码替换老的模块后,但是我们的业务代码并不能知道代码已经发生变化,也就是说,当 hello.js 文件修改后,我们需要在 index.js 文件中调用 HMR 的 accept 方法,添加模块更新后的处理函数,及时将 hello 方法的返回值插入到页面中。代码如下:
// index.js
if(module.hot) {
module.hot.accept('./hello.js', function() {
div.innerHTML = hello()
})
}
这样就是整个 HMR 的工作流程了。
写在最后
这篇文章的作用并不是对 webpack HMR 的详尽解析,很多细节方面也没过多讨论,而只想起到一个抛砖引玉的作用,给大家展现一个 HMR 概述的工作流程,如果对 webpack 感兴趣,想了解 webpack HMR 更多的底层细节,相信阅读 webpack 源码将是一个不错的选择,也希望这篇文章能够对你阅读源码有所帮助,这才是我真正的写作目的。
参考资料
https://zhuanlan.zhihu.com/p/30669007
120 行代码帮你了解 Webpack 下的 HMR 机制
朱海华: 微医前端技术部平台支撑组 我本地是好的,你再试试~
HMR 的背景
在使用Webpack Dev Server
以后 可以让我们在开发工程中 专注于 Coding, 因为它可以监听代码的变化 从而实现打包更新,并且最后通过自动刷新的方式同步到浏览器,便于我们及时查看效果。但是 Dev Server 从监听到打包再到通知浏览器整体刷新页面
就会导致一个让人困扰的问题 那就是 无法保存应用状态
因此 针对这个问题,Webpack 提供了一个新的解决方案 Hot Module Replacement
HMR 简单概念
Hot Module Replacement 是指当我们对代码修改并保存后,Webpack 将会对代码进行重新打包,并将新的模块发送到浏览器端,浏览器用新的模块替换掉旧的模块,以实现在不刷新浏览器的前提下更新页面。最明显的优势就是相对于传统的live reload
而言,HMR 并不会丢失应用的状态,提高开发效率。在开始深入了解 Webpack HMR 之前 我们可以先简单过一下下面这张流程图
HRM 流程概览

-
Webpack Compile: watch 打包本地文件 写入内存 -
Boundle Server: 启一个本地服务,提供文件在浏览器端进行访问 -
HMR Server: 将热更新的文件输出给 HMR Runtime -
HRM Runtime: 生成的文件,注入至浏览器内存 -
Bundle: 构建输出文件
HMR 入门体验
开启 HMR 其实也极其容易 因为 HMR 本身就已经集成在了 Webpack 里 开启方式有两种
-
直接通过运行 webpack-dev-server 命令时 加入 --hot
参数 直接开启 HMR -
写入配置文件 代码如下
// ./webpack.config.js
const webpack = require(''webpack'')
module.exports = {
// ...
devServer: {
// 开启 HMR 特性 如果不支持 MMR 则会 fallback 到 live reload
hot: true,
},
plugins: [
// ...
// HMR 依赖的插件
new webpack.HotModuleReplacementPlugin()
]
}
HMR 中的 Server 和 Client
devServer 通知浏览器文件变更
通过翻阅 webpack-dev-server 源码 在这一过程中,依赖于 sockjs 提供的服务端与浏览器端之间的桥梁,在 devServer 启动的同时,建立了一个 webSocket 长链接,用于通知浏览器在 webpack 编译和打包下的各个状态,同时监听 compile 下的 done 事件,当 compile 完成以后,通过 sendStats 方法, 将重新编译打包好的新模块 hash 值发送给浏览器。
// webpack-dev-server/blob/master/lib/Server.js
sendStats(sockets, stats, force) {
const shouldEmit =
!force &&
stats &&
(!stats.errors || stats.errors.length === 0) &&
(!stats.warnings || stats.warnings.length === 0) &&
stats.assets &&
stats.assets.every((asset) => !asset.emitted);
if (shouldEmit) {
this.sockWrite(sockets, ''still-ok'');
return;
}
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'');
}
}
Client 接收到服务端消息做出响应
webpack-dev-server/client 当接收到 type 为 hash 消息后会将 hash 值暂时缓存起来,同时当接收到到 type 为 ok 的时候,对浏览器执行 reload 操作。
reload 策略选择
function reloadApp(
{ hotReload, hot, liveReload },
{ isUnloading, currentHash }
) {
if (isUnloading || !hotReload) {
return;
}
if (hot) {
log.info(''App hot update...'');
const hotEmitter = require(''webpack/hot/emitter'');
hotEmitter.emit(''webpackHotUpdate'', currentHash);
if (typeof self !== ''undefined'' && self.window) {
// broadcast update to window
self.postMessage(`webpackHotUpdate${currentHash}`, ''*'');
}
}
// allow refreshing the page only if liveReload isn''t disabled
else if (liveReload) {
let rootWindow = self;
// use parent window for reload (in case we''re in an iframe with no valid src)
const intervalId = self.setInterval(() => {
if (rootWindow.location.protocol !== ''about:'') {
// reload immediately if protocol is valid
applyReload(rootWindow, intervalId);
} else {
rootWindow = rootWindow.parent;
if (rootWindow.parent === rootWindow) {
// if parent equals current window we''ve reached the root which would continue forever, so trigger a reload anyways
applyReload(rootWindow, intervalId);
}
}
});
}
function applyReload(rootWindow, intervalId) {
clearInterval(intervalId);
log.info(''App updated. Reloading...'');
rootWindow.location.reload();
}
通过翻阅 webpack-dev-server/client源码,我们可以看到,首先会根据 hot 配置决定是采用哪种更新策略,刷新浏览器或者代码进行热更新(HMR),如果配置了 HMR,就调用 webpack/hot/emitter
将最新 hash 值发送给 webpack,如果没有配置模块热更新,就直接调用 applyReload
下的location.reload
方法刷新页面。
webpack 根据 hash 请求最新模块代码
在这一步,其实是 webpack 中三个模块(三个文件,后面英文名对应文件路径)之间配合的结果,首先是 webpack/hot/dev-server(以下简称 dev-server) 监听第三步 webpack-dev-server/client 发送的 webpackHotUpdate 消息,调用 webpack/lib/HotModuleReplacement.runtime(简称 HMR runtime)中的 check 方法,检测是否有新的更新,在 check 过程中会利用 webpack/lib/JsonpMainTemplate.runtime(简称 jsonp runtime)中的两个方法 hotDownloadUpdateChunk 和 hotDownloadManifest , 第二个方法是调用 AJAX 向服务端请求是否有更新的文件,如果有将发更新的文件列表返回浏览器端,而第一个方法是通过 jsonp 请求最新的模块代码,然后将代码返回给 HMR runtime,HMR runtime 会根据返回的新模块代码做进一步处理,可能是刷新页面,也可能是对模块进行热更新。
在这个过程中,其实是 webpack 三个模块配合执行之后获取的结果
-
webpack/hot/dev-server
监听 client 发送的webpackHotUpdate
消息
// ....
var hotEmitter = require("./emitter");
hotEmitter.on("webpackHotUpdate", function (currentHash) {
lastHash = currentHash;
if (!upToDate() && module.hot.status() === "idle") {
log("info", "[HMR] Checking for updates on the server...");
check();
}
});
log("info", "[HMR] Waiting for update signal from WDS...");
} else {
throw new Error("[HMR] Hot Module Replacement is disabled.");
-
[HMR runtime/check()](https://github.com/webpack/webpack/blob/v4.41.5/lib/HotModuleReplacement.runtime.js)
检测是否有新的更新,check 过程中会利用webpack/lib/web/JsonpMainTemplate.runtime.js中的hotDownloadUpdateChunk
(通过 jsonp 请求新的模块代码并且返回给 HMR Runtime)以及hotDownloadManifest
(发送 AJAx 请求向 Server 请求是否有更新的文件,如果有则会将新的文件返回给浏览器)
获取更新文件列表
获取模块更新以后的最新代码
HMR Runtime 对模块进行热更新
这里就是整个 HMR 最关键的步骤了,而其中 最关键的 无非就是hotApply这个方法了,由于代码量实在太多,这里我们直接进入过程解析(关键代码),有兴趣的同学可以阅读一下源码。
-
找出 outdatedModules
和outdatedDependencies
-
删除过期的模块以及对应依赖
// remove module from cache
delete installedModules[moduleId];
// when disposing there is no need to call dispose handler
delete outdatedDependencies[moduleId];
-
新模块添加至 modules 中
for(moduleId in appliedUpdate) {
if(Object.prototype.hasOwnProperty.call(appliedUpdate, moduleId)) {
modules[moduleId] = appliedUpdate[moduleId];
}
}
至此 一整个模块替换的流程已经结束了,已经可以获取到最新的模块代码了,接下来就轮到业务代码如何知晓模块已经发生了变化~
HMR 中的 hot 成员
HotModuleReplaceMentPlugin
由于我们编写的 JavaScript 代码是没有任何规律可言的模块,可以导出的是一个模块、函数、甚至于只是一个字符串 而对于这些毫无规律可言的模块来说 Webpack 是无法提供一个通用的模块替换方案去处理的 因此在这种情况下,还想要体验完整的 HMR 开发流程 是需要我们自己手动处理 当 JS 模块更新以后,如何将更新以后的 JS 模块替换至页面当中 因此 HotModuleReplacementPlugin 为我们提供了一系列关于 HMR 的 API 而其中 最关键的部分则是hot.accept
。
接下来 我们将尝试 自己手动处理 JS 模块更新 并通知到浏览器实现对应的局部刷新
:::info 当前主流开发框架 Vue、React 都提供了统一的模块替换函数, 因此 Vue、React 项目并不需要针对 HMR 做手动的代码处理,同时 css 文件也由 style-loader 统一处理 因此也不需要额外的处理,因此接下去的代码处理逻辑,全部建立在纯原生开发的基础之上实现 :::
回到代码中来 假设当前 main.js 文件如下
// ./src/main.js
import createChild from ''./child''
const child = createChild()
document.body.appendChild(child)
main.js 是 Webpack 打包的入口文件 在文件中引入了 Child 模块 因此 当 Child 模块里的业务代码更改以后 webpack 必然会重新打包,并且重新使用这些更新以后的模块,所以,我们需要在 main.js 里实现去处理它所依赖的这些模块更新后的热替换逻辑
在 HMR 已开启的情况下,我们可以通过访问全局的module
对象下的hot 成员
它提供了一个accept 方法
,这个方法用来注册当某个模块更新以后需要如何处理,它接受两个参数 一个是需要监听模块的 path(相对路径),第二个参数就是当模块更新以后如何处理 其实也就是一个回调函数
// main.js
// 监听 child 模块变化
module.hot.accept(''./child'', () => {
console.log(''老板好,child 模块更新啦~'')
})
当做完这些以后,重新运行 npm run serve 同时修改 child 模块 你会发现,控制台会输出以上的 console 内容,同时,浏览器也不会自动更新了,因此,我们可以得出一个结论 当你手动处理了某个模块的更新以后,是不会出发自动刷新机制的,接下来 就来一起看看 其中的原理 以及 如何实现 HMR 中的 JS 模块替换逻辑
module.hot.accept 原理
为什么我们只有调用了moudule.hot.accept
才可以实现热更新, 翻看源码 其实可以发现实现如下
// 部分源码
accept: function (dep, callback, errorHandler) {
if (dep === undefined) hot._selfAccepted = true;
else if (typeof dep === "function") hot._selfAccepted = dep;
else if (typeof dep === "object" && dep !== null) {
for (var i = 0; i < dep.length; i++) {
hot._acceptedDependencies[dep[i]] = callback || function () {};
hot._acceptedErrorHandlers[dep[i]] = errorHandler;
}
} else {
hot._acceptedDependencies[dep] = callback || function () {};
hot._acceptedErrorHandlers[dep] = errorHandler;
}
},
// module.hot.accept 其实等价于 module.hot._acceptedDependencies(''./child) = render
// 业务逻辑实现
module.hot.accept(''./child'', () => {
console.log(''老板好,child 模块更新啦~'')
})
accept 往hot._acceptedDependencies
这个对象里存入局部更新的 callback, 当模块改变时,对模块需要做的变更,搜集到_acceptedDependencies
中,同时当被监听的模块内容发生了改变以后,父模块可以通过_acceptedDependencies
知道哪些内容发生了变化。
实现 JS 模块替换
当了解了 accpet 方法以后,其实我们要考虑的事情就非常简单了,也就是如何实现 cb 里的业务逻辑,其实当 accept 方法执行了以后,在其回调里是可以获取到最新的被修改了以后的模块的函数内容的
// ./src/main.js
import createChild from ''./child''
console.log(createChild) // 未更新前的函数内容
module.hot.accept(''./child'', ()=> {
console.log(createChild) // 此时已经可以获取更新以后的函数内容
})
既然是可以获取到最新的函数内容 其实也就很简单了 我们只需要移除之前的 dom 节点 并替换为最新的 dom 节点即可,同时我们也需要记录节点里的内容状态,当节点替换为最新的节点以后,追加更新原本的内容状态
// ./src/main.js
import createChild from ''./child''
const child = createChild()
document.body.appendChild(child)
// 这里需要额外注意的是,child 变量每一次都会被移除,所以其实我们一个记录一下每次被修改前的 child
let lastChild = child
module.hot.accept(''./child'', ()=> {
// 记录状态
const value = lastChild.innerHTML
// 删除节点
document.body.remove(child)
// 创建最新节点
lastChild = createChild()
// 恢复状态
lastChild.innerHTMl = value
// 追加内容
document.body.appendChild(lastChild)
})
到这里为止,对于如何手动实现一个 child 模块的热更新替换逻辑已经全部实现完毕了,有兴趣的同学可以自己也手动实现一下~
:::tips tips: 手动处理 HMR 逻辑过程中 如果 HMR 过程中出现报错 导致的 HRM 失效,其实只需要在配置文件中将hot: true 修改为 hotOnly: true
即可 :::
写在最后
希望通过这篇文章,能够帮助到大家加深对 HMR 的理解,同时解决一下开发场景会遇到的问题(例如 脱离框架自己实现模块热更新),最后,欢迎大家一键三连~
感谢
欢迎长按图片加 ssh 为好友
,我会第一时间和你分享前端行业趋势,学习途径等等。2021 陪你一起度过!
本文分享自微信公众号 - 前端从进阶到入院(code_with_love)。
如有侵权,请联系 support@oschina.cn 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一起分享。
Vue(基础七)_webpack(webpack异步加载原理)
---恢复内容开始---
一、前言
1、webpack异步加载原理’
2、webpack.ensure原理
3、例子
二、主要内容
1、webpack异步加载原理’
webpack ensure有人称他为异步加载,也有人称为代码切割,他其实就是将js模块给独立导出一个.js文件,然后使用这个模块的时候,webpack会构造script dom元素,由浏览器异步请求这个js文件
2、webpack.ensure原理
就是把一些js模块给独立出一个个js文件,然后需要用到的时候,再创建一个script对象,加入到document.head对象中就可,浏览器会自动帮我们发起请求,去请求这个js文件,然后写个回调函数,让请求到的js文件做一些业务操作
3、例子
假设:main.js依赖三个js文件:
A.js是点击aBtn按钮后,才执行的逻辑
B.js是点击bBtn按钮后,才执行的逻辑
vue.js是封装main.js时需要用到的包
(1)新建项目目录如下:
(2)main.js如下,main.js依赖vue.js和两个异步加载的A.js 和B.js
import Vue from ''vue''
console.log(Vue)
document.getElementById(''aBtn'').onclick=function(){
//异步加载A
require.ensure([], function(){//当A.js需要依赖于B.js的时候,需要在[]中加入
var A = require(''./A.js'');
alert(A.data)
})
}
document.getElementById(''bBtn'').onclick=function(){
//异步加载b
require.ensure([], function(){
var B = require(''./B.js'');
alert(B.data)
})
}
(3)
A.js
var A = {
"data":"hello A"
}
module.exports = A;
B.js
var B = {
"data":"hello B"
}
module.exports = B;
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Document</title>
</head>
<body>
<div id=''app''></div>
<button id=''aBtn''>aBtn</button>
<button id=''bBtn''>bBtn</button>
</body>
</html>
(4)config文件配置
下面inject:是将打包编译的.js文件注入到你的index.html文件中,他有四个值:true(注入在body内底部) body(注入在body标签下面,head 注入在head标签下面
var path = require(''path'');
const HtmlWebpackPlugin = require(''html-webpack-plugin'')
const webpack = require(''webpack'');
const packagejson = require(''./package.json'')
module.exports = {
//入口
entry:{
"main":"./src/main.js",
"util":Object.keys(packagejson.dependencies)//获取生产环境依赖库,抽离vue模块
},
output:{
path:path.resolve(''./dist''),
filename:''[name].js''
},
watch:true,
plugins:[
new webpack.optimize.CommonsChunkPlugin({
name:''common'',//这里任意取名,第二个输出的是webpack运行时文件
filename:''[name].js''
}),
new HtmlWebpackPlugin({
chunks:[''common'',''util'',''main''], //chunk主要用于多入口文件,会编译生成多个打包文件,common一定前面,
template:"./src/index.html",
inject:true //inject是向index.html注入的位置,head body
})
]
}
package.json
{
"name": "chunk",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"dev": "webpack --config ./webpack.dev.config.js",
"prod": "webpack --config ./webpack.prod.config.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"html-webpack-plugin": "^3.2.0",
"webpack": "^2.6.1"
},
"dependencies": {
"vue": "^2.6.10"
}
}
(5)运行测试
运行之后发现多出一个dist文件夹,里面是打包后的文件,
一开始只加载
当点击按钮,才开始加载a.js和b.js
三、总结
---恢复内容结束---
webpack - hmr热更新
文章首发于个人blog,欢迎关注~
webpack hmr
webpack-dev-server
在使用 webpack-dev-server 的过程中,如果指定了 hot 配置的话(使用 inline mode 的前提下), wds 会在内部更新 webpack 的相关配置,即将 HotModuleReplacementPlugin 加入到 webpack 的 plugins 当中。
HotModuleReplacementPlugin
在 HotModuleReplacementPlugin 执行的过程中主要是完成了以下几个工作:
- 在创建 normalModule 的阶段添加 parser 的 hook,即在之后的 module 编译解析阶段 parser 处理不同的语法时可以交由在这个阶段添加的 hook 回调来进行相关的处理。
normalModuleFactory.hooks.parser
.for("javascript/auto")
.tap("HotModuleReplacementPlugin", addParserPlugins);
normalModuleFactory.hooks.parser
.for("javascript/dynamic")
.tap("HotModuleReplacementPlugin", addParserPlugins);
其中在 addParserPlugins 方法当中添加了具体有关 parser hook 的回调,有几个比较关键的 hook 单独拿出来说下:
parser.hooks.call
.for("module.hot.accept")
.tap("HotModuleReplacementPlugin")
这个 hook 主要是在 parser 编译代码过程中遇到module.hot.accept
的调用的时候会触发,主要的工作就是处理当前模块部署依赖模块的依赖分析,在编译阶段处理好依赖的路径替换等内容。
parser.hooks.call
.for("module.hot.decline")
.tap("HotModuleReplacementPlugin")
这个 hook 同样是在 parser 编译代码过程中遇到module.hot.decline
的调用的时候触发,所做的工作和上面的 hook 类似。
- 在 mainTemplate 上添加不同 hook 的处理回调来完成对于 webpack 在生成 bootstrap runtime 的代码阶段去注入和 hmr 相关的运行时代码,有几个比较关键的 hook 单独拿出来说下:
const mainTemplate = compilation.mainTemplate
mainTemplate.hooks.moduleRequire.tap(
"HotModuleReplacementPlugin",
(_, chunk, hash, varModuleId) => {
return `hotCreateRequire(${varModuleId})`;
})
这个 hook 主要完成的工作是在生成 webpack bootstrap runtime 代码当中对加载 module 的 require function
进行替换,变为hotCreateRequire(${varModuleId})
的形式,这样做的目的其实就是对于 module 的加载做了一层代理,在加载 module 的过程当中建立起相关的依赖关系(需要注意的是这里的依赖关系并非是 webpack 在编译打包构建过程中的那个依赖关系,而是在 hmr 模式下代码执行阶段,一个 module 加载其他 module 时在 hotCreateRequire 内部会建立起相关的加载依赖关系,方便之后的修改代码之后进行的热更新操作),具体这块的分析可以参见下面的章节。
mainTemplate.hooks.bootstrap.tap(
"HotModuleReplacementPlugin",
(source, chunk, hash) => {
// 在生成 runtime 最终的代码前先通过 hooks.hotBootstrap 钩子生成相关的 hmr 代码然后再完成代码的拼接
source = mainTemplate.hooks.hotBootstrap.call(source, chunk, hash);
return Template.asString([
source,
"",
hotInitCode
.replace(/\$require\$/g, mainTemplate.requireFn)
.replace(/\$hash\$/g, JSON.stringify(hash))
.replace(/\$requestTimeout\$/g, requestTimeout)
.replace(
/\/\*foreachInstalledChunks\*\//g, // 通过一系列的占位字符串,在生成代码的阶段完成代码的替换工作
needChunkLoadingCode(chunk)
? "for(var chunkId in installedChunks)"
: `var chunkId = ${JSON.stringify(chunk.id)};`
)
]);
}
)
在这个 hooks.bootstrap 当中所做的工作是在 mainTemplate 渲染 bootstrap runtime 的代码的过程中,对于hotInitCode
代码进行字符串的匹配和替换工作。hotInitCode
这部分的代码其实就是下面章节所要讲的HotModuleReplacement.runtime
向 bootstrap runtime 代码里面注入的 hmr 运行时代码。
mainTemplate.hooks.moduleObj.tap(
"HotModuleReplacementPlugin",
(source, chunk, hash, varModuleId) => {
return Template.asString([
`${source},`,
`hot: hotCreateModule(${varModuleId}),`, // 这部分的内容即这个 hook 对相关内容的拓展
"parents: (hotCurrentParentsTemp = hotCurrentParents, hotCurrentParents = [], hotCurrentParentsTemp),",
"children: []"
]);
}
)
在这个 hooks.moduleObj 当中所做的工作是对__webpack_require__
这个函数体内部的 installedModules 缓存模块变量进行拓展。几个非常关键的点就是:
- 新增了 module 上的
hot: hotCreateModule(${varModuleId})
配置。这个 module.hot api 即对应这个 module 有关热更新的 api,可以看到这个部署 hot api 的工作是由 hotCreateModule 这个方法来完成的(这个方法是由 hmr runtime 代码提供的,下面的章节会讲)。最终和这个 module 所有有关热更新相关的接口都通过module.hot.*
去访问。 - 新增 parents 属性配置:初始化有关这个 module 在 hmr 下,它的 parents(这个 module 被其他 module 依赖);
- 新增 children 属性配置:初始化有关这个 module 在 hmr 下,它的 children(这个 module 所依赖的 module)
HotModuleReplacement.runtime
Webpack 内部提供了 HotModuleReplacement.runtime 即热更新运行时部分的代码。这部分的代码并不是通过通过添加 webpack.entry 入口文件的方式来注入这部分的代码,而是通过 mainTemplate 在渲染 boostrap runtime 代码的阶段完成代码的注入工作的(对应上面的 mainTemplate.hooks.boostrap 所做的工作)。
在这部分热更新运行时的代码当中所做的工作主要包含了以下几个点:
- 提供运行时的
hotCreateRequire
方法,用以对__webpack_require__
模块引入方法进行代理,当一个模块依赖其他模块,并将其引入的时候,会建立起宿主模块和依赖模块之间的相互依赖关系,这个依赖关系也是作为之后某个模块发生更新后,寻找与其有依赖关系的模块的凭证。
function hotCreateRequire(moduleId) {
var me = installedModules[moduleId];
if (!me) return $require$;
var fn = function(request) { // 这个是 hmr 模式下,对原来的 __webpack_require__ 引入模块的函数做的一层代理
// 通过 depModule.parents 和 module.children 来双向建立起 module 之间的依赖关系
if (me.hot.active) {
if (installedModules[request]) {
if (installedModules[request].parents.indexOf(moduleId) === -1) {
installedModules[request].parents.push(moduleId); // 建立 module 之间的依赖关系,在被引入的 module 的 module.parents 当中添加当前这个 moduleId
}
} else {
hotCurrentParents = [moduleId];
hotCurrentChildModule = request;
}
if (me.children.indexOf(request) === -1) {
me.children.push(request); // 在当前 module 的 module.children 属性当中添加被引入的 moduleId
}
} else {
console.warn(
"[HMR] unexpected require(" +
request +
") from disposed module " +
moduleId
);
hotCurrentParents = [];
}
return $require$(request); // 引入模块
};
...
return fn
}
- 提供运行时的
hotCreateModule
方法,用以给每个 module 都部署热更新相关的 api:
function hotCreateModule(moduleId) {
var hot = {
// private stuff
_acceptedDependencies: {},
_declinedDependencies: {},
_selfAccepted: false,
_selfDeclined: false,
_disposeHandlers: [],
_main: hotCurrentChildModule !== moduleId,
// Module API
active: true,
accept: function(dep, callback) {
if (dep === undefined) hot._selfAccepted = true; // 表示这个 module 可以进行 hmr
else if (typeof dep === "function") hot._selfAccepted = dep;
else if (typeof dep === "object") // 和其他 module 建立起热更新之间的关系
for (var i = 0; i < dep.length; i++)
hot._acceptedDependencies[dep[i]] = callback || function() {};
else hot._acceptedDependencies[dep] = callback || function() {};
},
decline: function(dep) {
if (dep === undefined) hot._selfDeclined = true; // 当前 module 不需要进行热更新
else if (typeof dep === "object") // 当其依赖的 module 发生更新后,并不会触发这个 module 的热更新
for (var i = 0; i < dep.length; i++)
hot._declinedDependencies[dep[i]] = true;
else hot._declinedDependencies[dep] = true;
},
dispose: function(callback) {
hot._disposeHandlers.push(callback);
},
addDisposeHandler: function(callback) {
hot._disposeHandlers.push(callback);
},
removeDisposeHandler: function(callback) {
var idx = hot._disposeHandlers.indexOf(callback);
if (idx >= 0) hot._disposeHandlers.splice(idx, 1);
},
// Management API
check: hotCheck,
apply: hotApply,
status: function(l) {
if (!l) return hotStatus;
hotStatusHandlers.push(l);
},
addStatusHandler: function(l) {
hotStatusHandlers.push(l);
},
removeStatusHandler: function(l) {
var idx = hotStatusHandlers.indexOf(l);
if (idx >= 0) hotStatusHandlers.splice(idx, 1);
},
//inherit from previous dispose call
data: hotCurrentModuleData[moduleId]
};
hotCurrentChildModule = undefined;
return hot;
}
在 hotCreateModule 方法当中完成 module.hot.* 和热更新相关接口的定义。这些 api 也是暴露给用户部署热更新代码的接口。
其中hot.accept
和hot.decline
方法主要是用户来定义发生热更新的模块及其依赖是否需要热更新的相关策略。例如hot.accept
方法用来决定当前模块所依赖的哪些模块发生更新的话,自身也需要完成一些更新相关的动作。而hot.decline
方法用来决定当前模块依赖的模块发生更新后,来决定自身是否需要进行更新。
而hot.check
和hot.apply
两个方法其实是 webpack 内部使用的2个方法,其中hot.check
方法:首先调用hotDownloadManifest
方法,通过发送一个 Get 请求去 server 获取本次发生变更的相关内容。// TODO: 相关内容的具体格式和字段?
{
c: { // 发生更新的 chunk 集合
app: true
},
h: ''xxxxx'' // 服务端本次生成的编译hash值,用来作为下次浏览器获取发生变更的 hash 值(相当于服务端下发的一个 token,浏览器拿着这个 token 去后端获取对应的内容)
}
function hotCheck(apply) {
if (hotStatus !== "idle") {
throw new Error("check() is only allowed in idle status");
}
hotApplyOnUpdate = apply;
hotSetStatus("check"); // 更新 热更新 流程的内部状态
return hotDownloadManifest(hotRequestTimeout).then(function(update) {
if (!update) {
hotSetStatus("idle");
return null;
}
hotRequestedFilesMap = {};
hotWaitingFilesMap = {};
hotAvailableFilesMap = update.c; // 发生更新的 chunk 集合
hotUpdateNewHash = update.h; // server 下发的本次生成的编译 hash 值,作为下次浏览器获取发生变更的 hash 值
hotSetStatus("prepare");
var promise = new Promise(function(resolve, reject) {
hotDeferred = {
resolve: resolve,
reject: reject
};
});
hotUpdate = {};
/*foreachInstalledChunks*/ // 这段注释在渲染 bootstrap runtime 部分的代码的时候会通过字符串匹配给替换掉,最终替换后的代码执行就是对已经下载的 chunk 进行循环 hotEnsureUpdateChunk(chunkId)
// eslint-disable-next-line no-lone-blocks
{
/*globals chunkId */
hotEnsureUpdateChunk(chunkId); // hotEnsureUpdateChunk(lib/web/JsonpMainTemplate.runtime.js) 方法内部其实就是通过创建 script 标签,然后传入到文档当中完成发生更新的 chunk 的下载
}
if (
hotStatus === "prepare" &&
hotChunksLoading === 0 &&
hotWaitingFiles === 0
) {
hotUpdateDownloaded();
}
return promise;
});
}
// TODO: 补一个 hot.check 执行的流程图
总结下hot.check
方法执行的流程其实就是:
- 通过 hotDownloadMainfest 方法发送一个 Get 方式的 ajax 请求用以获取发生更新的 chunk 集合以及本次编译生成的 hash;
- 遍历已经安装完成的所有 chunk,找出需要发生更新的 chunk 名,调用 hotEnsureUpdateChunk 方法通过 jsonp 的方式完成发生更新的 chunk 下载。
接下来看下被下载的更新的 chunk 具体内容:
webpackHotUpdate(''app'', {
''compiled/module1/path'': (function() {
eval(''...script...'')
}),
''compiled/module2/path'': (function() {
eval(''...script...'')
})
})
可以看到的是返回的 chunk 内容是可以立即执行的函数:
function hotAddUpdateChunk(chunkId, moreModules) {
if (!hotAvailableFilesMap[chunkId] || !hotRequestedFilesMap[chunkId])
return;
hotRequestedFilesMap[chunkId] = false;
for (var moduleId in moreModules) {
if (Object.prototype.hasOwnProperty.call(moreModules, moduleId)) {
hotUpdate[moduleId] = moreModules[moduleId];
}
}
if (--hotWaitingFiles === 0 && hotChunksLoading === 0) {
hotUpdateDownloaded();
}
}
对应所做的工作就是将需要更新的模块缓存至hotUpdate
上,同时判断需要更新的 chunk 是否已经下载完了,如果全部下载完成那么执行hotUpdateDownloaded
方法,其内部实际就是调用hotApply
进行接下来进行细粒度的模块更新和替换的工作。
首先先讲下hotApply
内部的执行流程:
- 遍历
hotUpdate
需要更新的模块,找出和需要更新的模块有依赖关系的模块;
function hotApply(options) {
function getAffectedStuff(updateModuleId) {
var outdatedModules = [updateModuleId]
var outdatedDependencies = {}
var queue = outdatedModules.slice().map(function (id) {
return {
chain: [id],
id: id
}
})
while (queue.length > 0) {
var queueItem = queue.pop()
var moduleId = queueItem.id
var chain = queueItem.chain
module = installedModules[moduleId] // installedModules 为在 bootstrap runtime 里面定义的已经被加载过的 module 集合,这里其实就是为了取到这个 module 自己定义部署的有关热更新的相关策略
if (!module || module.hot._selfAccepted) continue // 如果这个 module 不存在或者只接受自更新,那么直接略过接下来的代码处理
if (module.hot._selfDeclined) {
return {
type: ''self-declined'',
chain: chain,
moduleId: moduleId
}
}
if (module.hot._main) {
return {
type: ''unaccepted'',
chain: chain,
moduleId: moduleId
}
}
for (var i = 0; i < module.parents.length; i++) { // 遍历所有依赖这个模块的 module
var parentId = module.parents[i]
var parent = installedModules[parentId]
if (!parent) continue
if (parent.hot._declinedDependencies[moduleId]) { // 如果这个 parentModule 的 module.hot._declinedDependencies 里面设置了不受更新影响的 moduleId
return {
type: ''declined'',
chain: chain.concat([parentId]),
moduleId: moduleId,
parentId: parentId
}
}
if (outdatedModules.indexOf(parentId) !== -1) continue
if (parent.hot._acceptedDependencies[moduleId]) { // 如果这个 parentModule 的 module.hot._acceptedDependencies 里面设置了其受更新影响的 moduleId
if (!outdatedDependencies[parentId])
outdatedDependencies[parentId] = []
addAllToSet(outdatedDependencies[parentId], [moduleId])
continue
}
// 如果这个 parentModule 没有部署任何相关热更新的**模块间依赖的更新策略**(不算_selfAccepted 和 _selfDeclined 状态),那么需要将这个 parentModule 加入到 outdatedModules 队列里面,同时更新 queue 来进行下一轮的遍历找出所有需要进行更新的 module
delete outdatedDependencies[parentId]
outdatedModules.push(parentId)
queue.push({
chain: chain.concat([parentId]),
id: parentId
})
}
}
return {
type: ''accepted'',
moduleId: updateModuleId,
outdatedModules: outdatedModules, // 本次更新当中所有过期的 modules
outdatedDependencies: outdatedDependencies // 所有过期的依赖 modules
}
}
for (var id in hotUpdate) {
if (Object.prototype.hasOwnProperty.call(hotUpdate, id)) {
moduleId = toModuleId(id)
/** @type {TODO} */
var result
if (hotUpdate[id]) {
result = getAffectedStuff(moduleId)
} else {
result = {
type: ''disposed'',
moduleId: id
}
}
/** @type {Error|false} */
var abortError = false
var doApply = false
var doDispose = false
var chainInfo = ''''
if (result.chain) {
chainInfo = ''\nUpdate propagation: '' + result.chain.join('' -> '')
}
switch (result.type) {
case ''self-declined'':
if (options.onDeclined) options.onDeclined(result)
if (!options.ignoreDeclined)
abortError = new Error(
''Aborted because of self decline: '' +
result.moduleId +
chainInfo
)
break
case ''declined'':
if (options.onDeclined) options.onDeclined(result)
if (!options.ignoreDeclined)
abortError = new Error(
''Aborted because of declined dependency: '' +
result.moduleId +
'' in '' +
result.parentId +
chainInfo
)
break
case ''unaccepted'':
if (options.onUnaccepted) options.onUnaccepted(result)
if (!options.ignoreUnaccepted)
abortError = new Error(
''Aborted because '' + moduleId + '' is not accepted'' + chainInfo
)
break
case ''accepted'':
if (options.onAccepted) options.onAccepted(result)
doApply = true
break
case ''disposed'':
if (options.onDisposed) options.onDisposed(result)
doDispose = true
break
default:
throw new Error(''Unexception type '' + result.type)
}
if (abortError) {
hotSetStatus(''abort'')
return Promise.reject(abortError)
}
if (doApply) {
appliedUpdate[moduleId] = hotUpdate[moduleId] // 需要更新的模块
addAllToSet(outdatedModules, result.outdatedModules) // 使用单独一个 outdatedModules 数组变量存放所有过期需要更新的 moduleId,其中 result.outdatedModules 是通过 getAffectedStuff 方法找到的当前遍历的 module 所依赖的过期的需要更新的模块
for (moduleId in result.outdatedDependencies) { // 使用单独的 outdatedDependencies 集合去存放相关依赖更新模块
if (
Object.prototype.hasOwnProperty.call(
result.outdatedDependencies,
moduleId
)
) {
if (!outdatedDependencies[moduleId])
outdatedDependencies[moduleId] = []
addAllToSet(
outdatedDependencies[moduleId],
result.outdatedDependencies[moduleId]
)
}
}
}
if (doDispose) {
addAllToSet(outdatedModules, [result.moduleId])
appliedUpdate[moduleId] = warnUnexpectedRequire
}
}
// Store self accepted outdated modules to require them later by the module system
// 在所有 outdatedModules 里面找到部署了 module.hot._selfAccepted 属性的模块。(部署了这个属性的模块会通过 webpack 的模块系统重新加载一次这个模块的新的内容来完成热更新)
var outdatedSelfAcceptedModules = []
for (i = 0; i < outdatedModules.length; i++) {
moduleId = outdatedModules[i]
if (
installedModules[moduleId] &&
installedModules[moduleId].hot._selfAccepted
)
outdatedSelfAcceptedModules.push({
module: moduleId,
errorHandler: installedModules[moduleId].hot._selfAccepted
})
}
// dispose phase TODO: 各个热更新阶段 hooks?
var idx
var queue = outdatedModules.slice()
while (queue.length > 0) {
moduleId = queue.pop()
module = installedModules[moduleId]
if (!module) continue
var data = {}
// Call dispose handlers
var disposeHandlers = module.hot._disposeHandlers
for (j = 0; j < disposeHandlers.length; j++) {
cb = disposeHandlers[j]
cb(data)
}
hotCurrentModuleData[moduleId] = data
// disable module (this disables requires from this module)
module.hot.active = false
// 从 installedModules 集合当中剔除掉过期的 module,即其他 module 引入这个被剔除掉的 module 的时候,其实是会重新执行这个 module,这也是为什么要从 installedModules 上剔除这个需要被更新的模块的原因
// remove module from cache
delete installedModules[moduleId]
// when disposing there is no need to call dispose handler
delete outdatedDependencies[moduleId]
// 将这个 module 所依赖的模块(module.children)当中剔除掉 module.children.parentModule,即解除模块之间的依赖关系
// remove "parents" references from all children
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)
}
}
}
// 这里同样是通过遍历 outdatedDependencies 里面需要更新的模块,需要注意的是 outdateDependencies 里面的 key 为被依赖的 module,这个 key 所对应的 value 数组里面存放的是发生了更新的 module。所以这是需要解除被依赖的 module 和这些发生更新了的 module 之间的引用依赖关系。
// remove outdated dependency from module children
var dependency
var moduleOutdatedDependencies
for (moduleId in outdatedDependencies) {
if (
Object.prototype.hasOwnProperty.call(outdatedDependencies, moduleId)
) {
module = installedModules[moduleId]
if (module) {
moduleOutdatedDependencies = outdatedDependencies[moduleId]
for (j = 0; j < moduleOutdatedDependencies.length; j++) {
dependency = moduleOutdatedDependencies[j]
idx = module.children.indexOf(dependency)
if (idx >= 0) module.children.splice(idx, 1)
}
}
}
}
// Not in "apply" phase
hotSetStatus(''apply'')
// 更新当前的热更新 hash 值(即通过 get 请求获取 server 下发的 hash 值)
hotCurrentHash = hotUpdateNewHash
// 遍历 appliedUpdate 发生更新的 module
// insert new code
for (moduleId in appliedUpdate) {
if (Object.prototype.hasOwnProperty.call(appliedUpdate, moduleId)) {
modules[moduleId] = appliedUpdate[moduleId] // HIGHLIGHT: 这里的 modules 变量为 bootstrap 代码里面接收到的所有的 modules 的集合,即在这里完成新老 module 的替换
}
}
// 执行那些在 module.hot.accept 上部署了依赖模块发生更新后的回调函数
// call accept handlers
var error = null
for (moduleId in outdatedDependencies) {
if (
Object.prototype.hasOwnProperty.call(outdatedDependencies, moduleId)
) {
module = installedModules[moduleId]
if (module) {
moduleOutdatedDependencies = outdatedDependencies[moduleId]
var callbacks = []
for (i = 0; i < moduleOutdatedDependencies.length; i++) {
dependency = moduleOutdatedDependencies[i]
cb = module.hot._acceptedDependencies[dependency]
if (cb) {
if (callbacks.indexOf(cb) !== -1) continue
callbacks.push(cb)
}
}
for (i = 0; i < callbacks.length; i++) {
cb = callbacks[i]
try {
cb(moduleOutdatedDependencies)
} catch (err) {
...
}
}
}
}
}
// 重新加载那些部署了 module.hot._selfAccepted 为 true 的 module,即这个 module 会被重新加载并执行一次,这样也就在 installedModules 上缓存了这个新的 module
// Load self accepted modules
for (i = 0; i < outdatedSelfAcceptedModules.length; i++) {
var item = outdatedSelfAcceptedModules[i]
moduleId = item.module
hotCurrentParents = [moduleId]
try {
$require$(moduleId) // $require$ 会在被最终渲染到 bootstrap runtime 当中被替换为 webpack require 加载模块的方法
} catch (err) {
if (typeof item.errorHandler === ''function'') {
try {
item.errorHandler(err)
} catch (err2) {
...
}
} else {
...
}
}
hotSetStatus(''idle'')
return new Promise(function (resolve) {
resolve(outdatedModules)
})
}
}
}
所以当一个模块发生变化后,依赖这个模块的 parentModule 有如下几种热更新执行的策略:
module.hot.accept()
当依赖的模块发生更新后,这个模块需要通过重新加载去完成本模块的全量更新。
module.hot.accept([''xxx''], callback)
当依赖的模块且为 xxx
模块发生更新后,这个模块会执行 callback 来完成相关的更新的动作。而不需要通过重新加载的方式去完成更新。
module.hot.decline()
这个模块不管其依赖的模块是否发生了变化。这个模块都不会发生更新。
module.hot.decline([''xxx''])
当依赖的模块为xxx
发生更新的情况下,这个模块不会发生更新。当依赖的其他模块(除了xxx
模块外)发生更新的话,那么最终还是会将本模块从缓存中删除。
这些热更新的 api 也是需要用户自己在代码当中进行部署的。就拿平时我们使用的 vue 来说,在本地开发阶段, vue sfc 经过 vue-loader 的编译处理后,会自动帮我们在组件代码当中当中注入和热更新相关的代码。
// vue-loader/lib/codegen/hotReload.js
const hotReloadAPIPath = JSON.stringify(require.resolve(''vue-hot-reload-api''))
const genTemplateHotReloadCode = (id, request) => {
return `
module.hot.accept(${request}, function () {
api.rerender(''${id}'', {
render: render,
staticRenderFns: staticRenderFns
})
})
`.trim()
}
exports.genHotReloadCode = (id, functional, templateRequest) => {
return `
/* hot reload */
if (module.hot) {
var api = require(${hotReloadAPIPath})
api.install(require(''vue''))
if (api.compatible) { // 判断使用的 vue 的版本是否支持热更新
module.hot.accept()
if (!api.isRecorded(''${id}'')) {
api.createRecord(''${id}'', component.options)
} else {
api.${functional ? ''rerender'' : ''reload''}(''${id}'', component.options)
}
${templateRequest ? genTemplateHotReloadCode(id, templateRequest) : ''''}
}
}
`.trim()
}
vue-loader
通过 genHotReloadCode 方法在处理 vue sfc 代码的时候完成热更新 api 的部署功能。这里大致讲下 vue component 进行热更新的流程:
- 当这个 vue component 被初次加载的时候,首先执行 module.hot.accept() 方法完成热更新接口的部署(上文也提到了这个接口执行的策略是会重新加载这个 vue component 来完成热更新);
- 如果这个 vue component 是被初次加载的话,那么会通过 api.createRecord 方法在全局缓存这个组件的 options 配置,如果这个 vue component 不是被初次加载的话(即全局已经缓存了这个组件的 options 配置),那么就直接调用 api.reload(或rerender) 方法来进行组件的重新渲染(
$forceUpdate
); - 如果这个 vue component 提供了 template 模板的话,也会部署模板的热更新代码(即这个 component 的模板发生了变化,那么会触发 api.rerender 方法);
- 当这个 vue component 的依赖发生了变化,且这些依赖都部署了热更新的代码(如果没有部署热更新的代码的话,可能会直接刷新页面 TODO:解释下为啥会刷新页面),那么这个 vue component 会被重新加载一次。对应的会重新进行前面的1,2,3流程。
- 在我们开发 vue 的应用当中,除了修改组件当中的
<template>
,<script>
中的内容外会进行热更新外,在我们修改<style>
样式内容的时候也有热更新的效果。这也是 vue component 在编译阶段在 vue style block 的代码当中部署了热更新代码的原因。具体更新策略可参见vue-style-loader
相关资料:
- Webpack HMR 原理解析
- Webpack 热更新实现原理分析
- Webpack HMR 官方文档
webpack -HMR-非常实用(6)
按需刷新用HMR
样式文件如 less , css 可以直接使用HMR
JS 不能直接用,需要到入口文件作判断,判断 module 的属性 hot是否开启;JS入口文件做不了HMR,因为一刷全刷
HTML也需要做点事情,在webpack.config.js的入口中加入
开启HMR
我们今天的关于Webpack HMR 原理解析和webpack_hmr的分享就到这里,谢谢您的阅读,如果想了解更多关于120 行代码帮你了解 Webpack 下的 HMR 机制、Vue(基础七)_webpack(webpack异步加载原理)、webpack - hmr热更新、webpack -HMR-非常实用(6)的相关信息,可以在本站进行搜索。
本文标签: