likes
comments
collection
share

DevServer与模块热替换之间的爱恨情仇💘以及HMR原理

作者站长头像
站长
· 阅读数 56

在前面的文章中讲到了如何开启 webpack-dev-server 以及 npm start 背后的原理,那么这篇文章我们将来讲解一下 HMR(模块热替换),上一篇文章地址 在终端里输入 npm start 后都发生了啥😮😮😮

模块热替换

在开始之前,首先我们应该了解一下什么是 模块热替换

在早期开发工具还比较简单和匮乏的年代,调试代码的方式基本都是改代码一刷新网页查看结果--然后再改代码,这反复地修改和测试。后来,一些Web开发框架和工具提供了更便捷的方式,就是只要检测到代码改动就会自动重新构建,然后触发网页刷新。

这种一般称为 live reload,Webpack 则在 live reload 的基础上又进了一步,可以让代码在网页不刷新的前提下得到最新的改动,甚至可以让我们不需要拍重新发起请求就能看到更新后的效果。这个就是模块热替换 (Hot Module Replacement) 功能。

HMR 对于大型应用尤其适用。试想一个辅助的系统每改动一个地方都要经历资源重新构建、网络请求、浏览器重新渲染等过程,怎么也要爱几秒甚至几十秒的时间才能完成。况且我们调试的页面可能位于很深的层级,每次都要通过一些人为操作才能验证结果,其效率是非常低下的。

HMR 功能会在应用程序运行过程中,替换、添加或删除模块,而无需重新加载整个页面,主要是通过以下几种方式,来显著加快开发速度:

  • 保留在完全重新加载页面期间丢失的应用程序状态;
  • 只更新变更内容,以节省宝贵的开发时间;
  • 在源代码中 CSS/JS 产生修改时,会立刻在浏览器中进行更新,这几乎相当于在浏览器 devtools 直接更改样式;

HMR 的基本使用

从 webpack-dev-server v4.0.0 开始,热模块替换是默认开启的。

DevServer与模块热替换之间的爱恨情仇💘以及HMR原理

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

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,也可以添加热替换之外的逻辑。

下面是一些使用的例子:

// 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 的内容时,浏览器会有以下输出:

DevServer与模块热替换之间的爱恨情仇💘以及HMR原理

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

DevServer与模块热替换之间的爱恨情仇💘以及HMR原理

大多数情况下,还是建议用于的开发者使用第三方提供的 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 的概念如下解释:

chun 字面的意思是代码亏啊,在 Webpack 中可以理解成被抽象和包装后的一些模块。它就像一个装着很多文件的文件袋,里面的文件就是各个模块,Webpack 在外面加了一层包裹,从而形成了 chunk,根据具体配置不同,一个工程打包时可能会产生一个或多个 chunk。

当在终端运行 webpack serve 的命令后,webpack-dev-server 会调用 Server 类中的 initialize() 方法(在上一篇文章有讲到),在这里面有一个 compiler,forEach 的语句:

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 drop 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 HMR plugin, you 
	don't have to add it manually to your webpack configuration.`
     );
   } else {
     // Apply the HMR plugin
     const plugin = new webpack.HotModuleReplacementPlugin();
     plugin.apply(compiler);
   }
 }
});

并且在每一个循环中都调用了 addAdditionalEntries() 方法,这里主要做的是将 websocket 需要的一些参数组装拼接到 client/index.jsrequire() 请求路径中:

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

随后又将 webpack/hot/dev-server 的请求存入 additionalEntrires 中,最后调用 webpack.EntryPlugin 将他们存入到模块请求 hooks 中:

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 被调用时再执行 compilationaddEntry 方法,然后又电泳 handleModuleCreate 函数,正式开始构建内容。

我们再回看 initialize() 方法中有段代码 new webpack.HotModuleReplacementPlugin ,这里的主要作用是如果 hot 设置为 true 就把 HMR 挂载到 plugin 上,开启模块热替换,具体代码如下操作:

DevServer与模块热替换之间的爱恨情仇💘以及HMR原理

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

DevServer与模块热替换之间的爱恨情仇💘以及HMR原理

hot 方法中,client端接收server端发送 hot 指定,开启HMR模式; 在 hash 方法中,接收 webpack 每次编译的最新 hash,浏览器端将 sendStats 发送过来的 hash 值保存下来,它将会用到后来的模块热更新;

DevServer与模块热替换之间的爱恨情仇💘以及HMR原理

DevServer与模块热替换之间的爱恨情仇💘以及HMR原理

DevServer与模块热替换之间的爱恨情仇💘以及HMR原理

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

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

DevServer与模块热替换之间的爱恨情仇💘以及HMR原理

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

DevServer与模块热替换之间的爱恨情仇💘以及HMR原理

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

DevServer与模块热替换之间的爱恨情仇💘以及HMR原理

hash 值发生变化时,webpack 会监听到浏览器端 webpackHotUpdate 的消息,这里 webpack 的定义文件为 webpack/hot/dev-server.js,将新模块 hash 值传到客户端 HMR 核心中的 HotModuleReplacement.runtime,文件目录为 webpack/lib/hmr/HotModuleReplacement.runtime.js,这个文件主要的功能我们稍后再看,在 dev-server 文件中,主要调用 check 方法检测更新,并判断是浏览器刷新还是模块热更新,如果是浏览器刷新的话,会自动调用 window.location.reload(),如果代码出错,也会调用 window.location.reload(),最后 HMR 重新开启。

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

DevServer与模块热替换之间的爱恨情仇💘以及HMR原理

check 代码如下图所示:

DevServer与模块热替换之间的爱恨情仇💘以及HMR原理

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

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

console.log($hmrDownloadManifest$);

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

__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() 有以下的定义:

__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 文件,将最新代码加载到本地:

DevServer与模块热替换之间的爱恨情仇💘以及HMR原理

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

__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:

DevServer与模块热替换之间的爱恨情仇💘以及HMR原理

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

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

DevServer与模块热替换之间的爱恨情仇💘以及HMR原理

参考文章

总结

本文主要讲解了 HMR 的基本使用,以及讲解了 HMR 的原理,下面通过一张图片来看看 HMR 的整体流程:

DevServer与模块热替换之间的爱恨情仇💘以及HMR原理

总的来说,WDS 不能没有 HMR ,HMR 需要 WDS,天生一对,完美结合😍😍😍

转载自:https://juejin.cn/post/7188086151075332153
评论
请登录