一图看懂 Webpack HMR 原理 🤔🤔🤔

面试导航 是一个专注于前、后端技术学习和面试准备的 免费 学习平台,提供系统化的技术栈学习,深入讲解每个知识点的核心原理,帮助开发者构建全面的技术体系。平台还收录了大量真实的校招与社招面经,帮助你快速掌握面试技巧,提升求职竞争力。如果你想加入我们的交流群,欢迎通过微信联系:yunmz777

什么是模块热替换(HMR)?

在前端开发的早期,开发者每次修改代码后,通常需要 刷新网页 来查看修改效果。刷新时,整个页面的内容都会重新加载,而且应用的状态也会丢失。例如,如果你正在调试一个表单,输入框里的内容、滚动位置等都会被清空。

随着开发工具的进步,Live Reload 技术应运而生。它会在检测到代码变动时,自动重新加载页面。虽然这种方式让我们能更快速地看到效果,但依然会丢失页面状态,而且等待重新加载的过程仍然存在。

模块热替换(HMR) 就是对 Live Reload 的进一步优化,它能够让你在 不刷新页面 的前提下,自动更新修改的部分。更棒的是,HMR 还能够 保留页面的状态,使得开发效率显著提升。

为什么 HMR 能让开发更高效?

1. 节省等待时间

如果每次修改代码后都要等待几秒钟,甚至几十秒,才能看到效果,开发效率就会大打折扣。特别是在调试较复杂的页面或多层级的组件时,频繁的刷新会让你浪费大量时间。

HMR 解决了这个问题。你修改代码后,浏览器会即时更新修改的内容,无需刷新页面 ,可以 快速验证每个小的变动。这样你就能节省大量时间,提升开发效率。

2. 保留页面状态

在传统的开发流程中,每次页面刷新,用户的输入内容、滚动位置等状态都会丢失。这对于调试复杂的交互型应用来说是个很大的障碍。

HMR 则可以在更新代码时,保留页面上的状态。举个例子,如果你正在填表单或浏览页面时,HMR 能让你修改代码后,表单数据和滚动位置不会丢失。即便是动态状态,也能在热替换过程中得以保留。

3. 只更新变动的部分

HMR 最大的优势之一是它只会更新你 修改过的部分,而不会刷新整个页面。无论你是修改了 JavaScript、CSS,还是其他资源,浏览器只会替换这些具体的模块,而不会重新加载整个页面,节省了大量的资源和时间。

例如,如果你修改了一个组件的样式,HMR 只会更新这个组件的样式,而页面上的其他部分和用户输入都会保持不变。这就像你直接在浏览器开发者工具(DevTools)中修改样式一样。

4. 快速看到效果

HMR 可以让你几乎 实时 地看到代码修改后的效果,几乎跟打字的速度一样快。你不再需要等待长时间才能看到结果,这大大提升了开发的互动性和效率。

小结

模块热替换(HMR) 是现代前端开发中的一项非常实用的技术,它极大提升了开发效率。通过 不刷新页面只更新修改的部分保留应用程序状态 ,HMR 能让你在开发过程中 快速验证修改的效果,避免了繁琐的页面重载操作。特别是在调试大型应用和复杂交互时,HMR 让开发者能更专注于修改和优化功能,而不必被频繁的页面重载所拖慢进度。

这种技术不仅仅是 Webpack 独有的,现在许多开发工具和框架都提供了类似的功能,帮助开发者更加高效地工作。

HMR 的基本使用

webpack-dev-server v4.0.0 开始,热模块替换(HMR)功能默认是启用的。

整个结论我们可以从 webpack-dev-server 中的 Server 类中的 initialize() 方法可以得出结论,只需要将 webpack.config.js 中的 hot 设置为 true 即可。

js 复制代码
const path = require("path");
const HtmlWebpackPlugin = require("html-webpack-plugin");

module.exports = {
  entry: "./src/index.js",
  output: {
    filename: "bundle.js",
    path: path.resolve(__dirname, "./dist"),
  },
  mode: "development",
  devServer: {
    hot: true, // 开启模块热替换
  },
  plugins: [
    new HtmlWebpackPlugin({
      template: "./index.html",
    }),
  ],
};

上面配置产生的结果是 Webpack 会为每个模块绑定一个 module.hot 对象,这个对象包含了 HMRAPI。借助这些 API 我们不仅可以实现对特定模块开启或关闭 HMR,也可以添加热替换之外的逻辑。

下面是一些使用的例子:

js 复制代码
// utils.js
export function sum() {
  return 1 + 2;
}

export function multiplication(x, y) {
  return x * y;
}

// index.js
import { multiplication, sum } from "./utils";

console.log(sum());

console.log(multiplication(3, 6));

if (module.hot) {
  module.hot.accept();
}

index.js 作为引用的入口,那么我们就可以把调用 HMR API 的代码放在该入口中,这样 HMR 对于 index.js 和其依赖的所有模块都会生效。当发现有模块发生变动时,HMR 会使用在当前浏览器环境下重新执行一遍 index.js (包括其依赖的内容),但是页面本身不会刷新。

例如当我修改了 index.js 的内容时,浏览器会有以下输出:

而当我们修改了 utils.js 文件的时候,index.js 文件也会跟着更新,因为 index.js 有对该模块的引用:

大多数情况下,还是建议用于的开发者使用第三方提供的 HMR 解决方案,因为 HMR 触发过程中可能会出现很多预想不到的问题,导致模块更新后应用的表现和正常加载的表现不一致。

Webpack 社区还提供了许多其他 loader 和示例,可以使 HMR 与各种框架和库平滑地进行交互:

  • react-refresh:实时编辑 React组件而不会丢失它们的状态;

  • Vue Loader: 此 loader 支持 vue 组件的 HMR,提供开箱即用体验;

  • Elm Hot webpack Loader: 支持 Elm 编程语言的 HMR;

  • Angular HMR: 没有必要使用 loader!直接修改 NgModule 主文件就够了,它可以完全控制 HMR API;

  • Svelte Loader: 此 loader 开箱即用地支持 Svelte 组件的热更新;

HMR 原理

在本地开发环境中,浏览器充当客户端,而 webpack-dev-server 相当于我们的服务端。HMR 的核心原理是浏览器从服务端拉取更新后的资源。但更准确地说,HMR 并不是获取整个资源文件,而是获取 chunk diff ,也就是需要更新的 chunk 部分。

什么是 chunk?:在 Webpack 中,chunk 可以理解为一块打包好的代码块。它就像一个装有多个模块的"文件袋",其中包含着被打包和优化过的各个模块。Webpack 会将这些模块捆绑在一起形成一个或多个 chunk,这些 chunk 就是打包后需要加载的代码块。

具体来说,chunk 是 Webpack 根据配置生成的文件,它是模块的集合,而不是单一的文件。一个项目的打包过程中,可能会产生一个或多个 chunk,具体数量取决于项目的构建配置。

当在终端运行 webpack serve 命令时,webpack-dev-server 会调用 Server 类中的 initialize() 方法。在这个方法里,有一个 compilers.forEach 的循环:

js 复制代码
compilers.forEach((compiler) => {
  this.addAdditionalEntries(compiler);

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

  new webpack.ProvidePlugin({
    __webpack_dev_server_client__: this.getClientTransport(),
  }).apply(compiler);

  // TODO remove after dropping webpack v4 support
  compiler.options.plugins = compiler.options.plugins || [];

  if (this.options.hot) {
    const HMRPluginExists = compiler.options.plugins.find(
      (p) => p.constructor === webpack.HotModuleReplacementPlugin
    );

    if (HMRPluginExists) {
      this.logger.warn(
        `"hot: true" automatically applies the HMR plugin, 
	you don't need to add it manually in your webpack configuration.`
      );
    } else {
      // Apply the HMR plugin
      const plugin = new webpack.HotModuleReplacementPlugin();
      plugin.apply(compiler);
    }
  }
});

在这个循环中,addAdditionalEntries() 方法被调用,主要是将 WebSocket 所需的参数拼接到 client/index.jsrequire() 请求路径中:

js 复制代码
additionalEntries.push(
  `${require.resolve("../client/index.js")}?${webSocketURLStr}`
);

接着,将 webpack/hot/dev-server 的请求路径也存入 additionalEntries 数组,最后通过 webpack.EntryPlugin 把它们加入到模块请求的 hooks 中:

js 复制代码
if (typeof webpack.EntryPlugin !== "undefined") {
  for (const additionalEntry of additionalEntries) {
    new webpack.EntryPlugin(compiler.context, additionalEntry, {
      // eslint-disable-next-line no-undefined
      name: undefined,
    }).apply(compiler);
  }
}

webpack.EntryPlugin 主要用于在编译时将一个新的入口 chunk 添加到编译过程中。它会在 compilermake hook 被调用时执行,并使用 compilation.addEntry 方法将其添加到编译中,接着通过调用 handleModuleCreate 函数来开始构建过程。

我们再来看 initialize() 方法中的一段代码 new webpack.HotModuleReplacementPlugin,其主要作用是当 hot 设置为 true 时,将 HMR 插件挂载到 plugin 上,从而启用模块热替换功能。具体操作如下:

那么我们再看看 webpack-dev-server/client/index.js 文件做了些什么?

hot 方法中,客户端接收服务器端发送的 hot 指令,从而开启模块热替换(HMR)模式;

hash 方法中,客户端接收 Webpack 每次编译生成的最新 hash,并将从 sendStats 发送过来的 hash 值保存下来,这个 hash 将在后续的模块热更新中使用。

在 hash 方法中,当 hash 消息发送完成后,socket 还会发送一次 ok 的信息告知 webpack-dev-server,第一次编译不需要执行 reloadApp,因为第一次全部编译的代码并没有额外的代码需要更新。

webpack-dev-server/client/index.js 当接收到 typehash 消息后会将 hash 值暂存起来,当接收到 typeok 的消息后对应用执行 reload 操作,如下图所示,hash 消息是在 ok 消息之前:

那么我们看看 reloadApp 内部都干了些啥?这里主要做的事情是通过 hotEmitter.emit 触发 webpackHotUpdate 事件:

我们再通过浏览器控制台查看,当我们修改了代码之后会触发这个方法,代码中的 log.info() 的内容会输出在浏览器控制台上:

hash 值发生变化时,Webpack 会监听到浏览器端的 webpackHotUpdate 消息。在这里,webpack 的定义文件为 webpack/hot/dev-server.js,它将新的模块 hash 值传递给客户端的 HMR 核心部分,具体位置在 webpack/lib/hmr/HotModuleReplacement.runtime.js。这个文件的主要功能我们稍后会详细探讨。

dev-server 文件中,关键的 check 方法用于检测更新,并判断是触发浏览器刷新还是执行模块热替换。如果是浏览器刷新,window.location.reload() 会被自动调用;如果代码发生错误,也会调用 window.location.reload()。最后,HMR 会重新启动。

当监听到文件变化,dev-server 可以监听到哪个文件发生变化,当我们修改 utils.js 文件时,index.js 的内容也要改变,所以会有以下输出:

check 代码如下图所示:

我们再来看看 HotModuleReplacement.runtime.js 这个文件主要做的事情是什么?

这个文件的主要功能是 hotCheck 方法,首先我们来看看 $hmrDownloadManifest$ 是个什么东西,我们在 hotCheck 方法打印一下:

js 复制代码
console.log($hmrDownloadManifest$);

通过查看控制台,返回的主要有以下内容:

js 复制代码
__webpack_require__.hmrM = () => {
  if (typeof fetch === "undefined")
    throw new Error("No browser support: need fetch API");
  return fetch(__webpack_require__.p + __webpack_require__.hmrF()).then(
    (response) => {
      if (response.status === 404) return; // no update available
      if (!response.ok)
        throw new Error(
          "Failed to fetch update manifest " + response.statusText
        );
      return response.json();
    }
  );
};

其中 __webpack_require__.p 的值为 http://localhost:8080/,而 __webpack_require__.hmrF() 有以下的定义:

js 复制代码
__webpack_require__.hmrF = () =>
  "main." + __webpack_require__.h() + ".hot-update.json";

__webpack_require__.h = () => "376a09b5cf1d64e19a6d";

__webpack_require__.h 中的字符串正是当前返回的 hash 值,全部拼接到一起就是 [name].[hash].hot-update.json,所以 hotCheck 的主要功能是提供客户端 HMR 运行时下载发生变化的 chunk 文件,将最新代码加载到本地:

我们通过打印,hotCheck 最终返回的结果是以下这样的结果:

js 复制代码
__webpack_require__.hmrC.jsonp = function (
  chunkIds,
  removedChunks,
  removedModules,
  promises,
  applyHandlers,
  updatedModulesList
) {
  applyHandlers.push(applyHandler);
  currentUpdateChunks = {};
  currentUpdateRemovedChunks = removedChunks;
  currentUpdate = removedModules.reduce(function (obj, key) {
    obj[key] = false;
    return obj;
  }, {});
  currentUpdateRuntime = [];
  chunkIds.forEach(function (chunkId) {
    if (
      __webpack_require__.o(installedChunks, chunkId) &&
      installedChunks[chunkId] !== undefined
    ) {
      promises.push(loadUpdateChunk(chunkId, updatedModulesList));
      currentUpdateChunks[chunkId] = true;
    } else {
      currentUpdateChunks[chunkId] = false;
    }
  });
  if (__webpack_require__.f) {
    __webpack_require__.f.jsonpHmr = function (chunkId, promises) {
      if (
        currentUpdateChunks &&
        __webpack_require__.o(currentUpdateChunks, chunkId) &&
        !currentUpdateChunks[chunkId]
      ) {
        promises.push(loadUpdateChunk(chunkId));
        currentUpdateChunks[chunkId] = true;
      }
    };
  }
};

在模块热替换的过程中,还有一个重要的步骤就是在更新之前会删除过期的模块和依赖,这个功能的代码为 webpack/lib/hmr/JavascriptHotModuleReplacement.runtime.js:

这些代码的主要功能是移除缓存中的模块,移除过期依赖中不需要使用的处理方法,以及移除所有子元素的引用和模块子组件中过时的依赖项。

处理完这个步骤之后,将新模块代码添加到 modules 中,当下次调用 __webpack-require__ 方法的时候,就是获取到了新的模块代码了:

一张图带你彻底了解 HMR 更新原理

首先我们借助一张流程图,如下所示:

接下来我们简单地讲解一下这个执行的流程。

步骤 1:Webpack 开始编译

Webpack 是一个模块化打包工具,它的主要任务是将项目中的各个模块(比如 JavaScript 文件、CSS 文件、图片等)打包成浏览器能够加载的文件。当你启动 Webpack 并运行 webpack servewebpack-dev-server 时,Webpack 会进入 监听模式,它开始监视文件的变化。如果文件内容发生了变化,Webpack 会重新编译这些文件。

  • 文件变化监控:Webpack 会自动监听你指定的文件夹(通常是源码目录),当文件内容有任何修改时,Webpack 会触发重新编译。这时,Webpack 会打包出一个新的构建版本,并准备将其发送给浏览器。

步骤 2:Webpack-dev-server 和 Webpack-dev-middleware

当 Webpack 完成编译后,webpack-dev-serverwebpack-dev-middleware 就会发挥作用。它们充当 Webpack 和浏览器之间的中介,负责将 Webpack 打包生成的文件发送到浏览器。

  • webpack-dev-server:是一个开发服务器,通常用于开发阶段。它会启动一个本地服务器来提供文件的服务,并且会监听文件的变化。当有文件变化时,它会自动刷新浏览器或使用热模块替换更新页面内容。
  • webpack-dev-middleware:是一个用于将 Webpack 构建结果与 Express 等服务器框架结合的中间件。它同样会提供文件服务,并实现实时更新。

步骤 3:文件/模块变化

当文件发生变化,Webpack 会检测到这些变化,并重新编译更新后的代码。然后,webpack-dev-server 会通过 WebSocket 将更新的信息推送给浏览器。这个过程是实时的,并且只会推送那些发生变化的模块。

  • WebSocket :WebSocket 是一种全双工通信协议,允许客户端和服务器之间进行实时、双向的数据传输。在这个步骤中,webpack-dev-server 会通过 WebSocket 向浏览器发送更新的通知,告诉它哪些模块发生了变化。

步骤 4:SockJs

SockJs 是 WebSocket 的一种实现,它确保了浏览器和服务器之间的实时通信。在 Webpack 中,webpack-dev-server 通过 SockJs 来保证客户端和服务器之间的数据流畅传输。SockJs 会在浏览器和服务器之间建立稳定的连接,确保即使在一些网络环境不稳定的情况下,数据也能顺利地传输。

  • SockJs 的作用:它为 WebSocket 提供了一种稳定的替代方案,确保浏览器端能够接收到从服务端推送的更新通知。

步骤 5:webpack/hot/dev-server

这个模块是处理 热模块替换(HMR) 的关键。它会把模块更新的信息从 webpack-dev-server 发送到浏览器,让浏览器知道哪些模块发生了变化,进而决定哪些模块需要更新。它是整个 HMR 流程的核心部分。

  • 热模块替换:是 Webpack 提供的一个功能,能够在不刷新整个页面的情况下,仅更新变化的模块。这样,页面上的状态(例如表单数据、滚动位置等)不会丢失,用户体验更好。

步骤 6:HotModuleReplacement Runtime

HotModuleReplacement.runtime 是在浏览器端运行的核心模块。它负责接收来自 webpack-dev-server 的更新通知,并执行相应的模块替换操作。当它接收到来自服务器的更新信息时,它会立即在浏览器中替换掉更新的模块,而不刷新整个页面。

  • 为什么不刷新页面:这正是 HMR 的优势。它能在不刷新页面的情况下,动态替换更新过的模块。比如你修改了一个 React 组件,HMR 会直接替换掉这个组件的模块,而不重新加载整个页面。

步骤 7:JsonpMain Template Runtime

在这个步骤中,Webpack 会使用模板来处理与模块更新相关的内容,主要包括加载清单文件(manifest)和模块的映射。Webpack 通过 JSONPAjax 请求从服务器获取这些内容,并动态加载需要更新的模块。

  • Manifest 文件:是 Webpack 打包后的模块清单,包含了所有模块的映射关系。浏览器用这个清单来查找并加载需要更新的模块。

步骤 8:Manifest 和 Ajax

浏览器会请求 Manifest 文件,它是一个 JSON 文件,记录了 Webpack 打包过程中生成的模块和对应的文件名。通过这个文件,浏览器能够确认哪些模块需要更新。

  • Ajax 请求:Ajax 用于从服务器异步获取更新的模块信息。Webpack 会通过 Ajax 请求 Manifest 文件,并获取更新后的模块列表。

步骤 9:Modules 和 JSONP

在这一步,浏览器通过 JSONPAjax 获取到更新的模块,并将其加载到页面上。具体来说,浏览器会把新模块和现有模块合并,替换掉过时的内容。

  • JSONP:是一种跨域加载数据的技术,Webpack 利用 JSONP 来动态加载模块,确保模块能够跨域更新。

步骤 10:App 更新

最后,一旦新的模块加载完成,浏览器就会更新页面上的内容,展示最新的效果。这个过程发生得非常迅速,用户几乎不需要等待,页面的状态会实时更新。

  • 热替换效果:在 HMR 完成后,页面的内容会立即显示最新的修改,且无需刷新页面。这样,开发者可以在修改代码的同时,实时查看修改后的效果,而不必中断开发流程。

这个过程展示了 Webpack 在开发过程中如何高效地实时更新代码。当你修改文件时,Webpack 会重新编译,并通过 webpack-dev-server 将更新的模块推送到浏览器,而 不需要刷新整个页面。通过 HMR,Webpack 实现了局部更新,保留了页面的状态,极大地提高了开发效率。

总结

模块热替换(HMR)是 Webpack 提供的一项功能,允许在开发过程中实时更新修改过的模块,而无需刷新整个页面。它通过 WebSocket 或其他通信协议与浏览器保持连接,检测到文件变化后,立即将更新的模块传递给浏览器,并替换掉旧的模块。这样不仅提高了开发效率,还保留了页面的状态,避免了页面重载带来的中断。总的来说,HMR 使得前端开发更加高效、流畅。

相关推荐
weifexie29 分钟前
ruby可变参数
开发语言·前端·ruby
千野竹之卫31 分钟前
3D珠宝渲染用什么软件比较好?渲染100邀请码1a12
开发语言·前端·javascript·3d·3dsmax
sunbyte31 分钟前
初识 Three.js:开启你的 Web 3D 世界 ✨
前端·javascript·3d
半兽先生1 小时前
WebRtc 视频流卡顿黑屏解决方案
java·前端·webrtc
南星沐2 小时前
Spring Boot 常用依赖介绍
java·前端·spring boot
孙_华鹏3 小时前
手撸一个可以语音操作高德地图的AI智能体
前端·javascript·coze
zhangxingchao3 小时前
Jetpack Compose 动画
前端
@PHARAOH3 小时前
HOW - 缓存 React 自定义 hook 的所有返回值(包括函数)
前端·react.js·缓存
拉不动的猪3 小时前
设计模式之--------工厂模式
前端·javascript·架构
前端开发张小七3 小时前
16.Python递归详解:从原理到实战的完整指南
前端·python