webpack热更新原理

1,113 阅读8分钟

前言

Webpack热更新( Hot Module Replacement,简称 HMR),它允许在运行时更新模块,而无需重新加载整个页面。

刷新一般分为两种:

  • 一种是页面刷新,不保留页面状态,直接window.location.reload()
  • 另一种是基于WDS (Webpack-dev-server)的模块热替换,只需要局部刷新页面上发生变化的模块,同时可以保留当前的页面状态,比如复选框的选中状态、输入框的输入等。

可以看到相比于第一种,热更新对于我们的开发体验以及开发效率都具有重大的意义。

如何使用热更新

设置 HotModuleReplacementPluginHotModuleReplacementPluginwebpack 是自带的

plugins: [
    new webpack.HotModuleReplacementPlugin()
]

再设置一下 devServer

devServer: {
    hot: true
}

入口文件中加入下面代码,开启只更新不刷新模式:

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

那么,HMR到底是怎么实现热更新的呢?下面让我们来了解一下吧!

直观感受

当我们启动项目,打开控制台,页面初次刷新,可以发现浏览器和开发服务器建立了一个websocket连接,根据传输的信息我们可以肯定这和热更新密切相关。

现在我们修改代码,然后等待热更新加载,发现浏览器请求了两个文件

一个json文件

h 代表本次新生成的 Hash 值为 2daf9e64c321c84ae958——本次输出的 Hash 值会被作为下次热更新的标识。c 表示当前要热更新的文件对应的是哪个模块,可以让 webpack 知道它要更新哪个模块

{
    "h": "2daf9e64c321c84ae958",
    "c": {
        "OvertimeAdjustment": true
    }
}

一个js文件

从这个 js 里面我们可以找到我们修改的内容,可以猜出这个文件里面的内容将把旧代码替换。

同时,观察代码发现,返回的内容是一个函数,webpackHotUpdate 方法就是用来更新模块的,OvertimeAdjustment 对应的是哪个模块(我们称它为模块标识),其他的就是要更新的模块的内容了

websocket 连接内容的变化

我们可以看到 websocket 的信息也添加了一些内容,而且第一次生成的 hash 值刚好是新请求到的 js 文件的 hash 值。

我们不难得出这样的一个大致的流程:

  • 热更新通过浏览器和 webpack 开发服务器的 websocket 实现通信。
  • 当我们修改文件时,webpack 开发服务器会通过 websocket 通知浏览器需要更新。
  • 浏览器知晓需要更新时,利用之前的 hash 值,通过 ajax 请求获取新的 js 文件,然后执行一系列业务逻辑,让新的代码覆盖旧的代码。

接下来让我们进一步的讨论关于热更新的原理

热更新原理

热更新的过程

几个重要的概念(这里有一个大致的概念就好,后面会把它们串起来):

  • Webpack-complierwebpack 的编译器,将 JavaScript 编译成 bundle(就是最终的输出文件)
  • HMR Server:将热更新的文件输出给 HMR Runtime
  • Bunble Server:提供文件在浏览器的访问,也就是我们平时能够正常通过 localhost 访问我们本地网站的原因
  • HMR Runtime:开启了热更新的话,在打包阶段会被注入到浏览器中的 bundle.js,这样 bundle.js 就可以使用 websocket 跟服务器建立连接,当收到服务器的更新指令的时候,就去更新文件的变化
  • bundle.js:构建输出的文件

启动阶段

文件经过 Webpack-complier 编译好后传输给 Bundle ServerBundle Server 可以让浏览器访问到我们打包出来的文件

下面流程图中的 1、2、A、B阶段

文件热更新阶段

文件经过 Webpack-complier 编译好后传输给 HMR ServerHMR Server 知道哪个资源(模块)发生了改变,并通知 HMR Runtime 有哪些变化(也就是上面我们看到的两个请求),HMR Runtime 就会更新我们的代码,这样我们浏览器就会更新并且不需要刷新

下面流程图的 1、2、3、4、5 阶段

深入——源码阅读

我们还看回上图,其中启动阶段图中的 1、2、A、B阶段就不讲解了,主要看热更新阶段主要讲 3、4 和 5 阶段

在开始接下开的阅读前,我们再回到最初的问题上我本地修改了文件,浏览器是怎么知道要更新的呢?

通过上面的流程图,其实我们可以猜测,本地实际上启动了一个 HMR Server 服务,而且在启动 Bundle Server 的时候已经往我们的 bundle.js 中注入了 HMR Runtime(主要用来启动 Websocket,接受 HMR Server 发来的变更)

所以我们聚焦以下几点:

  • Webpack 如何启动了 HMR Server
  • HMR Server 如何跟 HMR Runtime 进行通信的
  • HMR Runtime 接受到变更之后,如何生效的

启动 HMR Server

这个工作主要是在 webpack-dev-server 中完成的

lib/Server.js setupApp 方法,下面的 express 服务实际上对应的是 Bundle Server

setupApp() {
  // Init express server
  // eslint-disable-next-line new-cap
  // 初始化 express 服务
  // 使用 express 框架启动本地 server,让浏览器可以请求本地的静态资源。
  this.app = new express();
}

启动服务结束之后就通过 createSocketServer 创建 websocket 服务

listen(port, hostname, fn) {
  this.hostname = hostname;
  return (
    findPort(port || this.options.port)
      .then((port) => {
        this.port = port;
        return this.server.listen(port, hostname, (err) => {
          if (this.options.hot || this.options.liveReload) {
            // 启动 express 服务之后,启动 websocket 服务
            this.createSocketServer();
          }
        });
      })
  );
}


createSocketServer() {
  this.socketServer = new this.SocketServerImplementation(this);

  this.socketServer.onConnection((connection, headers) => {
	
  });
}

HMR Server 和 HMR Runtime 的通信

首先要通信的第一个问题在于——通信的时机,什么时候我去通知客户端我的文件更新。通过 webpack 创建的 compiler 实例(监听本地文件的变化、文件改变自动编译、编译输出),可以往 compiler.hooks.done 钩子(代表 webpack 编译完之后触发)注册事件, 当监听到一次 webpack 编译结束,就会调用 sendStats 方法

lib/Server.js 中的 setupHooks 方法

// lib/Server.js
// 绑定监听事件
setupHooks() {
  // ...
  const addHooks = (compiler) => {
    // 监听 webpack 的 done 钩子,tapable 提供的监听方法
    // done 标识编译结束
    const { compile, invalid, done } = compiler.hooks;
    compile.tap('webpack-dev-server', invalidPlugin);
    invalid.tap('webpack-dev-server', invalidPlugin);
    done.tap('webpack-dev-server', (stats) => {
      // 当监听到一次webpack编译结束,就会调用 sendStats 方法
      this.sendStats(this.sockets, this.getStats(stats));
      this.stats = stats;
    });
  };
}

当监听到一次 webpack 编译结束,就会调用 sendStats 方法,通过 sockWrite 以 websocket 的形式向浏览器发送 hashok 事件

// lib/Server.js
// send stats to a socket or multiple sockets
sendStats(sockets, stats, force) {
  // ok和 hash
  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');
  }
}

浏览器收到了 webpack-dev-server 传来的通知。(接下来的代码全部都在浏览器里执行。这些代码都被 webpack 打包时一起输出到 bundle.js 中)

我们可以在 webpack-dev-server 的代码中找到打包到 bundle.js的代码,在 client-src/default/index.js 中我们可以找到一个 onSocketMessage 常量,它就是对 websocket 服务的客户端监听,hash 指令将重置 currentHash 变量,ok 指令将执行reloadApp()

// client-src/default/index.js 
const onSocketMessage = {
  // 更新 current Hash
  hash(hash) {
    status.currentHash = hash;
  },
  'progress-update': function progressUpdate(data) {
    if (options.useProgress) {
      log.info(`${data.percent}% - ${data.msg}.`);
    }

    sendMessage('Progress', data);
  },
  ok() {
    sendMessage('Ok');

    if (options.useWarningOverlay || options.useErrorOverlay) {
      overlay.clear();
    }

    if (options.initial) {
      return (options.initial = false);
    }
    // 进行更新检查等操作
    reloadApp(options, status);
  }

};

再来我们看看 client-src/default/utils/reloadApp.js 中的 reloadApp。这里又利用 node.jsEventEmitter,发出webpackHotUpdate 消息。这里又将更新的事情给回了 webpack(为了更好的维护代码,以及职责划分的更明确。)

function reloadApp(
  { hotReload, hot, liveReload },
  { isUnloading, currentHash }
) {
  // ...
  if (hot) {
    log.info('App hot update...');
    //  hotEmitter 其实就是 EventEmitter 的实例
    const hotEmitter = require('webpack/hot/emitter');
    // 又利用 node.js 的 EventEmitter,发出 webpackHotUpdate 消息。
    // websocket 仅仅用于客户端(浏览器)和服务端进行通信。而真正做事情的活还是交回给了 webpack。
    hotEmitter.emit('webpackHotUpdate', currentHash);
    if (typeof self !== 'undefined' && self.window) {
      // broadcast update to window
      self.postMessage(`webpackHotUpdate${currentHash}`, '*');
    }
  }
  // ...
}

module.exports = reloadApp; 

监听 webpackHotUpdate 事件是在 webpackhot/dev-server.js 中,并执行 check 方法。并在 check 方法中调用 module.hot.check 方法进行热更新。

// hot/dev-server.js
// 监听webpackHotUpdate事件
hotEmitter.on("webpackHotUpdate", function (currentHash) {
  lastHash = currentHash;
  if (!upToDate() && module.hot.status() === "idle") {
    log("info", "[HMR] Checking for updates on the server...");
    check();
  }
});


var check = function check() {
  //  moudle.hot.check 开始热更新
  // 之后的源码都是HotModuleReplacementPlugin塞入到bundle.js中的哦,我就不写文件路径了
  module.hot
    .check(true)
    .then(function (updatedModules) {
      // ...
    })
    .catch(function (err) {
      // ...
    });
};

至于 module.hot.check ,实际上通过 HotModuleReplacementPlugin 已经注入到我们 chunk 中了(也就是我们上面所说的 HMR Runtime),所以后面就是它是如何更新 bundle.js 的呢?

HMR Runtime 中更新 bundle.js

请求资源清单

首先会执行 __webpack_require__.hmrM(),其中 __webpack_require__.p 指的是我们本地服务的域名,类似 http://192.168.2.234:7360 , 另外 __webpack_require__.hmrF 去获取 .hot-update.json 文件的地址,就是我们之前提到的json文件

__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/runtime/get update manifest filename */
(() => {
  __webpack_require__.hmrF = () => ("main." + __webpack_require__.h() + ".hot-update.json");
})();

加载要更新的模块

拿到需要请求的资源信息之后,我们会去请求新的 js 文件。核心是 __webpack_require__.hmrC 函数。主要通过 JSONP 的方式,向 dom 插入 script 标签达到请求资源的目的。JSONP获取的代码可以直接执行,之后我们就可以进行模块替换(apply)了。

__webpack_require__.l = (url, done, key, chunkId) => {
  // ...
  if (!script) {
    script = document.createElement("script");

    script.charset = "utf-8";
    script.timeout = 120;
    if (__webpack_require__.nc) {
      script.setAttribute("nonce", __webpack_require__.nc);
    }
    script.setAttribute("data-webpack", dataWebpackPrefix + key);
    script.src = url;
  }
  // ...
  needAttach && document.head.appendChild(script);
};

以上整体的流程如下所示:

总结

本文介绍了 webpack 热更新的简单使用、相关的流程以及原理。小结一下,webpack 如果开启了热更新的时候

  • HMR Runtime 通过 HotModuleReplacementPlugin 已经注入到我们 chunk 中了

  • 除了开启一个 Bundle Server,还开启了 HMR Server,主要用来和 HMR Runtime 中通信

  • 在编译结束的时候,通过 compiler.hooks.done,监听并通知客户端

  • 客户端接收到之后,就会调用 module.hot.check 等,发起 http 请求去服务器端获取新的模块资源解析并局部刷新页面

参考

聊聊 Webpack 热更新以及原理

轻松理解webpack热更新原理