webpack原理
一文吃透 Webpack 核心原理
深入了解 webpack 模块加载原理
webpack-dev-server 运行原理
轻松理解webpack热更新原理
本文档使用 MrDoc 发布
-
+
首页
轻松理解webpack热更新原理
## 一、前言 - webpack 热更新 > `Hot Module Replacement`,简称 `HMR`,无需完全刷新整个页面的同时,更新模块。`HMR` 的好处,在日常开发工作中体会颇深: **节省宝贵的开发时间、提升开发体验** 。 刷新我们一般分为两种: * 一种是页面刷新,不保留页面状态,就是简单粗暴,直接 `window.location.reload()`。 * 另一种是基于 `WDS (Webpack-dev-server)` 的模块热替换,只需要局部刷新页面上发生变化的模块,同时可以保留当前的页面状态,比如复选框的选中状态、输入框的输入等。 `HMR` 作为一个 `Webpack` 内置的功能,可以通过 `HotModuleReplacementPlugin` 或 `--hot` 开启。那么,`HMR` 到底是怎么实现热更新的呢?下面让我们来了解一下吧! ## 二、webpack 的编译构建过程 项目启动后,进行构建打包,控制台会输出构建过程,我们可以观察到生成了一个** ** **Hash 值** :`a93fd735d02d98633356`。 ![首次构建控制台输出日志](/media/202203/2022-03-03_0134220.5471403022296909.png) 然后,在我们每次修改代码保存后,控制台都会出现** **`Compiling…` 字样,触发新的编译中...可以在控制台中观察到: * **新的 Hash 值** :`a61bdd6e82294ed06fa3` * **新的 json 文件** :** **`a93fd735d02d98633356.hot-update.json` * **新的 js 文件** :`index.a93fd735d02d98633356.hot-update.js` ![修改代码的编译日志](/media/202203/2022-03-03_0134220.5228602306208725.png) 首先,我们知道 `Hash` 值代表每一次编译的标识。其次,根据新生成文件名可以发现,上次输出的 `Hash` 值会作为本次编译新生成的文件标识。依次类推,本次输出的 `Hash` 值会被作为下次热更新的标识。 然后看一下,新生成的文件是什么?每次修改代码,紧接着触发重新编译,然后浏览器就会发出 2 次请求。请求的便是本次新生成的 2 个文件。如下: ![浏览器请求](/media/202203/2022-03-03_0134220.3316326975682735.png) 首先看 `json` 文件,返回的结果中,`h` 代表本次新生成的 `Hash` 值,用于下次文件热更新请求的前缀。`c` 表示当前要热更新的文件对应的是 `index` 模块。 再看下生成的 `js` 文件,那就是本次修改的代码,重新编译打包后的。 ![](/media/202203/2022-03-03_0134220.3685279860387063.png) 还有一种情况是,如果没有任何代码改动,直接保存文件,控制台也会输出编译打包信息的。 * **新的 Hash 值** :`d2e4208eca62aa1c5389` * **新的 json 文件** :`a61bdd6e82294ed06fa3.hot-update.json` ![](/media/202203/2022-03-03_0134220.10561312143581636.png) 但是我们发现,并没有生成新的 `js` 文件,因为没有改动任何代码,同时浏览器发出的请求,可以看到 `c` 值为空,代表本次没有需要更新的代码。 ![](/media/202203/2022-03-03_0134230.35712817661215956.png) 小声说下,`webapck` 以前的版本这种情况 `hash` 值是不会变的,后面可能出于什么原因改版了。细节不用在意,了解原理才是真谛!!! 最后思考下 🤔,浏览器是如何知道本地代码重新编译了,并迅速请求了新生成的文件?是谁告知了浏览器?浏览器获得这些文件又是如何热更新成功的?那让我们带着疑问看下热更新的过程,从源码的角度看原理。 ## 三、热更新实现原理 相信大家都会配置 `webpack-dev-server` 热更新,我就不示意例子了。自己网上查下即可。接下来我们就来看下 `webpack-dev-server` 是如何实现热更新的?(源码都是精简过的,第一行会注明代码路径,看完最好结合源码食用一次)。 ### 1. webpack-dev-server 启动本地服务 我们根据 `webpack-dev-server` 的 `package.json` 中的 `bin` 命令,可以找到命令的入口文件 `bin/webpack-dev-server.js`。 ``` // node_modules/webpack-dev-server/bin/webpack-dev-server.js // 生成webpack编译主引擎 compiler let compiler = webpack(config); // 启动本地服务 let server = new Server(compiler, options, log); server.listen(options.port, options.host, (err) => { if (err) {throw err}; }); 复制代码 ``` 本地服务代码: ``` // node_modules/webpack-dev-server/lib/Server.js class Server { constructor() { this.setupApp(); this.createServer(); } setupApp() { // 依赖了express this.app = new express(); } createServer() { this.listeningApp = http.createServer(this.app); } listen(port, hostname, fn) { return this.listeningApp.listen(port, hostname, (err) => { // 启动express服务后,启动websocket服务 this.createSocketServer(); } } } 复制代码 ``` 这一小节代码主要做了三件事: * 启动 `webpack`,生成 `compiler` 实例。`compiler` 上有很多方法,比如可以启动** **`webpack` 所有**编译**工作,以及**监听**本地文件的变化。 * 使用 `express` 框架启动本地 `server`,让浏览器可以请求本地的 **静态资源** 。 * 本地 `server` 启动之后,再去启动 `websocket` 服务,如果不了解 `websocket`,建议简单了解一下 [websocket 速成](https://link.juejin.cn/?target=https%3A%2F%2Fwww.ruanyifeng.com%2Fblog%2F2017%2F05%2Fwebsocket.html "https://www.ruanyifeng.com/blog/2017/05/websocket.html")。通过 `websocket`,可以建立本地服务和浏览器的双向通信。这样就可以实现当本地文件发生变化,立马告知浏览器可以热更新代码啦! 上述代码主要干了三件事,但是源码在启动服务前又做了很多事,接下来便看看 `webpack-dev-server/lib/Server.js` 还做了哪些事? ### 2. 修改 webpack.config.js 的 entry 配置 启动本地服务前,调用了 `updateCompiler(this.compiler)` 方法。这个方法中有 2 段关键性代码。一个是获取 `websocket` 客户端代码路径,另一个是根据配置获取 `webpack` 热更新代码路径。 ``` // 获取websocket客户端代码 const clientEntry = `${require.resolve( '../../client/' )}?${domain}${sockHost}${sockPath}${sockPort}`; // 根据配置获取热更新代码 let hotEntry; if (options.hotOnly) { hotEntry = require.resolve('webpack/hot/only-dev-server'); } else if (options.hot) { hotEntry = require.resolve('webpack/hot/dev-server'); } 复制代码 ``` 修改后的 `webpack` 入口配置如下: ``` // 修改后的entry入口 { entry: { index: [ // 上面获取的clientEntry 'xxx/node_modules/webpack-dev-server/client/index.js?http://localhost:8080', // 上面获取的hotEntry 'xxx/node_modules/webpack/hot/dev-server.js', // 开发配置的入口 './src/index.js' ], }, } 复制代码 ``` 为什么要新增了 2 个文件?在入口默默增加了 2 个文件,那就意味会一同打包到 `bundle` 文件中去,也就是线上运行时。 **(1)webpack-dev-server/client/index.js** 首先这个文件用于 `websocket` 的,因为 `websoket` 是双向通信,如果不了解 `websocket`,建议简单了解一下 [websocket 速成](https://link.juejin.cn/?target=https%3A%2F%2Fwww.ruanyifeng.com%2Fblog%2F2017%2F05%2Fwebsocket.html "https://www.ruanyifeng.com/blog/2017/05/websocket.html")。我们在第 1 步** **`webpack-dev-server` 初始化 的过程中,启动的是本地服务端的 `websocket`。那客户端也就是我们的浏览器,浏览器还没有和服务端通信的代码呢?总不能让开发者去写吧 hhhhhh。因此我们需要把 `websocket` 客户端通信代码偷偷塞到我们的代码中。客户端具体的代码后面会在合适的时机细讲哦。 **(2)webpack/hot/dev-server.js** 这个文件主要是用于检查更新逻辑的,这里大家知道就好,代码后面会在合适的时机( **第 5 步** )细讲。 ### 3. 监听 webpack 编译结束 修改好入口配置后,又调用了 `setupHooks` 方法。这个方法是用来注册监听事件的,监听每次 `webpack` 编译完成。 ``` // node_modules/webpack-dev-server/lib/Server.js // 绑定监听事件 setupHooks() { const {done} = compiler.hooks; // 监听webpack的done钩子,tapable提供的监听方法 done.tap('webpack-dev-server', (stats) => { this._sendStats(this.sockets, this.getStats(stats)); this._stats = stats; }); }; 复制代码 ``` 当监听到一次 `webpack` 编译结束,就会调用 `_sendStats` 方法通过 `websocket` 给浏览器发送通知,`ok` 和 `hash` 事件,这样浏览器就可以拿到最新的 `hash` 值了,做检查更新逻辑。 ``` // 通过websoket给客户端发消息 _sendStats() { this.sockWrite(sockets, 'hash', stats.hash); this.sockWrite(sockets, 'ok'); } 复制代码 ``` ### 4. webpack 监听文件变化 每次修改代码,就会触发编译。说明我们还需要监听本地代码的变化,主要是通过 `setupDevMiddleware` 方法实现的。 这个方法主要执行了 `webpack-dev-middleware` 库。很多人分不清 `webpack-dev-middleware` 和 `webpack-dev-server` 的区别。其实就是因为 `webpack-dev-server` 只负责启动服务和前置准备工作,所有文件相关的操作都抽离到 `webpack-dev-middleware` 库了,主要是本地文件的**编译**和**输出**以及 **监听** ,无非就是职责的划分更清晰了。 那我们来看下 `webpack-dev-middleware` 源码里做了什么事: ``` // node_modules/webpack-dev-middleware/index.js compiler.watch(options.watchOptions, (err) => { if (err) { /*错误处理*/ } }); // 通过“memory-fs”库将打包后的文件写入内存 setFs(context, compiler); 复制代码 ``` (1)调用了 `compiler.watch` 方法,在第 1 步中也提到过,`compiler` 的强大。这个方法主要就做了 2 件事: * 首先对本地文件代码进行编译打包,也就是 `webpack` 的一系列编译流程。 * 其次编译结束后,开启对本地文件的监听,当文件发生变化,重新编译,编译完成之后继续监听。 为什么代码的改动保存会自动编译,重新打包?这一系列的重新检测编译就归功于 `compiler.watch` 这个方法了。监听本地文件的变化主要是通过**文件的生成时间**是否有变化,这里就不细讲了。 (2)执行 `setFs` 方法,这个方法主要目的就是将编译后的文件打包到内存。这就是为什么在开发的过程中,你会发现 `dist` 目录没有打包后的代码,因为都在内存中。原因就在于访问内存中的代码比访问文件系统中的文件更快,而且也减少了代码写入文件的开销,这一切都归功于 `memory-fs`。 ### 5. 浏览器接收到热更新的通知 我们已经可以监听到文件的变化了,当文件发生变化,就触发重新编译。同时还监听了每次编译结束的事件。当监听到一次 `webpack` 编译结束,`_sendStats` 方法就通过 `websoket` 给浏览器发送通知,检查下是否需要热更新。下面重点讲的就是 `_sendStats` 方法中的 `ok` 和 `hash` 事件都做了什么。 那浏览器是如何接收到 `websocket` 的消息呢?回忆下第 2 步骤增加的入口文件,也就是 `websocket` 客户端代码。 ``` 'xxx/node_modules/webpack-dev-server/client/index.js?http://localhost:8080' 复制代码 ``` 这个文件的代码会被打包到 `bundle.js` 中,运行在浏览器中。来看下这个文件的核心代码吧。 ``` // webpack-dev-server/client/index.js var socket = require('./socket'); var onSocketMessage = { hash: function hash(_hash) { // 更新currentHash值 status.currentHash = _hash; }, ok: function ok() { sendMessage('Ok'); // 进行更新检查等操作 reloadApp(options, status); }, }; // 连接服务地址socketUrl,?http://localhost:8080,本地服务地址 socket(socketUrl, onSocketMessage); function reloadApp() { if (hot) { log.info('[WDS] App hot update...'); // hotEmitter其实就是EventEmitter的实例 var hotEmitter = require('webpack/hot/emitter'); hotEmitter.emit('webpackHotUpdate', currentHash); } } 复制代码 ``` `socket` 方法建立了 `websocket` 和服务端的连接,并注册了 2 个监听事件。 * `hash` 事件,更新最新一次打包后的 `hash` 值。 * `ok` 事件,进行热更新检查。 热更新检查事件是调用 `reloadApp` 方法。比较奇怪的是,这个方法又利用 `node.js` 的 `EventEmitter`,发出 `webpackHotUpdate` 消息。这是为什么?为什么不直接进行检查更新呢? 个人理解就是为了更好的维护代码,以及职责划分的更明确。`websocket` 仅仅用于客户端(浏览器)和服务端进行通信。而真正做事情的活还是交回给了 `webpack`。 那 `webpack` 怎么做的呢?再来回忆下第 2 步。入口文件还有一个文件没有讲到,就是: ``` 'xxx/node_modules/webpack/hot/dev-server.js' 复制代码 ``` 这个文件的代码同样会被打包到 `bundle.js` 中,运行在浏览器中。这个文件做了什么就显而易见了吧!先瞄一眼代码: ``` // node_modules/webpack/hot/dev-server.js var check = function check() { module.hot.check(true) .then(function(updatedModules) { // 容错,直接刷新页面 if (!updatedModules) { window.location.reload(); return; } // 热更新结束,打印信息 if (upToDate()) { log("info", "[HMR] App is up to date."); } }) .catch(function(err) { window.location.reload(); }); }; var hotEmitter = require("./emitter"); hotEmitter.on("webpackHotUpdate", function(currentHash) { lastHash = currentHash; check(); }); 复制代码 ``` 这里 `webpack` 监听到了 `webpackHotUpdate` 事件,并获取最新了最新的 `hash` 值,然后终于进行检查更新了。检查更新呢调用的是 `module.hot.check` 方法。那么问题又来了,`module.hot.check` 又是哪里冒出来了的!答案是 `HotModuleReplacementPlugin` 搞得鬼。这里留个疑问,继续往下看。 ### 6. HotModuleReplacementPlugin 前面好像一直是 `webpack-dev-server` 做的事,那 `HotModuleReplacementPlugin` 在热更新过程中又做了什么伟大的事业呢? 首先你可以对比下,配置热更新和不配置时 `bundle.js` 的区别。内存中看不到?直接执行 `webpack` 命令就可以看到生成的 `bundle.js` 文件啦。不要用 `webpack-dev-server` 启动就好了。 (1)没有配置的。 ![](/media/202203/2022-03-03_0134230.8384666093204021.png) (2)配置了 `HotModuleReplacementPlugin` 或 `--hot` 的。![](/media/202203/2022-03-03_0134230.4251291417310198.png) 哦~ 我们发现 `moudle` 新增了一个属性为 `hot`,再看 `hotCreateModule` 方法。 这不就找到 `module.hot.check` 是哪里冒出来的。![](/media/202203/2022-03-03_0134230.4255244505770277.png) 经过对比打包后的文件,`__webpack_require__` 中的 `moudle` 以及代码行数的不同。我们都可以发现 `HotModuleReplacementPlugin` 原来也是默默的塞了很多代码到 `bundle.js` 中呀。这和第 2 步骤很是相似哦!为什么,因为检查更新是在浏览器中操作呀。这些代码必须在运行时的环境。 你也可以直接看浏览器 `Sources` 下的代码,会发现 `webpack` 和 `plugin` 偷偷加的代码都在哦。在这里调试也很方便。 ![](/media/202203/2022-03-03_0134230.09503321265076847.png) `HotModuleReplacementPlugin` 如何做到的?这里我就不讲了,因为这需要你对 `tapable` 以及 `plugin` 机制有一定了解,可以看下我写的文章 [Webpack 插件机制之 Tapable-源码解析](https://juejin.cn/post/6844904004435050503 "https://juejin.cn/post/6844904004435050503")。当然你也可以选择跳过,只关心热更新机制即可,毕竟信息量太大。 ### 7. moudle.hot.check 开始热更新 通过第 6 步,我们就可以知道 `moudle.hot.check` 方法是如何来的啦。那都做了什么?之后的源码都是 `HotModuleReplacementPlugin` 塞入到 `bundle.js` 中的哦,我就不写文件路径了。 * 利用上一次保存的 `hash` 值,调用 `hotDownloadManifest` 发送 `xxx/hash.hot-update.json` 的 `ajax` 请求; * 请求结果获取热更新模块,以及下次热更新的 `Hash` 标识,并进入热更新准备阶段。 ``` hotAvailableFilesMap = update.c; // 需要更新的文件 hotUpdateNewHash = update.h; // 更新下次热更新hash值 hotSetStatus("prepare"); // 进入热更新准备状态 复制代码 ``` * 调用 `hotDownloadUpdateChunk` 发送 `xxx/hash.hot-update.js` 请求,通过 `JSONP` 方式。 ``` function hotDownloadUpdateChunk(chunkId) { var script = document.createElement("script"); script.charset = "utf-8"; script.src = __webpack_require__.p + "" + chunkId + "." + hotCurrentHash + ".hot-update.js"; if (null) script.crossOrigin = null; document.head.appendChild(script); } 复制代码 ``` 这个函数体为什么要单独拿出来,因为这里要解释下为什么使用 `JSONP` 获取最新代码?主要是因为 `JSONP` 获取的代码可以直接执行。为什么要直接执行?我们来回忆下 `/hash.hot-update.js` 的代码格式是怎么样的。 ![](/media/202203/2022-03-03_0134230.3070281438387039.png) 可以发现,新编译后的代码是在一个 `webpackHotUpdate` 函数体内部的。也就是要立即执行 `webpackHotUpdate` 这个方法。 再看下 `webpackHotUpdate` 这个方法。 ``` window["webpackHotUpdate"] = function (chunkId, moreModules) { hotAddUpdateChunk(chunkId, moreModules); } ; 复制代码 ``` * `hotAddUpdateChunk` 方法会把更新的模块 `moreModules` 赋值给全局全量 `hotUpdate`。 * `hotUpdateDownloaded` 方法会调用 `hotApply` 进行代码的替换。 ``` function hotAddUpdateChunk(chunkId, moreModules) { // 更新的模块moreModules赋值给全局全量hotUpdate for (var moduleId in moreModules) { if (Object.prototype.hasOwnProperty.call(moreModules, moduleId)) { hotUpdate[moduleId] = moreModules[moduleId]; } } // 调用hotApply进行模块的替换 hotUpdateDownloaded(); } 复制代码 ``` ### 8. hotApply 热更新模块替换 热更新的核心逻辑就在 `hotApply` 方法了。** **`hotApply` 代码有将近 400 行,还是挑重点讲了,看哭 😭 #### ① 删除过期的模块,就是需要替换的模块 通过 `hotUpdate` 可以找到旧模块 ``` var queue = outdatedModules.slice(); while (queue.length > 0) { moduleId = queue.pop(); // 从缓存中删除过期的模块 module = installedModules[moduleId]; // 删除过期的依赖 delete outdatedDependencies[moduleId]; // 存储了被删掉的模块id,便于更新代码 outdatedSelfAcceptedModules.push({ module: moduleId }); } 复制代码 ``` #### ② 将新的模块添加到 modules 中 ``` appliedUpdate[moduleId] = hotUpdate[moduleId]; for (moduleId in appliedUpdate) { if (Object.prototype.hasOwnProperty.call(appliedUpdate, moduleId)) { modules[moduleId] = appliedUpdate[moduleId]; } } 复制代码 ``` #### ③ 通过__webpack_require__执行相关模块的代码 ``` for (i = 0; i < outdatedSelfAcceptedModules.length; i++) { var item = outdatedSelfAcceptedModules[i]; moduleId = item.module; try { // 执行最新的代码 __webpack_require__(moduleId); } catch (err) { // ...容错处理 } } 复制代码 ``` `hotApply` 的确比较复杂,知道大概流程就好了,这一小节,要求你对 webpack 打包后的文件如何执行的有一些了解,大家可以自去看下。 ## 四、总结 还是以阅读源码的形式画的图,①-④ 的小标记,是文件发生变化的一个流程。 ![](/media/202203/2022-03-03_0134230.3342845846861827.png) ## 写在最后 本次是以阅读源码的方式讲解原理,是因为觉得热更新这块涉及的知识量比较多。所以知识把关键性代码拿出来,因为每一个块细节说起来都能写一篇文章了,大家可以自己对着源码再理解下。 还是建议提前了解以下知识会更好理解热更新: * **websocket** :[websocket 基础知识了解](https://link.juejin.cn/?target=https%3A%2F%2Fwww.ruanyifeng.com%2Fblog%2F2017%2F05%2Fwebsocket.html "https://www.ruanyifeng.com/blog/2017/05/websocket.html") * 打包后的 `bundle` 文件如何运行的。 * `webpack` 启动流程,`webpack` 生命周期。 * **tapable** :** **[Webpack 插件机制之 Tapable-源码解析](https://juejin.cn/post/6844904004435050503 "https://juejin.cn/post/6844904004435050503") ## 参考链接 * [Webpack Hot Module Replacement 的原理解析](https://link.juejin.cn/?target=https%3A%2F%2Fgithub.com%2FJocs%2Fjocs.github.io%2Fissues%2F15 "https://github.com/Jocs/jocs.github.io/issues/15") * [看完这篇,面试再也不怕被问 Webpack 热更新](https://juejin.cn/post/6844903953092591630 "https://juejin.cn/post/6844903953092591630") 参考的文章大家也可以看下,但是由于源码版本不同,所以不要太纠结与细节。 作者:雪凌同学 链接:https://juejin.cn/post/6844904008432222215 来源:稀土掘金 著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
追风者
2022年3月3日 01:34
转发文档
收藏文档
上一篇
下一篇
手机扫码
复制链接
手机扫一扫转发分享
复制链接
关于 MrDoc
觅思文档MrDoc
是
州的先生
开发并开源的在线文档系统,其适合作为个人和小型团队的云笔记、文档和知识库管理工具。
如果觅思文档给你或你的团队带来了帮助,欢迎对作者进行一些打赏捐助,这将有力支持作者持续投入精力更新和维护觅思文档,感谢你的捐助!
>>>捐助鸣谢列表
微信
支付宝
QQ
PayPal
Markdown文件
分享
链接
类型
密码
更新密码