一文读懂 Webpack HMR(热更新)的实现原理

HMR 全称 hot module replacement,可以翻译为「模块热更新」,最初由 Webpack 设计实现,至今已几乎成为现代工程化必备工具之一,它能够在保持页面状态不变的情况下动态替换、删除、添加代码模块,而无需重新加载整个页面。

主要是通过以下几种方式,来显著加快开发速度:

  • 保留在完全重新加载页面期间丢失的应用程序状态。

  • 只更新变更内容,以节省宝贵的开发时间。

  • 在源代码中 CSS/JS 产生修改时,会立刻在浏览器中进行更新,这几乎相当于在浏览器 devtools 直接更改样式。

在 HMR 之前 ,即使应用只是单个代码文件发生变更,都需要刷新整个页面,才能将最新代码映射到浏览器上,这会丢失之前在页面执行过的所有交互与状态,整体开发效率偏低而引入 HMR 后,虽然无法覆盖所有场景,但大多数小改动都可以通过模块热替换方式更新到页面上,从而确保连续、顺畅的开发调试体验,极大提升开发效率。

使用 HMR

Webpack 生态下,只需要经过简单的配置,即可启动 HMR 功能,大致分两步:

  • 设置 devServer.hot 属性为 true:
js 复制代码
module.exports = {
  // ...
  devServer: {
    // 必须设置 devServer.hot = true,启动 HMR 功能
    hot: true
  }
};
  • 在代码调用 module.hot.accept 接口,声明如何将模块安全地替换为最新代码
js 复制代码
import component from "./component";

const currentComponent = component();
document.body.appendChild(currentComponent);

// HMR interface
if (module.hot) {
  // Capture hot update
  module.hot.accept("./component", () => {
    const nextComponent = component();

    // Replace old content with the hot loaded one
    document.body.replaceChild(nextComponent, currentComponent);

    currentComponent = nextComponent;
  });
}

原理

  • 使用 webpack-dev-server 托管静态资源,同时以 Runtime 方式注入一段处理 HMR 逻辑的客户端代码;
  • 浏览器加载页面后,与 webpack-dev-server 建立 WebSocket 连接;
  • Webpack 监听到文件变化后,增量构建发生变更的模块,并通过 WebSocket 发送 hash 事件;
  • 浏览器接收到 hash 事件后,请求 manifest 资源文件,确认增量变更范围;
  • 浏览器加载发生变更的增量模块;
  • Webpack 运行时触发变更模块的 module.hot.accept 回调,执行代码变更逻辑;

当 HMR 失败后,回退到 live reload 操作,也就是进行浏览器刷新来获取最新打包代码。这就是 Webpack HMR 特性的执行过程

当我们在配置文件中配置了devServer.watchContentBase 为 true 的时候,Server 会监听这些配置文件夹中静态文件的变化,变化后会通知浏览器端对应用进行 live reload。

注意,这儿是浏览器刷新,和 HMR 是两个概念。

启动服务

js 复制代码
class Server {

  async initialize() {
    // ...
    this.setupApp();
    this.createServer();
    // ...
  }

  setupApp() {
    this.app = new (getExpress())();
  }

  createServer() {
    const { type, options } = (
      this.options.server
    );

    // 启动静态资源服务
    this.server = require((type)).createServer(
      options,
      this.app, // express
    );

    // 每次连接时将新的 socket 添加到 this.sockets 数组中
    (this.server).on(
      "connection",
      (socket) => {
        this.sockets.push(socket);

        socket.once("close", () => {
          this.sockets.splice(this.sockets.indexOf(socket), 1);
        });
      },
    );
  }

  async start () {
    // 这里对 options.webSocketServer 赋值,默认为 { type: 'ws', options: { path: 'ws' } }
    await this.normalizeOptions();
    
    // ...
    await initialize()

    // 创建 socket
    if (this.options.webSocketServer) {
      this.createWebSocketServer();
    }
  }
}

⭐ 使用 express 框架启动本地 server,让浏览器可以请求本地的静态资源

⭐ 本地server启动之后,再去启动websocket服务,通过websocket,可以建立本地服务和浏览器的双向通信。

增加 Entry 文件

在启动服务时候,webpack-dev-server会在webpack配置项的entry中注入两个文件:

js 复制代码
addAdditionalEntries(compiler) {
  const additionalEntries = [];
  const isWebTarget = Server.isWebTarget(compiler);

  if (this.options.client && isWebTarget) {
    // ...

    // 获取 webSocket 客户端代码
    additionalEntries.push(
      `${require.resolve("../client/index.js")}?${webSocketURLStr}`,
    );
  }

  // 根据配置获取热更新代码
  if (this.options.hot === "only") {
    additionalEntries.push(require.resolve("webpack/hot/only-dev-server"));
  } else if (this.options.hot) {
    additionalEntries.push(require.resolve("webpack/hot/dev-server"));
  }

  const webpack = compiler.webpack || require("webpack");

  for (const additionalEntry of additionalEntries) {
    new webpack.EntryPlugin(compiler.context, additionalEntry, {
      name: undefined,
    }).apply(compiler);
  }
}

修改后的 webpack 入口配置如下:

js 复制代码
// 修改后的 entry 入口
{
  entry: {
    index: [
      // 上面获取的clentEntry
      '/node_modules/webpack-dev-server/client/index.js?http://localhost:8080',
      // 上面获取的hotEntry
      '/node_modules/webpack/hot/dev-server.js',
      // 开发配置的入口
      './src/index.js'
    ]
  }
}

在入口默默增加了2个文件,那就意味会一同打包到 bundle 文件中去,也就是线上运行时。

⭐️ webpack-dev-server/client/index.js

在客户端中加入 websocket 客户端通信代码,以便后续的更新替换操作

⭐️ webpack/hot/dev-server.js

负责与 webpack-dev-server 进行通信,接收关于模块更新的通知,并执行相应的模块替换操作

监听 webpack 编译结束

修改好入口配置后,又调用了setupHooks 方法,用来注册监听事件的,监听每次 webpack 编译完成

js 复制代码
 setupHooks() {
  this.compiler.hooks.done.tap(
    "webpack-dev-server",
    (stats) => {
      if (this.webSocketServer) {
        this.sendStats(this.webSocketServer.clients, this.getStats(stats));
      }

      this.stats = stats;
    },
  );
}

当监听到一次 webpack 编译结束,就会调用 sendStats 方法通过 websocket 给浏览器发送通知,ok 和 hash 事件,这样浏览器就可以拿到最新的 hash 值了,做检查更新逻辑。

js 复制代码
sendStats(clients, stats, force) {
    // ...

    this.currentHash = stats.hash;
    // 通过 websocket 给客户端发消息
    this.sendMessage(clients, "hash", stats.hash);

    if (
      (stats.errors).length > 0 ||
      (stats.warnings).length > 0
    ) {
      // ...
    } else {
      // 通过 websocket 给客户端发消息
      this.sendMessage(clients, "ok");
    }
  }

监听文件变化

在了解如何监听文件变化前,我们需要了解到 dev-server 和 dev-middleware 的区别

  • webpack-dev-server:只负责启动服务和前置准备工作

  • webpack-dev-middleware:所有文件相关的操作都抽离至此,主要是本地文件的编译和输出以及监听

js 复制代码
const context = {
  // ...
  options,
  compiler,
  watching: undefined,
  outputFileSystem: undefined,
};

// ...

let watchOptions;

const errorHandler = (error) => {
  if (error) {
    context.logger.error(error);
  }
};

if (
  Array.isArray((context.compiler).compilers)
) {
  watchOptions =
    (context.compiler).compilers.map(
      (childCompiler) => childCompiler.options.watchOptions || {},
    );

  context.watching =
    (
      context.compiler.watch(
        (watchOptions),
        errorHandler,
      )
    );
} else {
  watchOptions = (context.compiler).options.watchOptions || {};

  context.watching = (
    context.compiler.watch(watchOptions, errorHandler)
  );
}

我们可以看出 webpack-dev-middleware 是借用 webpack 的 watch 钩子开启对本地文件的监听,当文件发生变化,重新编译,编译完成之后继续监听

outputFileSystem

每次文件改变都要重新编译,性能不应该很差吗,为什么开发时没有体会到?

为了提升性能,webpack-dev-middleware 使用了一个库叫 memfs,是 Webpack 官方写的。这样每次打包之后的结果并不会进行输出(把文件写入到硬盘上会耗费很长的时间),而是将打包后的文件保留在内存中,以此来提升性能。

js 复制代码
function setupOutputFileSystem(context) {
  let outputFileSystem;

  // 默认为 undefined
  if (context.options.outputFileSystem) {
    // ...
  }
  // 这里需要手动设置为 true,若不设置则为 undefined
  else if (context.options.writeToDisk !== true) {
    outputFileSystem = memfs.createFsFromVolume(new memfs.Volume());
  } else {
    // ...
  }

  // ...
  context.outputFileSystem = outputFileSystem;
}

我们可以看出,如果不设置 writeToDisk 为 true,则会改写 webpack outputFileSystem 为 memfs,输出到内存中。

浏览器接收到热更新的通知

我们上文已经提到,webpack-dev-server 会对 entry 进行加工,对客户端加入 ws 的代码,并会被打包到 bundle 中

js 复制代码
var socket = require('./socket');

const onSocketMessage = {
  hash(hash) {
    status.previousHash = status.currentHash;
    status.currentHash = hash;
  },
  ok() {
    sendMessage("Ok");

    if (options.overlay) {
      overlay.send({ type: "DISMISS" });
    }
    
    // 进行更新检查等操作
    reloadApp(options, status);
  },
}


function reloadApp({ hot, liveReload }, status) {
  const search = self.location.search.toLowerCase();
  const allowToHot = search.indexOf("webpack-dev-server-hot=false") === -1;

  if (hot && allowToHot) {
    log.info("App hot update...");

    // hotEmitter 其实就是EventEmittter 的实例
    hotEmitter.emit("webpackHotUpdate", status.currentHash);

    if (typeof self !== "undefined" && self.window) {
      // broadcast update to window
      self.postMessage(`webpackHotUpdate${status.currentHash}`, "*");
    }
  }
}

从代码中,我们可以看出

  • hash事件:更新最新 status 的 hash 值

  • ok事件:调用 webpackHotUpdate 事件

这里就要提到 entry 新增打包的 webpack/hot/dev-server

js 复制代码
if (module.hot) {
	var check = function check() {
		module.hot
			.check(true)
			.then(function (updatedModules) {})
			.catch(function (err) {});
	};
  
	var hotEmitter = require("./emitter");
	hotEmitter.on("webpackHotUpdate", function (currentHash) {
		lastHash = currentHash;
		
    if (!upToDate() && module.hot.status() === "idle") {
			check();
		}
	});
}

这里调用了 module.hot 方法,那么这个方法是从哪里来的?这里我就不卖关子了,这里调用了 HotModuleReplacementPlugin 插件进行改写

热更新插件

在上面的配置中并没有配置 HotModuleReplacementPlugin,原因在于当我们设置 devServer.hot 为 true 后,devServer 会告诉 webpack 自动引入 HotModuleReplacementPlugin

  • HMR Server 是服务端,用来将变化的 JS 模块通过 WebSocket 的消息通知给浏览器端。
  • HMR Runtime 是浏览器端,用于接受 HMR Server 传递的模块数据,浏览器端可以看到 .hot-update.JSON 的文件过来。

这里的 hot 就是 HotModuleReplacementRuntime 中定义的

js 复制代码
$hmrDownloadUpdateHandlers$ = {}


function createModuleHotObject(moduleId, me) {
		var _main = currentChildModule !== moduleId;
		var hot = {
      // ...
			// Management API
			check: hotCheck,
			apply: hotApply,
      // ...
		};
		currentChildModule = undefined;
		return hot;
	}
}

function hotCheck(applyOnUpdate) {
    return setStatus("check")
        .then($hmrDownloadManifest$) // undefined
        .then(function (update) {
            return setStatus("prepare").then(function () {
            var updatedModules = [];
            currentUpdateApplyHandlers = [];

            return Promise.all(
                Object.keys($hmrDownloadUpdateHandlers$).reduce(function (
                    promises,
                    key
                ) {
                    $hmrDownloadUpdateHandlers$[key](
                            update.c,
                            update.r,
                            update.m,
                            promises,
                            currentUpdateApplyHandlers,
                            updatedModules
                    );
                    return promises;
            }, [])
        ).then(function () {
            return waitForBlockingPromises(function () {
                if (applyOnUpdate) {
                    // 触发 internalApply 方法
                    return internalApply(applyOnUpdate);
                } else {
                    return setStatus("ready").then(function () {
                        return updatedModules;
                    });
                }
            });
            });
        });
    });
}

这里代码太多,不是很想看,但我的理解是通过

  • HotModuleReplacementPlugin 在 webapck hooks上做了一些事情,例如 compilation.hooks.processAssets 钩子调用emitAsset()先后产生了新模块的js文件和menifest文件
  • HMR runtime 则是注入到 bundle.js,如 module.hot.check 等方法

当文件发生改变时候,触发 webpack 增量构建,构建结束后触发 this.sendStats 方法通知提交服务器,服务器再通过 socket 告诉浏览器,浏览器再去下载更新文件,进行替换。

这里热更新主要是这两个方法 check 和 apply

  • check : hotCheck 发送一个 fetch 请求来更新 Manifest 文件(xxx.hot-update.json) 会与当前的 loaded chunk 列表进行比较。然后会下载并执行 xxx.hot-update.js 文件。当所有更新 chunk 完成下载,runtime 就会切换到 ready 状态
  • apply :将所有 updated module 标记为无效。对于每个无效 module,都需要在模块中有一个 update handler,或者在此模块的父级模块中有 update handler。否则,会进行无效标记冒泡,并且父级也会被标记为无效。继续每个冒泡,直到到达应用程序入口起点,或者到达带有 update handler 的 module(以最先到达为准,冒泡停止)。如果它从入口起点开始冒泡,则此过程失败。

热更新

加载热更新资源的 RuntimeGlobals.hmrDownloadManifest 与RuntimeGlobals.hmrDownloadUpdateHandlers

js 复制代码
function hotCheck(applyOnUpdate) {
    return setStatus("check")
    .then($hmrDownloadManifest$)
    .then(function (update) {
        if (!update) {
            return setStatus(applyInvalidatedModules() ? "ready" : "idle").then(
                function () {
                        return null;
                }
            );
        }

        return setStatus("prepare").then(function () {
            var updatedModules = [];
            currentUpdateApplyHandlers = [];

            return Promise.all(
                Object.keys($hmrDownloadUpdateHandlers$).reduce(function (
                        promises,
                        key
                ) {
                    $hmrDownloadUpdateHandlers$[key](
                        update.c,
                        update.r,
                        update.m,
                        promises,
                        currentUpdateApplyHandlers,
                        updatedModules
                    );
                    return promises;
                }, [])
        ).then(function () {
            return waitForBlockingPromises(function () {
                if (applyOnUpdate) {
                    return internalApply(applyOnUpdate);
                } else {
                    return setStatus("ready").then(function () {
                        return updatedModules;
                    });
                }
            });
            });
        });
    });
}

HotModuleReplacementPlugin 插件借助 Webpack 的 watch 能力,在代码文件发生变化后执行增量构建,生成:

  • manifest 文件:JSON 格式文件,包含所有发生变更的模块列表,命名为 [hash].hot-update.json
  • 模块变更文件:js 格式,包含编译后的模块代码,命名为 [hash].hot-update.js

增量构建完毕后,Webpack 将触发 compilation.hooks.done 钩子,并传递本次构建的统计信息对象 stats。WDS 则监听 done 钩子,在回调中通过 WebSocket 发送模块更新消息:

js 复制代码
{ "type" : "hash", "data": "${stats.hash}" }

再次加载更新:客户端通过 WebSocket 接收到 hash 消息后,首先发出 manifest 请求获取本轮热更新涉及的 chunk。manifest 请求完成后,客户端 HMR 运行时开始下载发生变化的 chunk 文件,将最新模块代码加载到本地

热更新模块替换

模块热替换主要分三个阶段:

  • 找出 outdatedModules 和 outdatedDependencies,并从缓存中删除过期的模块和依赖

  • 添加新的模块到 modules 中

  • 当下次调用 webpack_require(Webpack 重写的 require 方法)方法的时候,就是获取到了新的模块代码了

这里就不贴找出过期模块 outdatedModules 和过期依赖 outdatedDependencies 的代码了,直接看删除和添加的代码

  • 当模块被删除时,调用dispose方法消除副作用;

  • 当模块更新时,冒泡寻求accept方法执行模块更新逻辑并重新加载模块;

从缓存中删除过期的模块和依赖

js 复制代码
return {
  dispose: function () {
    currentUpdateRemovedChunks.forEach(function (chunkId) {
      delete $installedChunks$[chunkId];
    });
    currentUpdateRemovedChunks = undefined;
    
    var queue = outdatedModules.slice();
    while (queue.length > 0) {
      var moduleId = queue.pop();
      var module = $moduleCache$[moduleId];
      if (!module) continue;
    
      var data = {};
    
      // Call dispose handlers
      var disposeHandlers = module.hot._disposeHandlers;
      for (j = 0; j < disposeHandlers.length; j++) {
        disposeHandlers[j].call(null, data);
      }
      $hmrModuleData$[moduleId] = data;
    
      module.hot.active = false;
    
      // 从缓存中删除过期的模块
      delete $moduleCache$[moduleId];
      // 删除过期的依赖
      delete outdatedDependencies[moduleId];
    
      // remove "parents" references from all children
      for (j = 0; j < module.children.length; j++) {
        var child = $moduleCache$[module.children[j]];
        if (!child) continue;
        idx = child.parents.indexOf(moduleId);
        if (idx >= 0) {
          child.parents.splice(idx, 1);
        }
      }
    }
    
    
    // 更新子模块的依赖关系,将需要被移除的模块从依赖的子模块的 children 数组中移除
    var dependency;
    for (var outdatedModuleId in outdatedDependencies) {
      if ($hasOwnProperty$(outdatedDependencies, outdatedModuleId)) {
        module = $moduleCache$[outdatedModuleId];
        if (module) {
          moduleOutdatedDependencies =
            outdatedDependencies[outdatedModuleId];
          for (j = 0; j < moduleOutdatedDependencies.length; j++) {
            dependency = moduleOutdatedDependencies[j];
            idx = module.children.indexOf(dependency);
            if (idx >= 0) module.children.splice(idx, 1);
          }
        }
      }
    }
  }
}

添加新增模块

将新的模块添加到 <math xmlns="http://www.w3.org/1998/Math/MathML"> m o d u l e F a c t o r i e s moduleFactories </math>moduleFactories 中。

js 复制代码
// insert new code
for (var updateModuleId in appliedUpdate) {
  if ($hasOwnProperty$(appliedUpdate, updateModuleId)) {
    $moduleFactories$[updateModuleId] = appliedUpdate[updateModuleId];
  }
}

// Load self accepted modules
for (var o = 0; o < outdatedSelfAcceptedModules.length; o++) {
  var item = outdatedSelfAcceptedModules[o];
  var moduleId = item.module;
  try {
    // 执行最新的代码
    item.require(moduleId);
  } catch (err) {
    // 错误处理
  }
}

经过上述步骤,浏览器加载完最新模块代码后,HMR 运行时会继续触发 module.hot.accept 回调,将最新代码替换到运行环境中。

module.hot.accept 是 HMR 运行时暴露给用户代码的重要接口之一,它在 Webpack HMR 体系中开了一个口子,让用户能够自定义模块热替换的逻辑,接口签名:

js 复制代码
/*
 * path:指定需要拦截变更行为的模块路径;
 * callback:模块更新后,将最新模块代码应用到运行环境的函数。
 **/
module.hot.accept(path?: string, callback?: function);

例如,对于如下代码:

js 复制代码
import component from "./component";

const currentComponent = component();
document.body.appendChild(currentComponent);

// HMR interface
if (module.hot) {
  // Capture hot update
  module.hot.accept("./component", () => {
    const nextComponent = component();

    // Replace old content with the hot loaded one
    document.body.replaceChild(nextComponent, currentComponent);

    currentComponent = nextComponent;
  });
}

示例中,module.hot.accept 函数监听 ./component 模块的变更事件,一旦代码发生变动,就触发回调,将 ./component 导出的值替换到页面上,从而实现热更新效果。

更新失败后的兜底

模块热更新的错误处理,如果在热更新过程中出现错误,热更新将回退到刷新浏览器,这部分代码在 dev-server 代码中

js 复制代码
if (module.hot) {
	var check = function check() {
		module.hot
			.check(true)
			.then(function (updatedModules) {
				if (!updatedModules) {
					if (typeof window !== "undefined") {
						window.location.reload();
					}
          
					return;
				}

        // ...
				if (upToDate()) {
					log("info", "[HMR] App is up to date.");
				}
			})
			.catch(function (err) {
				var status = module.hot.status();
        
				if (["abort", "fail"].indexOf(status) >= 0) {1
					if (typeof window !== "undefined") {
						window.location.reload();
					}
				} else {
					log("warning", "[HMR] Update failed: " + log.formatError(err));
				}
			});
	};
  
	var hotEmitter = require("./emitter");
	hotEmitter.on("webpackHotUpdate", function (currentHash) {
		lastHash = currentHash;
		
    if (!upToDate() && module.hot.status() === "idle") {
			check();
		}
	});
}

dev-server 先验证是否有更新,没有代码更新的话,重载浏览器。如果在 hotApply 的过程中出现 abort 或者 fail 错误,也进行重载浏览器。

相关推荐
LCG元18 分钟前
【面试问题】JIT 是什么?和 JVM 什么关系?
面试·职场和发展
迷雾漫步者21 分钟前
Flutter组件————FloatingActionButton
前端·flutter·dart
向前看-1 小时前
验证码机制
前端·后端
燃先生._.2 小时前
Day-03 Vue(生命周期、生命周期钩子八个函数、工程化开发和脚手架、组件化开发、根组件、局部注册和全局注册的步骤)
前端·javascript·vue.js
高山我梦口香糖3 小时前
[react]searchParams转普通对象
开发语言·前端·javascript
m0_748235243 小时前
前端实现获取后端返回的文件流并下载
前端·状态模式
m0_748240254 小时前
前端如何检测用户登录状态是否过期
前端
black^sugar4 小时前
纯前端实现更新检测
开发语言·前端·javascript
寻找沙漠的人4 小时前
前端知识补充—CSS
前端·css