webpack热更新

Hot Module Replacement,简称HMR,无需完全刷新整个页面的同时,更新模块。HMR的好处,在日常开发工作中体会颇深:节省宝贵的开发时间、提升开发体验

在这里简单介绍一个 HMR 的原理

HMR 初始化

webpack-dev-server

Webpack-dev-server 的执行过程,其实也是一个 HMR 开启的过程

  • 修改 webpackOptions,添加两个入口文件,一个是 websocket 客户端代码,一个是热更新客户端代码(主要是用于检查更新逻辑)。

  • 启动webpack,生成compiler实例。compiler上有很多方法,比如可以启动 webpack 所有编译工作,以及监听本地文件的变化。

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

    • 启动 server 的时候,监听 compiler 的 done 事件,当监听到一次webpack编译结束,就会调用_sendStats方法通过websocket给浏览器发送通知,okhash事件,这样浏览器就可以拿到最新的hash值了,做检查更新逻辑
    • 生成 webpack-dev-middleware 中间件实例,保存在 this.middleware(主要是本地文件的编译输出以及监听
  • 本地server启动之后,再去启动websocket服务,通过websocket,可以建立本地服务和浏览器的双向通信。这样就可以实现当本地文件发生变化,立马告知浏览器可以热更新代码啦!

webpack-dev-middleware

文件相关的操作都抽离到webpack-dev-middleware库了,主要是本地文件的编译输出以及监听

主要流程都在下述代码中

1
2
3
4
5
6
7
8
9
share.setOptions(context.options);
share.setFs(context.compiler);

context.compiler.plugin("done", share.compilerDone);
context.compiler.plugin("invalid", share.compilerInvalid);
context.compiler.plugin("watch-run", share.compilerInvalid);
context.compiler.plugin("run", share.compilerInvalid);

share.startWatch();
  • 初始化配置,利用memory-fs库将文件打包到内存中(访问文件系统中的文件更快,而且也减少了代码写入文件的开销)
  • 注册一系列事件。
  • 开启对本地文件的监听,当文件发生变化,重新编译,编译完成之后继续监听

一次完整的 HMR 流程

  1. 当文件发生变化,就触发重新编译。当监听到一次webpack编译结束,_sendStats方法就通过websoket给浏览器发送通知

    1. hash事件,更新最新一次打包后的hash
    2. ok事件,进行热更新检查
    1
    2
    3
    4
    5
    6
    this.sockWrite(sockets, 'hash', stats.hash);
    if (stats.errors.length > 0) {
    this.sockWrite(sockets, 'errors', stats.errors);
    } else if (stats.warnings.length > 0) {
    this.sockWrite(sockets, 'warnings', stats.warnings);
    } else { this.sockWrite(sockets, 'ok'); }

    image-20220111134320575

  2. 客户端接受到 ws 消息后,hash事件更新当前hash值,ok 事件触发hotEmitter

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// webpack-dev-server/client/index.js
var socket = require('./socket');
var onSocketMessage = {
hash: function hash(_hash) {
// 更新currentHash值
status.currentHash = _hash;
},
ok: function ok() {
sendMessage('Ok');
// 进行更新检查等操作
reloadApp(options, status);
},
};
// 连接服务地址socketUrl,?http://localhost:8080,本地服务地址
socket(socketUrl, onSocketMessage);

function reloadApp() {
if (hot) {
log.info('[WDS] App hot update...');

// hotEmitter其实就是EventEmitter的实例
var hotEmitter = require('webpack/hot/emitter');
hotEmitter.emit('webpackHotUpdate', currentHash);
}
}
  1. web-dev-server 插入的客户端的另一个入口文件 webpack/hot/dev-server.js,监听hotEmitter事件,进行热更新检查 check

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    // webpack/hot/dev-server.js
    var check = function check() {
    // 热更新核心代码
    module.hot.check(true)
    .then(function(updatedModules) {
    // 容错,直接刷新页面
    if (!updatedModules) {
    window.location.reload();
    return;
    }
    // 热更新结束,打印信息
    if (upToDate()) {
    log("info", "[HMR] App is up to date.");
    }
    })
    .catch(function(err) {
    window.location.reload();
    });
    };

    var hotEmitter = require("./emitter");
    hotEmitter.on("webpackHotUpdate", function(currentHash) {
    lastHash = currentHash;
    check();
    });
  2. check代码module.hot.checkHotModuleReplacementPlugin.runtime.js

    hotCheck主要做了三件事

    1. 利用上一次保存的hash值,调用hotDownloadManifest发送xxx/hash.hot-update.jsonajax请求;

    2. 请求结果获取热更新模块,以及下次热更新的Hash 标识,并进入热更新准备阶段。

    3. 调用hotDownloadUpdateChunk发送xxx/hash.hot-update.js 请求,通过JSONP方式。

      img

    4. 返回结果后,要立即执行webpackHotUpdate这个方法。

      1
      2
      3
      window["webpackHotUpdate"] = function (chunkId, moreModules) {
      hotAddUpdateChunk(chunkId, moreModules);
      } ;
    5. hotAddUpdateChunk方法会把更新的模块moreModules赋值给全局全量hotUpdate

    6. hotUpdateDownloaded方法会调用hotApply进行代码的替换。

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      function hotAddUpdateChunk(chunkId, moreModules) {
      // 更新的模块moreModules赋值给全局全量hotUpdate
      for (var moduleId in moreModules) {
      if (Object.prototype.hasOwnProperty.call(moreModules, moduleId)) {
      hotUpdate[moduleId] = moreModules[moduleId];
      }
      }
      // 调用hotApply进行模块的替换
      hotUpdateDownloaded();
      }
    7. hotApply 热更新模块替换

      1. 删除过期的模块,就是需要替换的模块

      通过hotUpdate可以找到旧模块

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      var queue = outdatedModules.slice();
      while (queue.length > 0) {
      moduleId = queue.pop();
      // 从缓存中删除过期的模块
      module = installedModules[moduleId];
      // 删除过期的依赖
      delete outdatedDependencies[moduleId];

      // 存储了被删掉的模块id,便于更新代码
      outdatedSelfAcceptedModules.push({
      module: moduleId
      });
      }
      复制代码
      1. 将新的模块添加到 modules 中
      1
      2
      3
      4
      5
      6
      7
      appliedUpdate[moduleId] = hotUpdate[moduleId];
      for (moduleId in appliedUpdate) {
      if (Object.prototype.hasOwnProperty.call(appliedUpdate, moduleId)) {
      modules[moduleId] = appliedUpdate[moduleId];
      }
      }
      复制代码
      1. 通过webpack_require执行相关模块的代码
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      for (i = 0; i < outdatedSelfAcceptedModules.length; i++) {
      var item = outdatedSelfAcceptedModules[i];
      moduleId = item.module;
      try {
      // 执行最新的代码
      __webpack_require__(moduleId);
      } catch (err) {
      // ...容错处理
      }
      }