在文章的开头,我想先抛出一些问题:
- webpack的热更新功能,需要哪些模块的支持?
- 一次热更新周期中,各个模块都做了哪些事情?
- webpack中的watchpatch模块做了什么事情?
- webpack-dev-server 和 webpack 中的 watchpack 是如何一起工作的?
- webpack一般会将构建的产物写入文件系统,但webpack-dev-server是将构建后的内容存在内存里了,这个是怎么实现的?(memfs)
- webpack-dev-server是将构建后的内容存在内存里了,我如何能看到构建后的产物呢?(好像不能,官方的做法是提供了一个writeToDisk相当于内存和磁盘里面写了两遍)
- 为什么要推荐react-refresh来替换react-hot-reload呢?支持hooks?
- react-refresh和@pmmmwh/react-refresh-webpack-plugin分别做了什么事情来支持React的热更新?
- 为什么某些情况下,我修改了文件内容,页面上的某个表单内容并没有保留?
- 为什么某些情况下,我的页面甚至直接刷新了?
Webpack实现热更新依赖的几个核心模块
- watchpack : webpack内置的watching能力,基于
EventEmitter
。当文件内容发生变化时,通知webpack触发重新构建。 - webpack-dev-server: 一个基于Express的服务,托管静态资源,并且通过WebSocket与客户端建立连接。HotModuleReplacementPlugin是webpack-dev-server内置的插件
- HotModuleReplacementPlugin : 向应用的主Chunk注入一系列热更新用的Runtime代码,用于建立WebSoket连接,处理hash消息、加载热更新的资源、提供用于处理更新策略的
module.hot.accept
接口 - react-refresh:react官方提供给react-native的hmr,由于其核心实现与平台无关,所以也适用于 Web。
- @pmmmwh/react-refresh-webpack-plugin : 这个插件通过 Webpack 的构建流程,使
react-refresh
的功能能够与 Webpack 构建集成。它会在 Webpack 配置中添加必要的插件和加载器,以便启用 React 组件的热更新。
热更新主流程
- **webpack-dev-server **中的webpack-dev-middleware会开启webpack 内置的watch模式,监听文件变化
- 同时 webpack-dev-server 中的 HotModuleReplacementPlugin 插件(webpack插件)会向应用的主 Chunk 注入一系列 HMR Runtime
- 当文件发生变动 webpack 的watch模块监听到文件变化触发webpack的重新构建
- webpack-dev-server 监听到webpack的this.compiler.hooks.done事件
- webpack-dev-server 在构建完成的回调中通过 WebSocket 向客户端发送模块hash消息
- 客户端的HMR Runtime在收到hash消息之后,发出mainfest请求获取本轮热更新涉及的chunk
- 客户端HMR Runtime开始下载变化的文件
- HMR Runtime触发变更模块的 module.hot.accept 回调
- 在module.hot.accept 中声明了如何将模块安全地替换为最新代码,替换的逻辑比较复杂,幸运的是大部分webpack loader已经帮我们做了这些事情,比如:
style-loader
内置 Css 模块热更vue-loader
内置 Vue 模块热更
我们结合源代码来看一下整个过程
- 我们使用webpack构建我们的react项目、并且使用webpack-dev-server来托管我们的静态资源
js
const webpack = require('webpack');
const WebpackDevServer = require('webpack-dev-server');
// Create a webpack compiler that is configured with custom messages.
const compiler = createCompiler({
appName,
config,
urls,
useYarn,
useTypeScript,
webpack,
});
...
// webpack-dev-server的配置
const serverConfig = {
...createDevServerConfig(proxyConfig, urls.lanUrlForConfig),
host: HOST,
port,
};
// 创建webpack-dev-server实例的时候,需要把webpack的compiler的示例传进去,这样才能共享一些状态,做一些联动
const devServer = new WebpackDevServer(serverConfig, compiler);
- webpack-dev-server内置的HotModuleReplacementPlugin插件会向应用的主 Chunk 注入一系列 HMR Runtime(热更新所需要的一些代码)
这是 HotModuleReplacementPlugin 向chunk注入runtime代码的源代码:
js
// node_modules/webpack/lib/HotModuleReplacementPlugin.js
compilation.hooks.additionalTreeRuntimeRequirements.tap(
PLUGIN_NAME,
// 在这里添加runtime代码
(chunk, runtimeRequirements) => {
// 这个方法用来请求 mainfest 文件
runtimeRequirements.add(RuntimeGlobals.hmrDownloadManifest);
// 这个方法用来修改下载修改的文件
runtimeRequirements.add(RuntimeGlobals.hmrDownloadUpdateHandlers);
runtimeRequirements.add(RuntimeGlobals.interceptModuleExecution);
runtimeRequirements.add(RuntimeGlobals.moduleCache);
compilation.addRuntimeModule(
chunk,
new HotModuleReplacementRuntimeModule()
);
}
);
注入代码之后的效果:
- 通过调试,可以看到webpack-dev-server → webpack-dev-middleware 在启动的时候调用了webpack构建实例的watch方法: compiler.watch
这里也解释了webpack-dev-server和webpack的watch是如何一起工作的
- compiler的watching会创建一个Watching的实例,而这个Watching,最终使用的是watchpack,我们重点来看这个watchpack
调用链路:compiler.watching(Watching实例) → compiler.watchFileSystem → NodeWatchFileSystem → Watchpack
js
// node_modules/webpack/lib/Compiler.js
watch(watchOptions, handler) {
if (this.running) {
return handler(new ConcurrentCompilationError());
}
this.running = true;
this.watchMode = true;
// compiler上有个watching属性,是Watching的实例
this.watching = new Watching(this, watchOptions, handler);
return this.watching;
}
// node_modules/webpack/lib/Watching.js
// Watching中有个watcher方法是用了 compiler.watchFileSystem.watch
this.watcher = this.compiler.watchFileSystem.watch(
// ...
);
// lib/node/NodeEnvironmentPlugin.js
// watchFileSystem 又是 NodeWatchFileSystem的实例
compiler.watchFileSystem = new NodeWatchFileSystem(
compiler.inputFileSystem
);
// lib/node/NodeWatchFileSystem.js
// NodeWatchFileSystem的watcher方法又是 WatchPack的实例,有点绕。
const Watchpack = require("watchpack");
// ...
class NodeWatchFileSystem {
// ...
this.watcher = new Watchpack(options);
}
- watchpack是一个基于发布订阅的模块,继承自
events
模块的EventEmitter
,在实例化的时候会注册change
和aggregated
事件。一旦监听到文件的变化,就会执行回调,触发webpack的重新编译(看下面代码)。
为什么是 aggregated 事件呢?由于修改操作可能在同一时间触发多次,所以将这些操作聚合一下
js
this.watcher.once("aggregated", (changes, removals) => {
// 暂停发出事件,避免在超时时清除已聚合的更改和删除
this.watcher.pause();
// 如果存在输入文件系统并支持清除操作,执行清除操作
if (this.inputFileSystem && this.inputFileSystem.purge) {
const fs = this.inputFileSystem;
for (const item of changes) {
fs.purge(item);
}
for (const item of removals) {
fs.purge(item);
}
}
// 获取文件和目录的时间信息
const { fileTimeInfoEntries, contextTimeInfoEntries } = fetchTimeInfo();
// 调用用户提供的回调函数
callback(
null,
fileTimeInfoEntries,
contextTimeInfoEntries,
changes,
removals
);
});
// node_modules/webpack/lib/Watching.js
// callback 回调函数最终执行到了这里,触发了webpack的重新编译
_go(fileTimeInfoEntries, contextTimeInfoEntries, changedFiles, removedFiles) {
this.compiler.hooks.watchRun.callAsync(this.compiler, err => {
if (err) return this._done(err);
const onCompiled = () => {};
// 执行编译
this.compiler.compile(onCompiled);
});
}
- 当webpack重新编译完成,webpack-dev-server监听到
this.compiler.hooks.done
事件后,会通过WebSocket向客户端发送消息
js
// node_modules/webpack-dev-server/lib/Server.js
this.compiler.hooks.done.tap(
"webpack-dev-server",
/**
* @param {Stats | MultiStats} stats
*/
(stats) => {
if (this.webSocketServer) {
this.sendStats(this.webSocketServer.clients, this.getStats(stats));
}
/**
* @private
* @type {Stats | MultiStats}
*/
this.stats = stats;
}
);
- 客户端收到webpack-dev-server发送过来的ok事件后,会向服务端请求main.48ce033e1a20d1c2a793.hot-update.json 文件,确认需要重新加载的范围
为什么webpack重新构建完成之后,除了会向client发送hash事件之后,还会向client发送一个ok事件?
webpack-dev-server在重新构建完成后发送"ok"事件的目的是通知客户端构建过程已经完成且没有出现错误或警告。这个事件是作为一种确认机制,用于确保客户端知道它所依赖的资源已经准备好,可以继续工作。
- main.48ce033e1a20d1c2a793.hot-update.json返回修改范围,客服端的Runtime代码开始下载变化的文件
-
这时候,客服端已经成功拿到了最新构建的内容,那么如何将更新的内容应用到页面上呢?一般都是由loader来进行处理的(react没有提供这种能力,需要使用react-refresh)
拿css文件为例,style-loader在处理文件的时候,会往编译产物中添加
module.hot.accept
的相关代码,当该文件发生变化的时候,会触发回调,在回调中更新css,以下是style-loader注入热更新相关的代码:
js
// style-loader/src/utils.js
function getStyleHmrCode(esModule, loaderContext, request, lazy) {
const modulePath = stringifyRequest(loaderContext, `!!${request}`);
return `
if (module.hot) {
if (!content.locals || module.hot.invalidate) {
var isEqualLocals = ${isEqualLocals.toString()};
var isNamedExport = ${esModule ? "!content.locals" : false};
var oldLocals = isNamedExport ? namedExport : content.locals;
module.hot.accept(
${modulePath},
function () {
${
esModule
? `if (!isEqualLocals(oldLocals, isNamedExport ? namedExport : content.locals, isNamedExport)) {
module.hot.invalidate();
return;
}
...
${
lazy
? `if (update && refs > 0) { update(content); }`
// 最终会走到 update 里面会重新加载最新的css文件,实现热更新
: `update(content);`
}`
}
}
)
}
module.hot.dispose(function() {
${
lazy
? `if (update) {
update();
}`
: `update();`
}
});
}
`;
}
至此,一次完整的热更新流程就结束了。
回答上述问题
-
webpack的热更新功能,需要哪些模块的支持?
watchpack监听文件变化触发重新编译、webpack-dev-server提供静态资源服务器和客户端通信、各种loader在拿到最新的资源后自行执行更新逻辑
-
一次热更新周期中,各个模块都做了哪些事情?
参考上述的热更新主流程
-
webpack中的watchpatch模块做了什么事情?
监听文件变化,触发webpack重新编译
-
webpack-dev-server 和 webpack 中的 watchpack 是如何一起工作的?
webpack-dev-server在启动的时候,内置的webpack-dev-middleware会启用compiler的watch功能
-
webpack一般会将构建的产物写入文件系统,但webpack-dev-server是将构建后的内容存在内存里了,这个是怎么实现的?
webpack-dev-server
-
webpack-dev-server是将构建后的内容存在内存里了,我如何能看到构建后的产物呢?(好像不能,官方的做法是提供了一个writeToDisk相当于内存和磁盘里面写了两遍)
-
为什么要推荐react-refresh来替换react-hot-reload呢?支持hooks?
-
react-refresh和@pmmmwh/react-refresh-webpack-plugin分别做了什么事情来支持React的热更新?
-
为什么某些情况下,我修改了文件内容,页面上的某个表单内容并没有保留?
-
为什么某些情况下,我的页面甚至直接刷新了?
参考资料
一分钟用上热更新 React Fast Refresh(react-refresh)
How should we set up apps for HMR now that Fast Refresh replaces react-hot-loader? #16604