Webpack 模块热替换

何为 HMR?

模块热替换(HMR - Hot Module Replacement)功能会在应用程序运行过程中替换、添加或删除模块,而无需重新加载整个页面

当我们对代码进行修改并保存后,webpack 将对代码重新打包,并将改动的模块发送到浏览器,浏览器通过新的模块替换老的模块,从而实现局部更新且不需要刷新页面

为何需要 HMR?

在 HMR 出现之后,程序的加载都是页面级别的,即使是单个文件的发生改变,都需要刷新整个页面才能够获得最新的代码,且在此之前的数据都会丢失。

当我们遇到如下情况的时候

  • 分步表单,意味着一次更改我们需要填写很多的数据
  • 弹窗信息,意味着必须重新执行弹窗交互

再细小的操作,更新样式文件、备注信息等等操作都需要刷新页面重新加载执行,极大的影响了开发效率。引入 HMR 能够将这些细小的更改通过模块热替换的方式更新到页面上,从而提升开发的效率。

如何使用 HMR?

在 webpack 的配置中,针对于 devServer 配置hot:true

// webpack.config.js
module.exports = {
  // ...
  devServer: {
    // 必须设置 devServer.hot = true,启动 HMR 功能
    hot: true
  }
};

在代码里面需要配置module.hot.accept接口,声明如何将模块安全地替换为最新代码

if (module.hot) {
  module.hot.accept(['./hello.js'], () => {
    render();
  });
}
// 注册后的效果
// hot._acceptedDependencies['./src/title.js'] = render

webpack 编译构建流程

// 项目目录
-hello.js - index.js - package.json - webpack.config.js;

const config = {
  entry: './index.js', // 入口文件
  output: {
    filename: 'bundle.js', // 输出文件名
    path: path.resolve('dist'), // 输出目录
  },
  plugins: [new HtmlWebpackPlugin()],
  mode: 'development', // 设置为开发模式
  stats: {
    modules: false, // 不输出模块信息
    hash: true, // 输出编译的 hash 值
  },
};

做好相关的配置之后,我们启动项目后,能够通过控制台发现生成了一个 hash 值,且通过浏览器打开网站之后,能够发现 websocket 中也传递了{type: "hash", data: "d76e2c3053202b29bf20"}对应的 hash 值

image.png

image.png

我们更新文件,触发新的编译,控制台中也会更新对应的数据

image.png

能够发现生成了新的hash值,且生成了[hash].hot-update.json/[hash].hot-update.js新的文件,文件上的hash值是上一次生成的hash值。

根据新生成文件名可以发现,上次输出的hash值会作为本次编译新生成的文件标识。依次类推,本次输出的hash值会被作为下次热更新的标识。

image.png

通过浏览器可以看到一次更新之后,会请求对应的[hash].hot-update.json/[hash].hot-update.js文件

image.png

  • c: 描述哪些 chunk 包含在此次更新中
  • r: 指示是否需要重新加载 Webpack runtime 代码
  • m: 列出本次更新中被修改的模块及其对应的新代码

image.png

热更新实现的原理

webpack-dev-server 启动本地服务

上述的 webpack 配置代码,我们通过webpack-dev-server启动代码

// package.json
{
  "scripts": {
    "dev": "webpack-dev-server",
    "build": "webpack"
  }
}

所有的命令行可以在对应项目的package.jsonbin命令中找到对应的入口文件

{
  "name": "webpack-dev-server",
  "bin": "bin/webpack-dev-server.js"
}

执行 pnpm dev 之后大致的流程(简易版本)

image.png

setupApp() {
	// 依赖了express
  this.app = new (memoize(() => require("express")))();
}

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

createWebSocketServer() {
	// 启动express服务后,启动websocket服务
	this.webSocketServer = new (require("./servers/WebsocketServer"))(this);
}

在整个启动本地服务时,涉及到的仓库很多,重点都在new Server()之后的操作

  • new Server之前会先启动webpack,生成compiler实例。compiler上有很多方法,比如可以启动webpack所有编译工作,以及监听本地文件的变化
  • 使用express启动本地服务,使得浏览器可以访问本地的静态资源
  • 本地server启动成功之后再去创建websocket服务,建立本地服务和浏览器的双向通信

修改 entry 配置

在我们启动本地服务之前,代码中修改了entry入口,自动注入了websocket客户端代码和热更新替换的代码

在进入start阶段的时候会调用initialize方法

image.png

client/index.jswebsocket客户端的代码,因为websocket是双向通信,上一步通过createServer是创建的本地服务端的websocket代码,还需要客户端代码,因此需要把客户端websocket代码塞到代码中

hot/dev-server.js主要用于检查更新逻辑

监听 webpack 编译结束

当修改完entry入口之后,会执行setupHooks方法,注册监听事件,监听webpack编译完成

setupHooks() {
	// 监听 webpack 的done hook,tapable 实现
  this.compiler.hooks.done.tap(
    "webpack-dev-server",
    (stats) => {
      if (this.webSocketServer) {
        this.sendStats(this.webSocketServer.clients, this.getStats(stats));
      }
      this.stats = stats;
    },
  );
}

sendStats(clients, stats, force) {
  this.currentHash = stats.hash;
  this.sendMessage(clients, "hash", stats.hash);

  if ((stats.errors).length > 0 ||(stats.warnings).length > 0) {
    const hasErrors = (stats.errors).length > 0;

    if ((stats.warnings).length > 0) {
      let params;
      if (hasErrors) {
        params = { preventReloading: true };
      }
      this.sendMessage(clients, "warnings", stats.warnings, params);
    }
    if ((stats.errors).length > 0) {
      this.sendMessage(clients, "errors", stats.errors);
    }
  } else {
    this.sendMessage(clients, "ok");
  }
}

每当webpack编译完成就会出发donehook,从而调用sendStats方法通过websocket给浏览器发送消息,hash/ok事件,浏览器能够拿到最新的hash值,检查更新逻辑

监听文件变化

每次文件发生变化之后,都需要触发文件编译,那么久还需要监听文件发生改变。该操作主要是通过webpack-dev-middleware库实现的。

start函数中,会执行setupDevMiddleware方法,该方法主要是执行webpack-dev-middleware库的。