四、HMR 完整原理和源码分析
通过上一节内容,我们大概知道 HMR 简单工作流程,那么或许你现在可能还有很多疑惑:文件更新是什么通知 HMR Plugin?HMR Plugin 怎么发送更新到 HMR Runtime?等等问题。
那么接下来我们开始详细结合源码分析整个 HMR 模块热更新流程,首先还是先看流程图,可以先不了解图中方法名称(红色字体黄色背景色部分):
Webpack HMR.png
上图展示了从我们修改代码,到模块热更新完成的一个 HMR 完整工作流程,图中已用红色阿拉伯数字符号将流程标识出来。
要了解上面工作原理,我们先理解图中这几个名称概念:
- Webpack-dev-server :一个服务器插件,相当于 express 服务器,启动一个 Web 服务,只适用于开发环境;
- Webpack-dev-middleware :一个 Webpack-dev-server 的中间件,作用简单总结为:通过watch mode,监听资源的变更,然后自动打包。
- Webpack-hot-middleware :结合 Webpack-dev-middleware 使用的中间件,它可以实现浏览器的无刷新更新,也就是 HMR;
下面一起学习 HMR 整个工作原理吧:
1.监控代码变化,重新编译打包
首先根据 devServer 配置,使用 npm start
将启动 Webpack-dev-server 启动本地服务器并进入 Webpack 的 watch 模式,然后初始化 Webpack-dev-middleware ,在 Webpack-dev-middleware 中通过调用 startWatch()
方法对文件系统进行 watch:
// webpack-dev-server\bin\webpack-dev-server.js // 1.启动本地服务器 Line 386 server = new Server(compiler, options); // webpack-dev-server\lib\Server.js // 2.初始化 Webpack-dev-middleware Line 109 this.middleware = webpackDevMiddleware(compiler, Object.assign({}, options, wdmOptions)); // webpack-dev-middleware\lib\Shared.js // 3.开始 watch 文件系统 Line 171 startWatch: function() { //... // start watching if(!options.lazy) { var watching = compiler.watch(options.watchOptions, share.handleCompilerCallback); context.watching = watching; } //... } share.startWatch(); // ...
当 startWatch()
方法执行后,便进入 watch 模式,若发现文件中代码发生修改,则根据配置文件对模块重新编译打包。
2.保存编译结果
Webpack 与 Webpack-dev-middleware 交互,Webpack-dev-middleware 调用 Webpack 的 API 对代码变化进行监控,并通知 Webpack 将重新编译的代码通过 JavaScript 对象保存在内存中。
我们会发现,在 output.path
指定的 dist
目录并没有保存编译结果的文件,这是为什么?
其实, Webpack 将编译结果保存在内存中,因为访问内存中的代码比访问文件系统中的文件快,这样可以减少代码写入文件的开销。
Webpack 能将代码保存到内存中,需要归功于 Webpack-dev-middleware 的 memory-fs
依赖库,它将原本 outputFileSystem
替换成了 MemoryFileSystem
的实例,便实现代码输出到内存中。其中部分源码如下:
// webpack-dev-middleware\lib\Shared.js Line 108 // store our files in memory var fs; var isMemoryFs = !compiler.compilers && compiler.outputFileSystem instanceof MemoryFileSystem; if(isMemoryFs) { fs = compiler.outputFileSystem; } else { fs = compiler.outputFileSystem = new MemoryFileSystem(); } context.fs = fs;
上述代码先判断 fileSystem
是否是 MemoryFileSystem
的实例,若不是,则用 MemoryFileSystem
的实例替换 compiler 之前的 outputFileSystem
。这样 bundle.js 文件代码就作为一个简单 JavaScript 对象保存在内存中,当浏览器请求 bundle.js 文件时,devServer 就直接去内存中找到上面保存的 JavaScript 对象并返回给浏览器端。
3.监控文件变化,刷新浏览器
Webpack-dev-server 开始监控文件变化,与第 1 步不同的是,这里并不是监控代码变化重新编译打包。
当我们在配置文件中配置了 devServer.watchContentBase
为 true
,Webpack-dev-server 会监听配置文件夹中静态文件的变化,发生变化时,通知浏览器端对应用进行浏览器刷新,这与 HMR 不一样。
// webpack-dev-server\lib\Server.js // 1. 读取参数 Line 385 if (options.watchContentBase) { defaultFeatures.push('watchContentBase'); } // 2. 定义 _watch 方法 Line 697 Server.prototype._watch = function (watchPath) { // ... const watcher = chokidar.watch(watchPath, options).on('change', () => { this.sockWrite(this.sockets, 'content-changed'); }); this.contentBaseWatchers.push(watcher); }; // 3. 执行 _watch() 监听文件变化 Line 339 watchContentBase: () => { if (/^(https?:)?\/\//.test(contentBase) || typeof contentBase === 'number') { throw new Error('Watching remote files is not supported.'); } else if (Array.isArray(contentBase)) { contentBase.forEach((item) => { this._watch(item); }); } else { this._watch(contentBase); } }
4.建立 WS,同步编译阶段状态
这一步都是 Webpack-dev-server 中处理,主要通过 sockjs(Webpack-dev-server 的依赖),在 Webpack-dev-server 的浏览器端(Client)和服务器端(Webpack-dev-middleware)之间建立 WebSocket 长连接。
然后将 Webpack 编译打包的各个阶段状态信息同步到浏览器端。其中有两个重要步骤:
- 发送状态
Webpack-dev-server 通过 Webpack API 监听 compile 的 done
事件,当 compile 完成后,Webpack-dev-server 通过 _sendStats
方法将编译后新模块的 hash 值用 socket 发送给浏览器端。
- 保存状态
浏览器端将_sendStats
发送过来的 hash
保存下来,它将会用到后模块热更新。
// webpack-dev-server\lib\Server.js // 1. 定义 _sendStats 方法 Line 685 // send stats to a socket or multiple sockets Server.prototype._sendStats = function (sockets, stats, force) { //... this.sockWrite(sockets, 'hash', stats.hash); }; // 2. 监听 done 事件 Line 86 compiler.plugin('done', (stats) => { // 将最新打包文件的 hash 值(stats.hash)作为参数传入 _sendStats() this._sendStats(this.sockets, stats.toJson(clientStats)); this._stats = stats; }); // webpack-dev-server\client\index.js // 3. 保存 hash 值 Line 74 var onSocketMsg = { // ... hash: function hash(_hash) { currentHash = _hash; }, // ... } socket(socketUrl, onSocketMsg);
5.浏览器端发布消息
当 hash
消息发送完成后,socket 还会发送一条 ok
的消息告知 Webpack-dev-server,由于客户端(Client)并不请求热更新代码,也不执行热更新模块操作,因此通过 emit
一个 "webpackHotUpdate"
消息,将工作转交回 Webpack。
// webpack-dev-server\client\index.js // 1. 处理 ok 消息 Line 135 var onSocketMsg = { // ... ok: function ok() { sendMsg('Ok'); if (useWarningOverlay || useErrorOverlay) overlay.clear(); if (initial) return initial = false; // eslint-disable-line no-return-assign reloadApp(); }, // ... } // 2. 处理刷新 APP Line 218 function reloadApp() { // ... if (_hot) { // 动态加载 emitter var hotEmitter = require('webpack/hot/emitter'); hotEmitter.emit('webpackHotUpdate', currentHash); if (typeof self !== 'undefined' && self.window) { // broadcast update to window self.postMessage('webpackHotUpdate' + currentHash, '*'); } } // ... }
6.传递 hash 到 HMR
Webpack/hot/dev-server 监听浏览器端 webpackHotUpdate
消息,将新模块 hash 值传到客户端 HMR 核心中枢的 HotModuleReplacement.runtime ,并调用 check
方法检测更新,判断是浏览器刷新还是模块热更新。 如果是浏览器刷新的话,则没有后续步骤咯~~
// webpack\hot\dev-server.js // 1.监听 webpackHotUpdate Line 42 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(); } }); var check = function check() { module.hot.check(true).then(function(updatedModules) { if(!updatedModules) { // ... window.location.reload();// 浏览器刷新 return; } if(!upToDate()) { check(); } }).catch(function(err) { /*...*/}); }; // webpack\lib\HotModuleReplacement.runtime.js // 3.调用 HotModuleReplacement.runtime 定义的 check 方法 Line 167 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) { //... }); }
7.检测是否存在更新
当 HotModuleReplacement.runtime 调用 check
方法时,会调用 JsonpMainTemplate.runtime 中的 hotDownloadUpdateChunk
(获取最新模块代码)和 hotDownloadManifest
(获取是否有更新文件)两个方法,这两个方法的源码,在下一步展开。
// webpack\lib\HotModuleReplacement.runtime.js // 1.调用 HotModuleReplacement.runtime 定义 hotDownloadUpdateChunk 方法 Line 171 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) { //... { // hotEnsureUpdateChunk 方法中会调用 hotDownloadUpdateChunk hotEnsureUpdateChunk(chunkId); } }); }
其中 hotEnsureUpdateChunk
方法中会调用 hotDownloadUpdateChunk
:
// webpack\lib\HotModuleReplacement.runtime.js Line 215 function hotEnsureUpdateChunk(chunkId) { if(!hotAvailableFilesMap[chunkId]) { hotWaitingFilesMap[chunkId] = true; } else { hotRequestedFilesMap[chunkId] = true; hotWaitingFiles++; hotDownloadUpdateChunk(chunkId); } }
8.请求更新最新文件列表
在调用 check
方法时,会先调用 JsonpMainTemplate.runtime 中的 hotDownloadManifest
方法, 通过向服务端发起 AJAX 请求获取是否有更新文件,如果有的话将 mainfest
返回给浏览器端。 这边涉及一些原生 XMLHttpRequest
,就不全部贴出了~
// webpack\lib\JsonpMainTemplate.runtime.js // hotDownloadManifest 定义 Line 22 function hotDownloadManifest(requestTimeout) { return new Promise(function(resolve, reject) { try { var request = new XMLHttpRequest(); var requestPath = $require$.p + $hotMainFilename$; request.open("GET", requestPath, true); request.timeout = requestTimeout; request.send(null); } catch(err) { return reject(err); } request.onreadystatechange = function() { // ... }; }); }
9.请求更新最新模块代码
在 hotDownloadManifest
方法中,还会执行 hotDownloadUpdateChunk
方法,通过 JSONP 请求最新的模块代码,并将代码返回给 HMR runtime 。
然后 HMR runtime 会将新代码进一步处理,判断是浏览器刷新还是模块热更新。
// webpack\lib\JsonpMainTemplate.runtime.js // hotDownloadManifest 定义 Line 12 function hotDownloadUpdateChunk(chunkId) { // 创建 script 标签,发起 JSONP 请求 var head = document.getElementsByTagName("head")[0]; var script = document.createElement("script"); script.type = "text/javascript"; script.charset = "utf-8"; script.src = $require$.p + $hotChunkFilename$; $crossOriginLoading$; head.appendChild(script); }
10.更新模块和依赖引用
这一步是整个模块热更新(HMR)的核心步骤,通过 HMR runtime 的 hotApply
方法,移除过期模块和代码,并添加新的模块和代码实现热更新。
从 hotApply
方法可以看出,模块热替换主要分三个阶段:
- 找出过期模块
outdatedModules
和过期依赖outdatedDependencies
;
// webpack\lib\HotModuleReplacement.runtime.js // 找出 outdatedModules 和 outdatedDependencies Line 342 function hotApply() { // ... var outdatedDependencies = {}; var outdatedModules = []; function getAffectedStuff(updateModuleId) { var outdatedModules = [updateModuleId]; var outdatedDependencies = {}; // ... return { type: "accepted", moduleId: updateModuleId, outdatedModules: outdatedModules, outdatedDependencies: outdatedDependencies }; }; function addAllToSet(a, b) { for (var i = 0; i < b.length; i++) { var item = b[i]; if (a.indexOf(item) < 0) a.push(item); } } for(var id in hotUpdate) { if(Object.prototype.hasOwnProperty.call(hotUpdate, id)) { // ... 省略多余代码 if(hotUpdate[id]) { result = getAffectedStuff(moduleId); } if(doApply) { for(moduleId in result.outdatedDependencies) { // 添加到 outdatedDependencies addAllToSet(outdatedDependencies[moduleId], result.outdatedDependencies[moduleId]); } } if(doDispose) { // 添加到 outdatedModules addAllToSet(outdatedModules, [result.moduleId]); appliedUpdate[moduleId] = warnUnexpectedRequire; } } } }
- 从缓存中删除过期模块、依赖和所有子元素的引用;
// webpack\lib\HotModuleReplacement.runtime.js // 从缓存中删除过期模块、依赖和所有子元素的引用 Line 442 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); } } } // 从模块子组件中删除过时的依赖项 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); } } } } }
- 将新模块代码添加到 modules 中,当下次调用
__webpack_require__
(webpack 重写的require
方法)方法的时候,就是获取到了新的模块代码了。
// webpack\lib\HotModuleReplacement.runtime.js // 将新模块代码添加到 modules 中 Line 501 function hotApply() { // ... for(moduleId in appliedUpdate) { if(Object.prototype.hasOwnProperty.call(appliedUpdate, moduleId)) { modules[moduleId] = appliedUpdate[moduleId]; } } }
hotApply
方法执行之后,新代码已经替换旧代码,但是我们业务代码并不知道这些变化,因此需要通过 accept
事件通知应用层使用新的模块进行“局部刷新”,我们在业务中是这么使用:
if (module.hot) { module.hot.accept('./library.js', function() { // 使用更新过的 library 模块执行某些操作... }) }
11.热更新错误处理
在热更新过程中,hotApply
过程中可能出现 abort
或者 fail
错误,则热更新退回到刷新浏览器(Browser Reload),整个模块热更新完成。
// webpack\hot\dev-server.js Line 13 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(); } });
五、总结
本文和大家主要分享 Webpack 的 HMR 使用和实现原理及源码分析,在源码分析中,通过一张“Webpack HMR 工作原理解析”图让大家对 HMR 整个工作流程有所了解,HMR 本身源码内容较多,许多细节之处本文没有完整写出,需要各位读者自己慢慢阅读和理解源码。
参考文章
1.官方文档《Hot Module Replacement》