HMR原理解析

webpack HMR 原理解析

Talk is cheap, show me the code

Hot Module Replacement (HMR)是当今webpack提供的特别有用的功能之一,它允许在运行时更新所有类型的模块,而无需完全刷新。

启动Wepack

HMR原理解析

上图先:

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

具体步骤如下:

  • 第一步,在 webpack 的 watch 模式下,文件系统中某一个文件发生修改,webpack 监听到文件变化,根据配置文件对模块重新编译打包,并将打包后的代码通过简单的 JavaScript 对象保存在内存中。
  • 第二步是 webpack-dev-server 和 webpack 之间的接口交互,而在这一步,主要是 dev-server 的中间件 webpack-dev-middleware 和 webpack 之间的交互,webpack-dev-middleware 调用 webpack 暴露的 API对代码变化进行监控,并且告诉 webpack,将代码打包到内存中。
  • 第三步是 webpack-dev-server 对文件变化的一个监控,这一步不同于第一步,并不是监控代码变化重新打包。当我们在配置文件中配置了devServer.watchContentBase 为 true 的时候,Server 会监听这些配置文件夹中静态文件的变化,变化后会通知浏览器端对应用进行 live reload。注意,这儿是浏览器刷新,和 HMR 是两个概念
  • 第四步也是 webpack-dev-server 代码的工作,该步骤主要是通过 sockjs(webpack-dev-server 的依赖)在浏览器端和服务端之间建立一个 websocket 长连接,将 webpack 编译打包的各个阶段的状态信息告知浏览器端,同时也包括第三步中 Server 监听静态文件变化的信息。浏览器端根据这些 socket 消息进行不同的操作。当然服务端传递的最主要信息还是新模块的 hash 值,后面的步骤根据这一 hash 值来进行模块热替换。
  • webpack-dev-server/client 端并不能够请求更新的代码,也不会执行热更模块操作,而把这些工作又交回给了 webpack,webpack/hot/dev-server 的工作就是根据 webpack-dev-server/client 传给它的信息以及 dev-server 的配置决定是刷新浏览器呢还是进行模块热更新。当然如果仅仅是刷新浏览器,也就没有后面那些步骤了。
  • HotModuleReplacement.runtime 是客户端 HMR 的中枢,它接收到上一步传递给他的新模块的 hash 值,它通过 JsonpMainTemplate.runtime 向 server 端发送 Ajax 请求,服务端返回一个 json,该 json 包含了所有要更新的模块的 hash 值,获取到更新列表后,该模块再次通过 jsonp 请求,获取到最新的模块代码。这就是上图中 7、8、9 步骤。
  • 而第 10 步是决定 HMR 成功与否的关键步骤,在该步骤中,HotModulePlugin 将会对新旧模块进行对比,决定是否更新模块,在决定更新模块后,检查模块之间的依赖关系,更新模块的同时更新模块间的依赖引用。
  • 最后一步,当 HMR 失败后,回退到 live reload 操作,也就是进行浏览器刷新来获取最新打包代码。

源码解析

首先开启HMR,在webpack.config.js中添加

1
2
3
4
5
6
7
8
devServer: {
hot: true
}
...
plugins: [
new webpack.HotModuleReplacementPlugin(), // 热更新插件
]

然后修改代码,并保存。

watch

webpack-dev-middleware中调用 webpack 的 api 对文件系统 watch, webpack 重新对文件进行编译打包,然后保存到内存中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// webpack-dev-middleware/index.js
// start watching
if (!options.lazy) {
context.watching = compiler.watch(options.watchOptions, (err) => {
if (err) {
context.log.error(err.stack || err);
if (err.details) {
context.log.error(err.details);
}
}
});
} else {
context.state = true;
}

不生成文件的原因就在于访问内存中的代码比访问文件系统中的文件更快,而且也减少了代码写入文件的开销

这一切都归功于memory-fs,memory-fs 是 webpack-dev-middleware 的一个依赖库,webpack-dev-middleware 将 webpack 原本的 outputFileSystem 替换成了MemoryFileSystem 实例,这样代码就将输出到内存中

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
26
27
28
29
30
31
// webpack-dev-middleware/lib/fs.js
const isMemoryFs =
!isConfiguredFs &&
!compiler.compilers &&
compiler.outputFileSystem instanceof MemoryFileSystem;
if (isConfiguredFs) {
// eslint-disable-next-line no-shadow
const { fs } = context.options;
if (typeof fs.join !== 'function') {
// very shallow check
throw new Error(
'Invalid options: options.fs.join() method is expected'
);
}
// eslint-disable-next-line no-param-reassign
compiler.outputFileSystem = fs;
fileSystem = fs;
} else if (isMemoryFs) {
fileSystem = compiler.outputFileSystem;
} else {
fileSystem = new MemoryFileSystem();
// eslint-disable-next-line no-param-reassign
compiler.outputFileSystem = fileSystem;
}
// eslint-disable-next-line no-param-reassign
context.fs = fileSystem;

这样 bundle.js 文件代码就作为一个简单 javascript 对象保存在了内存中,当浏览器请求 bundle.js 文件时,devServer就直接去内存中找到上面保存的 javascript 对象返回给浏览器端

devServer通知浏览器文件发生改变

sockjs 是服务端和浏览器端之间的桥梁,在启动 devServer 的时候,sockjs 在服务端和浏览器端建立了一个 webSocket 长连接,以便将 webpack 编译和打包的各个阶段状态告知浏览器

关键点在webpack-dev-server 调用 webpack api 监听 compile的 done 事件,当compile 完成后,webpack-dev-server通过 _sendStatus 方法将编译打包后的新模块 hash 值发送到浏览器端。

Server中给done事件添加钩子函数,在compile完成后调用_sendStatus。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// webpack-dev-server/lib/Server.js
setupHooks() {
// Listening for events
const invalidPlugin = () => {
this.sockWrite(this.sockets, 'invalid');
};
const addHooks = (compiler) => {
const { compile, invalid, done } = compiler.hooks;
compile.tap('webpack-dev-server', invalidPlugin);
invalid.tap('webpack-dev-server', invalidPlugin);
done.tap('webpack-dev-server', (stats) => {
this._sendStats(this.sockets, this.getStats(stats));
this._stats = stats;
});
};
if (this.compiler.compilers) {
this.compiler.compilers.forEach(addHooks);
} else {
addHooks(this.compiler);
}
}

webpack-dev-server/client 接收到服务端消息做出响应

我们本身没有在bundle.js和entry中加入ws相关代码,这里客户端是如何接受ws消息通信的呢?

是因为webpack-dev-server 修改了webpack 配置中的 entry 属性,在里面添加了 webpack-dev-client 的代码,这样在最后的 bundle.js 文件中就会有接收 websocket 消息的代码了。

webpack-dev-server/client 当接收到 type 为 hash 消息后会将 hash 值暂存起来,当接收到 type 为 ok 的消息后对应用执行 reload 操作

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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
// 接受socket消息
var onSocketMsg = {
hot: function hot() {
_hot = true;
log.info('[WDS] Hot Module Replacement enabled.');
},
liveReload: function liveReload() {
_liveReload = true;
log.info('[WDS] Live Reloading enabled.');
},
invalid: function invalid() {
log.info('[WDS] App updated. Recompiling...'); // fixes #1042. overlay doesn't clear if errors are fixed but warnings remain.
if (useWarningOverlay || useErrorOverlay) {
overlay.clear();
}
sendMsg('Invalid');
},
hash: function hash(_hash) {
currentHash = _hash;
},
'still-ok': function stillOk() {
log.info('[WDS] Nothing changed.');
if (useWarningOverlay || useErrorOverlay) {
overlay.clear();
}
sendMsg('StillOk');
},
...
ok: function ok() {
sendMsg('Ok');
if (useWarningOverlay || useErrorOverlay) {
overlay.clear();
}
if (initial) {
return initial = false;
} // eslint-disable-line no-return-assign
reloadApp();
},
...
}
// reloadApp
function reloadApp() {
if (isUnloading || !hotReload) {
return;
}
if (_hot) {
log.info('[WDS] App hot update...'); // eslint-disable-next-line global-require
var hotEmitter = require('webpack/hot/emitter');
hotEmitter.emit('webpackHotUpdate', currentHash);
if (typeof self !== 'undefined' && self.window) {
// broadcast update to window
self.postMessage("webpackHotUpdate".concat(currentHash), '*');
}
} // allow refreshing the page only if liveReload isn't disabled
else if (_liveReload) {
var rootWindow = self; // use parent window for reload (in case we're in an iframe with no valid src)
var intervalId = self.setInterval(function () {
if (rootWindow.location.protocol !== 'about:') {
// reload immediately if protocol is valid
applyReload(rootWindow, intervalId);
} else {
rootWindow = rootWindow.parent;
if (rootWindow.parent === rootWindow) {
// if parent equals current window we've reached the root which would continue forever, so trigger a reload anyways
applyReload(rootWindow, intervalId);
}
}
});
}
function applyReload(rootWindow, intervalId) {
clearInterval(intervalId);
log.info('[WDS] App updated. Reloading...');
rootWindow.location.reload();
}
}

在 reload 操作中,webpack-dev-server/client 会根据 hot 配置决定是刷新浏览器还是对代码进行热更新(HMR)。

如果配置了热更新模块,就走热更新

1
2
3
var hotEmitter = require('webpack/hot/emitter');
hotEmitter.emit('webpackHotUpdate', currentHash);

webpack 接收到最新 hash 值验证并请求模块代码

webpack/hot/dev-server(以下简称 dev-server) 监听第三步 webpack-dev-server/client 发送的 webpackHotUpdate 消息

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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
/*
MIT License http://www.opensource.org/licenses/mit-license.php
Author Tobias Koppers @sokra
*/
/*globals window __webpack_hash__ */
if (module.hot) {
var lastHash;
var upToDate = function upToDate() {
return lastHash.indexOf(__webpack_hash__) >= 0;
};
var log = require("./log");
var check = function check() {
module.hot
.check(true) // check
.then(function(updatedModules) {
if (!updatedModules) {
log("warning", "[HMR] Cannot find update. Need to do a full reload!");
log(
"warning",
"[HMR] (Probably because of restarting the webpack-dev-server)"
);
window.location.reload();
return;
}
if (!upToDate()) {
check();
}
require("./log-apply-result")(updatedModules, updatedModules);
if (upToDate()) {
log("info", "[HMR] App is up to date.");
}
})
.catch(function(err) {
var status = module.hot.status();
if (["abort", "fail"].indexOf(status) >= 0) {
log(
"warning",
"[HMR] Cannot apply update. Need to do a full reload!"
);
log("warning", "[HMR] " + (err.stack || err.message));
window.location.reload();
} else {
log("warning", "[HMR] Update failed: " + (err.stack || err.message));
}
});
};
var hotEmitter = require("./emitter");
// 监听webpackHotUpdate消息
hotEmitter.on("webpackHotUpdate", function(currentHash) {
lastHash = currentHash;
if (!upToDate() && module.hot.status() === "idle") {
log("info", "[HMR] Checking for updates on the server...");
check();
}
});
log("info", "[HMR] Waiting for update signal from WDS...");
} else {
throw new Error("[HMR] Hot Module Replacement is disabled.");
}

调用 webpack/lib/HotModuleReplacement.runtime(简称 HMR runtime)中的 check 方法,检测是否有新的更新,在 check 过程中会利用 webpack/lib/JsonpMainTemplate.runtime(简称 jsonp runtime)中的两个方法 hotDownloadUpdateChunk 和 hotDownloadManifest

第一个方法hotDownloadUpdateChunk是通过 jsonp 请求最新的模块代码,然后将代码返回给 HMR runtime,HMR runtime 会根据返回的新模块代码做进一步处理,可能是刷新页面,也可能是对模块进行热更新。

1
2
3
4
5
6
7
8
9
10
// webpack/lib/web/JsonpMaintemplate.runtime
// eslint-disable-next-line no-unused-vars
function hotDownloadUpdateChunk(chunkId) {
var script = document.createElement("script");
script.charset = "utf-8";
script.src = $require$.p + $hotChunkFilename$;
if ($crossOriginLoading$) script.crossOrigin = $crossOriginLoading$;
document.head.appendChild(script);
}

可以看出,这个函数的逻辑是向 HTML 文档插入 hot-update.js script 脚本。

第二个方法hotDownloadManifest是调用 AJAX 向服务端请求是否有更新的文件,如果有将发更新的文件列表返回浏览器端

为什么更新模块的代码不直接在第三步通过 websocket 发送到浏览器端,而是通过 jsonp 来获取呢?

可能是为了功能块的解耦,各个模块各司其职,dev-server/client 只负责消息的传递而不负责新模块的获取,而这些工作应该有 HMR runtime 来完成,HMR runtime 才应该是获取新代码的地方。再就是因为不使用 webpack-dev-server 的前提,使用 webpack-hot-middleware 和 webpack 配合也可以完成模块热更新流程,在使用 webpack-hot-middleware 中有件有意思的事,它没有使用 websocket,而是使用的 EventSource。综上所述,HMR 的工作流中,不应该把新模块代码放在 websocket 消息中。

HMR.runtime

模块热更新的整个过程都是发生在hotApply方法中。

从上面 hotApply 方法可以看出,模块热替换主要分三个阶段:

  • 第一个阶段是找出 outdatedModules 和 outdatedDependencies。
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
26
27
28
29
30
31
32
33
34
35
36
var idx;
var queue = outdatedModules.slice();
while (queue.length > 0) {
moduleId = queue.pop();
module = installedModules[moduleId];
if (!module) continue;
var data = {};
// Call dispose handlers
var disposeHandlers = module.hot._disposeHandlers;
for (j = 0; j < disposeHandlers.length; j++) {
cb = disposeHandlers[j];
cb(data);
}
hotCurrentModuleData[moduleId] = data;
// disable module (this disables requires from this module)
module.hot.active = false;
// remove module from cache
delete installedModules[moduleId];
// when disposing there is no need to call dispose handler
delete outdatedDependencies[moduleId];
// remove "parents" references from all children
for (j = 0; j < module.children.length; j++) {
var child = installedModules[module.children[j]];
if (!child) continue;
idx = child.parents.indexOf(moduleId);
if (idx >= 0) {
child.parents.splice(idx, 1);
}
}
}
  • 第二个阶段从缓存中删除过期的模块和依赖

// when disposing there is no need to call dispose handler
delete outdatedDependencies[moduleId];

  • 第三个阶段是将新的模块添加到 modules 中,当下次调用 __webpackrequire\_ (webpack 重写的 require 方法)方法的时候,就是获取到了新的模块代码了。
1
2
3
4
5
6
7
8
9
// webpack/lib/HotModuleReplacement.runtime
// insert new code
for (moduleId in appliedUpdate) {
if (Object.prototype.hasOwnProperty.call(appliedUpdate, moduleId)) {
modules[moduleId] = appliedUpdate[moduleId];
}
}

与浏览器live reload区别

这里需要注意三点就可以了。

  • live reload是没有保存应用状态的,即刷新之后页面的状态也会丢失。而webapck HMR 则不会刷新浏览器,而是运行时对模块进行热替换,保证了应用状态不会丢失,提升了开发效率。
  • 自动化。 可以自动刷新,不需要保存再手动刷新的流程
  • 兼容大多数框架,如React、Vue等

参考

饿了么前端-Webpack HMR 原理解析

阿里前端-Webpack HMR 原理解析

HMR热更新原理