前言

刷新分为两种:一种是页面刷新,不保留页面状态,就是简单粗暴,直接window.location.reload();另一种只需要局部刷新页面上发生变化的模块,同时可以保留当前的页面状态,比如复选框的选中状态、输入框的输入等。

Webpack热更新( Hot Module Replacement,简称 HMR,后续均以 HMR 替代),无需完全刷新整个页面的同时,更新代码变动的模块,是 Webpack 内置的最有用的功能之一。

HMR 的好处,在日常开发工作中体会颇深:节省宝贵的开发时间、提升开发体验。引用官网的描述来概述一下:

HMR 功能会在应用程序运行过程中,替换、添加或删除模块,而无需重新加载整个页面。主要是通过以下几种方式,来显著加快开发速度:

  • 保留在完全重新加载页面期间丢失的应用程序状态。
  • 只更新变更内容,以节省宝贵的开发时间。
  • 在源代码中对 CSS / JS 进行修改,会立刻在浏览器中进行更新,这几乎相当于在浏览器 devtools 直接更改样式。

基础配置

创建项目

mkdir webpack-test && cd webpack-test // 创建文件夹并进入
npm init -y // 快速创建一个项目配置
npm i webpack webpack-dev-server webpack-cli -D // 下载开发环境依赖项
mkdir src && mkdir dist // 创建资源目录和输出目录
type nul>webpack.dev.js // 因为是在开发环境需要热更新,所以直接创建dev配置文件

当前npm包版本如下:

"devDependencies": {
  "webpack": "^5.90.3",
  "webpack-cli": "^5.1.4",
  "webpack-dev-server": "^5.0.2"
}

编写配置文件 webpack.dev.js

'use strict';

const path = require('path');

module.exports = {
    entry: './src/index.js', // 入口文件
    output: {
			path: path.resolve(__dirname, 'dist'), // 输出到哪个文件夹
			filename: 'output.js' // 输出的文件名
    },
    mode: 'development', // 开发模式
    devServer: {
      static: path.resolve(__dirname, "dist")
    }
};

新建文件 src/index.js

document.write('hello world~')

package.json添加一条命令。

  "scripts": {
    "dev": "webpack-dev-server --config webpack.dev.js"
  },

npm run dev 运行

我们看到文件已经打包完成了,但是在dist目录里并没有看到文件,这是因为WDS(webpack-dev-server)是把编译好的文件放在缓存中,没有放在磁盘上,但是我们是可以访问到的,

output.js 对应你在webpack配置文件中的输出文件,配置的是什么就访问什么

http://localhost:8080/output.js

显然我们想看效果而不是打包后的代码,所以我们在dist目录里创建一个index.html文件引入即可,

<script src="./output.js"></script>

重新访问 http://localhost:8080,内容出来了,我们接下来修改index.js文件,来看下是否可以自动刷新。

'use strict' 

document.write('hello world changed')

这确实是热更新,但是这种是每一次修改会重新刷新整个页面,大家可以打开控制台查看。WDS 提供了实时重加载的功能,但是不能局部刷新。必须配合后两步的配置才能实现局部刷新。

HMR配置

我们需要的是更新修改的模块,但是不要刷新页面。

修改 webpack.dev.js

...
module.exports = {
    ...
    devServer: {
        ...
        hot: true // 多了这一行

    },
    ...
};

重新执行 npm run dev

我们修改一下文件,形成引用关系

index.js

import { test } from './child' 

console.log('index.js文件')
test()

child.js

export function test() {
  console.log('child.js文件')
}

但是,当我们修改并保存js文件之后,页面依旧自动刷新了,这里并没有触发热模块。

所以,HMR并不像 Webpack 的其他特性一样可以开箱即用,需要有一些额外的操作。我们需要去指定哪些模块发生更新时进行HMR,如下处理:

在入口页index.js面再添加一段

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

会看到修改index.js或者child.js文件,都会进行模块热更新。

也可以去指定哪些模块发生更新时进行HMR,如下代码:

if (module.hot) {
  module.hot.accept('./child', () => {
    console.log('child.js文件进行了更新')
  });
}

修改后会看到,当修改index.js文件时,会直接reload,但修改child.js文件时,会进行模块热更新。

那为什么平时修改代码的时候不用监听 module.hot.accept 也能实现热更新?那是因为我们使用的 loader 已经在幕后帮我们实现了。

HMR交互概览

我们通过观察编译及前后端的流程交互,来对热更新过程有个初步了解。

项目启动之后,会进行首次构建打包,控制台中会输出整个构建过程。

在浏览器websocket通讯中可以看到服务端告知编译后的hash值

在代码修改后,可以在控制台中观察到新生成文件,注意到新生成的文件hash值是上一次编译后告知浏览器的hash值。

  • main.86ed99e1dcba0ac82fdf.hot-update.js
  • main.86ed99e1dcba0ac82fdf.hot-update.json

这时候再去看浏览器websocket通讯,后端又告知了最新编译后的hash值。

之后前端向后端依次进行 json,js 文件的请求,文件拼接的hash值是上一次后端通知的值。

而最新告知的hash值留待下次进行文件请求进行hash拼接。

点开查看 main.hash.hot-update.json 请求,返回的结果中,c(main) 表示当前要热更新的文件名是 main。m(remove)表示移除的文件(包含路径)。

查看 main.hash.hot-update.js,返回的内容是使用 webpackHotUpdate+当前的项目名(webpack_test) 标识的 main 内容。

如果没有任何改动,对 child.js 文件直接保存,控制台输出编译打包信息,并没有生成新的文件和hash值。

控制台输出如下

assets by status 261 KiB [cached] 1 asset
cached modules 173 KiB (javascript) 27.4 KiB (runtime) [cached] 39 modules
./src/child.js 58 bytes [built]
webpack 5.90.3 compiled successfully in 126 ms

websocket通讯如下:

HMR流程概述

接下来我们开始从源码角度,简述 HMR 实现热更新的过程。

上图是 webpack 配合 webpack-dev-server 进行应用开发的模块热更新流程图。

  • 上图底部红色框内是服务端,而上面的橙色框内是浏览器端。
  • 绿色填充的方框是 webpack 代码控制的区域。深蓝色填充的方框是 webpack-dev-server 代码控制的区域,洋红色填充的方框是文件系统,文件修改后的变化就发生在这,而青色填充的方框是应用本身。

上图显示了我们修改代码到模块热更新完成的一个周期,通过深绿色的阿拉伯数字符号已经将 HMR 的整个过程标识了出来。

  1. 在 webpack 的 watch 模式下,文件系统中某一个文件发生修改,webpack 监听到文件变化,根据配置文件对模块重新编译打包。

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

  3. webpack-dev-server 对文件变化的一个监控,这一步不同于第一步,并不是监控代码变化重新打包。当我们在配置文件中配置了 static 属性时,webpack-dev-server 会监听这些文件夹中静态文件的变化,变化后会通知浏览器端对应用进行 window.location.reload()。注意,这儿是浏览器刷新,和 HMR 是两个概念。

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

  5. webpack-dev-server/client 端并不能够请求更新的代码,也不会执行热更新模块操作,而把这些工作又交回给了 webpack,webpack/hot/dev-server 的工作就是根据 webpack-dev-server/client 传给它的信息以及 webpack-dev-server 的配置决定是刷新浏览器呢还是进行模块热更新。当然如果仅仅是刷新浏览器,也就没有后面那些步骤了。

  6. HotModuleReplacement.runtime 是客户端 HMR 的中枢,它接收到上一步传递给他的新模块的 hash 值,它通过 webpack_require.hmrM 向 server 端发送 fetch 请求,服务端返回一个 json,该 json 包含模块变更的信息的 json 文件,模块名与 hash进行组合获取到更新列表后,该模块再次通过 jsonp 请求,获取到最新的模块代码。这就是上图中 7、8、9 步骤。

  7. 而第 10 步是决定 HMR 成功与否的关键步骤,在该步骤中,HotModuleReplacement.runtime 将会对新旧模块进行对比,决定是否更新模块,在决定更新模块后,检查模块之间的依赖关系,更新模块的同时更新模块间的依赖引用。最后一步,当 HMR 失败后,回退到 window.location.reload() 操作,也就是进行浏览器刷新来获取最新打包代码。

HMR实现细节

下面我们通过分析源代码,来对热更新过程进行更深一层了解,此次分析具体实现时仅关注核心代码实现。

初始化

热更新开始,new Server() 后会直接调用 server.start()。

// node_modules/webpack-dev-server/lib/Server.js

class Server {
	async start() {
		await this.normalizeOptions();
		await this.initialize();

		if (this.options.webSocketServer) {
			this.createWebSocketServer();
		}
	}
}

可以看到在 start 方法中,即开始进行webSocket服务的初始化。

normalizeOptions 方法构造 webSocket 请求地址。最终得到的结果为:‘protocol=ws:&hostname=0.0.0.0&port=9000&pathname=/ws’

// node_modules/webpack-dev-server/lib/Server.js

async normalizeOptions() {
	const { options } = this;
	options.client.webSocketURL = {
		protocol: parsedURL.protocol,
		hostname: parsedURL.hostname,
		port: parsedURL.port.length > 0 ? Number(parsedURL.port) : "",
		pathname: parsedURL.pathname,
		username: parsedURL.username,
		password: parsedURL.password,
	};


	const defaultWebSocketServerOptions = { path: "/ws" };

	if (typeof options.webSocketServer === "undefined") {
		options.webSocketServer = {
			type: defaultWebSocketServerType,
			options: defaultWebSocketServerOptions,
		};
	}
}

执行 createWebSocketServer 方法,创建websocket服务

// node_modules/webpack-dev-server/lib/Server.js

createWebSocketServer() {
	this.webSocketServer = new (this.getServerTransport())(this); // this.webSocketServer = new WebsocketServer(this);

	if (this.options.hot === true || this.options.hot === "only") {
		this.sendMessage([client], "hot");
	}
	if (this.options.liveReload) {
		this.sendMessage([client], "liveReload");
	}
	this.sendStats([client], this.getStats(this.stats), true);
}

getServerTransport() {
	let implementation;
	if (this.options.webSocketServer.type === "ws") {
		implementation = require("./servers/WebsocketServer");
	}
	return implementation;
}

start 函数中,还有 initialize 方法没有看,这个函数中首先执行 addAdditionalEntries 方法,进行客户端的初始化。添加 node_modules/webpack-dev-server/client/index.js 和 node_modules/webpack/hot/dev-server.js 到入口文件中。

// node_modules/webpack-dev-server/lib/Server.js

async initialize() {
	compilers.forEach((compiler) => {
		this.addAdditionalEntries(compiler);

		if (this.options.hot) {
			// Apply the HMR plugin
			const plugin = new webpack.HotModuleReplacementPlugin();
			plugin.apply(compiler);
		}
	});
}

addAdditionalEntries(compiler) {
	let additionalEntries = [];
	if (this.options.webSocketServer) {
		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"));
	}

	if (typeof webpack.EntryPlugin !== "undefined") {
		// node_modules/webpack-dev-server/client/index.js?protocol=ws%3A&hostname=0.0.0.0&port=9000&pathname=%2Fws&logging=info&overlay=true&reconnect=10&hot=true&live-reload=true
		// node_modules/webpack/hot/dev-server.js
		for (const additionalEntry of additionalEntries) {
			new webpack.EntryPlugin(compiler.context, additionalEntry, {
					name: undefined,
			}).apply(compiler);
		}
	}
}

initialize 中还有如下函数,接下来我们对重点进行介绍。

// node_modules/webpack-dev-server/lib/Server.js

async initialize() {
	this.setupHooks();
	this.setupApp();
	this.setupDevMiddleware();
	this.createServer();
}

注册监听编译完成事件

首先执行的是 setupHooks 方法来注册监听事件的,监听每次 webpack 编译完成,该方式利用的是 webpack 的 done 钩子。

// node_modules/webpack-dev-server/lib/Server.js

setupHooks() {
	this.compiler.hooks.done.tap(
		"webpack-dev-server",
		(stats) => {
			...
		},
	);
}

启动服务

接着执行 setupApp 方法,启动node静态资源服务,可以让浏览器可以请求本地的静态资源。

// node_modules/webpack-dev-server/lib/Server.js

const getExpress = memoize(() => require("express"));
setupApp() {
	this.app = new getExpress();
}

在 initialize 方法的最后,执行了 createServer 方法

createServer() {
		this.server = require("http").createServer(
				options,
				this.app
		);

		this.server.on("connection", (socket) => {
				// Add socket to list
				this.sockets.push(socket);
		});
}

监听文件代码变化

每次修改代码,就会触发编译。说明我们还需要监听本地代码的变化,这里主要是通过 setupDevMiddleware 方法实现的。

// node_modules/webpack-dev-server/lib/Server.js

setupDevMiddleware() {
	const webpackDevMiddleware = require("webpack-dev-middleware");

	// middleware for serving webpack bundle
	this.middleware = webpackDevMiddleware(
		this.compiler,
		this.options.devMiddleware,
	);
}

webpack-dev-middleware 内置于 webpack-dev-server,主要是用于监测代码文件变化,处理文件编译等流程。那我们来看下 webpack-dev-middleware 源码里做了什么事。

// node_modules/webpack-dev-middleware/dist/index.js

function wdm() {
	const context = { compiler };

	// 若writeToDisk配置项为true,则打包到磁盘
	if (options.writeToDisk) {
		setupWriteToDisk(context);
	}

	// 打包到内存(通过memfs)
	setupOutputFileSystem(context); 

	// 开始监听
	context.compiler.watch(watchOptions, errorHandler);
}

当 writeToDisk 进行了配置,则进行编译,并将编译后的文件输出到磁盘。

执行 setupOutputFileSystem 方法,这个方法主要目的就是将编译后的文件打包到内存。这就是为什么在开发的过程中,你会发现 dist 目录没有打包后的代码,因为都在内存中。原因就在于访问内存中的代码比访问文件系统中的文件更快,而且也减少了代码写入文件的开销,这一切都归功于memfs。

为什么代码的改动保存会自动编译,重新打包?这一系列的重新检测编译就归功于 compiler.watch 这个方法了,该方法开启对本地文件的监听,当文件发生变化,重新编译,编译完成之后继续监听。监听本地文件的变化主要是通过文件的生成时间是否有变化。

每个打包的文件作为一个简单的 javascript 对象保存在了内存中,当浏览器请求该文件时,上一步开启的静态资源服务直接去内存中找保存的 javascript 对象返回给浏览器端。

我们可以继续深入了解,compiler 中 watch 的具体实现

// node_modules/webpack/lib/Compiler.js

watch(watchOptions, handler) {
   this.watching = new Watching(this, watchOptions, handler);
   return this.watching;
}

第一次会主动触发this._go()进行编译

// node_modules/webpack/lib/Watching.js

watch(files, dirs, missing) {
 this.watcher = this.compiler.watchFileSystem.watch(...args, () => {
     this._invalidate(
         fileTimeInfoEntries,
         contextTimeInfoEntries,
         changedFiles,
         removedFiles
     );
     this._onChange();
 });
}

_invalidate() {
 this._go(...args);
}

// Watching.js的constructor()->_invalidate()->_go()
_go(fileTimeInfoEntries, contextTimeInfoEntries, changedFiles, removedFiles) {
   const run = () => {
       this.compiler.compile(onCompiled);
   };
   run();
}

执行编译

// node_modules/webpack/lib/Compiler.js
compile(callback) {
   this.hooks.make.callAsync(compilation, err => {});
}

每次编译结束时注册监听

// node_modules/webpack/lib/Watching.js

_done(err, compilation) {
 this.watch(
     compilation.fileDependencies,
     compilation.contextDependencies,
     compilation.missingDependencies
 );
}

服务端发送消息

在热更新开始时,代码中执行了注册监听事件的逻辑。监听的完整实现如下。

// node_modules/webpack-dev-server/lib/Server.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 方法。

// node_modules/webpack-dev-server/lib/Server.js

// Send stats to a socket or multiple sockets
sendStats(clients, stats, force) {
	// 更新当前的hash
	this.currentHash = stats.hash;
	// 发送给客户端当前的hash值
	this.sendMessage(clients, "hash", stats.hash);
	// 发送给客户端ok的指令
	this.sendMessage(clients, "ok");
}

通过 websocket 给浏览器发送通知,ok 和 hash 事件,这样浏览器就可以拿到最新的 hash 值了,做检查更新逻辑。

客户端收到消息

那客户端和服务端如何通讯的呢?打开浏览器开发者调试工具,可以看到在 webpack 打包好的 output.js 中包含了以下代码。

__webpack_require__("./node_modules/webpack-dev-server/client/index.js?protocol=ws%3A&hostname=0.0.0.0&port=8080&pathname=%2Fws&logging=info&overlay=true&reconnect=10&hot=true&live-reload=true");

在上文介绍过初始化过程会中注入 webpack-dev-server/client/index.js 和 webpack/hot/dev-server.js 到入口文件中。

  • webpack-dev-server/client/index.js

    首先这个文件用于 websocket 的。我们在 webpack-dev-server 初始化的过程中,启动的是本地服务端的 websocket。那客户端也就是我们的浏览器,浏览器还没有和服务端通信的代码呢?因此我们需要把websocket客户端通信代码偷偷塞到我们的代码中。

  • webpack/hot/dev-server.js

    这个文件主要是用于检查更新逻辑的。

下面重点讲的就是 sendStats 方法中的 ok 和 hash 事件都做了什么。

// node_modules/webpack-dev-server/client/index.js

import reloadApp from "./utils/reloadApp.js";

var onSocketMessage = {
  hash: function hash(_hash) {
    status.previousHash = status.currentHash;
    status.currentHash = _hash;
  },
  ok: function ok() {
    sendMessage("Ok");
    reloadApp(options, status);
  },
};

var socketURL = createSocketURL(parsedResourceQuery);
socket(socketURL, onSocketMessage, options.reconnect);

webpack-dev-server/client/index.js 当接收到 hash 消息后会将 hash 值暂存到 currentHash 变量,当接收到 ok 的消息后执行 reloadApp 方法。且 hash 消息是在 ok 消息之前。

热更新检查事件是调用reloadApp方法。

// node_modules/webpack-dev-server/client/utils/reloadApp.js

import hotEmitter from "webpack/hot/emitter.js";
function reloadApp(_ref, status) {
 function applyReload(rootWindow, intervalId) {
     rootWindow.location.reload();
 }

 var search = self.location.search.toLowerCase();
 var allowToHot = search.indexOf("webpack-dev-server-hot=false") === -1;
 var allowToLiveReload = search.indexOf("webpack-dev-server-live-reload=false") === -1;

 if (hot && allowToHot) {
     hotEmitter.emit("webpackHotUpdate", status.currentHash);
 }
 else if (liveReload && allowToLiveReload) {
     // 根据条件判断执行applyReload()方法
 }
}

如果配置了模块热更新,则执行 hotEmitter.emit(“webpackHotUpdate”, status.currentHash) 将最新 hash 值发送给 webpack,然后将控制权交给 webpack 客户端代码。如果没有配置模块热更新,就直接调用 location.reload 方法刷新页面。

比较奇怪的是,这个方法利用 node.js 的 EventEmitter,发出webpackHotUpdate 消息。

// node_modules/webpack/hot/emitter.js
var EventEmitter = require("events");
module.exports = new EventEmitter();

这是为什么?为什么不直接进行检查更新呢?

个人理解就是为了更好的维护代码,以及职责划分的更明确。websocket 仅仅用于客户端和服务端进行通信。而真正做事情的活还是交回给了webpack。即 webpack/hot/dev-server.js 监听 webpack-dev-server/client/index.js 发送的 webpackHotUpdate 消息。

webpack/hot/dev-server.js 监听到 webpackHotUpdate 的消息后,获取到最新的hash值,然后进行检查更新了,调用 module.hot.check 方法。

module.hot.check(true) 触发,然后判断是否需要重启。

// node_modules/webpack/hot/dev-server.js

if (module.hot) {
	var lastHash;
	var check = function check() {
		module.hot
			.check(true)
			.then(function (updatedModules) {
				if (!updatedModules) {
					// 容错,直接刷新页面
					if (typeof window !== "undefined") {
						window.location.reload();
					}
					return;
				}
			})
	};
	var hotEmitter = require("./emitter");
	hotEmitter.on("webpackHotUpdate", function (currentHash) {
		lastHash = currentHash;
	});

	check();
}

问题又来了,module.hot.check 又是哪里冒出来了的!可以通过阅读下面的说明得到答案。

在编译形成最终代码时,会注入 HotModuleReplacement.runtime.js 代码,拦截require,进行 createRequire 和 createModuleHotObject。

  • createRequire

    构建当前 request 的 parent 和 children,本质是在 require 的基础上保存各个模块之间的依赖关系,为后面的热更新做准备,因为一个文件的更新必定涉及到另外依赖模块的相关更新。

  • createModuleHotObject

    构建当前 module 的 hotAPI,后面的热更新都需要通过 hotCheck 和 hotApply 进行操作。

// node_modules/webpack/lib/hmr/HotModuleReplacement.runtime.js

function __webpack_require__(moduleId) {
   var execOptions = { id: moduleId, module: module, factory: __webpack_modules__[moduleId], require: __webpack_require__ };
   __webpack_require__.i.forEach(function (handler) { handler(execOptions); });

   return module.exports;
}


__webpack_require__.i.push(function (options) {
   var module = options.module;
   var require = createRequire(options.require, options.id);
   module.hot = createModuleHotObject(options.id, module);
   module.parents = currentParents;
   module.children = [];
   currentParents = [];
   options.require = require;
});


function createRequire(require, moduleId) {
   var me = installedModules[moduleId];
   var fn = function (request) {
       if (me.hot.active) {
           if (installedModules[request]) {
               var parents = installedModules[request].parents;
               if (parents.indexOf(moduleId) === -1) {
                   parents.push(moduleId);
               }
           } else {
               currentParents = [moduleId];
               currentChildModule = request;
           }
           if (me.children.indexOf(request) === -1) {
               me.children.push(request);
           }
       } else {
           currentParents = [];
       }
       return require(request);
   };
   return fn;
}

function createModuleHotObject(moduleId, me) {
 var hot = {
			active: true,
			accept: function (dep, callback, errorHandler) {
     },
     check: hotCheck,
     apply: hotApply,
     data: currentModuleData[moduleId]
 };
 currentChildModule = undefined;
 return hot;
}

module.hot.check 最终会触发 hotCheck() 方法。

热更新文件请求

进入 HotCheck 方法,利用上一次保存的 hash 值,调用 __webpack_require__.hmrM 发送获取 app.hash.hot-update.json 的 fetch 请求,得到 update = {c:[“main”], m:[], r:[]} 的更新内容。

// node_modules/webpack/lib/hmr/HotModuleReplacement.runtime.js

function hotCheck(applyOnUpdate) {
   return setStatus("check")
       .then(__webpack_require__.hmrM) // 为fetch("http://localhost:8080/main.fc1c69066ce336693703.hot-update.json")
       .then(function (update) {
          // update = {c:["main"], m:[], r:[]} 更新内容

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

               return Promise.all(
                   Object.keys(__webpack_require__.hmrC).reduce(function (
                       promises,
                       key
                   ) {
                       // key=jsonp
                       // __webpack_require__.hmrC[key](
                       //     update.c,
                       //     update.r,
                       //     update.m,
                       //     promises,
                       //     currentUpdateApplyHandlers,
                       //     updatedModules
                       // ); ===> 转化为jsonp,便于理解
                       __webpack_require__.hmrC.jsonp(update.c, update.r, update.m, promises, currentUpdateApplyHandlers, updatedModules);
                       // chunkIds, removedChunks, removedModules, promises, applyHandlers, updatedModulesList
                       return promises;
                   },
                       [])
               ).then(function () {
                   return waitForBlockingPromises(function () { // 等待所有的promise更新完成
                       if (applyOnUpdate) {
                           // hotCheck(true)
                           return internalApply(applyOnUpdate);
                       } else {
                           return setStatus("ready").then(function () {
                               return updatedModules;
                           });
                       }
                   });
               });
           });
       });
}

__webpack_require__.hmrM = () => {
 if (typeof fetch === "undefined") throw new Error("No browser support: need fetch API");

 // 保留的是client客户端的域名:
 __webpack_require__.p = "http://localhost:8080/"

 // 保留的是上一次的hash值:
 __webpack_require__.h = () => ("fc1c69066ce336693703")

  __webpack_require__.hmrF = () => ("main." + __webpack_require__.h() + ".hot-update.json"); 
 // fetch("http://localhost:8080/main.fc1c69066ce336693703.hot-update.json")
 return fetch(__webpack_require__.p + __webpack_require__.hmrF()).then((response) => {
     return response.json();
 });
};

用上一步获取到的 app.hash.hot-update.json 请求结果来进一步来获取热更新js模块,触发 __webpack_require__.hmrC.jsonp() 通过 JSONP 方式请求 app.hash.hot-update.js,并进入热更新准备阶段。

// node_modules/webpack/lib/hmr/HotModuleReplacement.runtime.js

// $hmrDownloadUpdateHandlers$[key] => runtime转化为:
__webpack_require__.hmrC.jsonp = function (chunkIds, ...) {
 applyHandlers.push(applyHandler);

 chunkIds.forEach(function (chunkId) {
		// 拼接jsonp请求的url
		promises.push($loadUpdateChunk$(chunkId, updatedModulesList));
 });
};

// 拼接jsonp请求的url
var waitingUpdateResolves = {};
function loadUpdateChunk(chunkId, updatedModulesList) {
 return new Promise((resolve, reject) => {

   waitingUpdateResolves[chunkId] = resolve;
   
     __webpack_require__.hu = "" + chunkId + "." + __webpack_require__.h() + ".hot-update.js";
     var url = __webpack_require__.p + __webpack_require__.hu(chunkId);
     __webpack_require__.l(url, loadingEnded);
 });
}

// document.body.appendChild(new Script()),正式发起get请求(jsonp请求)
var inProgress = {};
__webpack_require__.l = (url, done, key, chunkId) => {
 inProgress[url] = [done];
 var onScriptComplete = (prev, event) => {
     var doneFns = inProgress[url];
     delete inProgress[url];
     script.parentNode && script.parentNode.removeChild(script);
     doneFns && doneFns.forEach((fn) => (fn(event)));
 };
 script.onload = onScriptComplete.bind(null, script.onload);
 needAttach && document.head.appendChild(script);
};

创建 http://localhost:8080/main.f1bcf354bbddd26daa90.hot-update.js 的 promise 请求,并且加入到 promise 数组中。

这里要解释下为什么使用 JSONP 获取最新代码?主要是因为JSONP获取的代码可以直接执行进行更新。为什么要直接执行?我们来回忆下app.hash.hot-update.js的代码格式是怎么样的。

可以发现,新编译后的代码是在一个webpackHotUpdate函数体内部的。也就是要立即执行 webpackHotUpdate 这个方法。

output.js 在 window 对象上定义了 webpackHotUpdate+当前的项目名(webpack_test) 方法;在这里定义了如何解析前面 app.hash.hot-update.js 请求返回的js内容。 webpackHotUpdate+当前的项目名(webpack_test)(chunkId, moreModules, runtime),直接遍历 moreModules,并且执行更新。

// app.hash.hot-update.js

self["webpackHotUpdate"] = (chunkId,moreModules,runtime)=>{
	for (var moduleId in moreModules) {
			if (__webpack_require__.o(moreModules, moduleId)) {
					currentUpdate[moduleId] = moreModules[moduleId];
					if (currentUpdatedModulesList)
							currentUpdatedModulesList.push(moduleId);
			}
	}
	if (runtime)
			currentUpdateRuntime.push(runtime);
	if (waitingUpdateResolves[chunkId]) {
			waitingUpdateResolves[chunkId]();
			waitingUpdateResolves[chunkId] = undefined;
	}
}

在js文件立即执行对应的 module 代码的缓存并且触发对应 promise 的 resolve 请求,从而顺利回调 internalApply() 方法

热更新代码替换

最终会调用 module.hot.apply 内部方法 internalApply 进行代码替换。

// node_modules/webpack/lib/hmr/HotModuleReplacement.runtime.js

function internalApply(options) {
		options = options || {};
		applyInvalidatedModules();
		// 这里的currentUpdateApplyHandlers存储的是上面jsonp请求js文件所创建的callback
		var results = currentUpdateApplyHandlers.map(function(handler) {
				return handler(options);
		});
		currentUpdateApplyHandlers = undefined;
		var errors =
		.map(function(r) {
				return r.error;
	
		.filter(Boolean);
		if (errors.length > 0) {
				return setStatus("abort").then(function() {
						throw errors[0];
				});
		}
		// Now in "dispose" phase
		var disposePromise = setStatus("dispose");
		results.forEach(function(result) {
				if (result.dispose)
						result.dispose();
		});
		// Now in "apply" phase
		var applyPromise = setStatus("apply");
		var error;
		var reportError = function(err) {
				if (!error)
						error = err;
		};
		var outdatedModules = [];
		results.forEach(function(result) {
				if (result.apply) {
						// 这里的result的是上面jsonp请求js文件所创建的callback所返回Object的apply方法
						var modules = result.apply(reportError);
						if (modules) {
								for (var i = 0; i < modules.length; i++) {
										outdatedModules.push(modules[i]);
								}
						}
				}
		});
		return Promise.all([disposePromise, applyPromise]).then(function() {
				// handle errors in accept handlers and self accepted module load
				if (error) {
						return setStatus("fail").then(function() {
								throw error;
						});
				}
				if (queuedInvalidatedModules) {
						return internalApply(options).then(function(list) {
								outdatedModules.forEach(function(moduleId) {
										if (list.indexOf(moduleId) < 0)
												list.push(moduleId);
								});
								return list;
						});
				}
				return setStatus("idle").then(function() {
						return outdatedModules;
				});
		});
}

问题思考

  1. HMR是怎样实现自动编译的?

    webpack通过watch可以监听文件的变化进行文件编译。

  2. 开发的过程中,并没有在 dist 目录中找到 webpack 打包好的文件,它们去哪呢?

    webpack 编译后,webpack-dev-middleware 通过 memfs 将文件打包到了内存中,不生成文件的原因就在于访问内存中的代码比访问文件系统中的文件更快,而且也减少了代码写入文件的开销。

  3. 编译后新产生的两个文件又是干嘛的?

    main.hash.hot-update.json

    告知哪些chunk发生了改变,以及移除哪些chunk

    main.hash.hot-update.js

    告知浏览器,main 代码块中的./src/xxx.js模块变更的内容

    首先是通过fetch的方式,利用上一次保存的hash值请求hot-update.json文件。这个描述文件的作用就是提供了修改的文件所在的chunkId。

    然后通过JSONP的方式,利用hot-update.json返回的chunkId及上一次保存的hash 拼接文件名进而获取文件内容。

  4. 模块内容的变更浏览器又是如何感知的?

    webpack-dev-middleware利用sockjs和webpack-dev-server/client建立webSocket长连接。将webpack的编译编译打包的各个阶段告诉浏览器端。主要告诉新模块hash的变化,但是webpack-dev-server/client是无法获取更新的代码的,通过webpack/hot/server获取更新的模块,然后HMR对比更新模块和模块的依赖。

  5. webpack-dev-server 依赖 webpack-dev-middleware 库,那么 webpack-dev-middleware 在 HMR 过程中扮演什么角色?

    webpack-dev-middleware扮演是中间件的角色,一头可以调用webpack暴露的API检测代码的变化,一头可以通过sockjs和webpack-dev-server/client建立webSocket长连接,将webapck打包编译的各个阶段发送给浏览器端。

  6. 怎么实现局部更新的?

    当hot-update.js文件加载好后,就会执行window.webpackHotUpdate,进而调用了hotApply。hotApply根据模块ID找到旧模块然后将它删除,然后执行父模块中注册的accept回调,从而实现模块内容的局部更新。

  7. 使用 HMR 的过程中,通过 Chrome 开发者工具我们知道浏览器是通过 websocket 和 webpack-dev-server 进行通信的,但是 websocket 的 message 中并没有发现新模块代码。打包后的新模块又是通过什么方式发送到浏览器端的呢?为什么新的模块不通过 websocket 随消息一起发送到浏览器端呢?

    功能块的解耦,各个模块各司其职,webpack-dev-server/client 只负责消息的传递而不负责新模块的获取,而这些工作应该有 HMR runtime 来完成,HMR runtime 才应该是获取新代码的地方。

  8. 当模块的热替换过程中,如果替换模块失败,有什么回退机制吗?

    模块热更新的错误处理,如果在热更新过程中出现错误,热更新将回退到刷新浏览器

Logo

开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!

更多推荐