一文读懂 Webpack HMR(热更新)的实现原理

HMR 全称 hot module replacement,可以翻译为「模块热更新」,最初由 Webpack 设计实现,至今已几乎成为现代工程化必备工具之一,它能够在保持页面状态不变的情况下动态替换、删除、添加代码模块,而无需重新加载整个页面。

主要是通过以下几种方式,来显著加快开发速度:

  • 保留在完全重新加载页面期间丢失的应用程序状态。

  • 只更新变更内容,以节省宝贵的开发时间。

  • 在源代码中 CSS/JS 产生修改时,会立刻在浏览器中进行更新,这几乎相当于在浏览器 devtools 直接更改样式。

在 HMR 之前 ,即使应用只是单个代码文件发生变更,都需要刷新整个页面,才能将最新代码映射到浏览器上,这会丢失之前在页面执行过的所有交互与状态,整体开发效率偏低而引入 HMR 后,虽然无法覆盖所有场景,但大多数小改动都可以通过模块热替换方式更新到页面上,从而确保连续、顺畅的开发调试体验,极大提升开发效率。

使用 HMR

Webpack 生态下,只需要经过简单的配置,即可启动 HMR 功能,大致分两步:

  • 设置 devServer.hot 属性为 true:
js 复制代码
module.exports = {
  // ...
  devServer: {
    // 必须设置 devServer.hot = true,启动 HMR 功能
    hot: true
  }
};
  • 在代码调用 module.hot.accept 接口,声明如何将模块安全地替换为最新代码
js 复制代码
import component from "./component";

const currentComponent = component();
document.body.appendChild(currentComponent);

// HMR interface
if (module.hot) {
  // Capture hot update
  module.hot.accept("./component", () => {
    const nextComponent = component();

    // Replace old content with the hot loaded one
    document.body.replaceChild(nextComponent, currentComponent);

    currentComponent = nextComponent;
  });
}

原理

  • 使用 webpack-dev-server 托管静态资源,同时以 Runtime 方式注入一段处理 HMR 逻辑的客户端代码;
  • 浏览器加载页面后,与 webpack-dev-server 建立 WebSocket 连接;
  • Webpack 监听到文件变化后,增量构建发生变更的模块,并通过 WebSocket 发送 hash 事件;
  • 浏览器接收到 hash 事件后,请求 manifest 资源文件,确认增量变更范围;
  • 浏览器加载发生变更的增量模块;
  • Webpack 运行时触发变更模块的 module.hot.accept 回调,执行代码变更逻辑;

当 HMR 失败后,回退到 live reload 操作,也就是进行浏览器刷新来获取最新打包代码。这就是 Webpack HMR 特性的执行过程

当我们在配置文件中配置了devServer.watchContentBase 为 true 的时候,Server 会监听这些配置文件夹中静态文件的变化,变化后会通知浏览器端对应用进行 live reload。

注意,这儿是浏览器刷新,和 HMR 是两个概念。

启动服务

js 复制代码
class Server {

  async initialize() {
    // ...
    this.setupApp();
    this.createServer();
    // ...
  }

  setupApp() {
    this.app = new (getExpress())();
  }

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

    // 启动静态资源服务
    this.server = require((type)).createServer(
      options,
      this.app, // express
    );

    // 每次连接时将新的 socket 添加到 this.sockets 数组中
    (this.server).on(
      "connection",
      (socket) => {
        this.sockets.push(socket);

        socket.once("close", () => {
          this.sockets.splice(this.sockets.indexOf(socket), 1);
        });
      },
    );
  }

  async start () {
    // 这里对 options.webSocketServer 赋值,默认为 { type: 'ws', options: { path: 'ws' } }
    await this.normalizeOptions();
    
    // ...
    await initialize()

    // 创建 socket
    if (this.options.webSocketServer) {
      this.createWebSocketServer();
    }
  }
}

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

⭐ 本地server启动之后,再去启动websocket服务,通过websocket,可以建立本地服务和浏览器的双向通信。

增加 Entry 文件

在启动服务时候,webpack-dev-server会在webpack配置项的entry中注入两个文件:

js 复制代码
addAdditionalEntries(compiler) {
  const additionalEntries = [];
  const isWebTarget = Server.isWebTarget(compiler);

  if (this.options.client && isWebTarget) {
    // ...

    // 获取 webSocket 客户端代码
    additionalEntries.push(
      `${require.resolve("../client/index.js")}?${webSocketURLStr}`,
    );
  }

  // 根据配置获取热更新代码
  if (this.options.hot === "only") {
    additionalEntries.push(require.resolve("webpack/hot/only-dev-server"));
  } else if (this.options.hot) {
    additionalEntries.push(require.resolve("webpack/hot/dev-server"));
  }

  const webpack = compiler.webpack || require("webpack");

  for (const additionalEntry of additionalEntries) {
    new webpack.EntryPlugin(compiler.context, additionalEntry, {
      name: undefined,
    }).apply(compiler);
  }
}

修改后的 webpack 入口配置如下:

js 复制代码
// 修改后的 entry 入口
{
  entry: {
    index: [
      // 上面获取的clentEntry
      '/node_modules/webpack-dev-server/client/index.js?http://localhost:8080',
      // 上面获取的hotEntry
      '/node_modules/webpack/hot/dev-server.js',
      // 开发配置的入口
      './src/index.js'
    ]
  }
}

在入口默默增加了2个文件,那就意味会一同打包到 bundle 文件中去,也就是线上运行时。

⭐️ webpack-dev-server/client/index.js

在客户端中加入 websocket 客户端通信代码,以便后续的更新替换操作

⭐️ webpack/hot/dev-server.js

负责与 webpack-dev-server 进行通信,接收关于模块更新的通知,并执行相应的模块替换操作

监听 webpack 编译结束

修改好入口配置后,又调用了setupHooks 方法,用来注册监听事件的,监听每次 webpack 编译完成

js 复制代码
 setupHooks() {
  this.compiler.hooks.done.tap(
    "webpack-dev-server",
    (stats) => {
      if (this.webSocketServer) {
        this.sendStats(this.webSocketServer.clients, this.getStats(stats));
      }

      this.stats = stats;
    },
  );
}

当监听到一次 webpack 编译结束,就会调用 sendStats 方法通过 websocket 给浏览器发送通知,ok 和 hash 事件,这样浏览器就可以拿到最新的 hash 值了,做检查更新逻辑。

js 复制代码
sendStats(clients, stats, force) {
    // ...

    this.currentHash = stats.hash;
    // 通过 websocket 给客户端发消息
    this.sendMessage(clients, "hash", stats.hash);

    if (
      (stats.errors).length > 0 ||
      (stats.warnings).length > 0
    ) {
      // ...
    } else {
      // 通过 websocket 给客户端发消息
      this.sendMessage(clients, "ok");
    }
  }

监听文件变化

在了解如何监听文件变化前,我们需要了解到 dev-server 和 dev-middleware 的区别

  • webpack-dev-server:只负责启动服务和前置准备工作

  • webpack-dev-middleware:所有文件相关的操作都抽离至此,主要是本地文件的编译和输出以及监听

js 复制代码
const context = {
  // ...
  options,
  compiler,
  watching: undefined,
  outputFileSystem: undefined,
};

// ...

let watchOptions;

const errorHandler = (error) => {
  if (error) {
    context.logger.error(error);
  }
};

if (
  Array.isArray((context.compiler).compilers)
) {
  watchOptions =
    (context.compiler).compilers.map(
      (childCompiler) => childCompiler.options.watchOptions || {},
    );

  context.watching =
    (
      context.compiler.watch(
        (watchOptions),
        errorHandler,
      )
    );
} else {
  watchOptions = (context.compiler).options.watchOptions || {};

  context.watching = (
    context.compiler.watch(watchOptions, errorHandler)
  );
}

我们可以看出 webpack-dev-middleware 是借用 webpack 的 watch 钩子开启对本地文件的监听,当文件发生变化,重新编译,编译完成之后继续监听

outputFileSystem

每次文件改变都要重新编译,性能不应该很差吗,为什么开发时没有体会到?

为了提升性能,webpack-dev-middleware 使用了一个库叫 memfs,是 Webpack 官方写的。这样每次打包之后的结果并不会进行输出(把文件写入到硬盘上会耗费很长的时间),而是将打包后的文件保留在内存中,以此来提升性能。

js 复制代码
function setupOutputFileSystem(context) {
  let outputFileSystem;

  // 默认为 undefined
  if (context.options.outputFileSystem) {
    // ...
  }
  // 这里需要手动设置为 true,若不设置则为 undefined
  else if (context.options.writeToDisk !== true) {
    outputFileSystem = memfs.createFsFromVolume(new memfs.Volume());
  } else {
    // ...
  }

  // ...
  context.outputFileSystem = outputFileSystem;
}

我们可以看出,如果不设置 writeToDisk 为 true,则会改写 webpack outputFileSystem 为 memfs,输出到内存中。

浏览器接收到热更新的通知

我们上文已经提到,webpack-dev-server 会对 entry 进行加工,对客户端加入 ws 的代码,并会被打包到 bundle 中

js 复制代码
var socket = require('./socket');

const onSocketMessage = {
  hash(hash) {
    status.previousHash = status.currentHash;
    status.currentHash = hash;
  },
  ok() {
    sendMessage("Ok");

    if (options.overlay) {
      overlay.send({ type: "DISMISS" });
    }
    
    // 进行更新检查等操作
    reloadApp(options, status);
  },
}


function reloadApp({ hot, liveReload }, status) {
  const search = self.location.search.toLowerCase();
  const allowToHot = search.indexOf("webpack-dev-server-hot=false") === -1;

  if (hot && allowToHot) {
    log.info("App hot update...");

    // hotEmitter 其实就是EventEmittter 的实例
    hotEmitter.emit("webpackHotUpdate", status.currentHash);

    if (typeof self !== "undefined" && self.window) {
      // broadcast update to window
      self.postMessage(`webpackHotUpdate${status.currentHash}`, "*");
    }
  }
}

从代码中,我们可以看出

  • hash事件:更新最新 status 的 hash 值

  • ok事件:调用 webpackHotUpdate 事件

这里就要提到 entry 新增打包的 webpack/hot/dev-server

js 复制代码
if (module.hot) {
	var check = function check() {
		module.hot
			.check(true)
			.then(function (updatedModules) {})
			.catch(function (err) {});
	};
  
	var hotEmitter = require("./emitter");
	hotEmitter.on("webpackHotUpdate", function (currentHash) {
		lastHash = currentHash;
		
    if (!upToDate() && module.hot.status() === "idle") {
			check();
		}
	});
}

这里调用了 module.hot 方法,那么这个方法是从哪里来的?这里我就不卖关子了,这里调用了 HotModuleReplacementPlugin 插件进行改写

热更新插件

在上面的配置中并没有配置 HotModuleReplacementPlugin,原因在于当我们设置 devServer.hot 为 true 后,devServer 会告诉 webpack 自动引入 HotModuleReplacementPlugin

  • HMR Server 是服务端,用来将变化的 JS 模块通过 WebSocket 的消息通知给浏览器端。
  • HMR Runtime 是浏览器端,用于接受 HMR Server 传递的模块数据,浏览器端可以看到 .hot-update.JSON 的文件过来。

这里的 hot 就是 HotModuleReplacementRuntime 中定义的

js 复制代码
$hmrDownloadUpdateHandlers$ = {}


function createModuleHotObject(moduleId, me) {
		var _main = currentChildModule !== moduleId;
		var hot = {
      // ...
			// Management API
			check: hotCheck,
			apply: hotApply,
      // ...
		};
		currentChildModule = undefined;
		return hot;
	}
}

function hotCheck(applyOnUpdate) {
    return setStatus("check")
        .then($hmrDownloadManifest$) // undefined
        .then(function (update) {
            return setStatus("prepare").then(function () {
            var updatedModules = [];
            currentUpdateApplyHandlers = [];

            return Promise.all(
                Object.keys($hmrDownloadUpdateHandlers$).reduce(function (
                    promises,
                    key
                ) {
                    $hmrDownloadUpdateHandlers$[key](
                            update.c,
                            update.r,
                            update.m,
                            promises,
                            currentUpdateApplyHandlers,
                            updatedModules
                    );
                    return promises;
            }, [])
        ).then(function () {
            return waitForBlockingPromises(function () {
                if (applyOnUpdate) {
                    // 触发 internalApply 方法
                    return internalApply(applyOnUpdate);
                } else {
                    return setStatus("ready").then(function () {
                        return updatedModules;
                    });
                }
            });
            });
        });
    });
}

这里代码太多,不是很想看,但我的理解是通过

  • HotModuleReplacementPlugin 在 webapck hooks上做了一些事情,例如 compilation.hooks.processAssets 钩子调用emitAsset()先后产生了新模块的js文件和menifest文件
  • HMR runtime 则是注入到 bundle.js,如 module.hot.check 等方法

当文件发生改变时候,触发 webpack 增量构建,构建结束后触发 this.sendStats 方法通知提交服务器,服务器再通过 socket 告诉浏览器,浏览器再去下载更新文件,进行替换。

这里热更新主要是这两个方法 check 和 apply

  • check : hotCheck 发送一个 fetch 请求来更新 Manifest 文件(xxx.hot-update.json) 会与当前的 loaded chunk 列表进行比较。然后会下载并执行 xxx.hot-update.js 文件。当所有更新 chunk 完成下载,runtime 就会切换到 ready 状态
  • apply :将所有 updated module 标记为无效。对于每个无效 module,都需要在模块中有一个 update handler,或者在此模块的父级模块中有 update handler。否则,会进行无效标记冒泡,并且父级也会被标记为无效。继续每个冒泡,直到到达应用程序入口起点,或者到达带有 update handler 的 module(以最先到达为准,冒泡停止)。如果它从入口起点开始冒泡,则此过程失败。

热更新

加载热更新资源的 RuntimeGlobals.hmrDownloadManifest 与RuntimeGlobals.hmrDownloadUpdateHandlers

js 复制代码
function hotCheck(applyOnUpdate) {
    return setStatus("check")
    .then($hmrDownloadManifest$)
    .then(function (update) {
        if (!update) {
            return setStatus(applyInvalidatedModules() ? "ready" : "idle").then(
                function () {
                        return null;
                }
            );
        }

        return setStatus("prepare").then(function () {
            var updatedModules = [];
            currentUpdateApplyHandlers = [];

            return Promise.all(
                Object.keys($hmrDownloadUpdateHandlers$).reduce(function (
                        promises,
                        key
                ) {
                    $hmrDownloadUpdateHandlers$[key](
                        update.c,
                        update.r,
                        update.m,
                        promises,
                        currentUpdateApplyHandlers,
                        updatedModules
                    );
                    return promises;
                }, [])
        ).then(function () {
            return waitForBlockingPromises(function () {
                if (applyOnUpdate) {
                    return internalApply(applyOnUpdate);
                } else {
                    return setStatus("ready").then(function () {
                        return updatedModules;
                    });
                }
            });
            });
        });
    });
}

HotModuleReplacementPlugin 插件借助 Webpack 的 watch 能力,在代码文件发生变化后执行增量构建,生成:

  • manifest 文件:JSON 格式文件,包含所有发生变更的模块列表,命名为 [hash].hot-update.json
  • 模块变更文件:js 格式,包含编译后的模块代码,命名为 [hash].hot-update.js

增量构建完毕后,Webpack 将触发 compilation.hooks.done 钩子,并传递本次构建的统计信息对象 stats。WDS 则监听 done 钩子,在回调中通过 WebSocket 发送模块更新消息:

js 复制代码
{ "type" : "hash", "data": "${stats.hash}" }

再次加载更新:客户端通过 WebSocket 接收到 hash 消息后,首先发出 manifest 请求获取本轮热更新涉及的 chunk。manifest 请求完成后,客户端 HMR 运行时开始下载发生变化的 chunk 文件,将最新模块代码加载到本地

热更新模块替换

模块热替换主要分三个阶段:

  • 找出 outdatedModules 和 outdatedDependencies,并从缓存中删除过期的模块和依赖

  • 添加新的模块到 modules 中

  • 当下次调用 webpack_require(Webpack 重写的 require 方法)方法的时候,就是获取到了新的模块代码了

这里就不贴找出过期模块 outdatedModules 和过期依赖 outdatedDependencies 的代码了,直接看删除和添加的代码

  • 当模块被删除时,调用dispose方法消除副作用;

  • 当模块更新时,冒泡寻求accept方法执行模块更新逻辑并重新加载模块;

从缓存中删除过期的模块和依赖

js 复制代码
return {
  dispose: function () {
    currentUpdateRemovedChunks.forEach(function (chunkId) {
      delete $installedChunks$[chunkId];
    });
    currentUpdateRemovedChunks = undefined;
    
    var queue = outdatedModules.slice();
    while (queue.length > 0) {
      var moduleId = queue.pop();
      var module = $moduleCache$[moduleId];
      if (!module) continue;
    
      var data = {};
    
      // Call dispose handlers
      var disposeHandlers = module.hot._disposeHandlers;
      for (j = 0; j < disposeHandlers.length; j++) {
        disposeHandlers[j].call(null, data);
      }
      $hmrModuleData$[moduleId] = data;
    
      module.hot.active = false;
    
      // 从缓存中删除过期的模块
      delete $moduleCache$[moduleId];
      // 删除过期的依赖
      delete outdatedDependencies[moduleId];
    
      // remove "parents" references from all children
      for (j = 0; j < module.children.length; j++) {
        var child = $moduleCache$[module.children[j]];
        if (!child) continue;
        idx = child.parents.indexOf(moduleId);
        if (idx >= 0) {
          child.parents.splice(idx, 1);
        }
      }
    }
    
    
    // 更新子模块的依赖关系,将需要被移除的模块从依赖的子模块的 children 数组中移除
    var dependency;
    for (var outdatedModuleId in outdatedDependencies) {
      if ($hasOwnProperty$(outdatedDependencies, outdatedModuleId)) {
        module = $moduleCache$[outdatedModuleId];
        if (module) {
          moduleOutdatedDependencies =
            outdatedDependencies[outdatedModuleId];
          for (j = 0; j < moduleOutdatedDependencies.length; j++) {
            dependency = moduleOutdatedDependencies[j];
            idx = module.children.indexOf(dependency);
            if (idx >= 0) module.children.splice(idx, 1);
          }
        }
      }
    }
  }
}

添加新增模块

将新的模块添加到 <math xmlns="http://www.w3.org/1998/Math/MathML"> m o d u l e F a c t o r i e s moduleFactories </math>moduleFactories 中。

js 复制代码
// insert new code
for (var updateModuleId in appliedUpdate) {
  if ($hasOwnProperty$(appliedUpdate, updateModuleId)) {
    $moduleFactories$[updateModuleId] = appliedUpdate[updateModuleId];
  }
}

// Load self accepted modules
for (var o = 0; o < outdatedSelfAcceptedModules.length; o++) {
  var item = outdatedSelfAcceptedModules[o];
  var moduleId = item.module;
  try {
    // 执行最新的代码
    item.require(moduleId);
  } catch (err) {
    // 错误处理
  }
}

经过上述步骤,浏览器加载完最新模块代码后,HMR 运行时会继续触发 module.hot.accept 回调,将最新代码替换到运行环境中。

module.hot.accept 是 HMR 运行时暴露给用户代码的重要接口之一,它在 Webpack HMR 体系中开了一个口子,让用户能够自定义模块热替换的逻辑,接口签名:

js 复制代码
/*
 * path:指定需要拦截变更行为的模块路径;
 * callback:模块更新后,将最新模块代码应用到运行环境的函数。
 **/
module.hot.accept(path?: string, callback?: function);

例如,对于如下代码:

js 复制代码
import component from "./component";

const currentComponent = component();
document.body.appendChild(currentComponent);

// HMR interface
if (module.hot) {
  // Capture hot update
  module.hot.accept("./component", () => {
    const nextComponent = component();

    // Replace old content with the hot loaded one
    document.body.replaceChild(nextComponent, currentComponent);

    currentComponent = nextComponent;
  });
}

示例中,module.hot.accept 函数监听 ./component 模块的变更事件,一旦代码发生变动,就触发回调,将 ./component 导出的值替换到页面上,从而实现热更新效果。

更新失败后的兜底

模块热更新的错误处理,如果在热更新过程中出现错误,热更新将回退到刷新浏览器,这部分代码在 dev-server 代码中

js 复制代码
if (module.hot) {
	var check = function check() {
		module.hot
			.check(true)
			.then(function (updatedModules) {
				if (!updatedModules) {
					if (typeof window !== "undefined") {
						window.location.reload();
					}
          
					return;
				}

        // ...
				if (upToDate()) {
					log("info", "[HMR] App is up to date.");
				}
			})
			.catch(function (err) {
				var status = module.hot.status();
        
				if (["abort", "fail"].indexOf(status) >= 0) {1
					if (typeof window !== "undefined") {
						window.location.reload();
					}
				} else {
					log("warning", "[HMR] Update failed: " + log.formatError(err));
				}
			});
	};
  
	var hotEmitter = require("./emitter");
	hotEmitter.on("webpackHotUpdate", function (currentHash) {
		lastHash = currentHash;
		
    if (!upToDate() && module.hot.status() === "idle") {
			check();
		}
	});
}

dev-server 先验证是否有更新,没有代码更新的话,重载浏览器。如果在 hotApply 的过程中出现 abort 或者 fail 错误,也进行重载浏览器。

相关推荐
Cwhat1 分钟前
前端性能优化2
前端
熊的猫1 小时前
JS 中的类型 & 类型判断 & 类型转换
前端·javascript·vue.js·chrome·react.js·前端框架·node.js
瑶琴AI前端1 小时前
uniapp组件实现省市区三级联动选择
java·前端·uni-app
会发光的猪。1 小时前
如何在vscode中安装git详细新手教程
前端·ide·git·vscode
我要洋人死3 小时前
导航栏及下拉菜单的实现
前端·css·css3
科技探秘人3 小时前
Chrome与火狐哪个浏览器的隐私追踪功能更好
前端·chrome
科技探秘人3 小时前
Chrome与傲游浏览器性能与功能的深度对比
前端·chrome
JerryXZR3 小时前
前端开发中ES6的技术细节二
前端·javascript·es6
七星静香3 小时前
laravel chunkById 分块查询 使用时的问题
java·前端·laravel
q2498596933 小时前
前端预览word、excel、ppt
前端·word·excel