Webpack是怎么实现热更新的?涉及那些模块?

在文章的开头,我想先抛出一些问题:

  1. webpack的热更新功能,需要哪些模块的支持?
  2. 一次热更新周期中,各个模块都做了哪些事情?
  3. webpack中的watchpatch模块做了什么事情?
  4. webpack-dev-server 和 webpack 中的 watchpack 是如何一起工作的?
  5. webpack一般会将构建的产物写入文件系统,但webpack-dev-server是将构建后的内容存在内存里了,这个是怎么实现的?(memfs)
  6. webpack-dev-server是将构建后的内容存在内存里了,我如何能看到构建后的产物呢?(好像不能,官方的做法是提供了一个writeToDisk相当于内存和磁盘里面写了两遍)
  7. 为什么要推荐react-refresh来替换react-hot-reload呢?支持hooks?
  8. react-refresh和@pmmmwh/react-refresh-webpack-plugin分别做了什么事情来支持React的热更新?
  9. 为什么某些情况下,我修改了文件内容,页面上的某个表单内容并没有保留?
  10. 为什么某些情况下,我的页面甚至直接刷新了?

Webpack实现热更新依赖的几个核心模块

  1. watchpack : webpack内置的watching能力,基于EventEmitter。当文件内容发生变化时,通知webpack触发重新构建。
  2. webpack-dev-server: 一个基于Express的服务,托管静态资源,并且通过WebSocket与客户端建立连接。HotModuleReplacementPlugin是webpack-dev-server内置的插件
  3. HotModuleReplacementPlugin : 向应用的主Chunk注入一系列热更新用的Runtime代码,用于建立WebSoket连接,处理hash消息、加载热更新的资源、提供用于处理更新策略的 module.hot.accept 接口
  4. react-refresh:react官方提供给react-native的hmr,由于其核心实现与平台无关,所以也适用于 Web。
  5. @pmmmwh/react-refresh-webpack-plugin : 这个插件通过 Webpack 的构建流程,使 react-refresh 的功能能够与 Webpack 构建集成。它会在 Webpack 配置中添加必要的插件和加载器,以便启用 React 组件的热更新。

热更新主流程

  1. **webpack-dev-server **中的webpack-dev-middleware会开启webpack 内置的watch模式,监听文件变化
  2. 同时 webpack-dev-server 中的 HotModuleReplacementPlugin 插件(webpack插件)会向应用的主 Chunk 注入一系列 HMR Runtime
  3. 当文件发生变动 webpack 的watch模块监听到文件变化触发webpack的重新构建
  4. webpack-dev-server 监听到webpack的this.compiler.hooks.done事件
  5. webpack-dev-server 在构建完成的回调中通过 WebSocket 向客户端发送模块hash消息
  6. 客户端的HMR Runtime在收到hash消息之后,发出mainfest请求获取本轮热更新涉及的chunk
  7. 客户端HMR Runtime开始下载变化的文件
  8. HMR Runtime触发变更模块的 module.hot.accept 回调
  9. 在module.hot.accept 中声明了如何将模块安全地替换为最新代码,替换的逻辑比较复杂,幸运的是大部分webpack loader已经帮我们做了这些事情,比如:
    • style-loader 内置 Css 模块热更
    • vue-loader 内置 Vue 模块热更

我们结合源代码来看一下整个过程

  1. 我们使用webpack构建我们的react项目、并且使用webpack-dev-server来托管我们的静态资源
js 复制代码
const webpack = require('webpack');
const WebpackDevServer = require('webpack-dev-server');
// Create a webpack compiler that is configured with custom messages.
const compiler = createCompiler({
  appName,
  config,
  urls,
  useYarn,
  useTypeScript,
  webpack,
});
...
// webpack-dev-server的配置
const serverConfig = {
  ...createDevServerConfig(proxyConfig, urls.lanUrlForConfig),
  host: HOST,
  port,
};

// 创建webpack-dev-server实例的时候,需要把webpack的compiler的示例传进去,这样才能共享一些状态,做一些联动
const devServer = new WebpackDevServer(serverConfig, compiler);
  1. webpack-dev-server内置的HotModuleReplacementPlugin插件会向应用的主 Chunk 注入一系列 HMR Runtime(热更新所需要的一些代码)

这是 HotModuleReplacementPlugin 向chunk注入runtime代码的源代码:

js 复制代码
// node_modules/webpack/lib/HotModuleReplacementPlugin.js
compilation.hooks.additionalTreeRuntimeRequirements.tap(
  PLUGIN_NAME,
  // 在这里添加runtime代码
  (chunk, runtimeRequirements) => {
    // 这个方法用来请求 mainfest 文件
    runtimeRequirements.add(RuntimeGlobals.hmrDownloadManifest);
    // 这个方法用来修改下载修改的文件
    runtimeRequirements.add(RuntimeGlobals.hmrDownloadUpdateHandlers);
    runtimeRequirements.add(RuntimeGlobals.interceptModuleExecution);
    runtimeRequirements.add(RuntimeGlobals.moduleCache);
    compilation.addRuntimeModule(
      chunk,
      new HotModuleReplacementRuntimeModule()
    );
  }
);

注入代码之后的效果:

  1. 通过调试,可以看到webpack-dev-server → webpack-dev-middleware 在启动的时候调用了webpack构建实例的watch方法: compiler.watch

这里也解释了webpack-dev-server和webpack的watch是如何一起工作的

  1. compiler的watching会创建一个Watching的实例,而这个Watching,最终使用的是watchpack,我们重点来看这个watchpack

调用链路:compiler.watching(Watching实例) → compiler.watchFileSystem → NodeWatchFileSystem → Watchpack

js 复制代码
// node_modules/webpack/lib/Compiler.js
watch(watchOptions, handler) {
  if (this.running) {
    return handler(new ConcurrentCompilationError());
  }

  this.running = true;
  this.watchMode = true;
  // compiler上有个watching属性,是Watching的实例
  this.watching = new Watching(this, watchOptions, handler);
  return this.watching;
}


// node_modules/webpack/lib/Watching.js
// Watching中有个watcher方法是用了 compiler.watchFileSystem.watch
this.watcher = this.compiler.watchFileSystem.watch(
	// ... 
);

// lib/node/NodeEnvironmentPlugin.js
// watchFileSystem 又是 NodeWatchFileSystem的实例
compiler.watchFileSystem = new NodeWatchFileSystem(
  compiler.inputFileSystem
);

// lib/node/NodeWatchFileSystem.js
// NodeWatchFileSystem的watcher方法又是 WatchPack的实例,有点绕。
const Watchpack = require("watchpack");
// ...
class NodeWatchFileSystem {
  // ...
  this.watcher = new Watchpack(options);
}
  1. watchpack是一个基于发布订阅的模块,继承自 events 模块的 EventEmitter,在实例化的时候会注册changeaggregated事件。一旦监听到文件的变化,就会执行回调,触发webpack的重新编译(看下面代码)。

为什么是 aggregated 事件呢?由于修改操作可能在同一时间触发多次,所以将这些操作聚合一下

js 复制代码
this.watcher.once("aggregated", (changes, removals) => {
  // 暂停发出事件,避免在超时时清除已聚合的更改和删除
  this.watcher.pause();

  // 如果存在输入文件系统并支持清除操作,执行清除操作
  if (this.inputFileSystem && this.inputFileSystem.purge) {
    const fs = this.inputFileSystem;
    for (const item of changes) {
      fs.purge(item);
    }
    for (const item of removals) {
      fs.purge(item);
    }
  }

  // 获取文件和目录的时间信息
  const { fileTimeInfoEntries, contextTimeInfoEntries } = fetchTimeInfo();

  // 调用用户提供的回调函数
  callback(
    null,
    fileTimeInfoEntries,
    contextTimeInfoEntries,
    changes,
    removals
  );
});

// node_modules/webpack/lib/Watching.js 
// callback 回调函数最终执行到了这里,触发了webpack的重新编译
_go(fileTimeInfoEntries, contextTimeInfoEntries, changedFiles, removedFiles) {
  this.compiler.hooks.watchRun.callAsync(this.compiler, err => {
				if (err) return this._done(err);
				const onCompiled = () => {};
				// 执行编译
				this.compiler.compile(onCompiled);
	});
}
  1. 当webpack重新编译完成,webpack-dev-server监听到this.compiler.hooks.done事件后,会通过WebSocket向客户端发送消息
js 复制代码
// node_modules/webpack-dev-server/lib/Server.js
this.compiler.hooks.done.tap(
  "webpack-dev-server",
  /**
   * @param {Stats | MultiStats} stats
   */
  (stats) => {
    if (this.webSocketServer) {
      this.sendStats(this.webSocketServer.clients, this.getStats(stats));
    }

    /**
     * @private
     * @type {Stats | MultiStats}
     */
    this.stats = stats;
  }
);
  1. 客户端收到webpack-dev-server发送过来的ok事件后,会向服务端请求main.48ce033e1a20d1c2a793.hot-update.json 文件,确认需要重新加载的范围

为什么webpack重新构建完成之后,除了会向client发送hash事件之后,还会向client发送一个ok事件?

webpack-dev-server在重新构建完成后发送"ok"事件的目的是通知客户端构建过程已经完成且没有出现错误或警告。这个事件是作为一种确认机制,用于确保客户端知道它所依赖的资源已经准备好,可以继续工作。

  1. main.48ce033e1a20d1c2a793.hot-update.json返回修改范围,客服端的Runtime代码开始下载变化的文件
  1. 这时候,客服端已经成功拿到了最新构建的内容,那么如何将更新的内容应用到页面上呢?一般都是由loader来进行处理的(react没有提供这种能力,需要使用react-refresh)

    拿css文件为例,style-loader在处理文件的时候,会往编译产物中添加module.hot.accept的相关代码,当该文件发生变化的时候,会触发回调,在回调中更新css,以下是style-loader注入热更新相关的代码:

js 复制代码
// style-loader/src/utils.js
function getStyleHmrCode(esModule, loaderContext, request, lazy) {
  const modulePath = stringifyRequest(loaderContext, `!!${request}`);

  return `
if (module.hot) {
  if (!content.locals || module.hot.invalidate) {
    var isEqualLocals = ${isEqualLocals.toString()};
    var isNamedExport = ${esModule ? "!content.locals" : false};
    var oldLocals = isNamedExport ? namedExport : content.locals;

    module.hot.accept(
      ${modulePath},
      function () {
        ${
          esModule
            ? `if (!isEqualLocals(oldLocals, isNamedExport ? namedExport : content.locals, isNamedExport)) {
                module.hot.invalidate();

                return;
              }

              ...

              ${
                lazy
                  ? `if (update && refs > 0) { update(content); }`
  // 最终会走到 update 里面会重新加载最新的css文件,实现热更新
                  : `update(content);`
              }`
        }
      }
    )
  }

  module.hot.dispose(function() {
    ${
      lazy
        ? `if (update) {
            update();
          }`
        : `update();`
    }
  });
}
`;
}

至此,一次完整的热更新流程就结束了。

回答上述问题

  1. webpack的热更新功能,需要哪些模块的支持?

    watchpack监听文件变化触发重新编译、webpack-dev-server提供静态资源服务器和客户端通信、各种loader在拿到最新的资源后自行执行更新逻辑

  2. 一次热更新周期中,各个模块都做了哪些事情?

    参考上述的热更新主流程

  3. webpack中的watchpatch模块做了什么事情?

    监听文件变化,触发webpack重新编译

  4. webpack-dev-server 和 webpack 中的 watchpack 是如何一起工作的?

    webpack-dev-server在启动的时候,内置的webpack-dev-middleware会启用compiler的watch功能

  5. webpack一般会将构建的产物写入文件系统,但webpack-dev-server是将构建后的内容存在内存里了,这个是怎么实现的?

    webpack-dev-server

  6. webpack-dev-server是将构建后的内容存在内存里了,我如何能看到构建后的产物呢?(好像不能,官方的做法是提供了一个writeToDisk相当于内存和磁盘里面写了两遍)

  7. 为什么要推荐react-refresh来替换react-hot-reload呢?支持hooks?

  8. react-refresh和@pmmmwh/react-refresh-webpack-plugin分别做了什么事情来支持React的热更新?

  9. 为什么某些情况下,我修改了文件内容,页面上的某个表单内容并没有保留?

  10. 为什么某些情况下,我的页面甚至直接刷新了?

参考资料

一分钟用上热更新 React Fast Refresh(react-refresh)

How should we set up apps for HMR now that Fast Refresh replaces react-hot-loader? #16604

相关推荐
SunTecTec24 分钟前
Flink Docker Application Mode 命令解析 - 修改命令以启用 Web UI
大数据·前端·docker·flink
拉不动的猪1 小时前
前端常见数组分析
前端·javascript·面试
小吕学编程2 小时前
ES练习册
java·前端·elasticsearch
Asthenia04122 小时前
Netty编解码器详解与实战
前端
袁煦丞2 小时前
每天省2小时!这个网盘神器让我告别云存储混乱(附内网穿透神操作)
前端·程序员·远程工作
一个专注写代码的程序媛3 小时前
vue组件间通信
前端·javascript·vue.js
一笑code3 小时前
美团社招一面
前端·javascript·vue.js
懒懒是个程序员4 小时前
layui时间范围
前端·javascript·layui
NoneCoder4 小时前
HTML响应式网页设计与跨平台适配
前端·html
凯哥19704 小时前
在 Uni-app 做的后台中使用 Howler.js 实现强大的音频播放功能
前端