HMR 介绍
模块热替换(HMR - hot module replacement)功能会在应用程序运行过程中,替换、添加或删除 模块,而无需重新加载整个页面。
版本
- webpack-cli 4.6 提供了一些命令来使 webpack 的工作变得更简单。
- webpack 5.35.0 是一个用于JavaScript 应用程序的静态模块打包工具。
- webpack-dev-server 4.0.0-beta.2 提供一个基本的 web server
- webpack-dev-middleware 4.1.0 express风格的中间件;把打包后端文件放到内存里;延迟请求;
总体流程
- 执行webapck serve
- 加入引导流程主要是 webpack 不支持执行这个命令,需要由 webpack-cli 来执行,所以这个阶段会引入 webpack-cli 来执行 serve 命令。
- 构造命令,webpack-cli 一开始也不支持 serve,但是可以通过扩展的方式来支持这个命令。
- 执行命令,执行命令时,会创建一个http server和 socket server 并 建立浏览器和 server 的连接。
- 在浏览器和server建立的连接基础上,进行HMR工作流程。
引导
因为 webpack 不支持执行这个命令,需要由webpack-cli 来执行,所以一开始就会校验webpack-cli是否已经安装了,没安装的话会有相应提示。这个阶段执行完成以后,会生成一个cli到实例,然后转到下个阶段执行。
【流程图】
构造命令
进到这个阶段首先说明一个问题:为什么会需要构造命令?原因是webpack-cli并没有内置serve命令到处理逻辑,而是将这个命令单独发布成一个package。他和 webpack plugin 一样是一个具有 apply 方法的 JavaScript 对象。
结合流程图和部分代码截图看一下具体流程:
- 开始执行。第一个阶段命令转移到webpack-cli 执行后,会首先调用 cli 实例的 run 方法。
- 查询serve信息,校验依赖,require引入,实例化命令package。 webpack-cli 内部维护了一个命令描述列表,run 执行的时候,会从这个列表里查找是否存在,根据命令的描述信息校验命令的依赖是否完整,询问安装。最后require引入实例化。
[
{
name: 'serve [entries...]', // 名称以及参数信息
alias: ['server', 's'], // 别名
pkg: '@webpack-cli/serve', // 依赖
}
]
-
serve 实例 的 apply 方法会被 cli 调用,方法参数是cli实例。
-
构造命令。apply 方法 会调用 cli 的 makeCommand 方法,参数别为 serve 命令的描述信息(类似2中的结构),命令的参数信息,命令的处理函数。
// 新增命令 const command = this.program.command(commandOptions.name, { noHelp: commandOptions.noHelp, hidden: commandOptions.hidden, isDefault: commandOptions.isDefault, }); -
为命令设置参数信息。命令构建完成以后,会给 serve 设置命令的参数信息 比如 -host, -port等。 这参数的获取来源有两个,分别是 ‘webpack-dev-server/bin/cli-flags’ 和 cli.getBuiltInOptions。
if (options) { // .... options.forEach((optionForCommand) => { this.makeOption(command, optionForCommand); }); } -
命令构造完成,再次解析命令参数信息,开始执行命令。
这个阶段主要通过 commander 实现的,常用的vue-cli 和 create-react-app, 有兴趣可以看下。
【流程图】
【简化代码】【serveCommand】【webpack-cli】
执行命令
【流程图】
createCompiler,通过调用 webpack 得到 compiler 对象
Compiler 模块是 webpack 的主要引擎,它通过 CLI 传递的所有选项, 或者 Node API,创建出一个 compilation 实例。用来注册和调用插件。 大多数面向用户的插件会首先在 Compiler 上注册。
async createCompiler(options, callback) {
// node 环境变量
this.applyNodeEnv(options);
// 获取config
let config = await this.resolveConfig(options);
// options 合并到 config上
config = await this.applyOptions(config, options);
// options 添加 cli-plugin
config = await this.applyCLIPlugin(config, options);
return this.webpack(config.options,callback)
}
-
-
解析配置文件 webpack.config (对象,函数,数组,promise)
-
命令行上的config 信息
命令行接口(Command Line Interface)参数的优先级,高于配置文件参数。
-
-
添加默认插件
- cli.progress → ProgressPlugin 自定义编译期间进度报告方法。
- cli.hot → HotModuleReplacementPlugin 热更新
- cli.prefetch → PrefetchPlugin
- cli.analyze → BundleAnalyzerPlugin 生成代码分析报告
-
调用webpack,得到complier对象
实例化server
-
初始化服务 new devServer()
-
-
打包入口 entry 添加 webpack/hot/dev-server 和 webpack-dev-server/client
-
自动加载模块 socket client (providePlugin),
// 引入模块 WebsocketClient const providePlugin = new webpack.ProvidePlugin({ __webpack_dev_server_client__: getSocketClientPath(options), }); providePlugin.apply(compiler); -
添加 HotModuleReplacementPlugin插件(如果插件列表么有的话)
-
-
- ws
- sockjs
- 自己实现
-
client.progress 根据配置(progress)添加webpack 进度插件ProgressPlugin,使用socket 将进度信息发送到浏览器,在浏览器中以百分比显示编译进度。
-
setupHooks 与 complier 建立链接,编译完成时将编译结果发送到前端。
compile.tap('webpack-dev-server', invalidPlugin); invalid.tap('webpack-dev-server', invalidPlugin); done.tap('webpack-dev-server', (stats) => { this.sendStats(this.sockets, this.getStats(stats)); this.stats = stats; }); -
初始化 express server
this.app = new express(); -
根据 配置 devMiddleware 初始化 webpackDevMiddleware,他的作用是以 watch 模式 启动 webpack,监听的资源一旦发生变更,便会自动编译;在编译期间,将请求延迟到最新的编译结果完成之后;webpack 编译后的资源会存储在内存中,当用户请求资源时,直接于内存中查找对应资源。
-
初始化一个context ,包含了当前的编译状态和请求的回调。
const context = { state: false, // 编译的状态 stats: null, callbacks: [], // 请求callbackFn options, compiler, watching: null, }; -
setupHooks 与complier 建立链接
/* 在 done 生命周期上注册 done 方法,该方法主要是 report 编译的信息以及执行 context.callbacks 回调函数 在 invalid、run、watchRun 等生命周期上注册 invalid 方法,该方法主要是修改编译的状态信息 */ context.compiler.hooks.watchRun.tap('webpack-dev-middleware', invalid); context.compiler.hooks.invalid.tap('webpack-dev-middleware', invalid); (context.compiler.webpack ? context.compiler.hooks.afterDone : context.compiler.hooks.done ).tap('webpack-dev-middleware', done); -
setupOutputFileSystem 作用是使用 memory-fs 对象替换掉 compiler 的文件系统对象,让 webpack 编译后的文件输出到内存中。(memfs)
-
compiler.watch 调用 compiler 的 watch 方法,之后 webpack 便会监听文件变更,一旦检测到文件变更,就会重新执行编译。
-
返回 middleware 中间件,根据当前的编译状态返回结果,如果没有在编译期间则直接返回,在编译期间,context.state 会被设置为 false,用户发起请求时,并不会直接返回对应的文件内容,而是会将回调函数 添加至 context.callbacks 中,编译完成后循环调用 context.callbacks。
-
-
设置路由
-
WatchFiles,根据配置 watchFiles 监听文件变化
-
在http服务上设置中间件
- 压缩中间件 compress → compression ,将静态资源经过压缩后返回
- onBeforeSetupMiddleware读取配置中设置的中间件列表,执行所有其他中间件之前执行。
- headers 为所有响应添加 headers
app.all('*', setContentHeaders(req, res, next) { if (this.options.headers) { for (const name in this.options.headers) { res.setHeader(name, this.options.headers[name]); } } next(); })- webpackDevMiddleware → webpack-dev-middleware
- proxy → http-proxy-middleware → http-proxy,服务代理
- static → express.static 配置项允许配置从目录提供静态文件的选项
- historyApiFallback → connect-history-api-fallback
- staticServeIndex → serveIndex 中间件会在查看没有 index.html 文件的目录时生成目录列表。
- staticWatch → static.watch 监听文件更新 文件的监听通过 chokidar 包实现。
- magicHtml
- onAfterSetupMiddleware 读取配置中设置的中间件列表,提供服务器内部在所有其他中间件之后执行。
-
http.createServer
启动服务,初始化client与server的连接
-
调用 server.listen() 启动http服务
-
寻找可用的端口 findPort portfinder + p-retry 默认重试3次
-
createScketServer 启动socket 服务
-
打来浏览器 open
-
首次获取的打包后的静态文件除了自己代码之外还包括了socket client以及一些热更新的代码,利用这些代码可以建立和服务端的socket链接。
listen(port, hostname, fn) { return ( findPort(port || this.options.port) // eslint-disable-next-line no-shadow .then((port) => { this.port = port; return this.server.listen(port, this.hostname, (error) => { if (this.options.hot || this.options.liveReload) { this.createSocketServer(); } // .... this.showStatus(); // ... }); }) .catch((error) => { if (fn) { fn.call(this.server, error); } }) ); }
HMR 工作流程
【热更新流程】
-
当文件系统中某一个文件发生修改,webpack 监听到文件变化,根据配置文件对模块重新编译打包,并将打包后的代码保存在内存中。然后通过webpack-dev-server/client/index.js 在浏览器端和服务端之间建立的 websocket 长连接,将 webpack 编译打包的状态信息告知浏览器端。发送本次编译的hash 值到前端
sendStats(sockets, stats, force) { // ...... this.sockWrite(sockets, 'hash', stats.hash); // ...... this.sockWrite(sockets, 'ok'); }
-
webpack-dev-server/client 在浏览器端接受服务端发过来的消息,
【webpack-dev-server/client/index】
// socket client 初始化,保存的状态和配置 const status = { isUnloading: false, currentHash: '', }; // 默认的配置 const defaultOptions = { hot: false, hotReload: true, liveReload: false, initial: true, useWarningOverlay: false, useErrorOverlay: false, useProgress: false, }; // 接受消息 const onSocketMessage = { // ..... hash(hash) { status.currentHash = hash; }, ok() { sendMessage('Ok'); // .... reloadApp(options, status); }, // .... } // reloadApp const hotEmitter = require('webpack/hot/emitter'); hotEmitter.emit('webpackHotUpdate', currentHash); -
浏览器端通过 webpack/hot/dev-server.js 接收到最新的编译信息,从而触发 module.hot.check 方法来开始热更新检查。
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(); } }); -
执行 webpack/lib/hmr/HotModuleReplacement.runtime.js 中的 hotCheck 方法,通过以 fetch 方式请求 hot-update.json 文件,然后执行 loadScript 以 jsonp 的方式加载 hot-update.js 文件。(manifest)
【webpack/lib/hmr/HotModuleReplacement.runtime.js 】
function hotCheck(applyOnUpdate) { // .... return $hmrDownloadManifest$().then(function (update) { // 下载hot-update.json return Promise.all( Object.keys($hmrDownloadUpdateHandlers$).reduce(function ( promises, key ) { $hmrDownloadUpdateHandlers$[key]( // loadScript update.c, update.r, update.m, promises, currentUpdateApplyHandlers, updatedModules ); return promises; }, []) ).then(function () { return waitForBlockingPromises(function () { if (applyOnUpdate) { return internalApply(applyOnUpdate); } else { return updatedModules; } }); }); }); }
```js
{"c":["main"],"r":[],"m":[]} => {chunkIds, removedChunks, removedModules,}
```
5. loadScript,调用loadUpdateChunk发送hash.hot-update.js 请求,通过JSONP方式。
[【loadScript】](https://github.com/webpack/webpack/blob/e05935f8969d873a20762767d348256e5ad8fb46/lib/web/JsonpChunkLoadingRuntimeModule.js#L275)
```js
var waitingUpdateResolves = {};
function loadUpdateChunk(chunkId) {
return new Promise((resolve,reject)=>{
waitingUpdateResolves[chunkId] = resolve;
var url = __webpack_require__.p + __webpack_require__.hu(chunkId);
// .....
var loadingEnded = (event)=>{
// 加载完成。。。
}
;
__webpack_require__.l(url, loadingEnded);
});
}
```
```js
// https://github.com/webpack/webpack/blob/e05935f8969d873a20762767d348256e5ad8fb46/lib/runtime/LoadScriptRuntimeModule.js
__webpack_require__.l = (url,done,key,chunkId)=>{
// ...
var script, needAttach;
// ...
if (!script) {
needAttach = true;
script = document.createElement('script');
script.charset = 'utf-8'
script.timeout = 120;
// ...
script.setAttribute("data-webpack", dataWebpackPrefix + key);
script.src = url;
}
var onScriptComplete = (prev,event)=>{
// ...
}
// ...
script.onerror = onScriptComplete.bind(null, script.onerror);
script.onload = onScriptComplete.bind(null, script.onload);
needAttach && document.head.appendChild(script);
}
```

```js
// https://github.com/webpack/webpack/blob/e05935f8969d873a20762767d348256e5ad8fb46/lib/web/JsonpChunkLoadingRuntimeModule.js#L305
// hotUpdateGlobal -> https://webpack.docschina.org/configuration/output/#outputhotupdateglobal
self["webpackHotUpdatewebpack_demo"] = (chunkId,moreModules,runtime)=>{
for (var moduleId in moreModules) {
// __webpack_require__.o === Object.prototype.hasOwnProperty
if (__webpack_require__.o(moreModules, moduleId)) {
currentUpdate[moduleId] = moreModules[moduleId]; // 收集更新的模块
if (currentUpdatedModulesList)
currentUpdatedModulesList.push(moduleId);
}
}
if (runtime) currentUpdateRuntime.push(runtime);
if (waitingUpdateResolves[chunkId]) {
waitingUpdateResolves[chunkId](); // js 加载完成了
waitingUpdateResolves[chunkId] = undefined;
}
}
```
6. internalApply 热更新模块替换
1. 删除过期的模块,就是需要替换的模块
1. 删除chunk
2. 删除过时的module
3. 删除过时的依赖
2. 更新代码,执行accept 回调,更新失败时会刷新页面
1. 插入新的代码
2. 执行accept 回调
3. 自更新模块的更新
