time: 2021-07-13 19:30:47
author: heyunjiang
今天做升级 webpack5 时,发现 dev-server 的热更新一直有问题,import 加载的入口 vue 修改之后无法更新界面,内部普通组件可以热更新,搜索引擎也查不到相关解决方案。
自己也一直对 webpack 热更新原理不太清楚,这里做一个全面总结
基本配置:在 devServer 配置 hot: true
即可开启热更新,并且 webpack5 无需再使用 webpack.HotModuleReplacementPlugin 插件,内部会默认使用
基本功能:
通常在入口文件实现 HMR 接口
if (module.hot) {
module.hot.accept('./print.js', function() {
console.log('Accepting the updated printMe module!');
})
}
热更新接口实现要求
module.hot.accept(
dependencies, // 可以是一个字符串或字符串数组
callback // 用于在模块更新后触发的函数
errorHandler // (err, {moduleId, dependencyId}) => {}
)
启动 webpack-dev-server.hot 之后,在 chrome-dev-tool network 中,发现有一个 websocket 链接,每当我们有内容修改时,会有如下流程
a["{\"type\":\"hash\",\"data\":\"0118df37df688211703d\"}"]
问题分析:查看更新后的 chunk,发现我修改的内容已经拿到了,说明是在应用时产生了问题
解决方案分析:需要查看 vue-loader 在实现 hmr 接口时的处理逻辑,初步判断不是 webpack hmr 的问题
vue-loader 有2个入口,一个是作为 loader,还有一个 VueLoaderPlugin
分析 package.json 查找入口 main 及相关 dependencies,查找到 loader 中有 genHotReloadCode 方法执行
if (needsHotReload) {
code += `\n` + genHotReloadCode(id, hasFunctional, templateRequest)
}
配置 vscode launch.json 启动命令后,在 node_modules 中给 vue-loader 打断点调试一下
launch.json 配置命令,或者 spawn 执行命令是进不到调试的
手动调用 webpack-dev-server 实例
const webpack = require('webpack')
const config = require('./webpack.dev.js')
const Server = require('webpack-dev-server/lib/Server');
const compiler = webpack(config)
server = new Server(compiler, {
...config.devServer,
progress: true,
hot: true
})
server.listen(config.devServer.port, 'localhost', (err) => {
if (err) {
throw err;
}
})
在 vue-loader 中,是这么添加热更新的
vue-loader index.js
if (needsHotReload) {
code += `\n` + genHotReloadCode(id, hasFunctional, templateRequest)
}
vue-loader codeGen/hotReload.js
const hotReloadAPIPath = JSON.stringify(require.resolve('vue-hot-reload-api'))
const genTemplateHotReloadCode = (id, request) => {
return `
module.hot.accept(${request}, function () {
api.rerender('${id}', {
render: render,
staticRenderFns: staticRenderFns
})
})
`.trim()
}
exports.genHotReloadCode = (id, functional, templateRequest) => {
return `
/* hot reload */
if (module.hot) {
var api = require(${hotReloadAPIPath})
api.install(require('vue'))
if (api.compatible) {
module.hot.accept()
if (!api.isRecorded('${id}')) {
api.createRecord('${id}', component.options)
} else {
api.${functional ? 'rerender' : 'reload'}('${id}', component.options)
}
${templateRequest ? genTemplateHotReloadCode(id, templateRequest) : ''}
}
}
`.trim()
}
结果分析:
为什么 module.hot.accept 回调函数不会执行?
这里要先思考几个问题:
目前只是知道了 vue-loader 在处理 .vue 模块时,给每个模块实现了 hmr 接口,并且在 module.hot.accept 回调函数中调用了 require.resolve('vue-hot-reload-api').rerender
,可惜在 webpack5 中没有执行。
但是通过控制台观察到 webpack 给了 hot-update.json, hot-update.js 文件给应用程序。
现在开始从头分析:编辑器代码保存时,webpack 做了什么操作?
猜想流程:webpack watch 到对应模块代码变更,会对该模块做独立构建,生成独立的 module.hot-update.js 文件
如果启动了 hmr,webpack-dev-server 则发起 ws 消息传递 hash 随机数,等待应用程序来取更新后的模块信息
并且替换掉之前构建好的 chunk 中的模块内容,包装浏览器刷新能获取到最新的代码
测试一:关闭 devServer.hot ,看看是否有新构建模块
答:会新构建,并且走 liveReload 模式,也就是浏览器刷新模式,这个是 webpack-dev-server 中配置的
问:从 webpack-dev-server 入口看起,看看是如何实例化 webpack 的
答:走 compiler.watch 模式,并且在 compiler.hooks.done 之后,会去监察 compilation.fileDependencies 等文件依赖,在变化之后做了什么
webpack-dev-middleware
context.watching = compiler.watch(options.watchOptions, (err) => {})
webpack-dev-middleware 是一个封装器,执行之后作为 express 或 koa 的中间件,将 compiler 产生的文件提供给对应服务器。
直接使用它时,在文件变更之后,需要手动刷新浏览器
webpack-dev-server 使用它时,只是作为静态文件服务器实现
webpack.watching.watch 方法
watch(files, dirs, missing) {
this.pausedWatcher = null;
this.watcher = this.compiler.watchFileSystem.watch(
files,
dirs,
missing,
this.lastWatcherStartTime,
this.watchOptions,
(
err,
fileTimeInfoEntries,
contextTimeInfoEntries,
changedFiles,
removedFiles
) => {
this._invalidate(
fileTimeInfoEntries,
contextTimeInfoEntries,
changedFiles,
removedFiles
);
this._onChange();
},
(fileName, changeTime) => {
if (!this._invalidReported) {
this._invalidReported = true;
this.compiler.hooks.invalid.call(fileName, changeTime);
}
this._onInvalid();
}
);
}
在文件变化的时候,会通过 watching._invalidate,依次走到 watching._go,将更改的文件名保存在 compiler.modifiedFiles
上。
问:在文件变更的时候,会去从入口重新构建所有文件吗?还是说走缓存,只构建变更的文件?或者说以变更文件作为新入口?
再去看看 compiler, compilation 是如何处理变更文件的
全局搜索了 webpack 项目,发现没有地方用到 compiler.modifiedFiles 属性,难道是要 webpack-dev-server 自己处理吗?或者说压根不处理
再回到 webpack-dev-server server 中来看,它是如何监听文件变化的
在 webpack-dev-server 中添加了如下钩子
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;
});
总结: 在 compiler.hooks.done
完成 emitAssets 之后,会根据 compilation 生成 stats 信息,并使用 getStats 获取 hash值 发送给 ws client
http 服务器
,还启动了 sockjs 服务器
。
并且给出口 main.js 插入 sock-client.js, dev-server.js 相关代码,添加 webpack.HotModuleReplacementPlugin 插件sockjs 和 websocket 有什么不同?sockjs 是模拟实现 websocket
http 服务器相关实现
setupApp() {
this.app = new express();
}
createServer() {
this.listeningApp = http.createServer(this.app);
}
前面了解到了 webpack-dev-server 是通过监听 compiler.hooks.done 来及时获取构建结果。
现在的问题是
在 devServer 配置中,inline 表示在 bundle 中插入 hmr runtime 脚本,来看看相关代码
在 updateCompiler 方法中,如果有 hot or hotOnly 配置,则会默认加上
class Server {
constructor(compiler, options = {}, _log) {
this.compiler = compiler;
this.options = options;
this.log = _log || createLogger(options);
normalizeOptions(this.compiler, this.options);
updateCompiler(this.compiler, this.options);
}
}
updateCompiler 函数 addEntries 及定义 __webpack_dev_server_client__
全局属性
```javascript
function updateCompiler(compiler, options) {
addEntries(webpackConfig, options);
compilers.forEach((compiler) => {
const config = compiler.options;
compiler.hooks.entryOption.call(config.context, config.entry);
const providePlugin = new webpack.ProvidePlugin({ webpack_dev_server_client: getSocketClientPath(options), }); providePlugin.apply(compiler); });
if (options.hot || options.hotOnly) { compilersWithoutHMR.forEach((compiler) => { const plugin = findHMRPlugin(compiler.options); if (plugin) { plugin.apply(compiler); } }); } }
3. addEntries(webpackConfig, options) 修改 webpack config 配置,并添加 hmr HotModuleReplacementPlugin
```javascript
function addEntries(config, options, server) {
// 1 定义 clientEntry, 通常是 webpack-dev-server/client/index.js?http://localhost:3000
const clientEntry = `${require.resolve(
'../../client/'
)}?${domain}${sockHost}${sockPath}${sockPort}`;
// 2 定义 hotEntry, 通常是 webpack/hot/dev-server.js
let hotEntry;
if (options.hotOnly) {
hotEntry = require.resolve('webpack/hot/only-dev-server');
} else if (options.hot) {
hotEntry = require.resolve('webpack/hot/dev-server');
}
// 3 修改 webpack.config.entry,增加 clientEntry 和 hotEntry
const additionalEntries = [clientEntry];
if (hotEntry && checkInject(options.injectHot, config, true)) {
additionalEntries.push(hotEntry);
}
config.entry = prependEntry(config.entry || './src', additionalEntries);
// 4 添加 HotModuleReplacementPlugin 插件
if (options.hot || options.hotOnly) {
config.plugins = config.plugins || [];
if (
!config.plugins.find(
(plugin) => plugin.constructor.name === 'HotModuleReplacementPlugin'
)
) {
config.plugins.push(new webpack.HotModuleReplacementPlugin());
}
}
}
归纳总结
webpack-dev-server/client/index.js?http://localhost:3000
、webpack/hot/dev-server.js
只要设置了 hot | hotonly,就会默认带上 hmr 相关插件 |
__webpack_dev_server_client__
指向 webpack-dev-server/client/clients/SockJsClient.js 文件定义的多入口和 HotModuleReplacementPlugin 做了什么?
引入面试题
前面我们看到了 webpack-dev-server 是修改了 entry 入口和 添加了 HotModuleReplacementPlugin 插件
来看看打包进去的 client/index.js 和 dev-server.js 做了什么。(注意:webpack-dev-server 服务端已经启动了一个 http 服务器和一个 sockjs 服务器)
client/index.js
var reloadApp = require('./utils/reloadApp');
var onSocketMessage = {
hash: function hash(_hash) {
status.currentHash = _hash;
},
ok: function ok() {
sendMessage('Ok');
reloadApp(options, status);
}
}
socket(socketUrl, onSocketMessage);
reloadApp
function reloadApp(_ref, _ref2) {
if (hot) {
log.info('[WDS] App hot update...');
var hotEmitter = require('webpack/hot/emitter');
hotEmitter.emit('webpackHotUpdate', currentHash);
if (typeof self !== 'undefined' && self.window) {
self.postMessage("webpackHotUpdate".concat(currentHash), '*');
}
}
}
dev-server.js
if (module.hot) {
var check = function check() {
module.hot
.check(true)
.then(function (updatedModules) {
if (upToDate()) {
log("info", "[HMR] App is up to date.");
}
}).catch(function (err) {});
};
var hotEmitter = require("./emitter");
hotEmitter.on("webpackHotUpdate", function (currentHash) {
lastHash = currentHash;
if (!upToDate() && module.hot.status() === "idle") {
log("info", "[HMR] Checking for updates on the server...");
check();
}
});
}
归纳总结:
module.hot.check
去更新前置知识
现在的问题:module.hot 是哪里加的?为什么 module.hot.check 能找到更新文件?仅根据变化后的 hash 是如何处理文件变更的?
还剩下 webpack.HotModuleReplacementPlugin 插件没有分析
插件实现了本地 hmr runtime
module.hot.check: 发起 http 请求更新 manifest,下载 updated chunk
module.hot.apply: 标记 updated module 为无效,然后解除所有无效 module,更新 hash,调用所有 accept handler
通过
http://localhost:8081/webpack-dev-server
查看 dev 环境的构建代码
webpack hmr 概念介绍
vue-loader hmr
vscode launch.json 配置
sockjs, websocket, stompjs
Webpack HMR 原理解析