一、什么是HMR

Hot Module Replacement是指当我们对代码修改并保存后,webpack将会对代码重新打包,并将新的模块发送给浏览器端,浏览器用新的模块代替老得模块,来实现不刷新页面的前提下更新页面。

二、图解HMR工作流程

1、第一步,在webpack的watch模式下,文件系统中的某一个文件发生修改,webpack监听到文件变化,根据配置文件对模块重新打包,并将打包后的代码通过简单的javascript对象保存在内存中。

2、第二步,是webpack-dev-server和webpack之间的接口交互。在这一步中主要是webpack-dev-server的中间件webpack-dev-middleware和webpack之间的交互,webpack-dev-middleware调用webpack暴露的API对代码进行监控,并告诉webpack,将代码打包到内存中。

3、第三步,webpack-dev-server对文件变化的一个监控,这一步不同于第一步,并不是监控代码变化重新打包。当我们在配置文件中配置了devServer.watchContentBase为true时,Server会监听这些配置文件夹中静态文件的变化,变化后会通知浏览器对应的应用进行live reload。这个是浏览器刷新,不是HMR。

4、第四步,是webpack-dev-server代码工作,该步骤主要是通过socket在浏览器和服务端之间建立一个websocket长连接,将webpack编译打包的各个阶段的状态信息告知浏览器,同时也包含第三步中server监听静态文件的变化信息。浏览器根据这些socket信息进行不同的操作。服务端传递的最主要信息还是新模块的hash值,后面的步骤都是根据这一hash值来进行模块热更新替换的。

5、webpack-dev-server/client端并不能够请求更新的代码,也不会执行热更新模块操作,而把这些工作又交给webpack,webpack/hot/dev/server的工作就是就是根据webpack-dev-server/client传给它的信息以及dev-server的配置就定了是浏览器刷新还是热模块更新。如果仅仅是刷新浏览器,就不会又后续过程。

6、HotModuleReplacement.runtime是客户端HMR的中枢,它接收上一步传递给他的新模块的hash值,它通过JsonpMainTemplate.runtime向server端发送ajax请求,服务端返回一个json,该json包含了所有要更新代模块的hash值,获取到更新列表后,该模块在通过jsonp请求,获取到最新的模块代码,这个就是下图中7、8、9三个步骤。

7、第10步是决定HMR是否成功的关键步骤,在该步骤中,HotModulePlugin将会对新旧模块进行对比,决定是否更新模块,在决定更新模块后进行依赖检查,更新模块的同时更新与该模块依赖的模块。

8、最后一步,当HMR失败后,回退到live Reload操作,也就是浏览器刷新来获取重新打包内容。

以下就是HMR流程图:image.png

三、详解流程

1、webpack对文件系统进行watch监听,并打包到内存中

webapck-dev-middle调用webpack中compiler的watch方法对文件系统进行监听,当文件发生改变后webpack进行重新编译打包,然后保存到内存中。

  1. // webpack-dev-middleware/index.js中,调用compiler的watch方法进行文件监听
  2. context.watching = compiler.watch(options.watchOptions, (err) => {
  3. if (err) {
  4. context.log.error(err.stack || err);
  5. if (err.details) {
  6. context.log.error(err.details);
  7. }
  8. }
  9. });

webpack将bundle.js文件打包到内存中,不生成文件的原因就是在于访问内存的代码比访问文件系统更快,并且能减少代码写入的开销。在webpack-dev-middle中引入了memory-fs的依赖库,webpack-dev-middle将webpack原本的outputFileSystem替换成了MemoryFileSystem的实例,这样就可以将代码写入内存中。

  1. // webpack-dev-middleware/lib/fs.js中,将fs替换成memory-fs
  2. const MemoryFileSystem = require('memory-fs');
  3. setFs(context, compiler) {
  4. let fileSystem;
  5. // store our files in memory
  6. const isConfiguredFs = context.options.fs;
  7. const isMemoryFs =
  8. !isConfiguredFs &&
  9. !compiler.compilers &&
  10. compiler.outputFileSystem instanceof MemoryFileSystem;
  11. context.fs = new MemoryFileSystem();
  12. }

2、启动devServer

在启动devServer的时候,sockjs在服务端和客户端之间建立了一个websocket长连接,以便将webpack编译和打包的各个阶段告诉浏览器端,最关键的步骤是webpack-dev-server调用webpack api监听compiler的done这个钩子,当compile完成后,webpack-dev-server通过_sendStatus方法将编译打包后的新模块的hash值发送给浏览器端。

  1. // webpack-dev-server/Server.js中,监听compiler的done事件
  2. setupHooks() {
  3. compiler.hooks.done.tap('webpack-dev-server', (stats) => {
  4. this._sendStats(this.sockets, this.statsObj.toJson(stats));
  5. this._stats = stats;
  6. });
  7. }
  8. // 给浏览器发送socket事件,将打包后的hash值进行发送
  9. _sendStats(sockets, stats, force) {
  10. this.sockWrite(sockets, 'hash', stats.hash);
  11. if (stats.errors.length > 0) {
  12. this.sockWrite(sockets, 'errors', stats.errors);
  13. } else if (stats.warnings.length > 0) {
  14. this.sockWrite(sockets, 'warnings', stats.warnings);
  15. } else {
  16. this.sockWrite(sockets, 'ok');
  17. }
  18. }
  19. sockWrite(sockets, type, data) {
  20. sockets.forEach((socket) => {
  21. this.socketServer.send(socket, JSON.stringify({ type, data }));
  22. });
  23. }

当文件进行了修改时,因为调用了compiler的watch方法,所以就会触发重新编译,HotModuleReplacement这个插件会监听compilation这个钩子,收集变更的文件信息。

3、webpack-dev-server/client接收服务端消息作出响应

webpack-dev-server修改了webpack配置中的entry属性,在里面添加了webpack-dev-client的代码,相当于增加了入口文件的配置,分别是webpack-dev-server/client/index.js和webpack/hot/dev-server两个入口文件,分别是用来作为浏览器端启动的WS客户端,监听WS的事件和得到接收消息后做更新操作。

  1. // webpack-dev-server/lib/utils/addEntris.js中,
  2. // 把webpack/hot/dev-server和webpack-dev-server/client/index.js添加到入口中
  3. let hotEntry;
  4. if (options.hotOnly) {
  5. hotEntry = require.resolve('webpack/hot/only-dev-server');
  6. } else if (options.hot) {
  7. hotEntry = require.resolve('webpack/hot/dev-server');
  8. }
  9. const clientEntry = `${require.resolve(
  10. '../../client/'
  11. )}?${domain}${sockHost}${sockPath}${sockPort}`;
  12. const additionalEntries = [clientEntry]
  13. additionalEntries.push(hotEntry);
  14. config.entry = prependEntry(config.entry || './src', additionalEntries

webpack-dev-server/client客户端接收到type为hash消息后会将hash值暂存起来,当接受到type为ok的消息后对应用执行reload操作。在reload操作中,webpack-dev-server/client会根据hot配置绝对浏览器是刷新还是进行热更新。

  1. // webpack-dev-server/client/index.js
  2. var onSocketMessage = {
  3. hash: function hash(_hash) { // 当接收到hash的消息后,对hash值进行缓存
  4. status.currentHash = _hash;
  5. },
  6. ok: function ok() { // 接收到ok的时候进行刷新还是reload操作
  7. sendMessage('Ok');
  8. reloadApp(options, status);
  9. }
  10. }
  11. // webpack-dev-server/client/reloadApp.js中
  12. function reloadApp() {
  13. if (hot) {
  14. var hotEmitter = require('webpack/hot/emitter');
  15. hotEmitter.emit('webpackHotUpdate', currentHash);
  16. } else if (liveReload) {
  17. rootWindow.location.reload();
  18. }
  19. }

在webpack/hot/dev-server中,监听客户端发出的webpackHotUpdate事件。

  1. // webpack/hot/dev-server
  2. var hotEmitter = require("./emitter");
  3. hotEmitter.on("webpackHotUpdate", function (currentHash) {
  4. lastHash = currentHash;
  5. if (!upToDate() && module.hot.status() === "idle") {
  6. log("info", "[HMR] Checking for updates on the server...");
  7. check();
  8. }
  9. });

4、webpack接收到最新hash进行验证

首先webpack/hot/dev-server接收到webpack-dev-server/client/index.js中发送的webpackHotUpdate这个消息,会进行check检查,会调用module.hot.check,实质调用webpack/lib/HotModuleReplacement.runtime中check方法,检查是否有更新。
在check过程中会利用webpack/lib/JsonpTemplate.runtime中的两个方法:hotDownloadManifest和hotDownloadUpdateChunk。其中hotDownloadManifest这个方法是根据调用Ajax请求manifest文件,会返回一个[hash].hot-update.json文件,该方法返回变化文件的hash值。hotDownloadUpdateChunk这个方法会根据返回的hash值向服务端发送jsonp请求,获取最新的模块代码,同时也将模块代码返回给HMR runtime,HMR runtime会根据返回的新的代码块进行处理。

  1. // webpack/hot/dev-server中
  2. // 当接收到webpackHotUpdate事件后会调用module.hot.check的方法
  3. var check = function check() {
  4. module.hot.check(true).catch(function(err) { // 监听错误,如果出错就重新加载页面
  5. var status = module.hot.status();
  6. if(["abort", "fail"].indexOf(status) >= 0) {
  7. window.location.reload();
  8. }
  9. });
  10. };
  11. var hotEmitter = require("./emitter");
  12. hotEmitter.on("webpackHotUpdate", function(currentHash) {
  13. lastHash = currentHash;
  14. if(!upToDate() && module.hot.status() === "idle") {
  15. log("info", "[HMR] Checking for updates on the server...");
  16. check();
  17. }
  18. });
  19. // webpack/hotModuleReplacement.runtime.js中
  20. module.hot = {
  21. check: function() {
  22. return hotDownloadManifest().then(function() { // 发送ajax请求获取更新的hash
  23. hotDownloadUpdateChunk(); // 通过jsonp请求获取最新的代码
  24. hotApply(); // 替换过期的模块
  25. return promise;
  26. });
  27. }
  28. }

5、对模块代码进行热更新

这一步是热更新关键的一步,更新代码都发生在hotModuleReplacement中的hotApply方法中。首先找出outdateModules和outdateDependencies,然后删除缓存中过期的模块和依赖,最后将新的模块和依赖添加到modules中,当再次调用调用的时候,就是获取最新的代码块了。

  1. // webpack/lib/HotModuleReplacement.runtime
  2. function hotApply() {
  3. var idx;
  4. var queue = outdatedModules.slice();
  5. while (queue.length > 0) {
  6. moduleId = queue.pop();
  7. module = installedModules[moduleId];
  8. // 删除过期的模块
  9. delete installedModules[moduleId];
  10. // 删除过期的依赖
  11. delete outdatedDependencies[moduleId];
  12. // 移除所有子节点
  13. for (j = 0; j < module.children.length; j++) {
  14. var child = installedModules[module.children[j]];
  15. if (!child) continue;
  16. idx = child.parents.indexOf(moduleId);
  17. if (idx >= 0) {
  18. child.parents.splice(idx, 1);
  19. }
  20. }
  21. }
  22. // 插入新的代码
  23. for (moduleId in appliedUpdate) {
  24. if (Object.prototype.hasOwnProperty.call(appliedUpdate, moduleId)) {
  25. modules[moduleId] = appliedUpdate[moduleId];
  26. }
  27. }
  28. }

6、最后更新页面

当用新的模块代码替换老的模块后,但是我们的业务代码并不能知道代码已经发生变化,也就是说,当文件修改后,我们需要在 入口文件中调用 HMR 的 accept 方法,添加模块更新后的处理函数,及时将修改方法进行调用。

  1. // index.js
  2. if(module.hot) {
  3. module.hot.accept('./hello.js', function() {
  4. // 业务代码
  5. })
  6. }

四、总结

一个完整的HMR流程整个周期分为启动阶段和文件监听更新流程。

1、在启动阶段

  • webpack和webpack-dev-server进行交互。在webpack-dev-server创建一个server,通过express的中间件系统注册在webpack-dev-middleware中定义好的中间件。
  • webpack-dev-server启动webpack打包的watch模式。在这种模式下webpack会监听文件变化,一旦文件发生改变,则就会重新打包,watch模式下打包的结果也不会写入硬盘上,写入到内存中。
  • webpack-dev-server通过webpack-dev-middleware和webpack进行交互,包含将fs重写成memory-fs对象、将静态js文件发送给浏览器等功能。
  • 在webpack-dev-server中会接收comipler对象,通过这个对象可以注册钩子函数,来监听打包构建过程中的生命中周期。
  • 如果webpack.config.js中devServer中的contentBase为true,则webpack-dev-server会监听文件夹中的文件变化,发生改变则通知浏览器重新刷新页面重新请求文件。
  • 打开浏览器后,webpack-dev-server会利用sockjs在浏览器和服务器中间建立一个Websocket长连接,这个长连接是浏览器和webpack-dev-server的通信桥梁,它之间通信内容主要是传递编译模块的文件信息(hash值),如果webpack监控的文件修改了,webpack/hot/dev-server来实现热更新还是刷新页面。

    2、更新阶段

  • 当文件发生改变后,webpack会重新编译文件,这个时候我们在webpack.config.js中添加的HotModuleReplacement插件会生成两次编译之间的差异列表(manifest)文件[hash].hot-update.json,这个manifest JSON文件包含了文件变化的内容[id].[hash].hot-update.js。编译完成后,这时会触发webpack-dev-server中注册的done的钩子会用Websocket推送编译之后的hash值,后续还会发送一个type为ok的消息。

  • 当客户端接收到服务端发送的hash和ok请求后,在webpack-dev-server/client客户端中也会发送一个webpackHotUpdate事件,这个事件接收方是webpack/hot/dev-server,接收这个事件后,会进行检查操作,这个就会调用hotModuleReplacement.runtime中注册的hotCheck方法。
  • hotCheck这个方法中,会将新生成的文件放入modules中来进行替换上一次打包编译生成的文件,并且会向服务端发起一个ajax请求,来获取这个manifest文件,其实就是[hash].hot-update.json文件内容,里面是发生变化的js文件。
  • manifest列表文件内容拿到后,会告诉HMR发生了哪些js文件改变,这个时候HMR runtime会按照变更信息列表发送Jsonp请求,将两次编译的差异文件[id].[hash].hot-update.js获取下来,最后插入到head标签中执行,最终完成全部流程。

    五、参考:

    Webpack HMR 原理解析
    带你看懂 HMR 热更新原理
    webpack 工程化实践总结之webpack 核心模块、Compiler 和 Compilation、基本流程和 HMR