之前被问到webpack和vite在热更新方面有什么区别,当时并不知道具体细节,这两天研究了一下,因为在看webpack热更新过程中有些吃力,所以自己又去理解并整理了一下,而vite热更新可以参考文中提到的另一篇文章,讲的很透彻了。
致敬:
一文了解 Webpack 热更新 (HMR) 原理
即时代码热更新,vite 热更新背后的原理
webpack热更新机制
首先要明确几个概念:
Webpack-complier
webpack 的编译器,将 JavaScript 编译成 bundle(就是最终的输出文件),编译器可以监听文件本地代码的变化,并给浏览器发送通知,这其中用到了webpack-dev-middleware中间件,主要用于处理文件的输入输出,将文件写入内存而非硬盘,加快构建速度。
HMR Runtime
Webpack 在开发模式下会注入热更新运行时到打包生成的代码中,也就说最终会在浏览器中运行。这个运行时负责和开发服务器建立连接,接收服务器发送的更新,并在客户端应用更新。主要涉及到两个文件: webpack-dev-server/client/index.js:这个文件是用于 websocket 的,因为 websocket 是双向通信,,启动的是本地服务端的websocket。那客户端也就是我们的浏览器,浏览器还没有和服务端通信的代码呢?因此我们需要把websocket客户端通信代码放到我们的代码中。 webpack/hot/dev-server.js:客户端收到更新通知后,最终去替换文件的操作就是依靠此文件。
HMR Server
Wepack-dev-server会利用express在启动一个本地服务,管理客户端的连接,同时创建socket服务器,通知浏览器文件的变化。
webpack热更新主要流程
编辑器修改文件 ------> webpack编译改动的文件 ------> 通知浏览器 ------> 浏览器获取更改后模块文件 ------> 将修改后的应用到页面中去
webpack热更新原理
1、webpack-dev-server启动本地服务,同时创建了websocket服务。
javascript
Server.prototype.listen = function (port, hostname, fn) {
this.listenHostname = hostname;
// eslint-disable-next-line
const returnValue = this.listeningApp.listen(port, hostname, (err) => {
const sockServer = sockjs.createServer({
// Use provided up-to-date sockjs-client
sockjs_url: '/__webpack_dev_server__/sockjs.bundle.js',
// Limit useless logs
log(severity, line) {
if (severity === 'error') {
log(line);
}
}
});
...
...
...
return returnValue;
};
2、webpack-dev-server.js在启动时会注入HMR Runtime
在启动服务时候,webpack-dev-server会在webpack配置项的entry中注入两个文件:webpack/client/index.js和webpack/hot/dev-server、
javascript
// webpack-dev-server/bin/webpack-dev-server.js
function startDevServer(webpackOptions, options) {
addDevServerEntrypoints(webpackOptions, options);
...
}
//webpack-dev-server/lib/util/addDevServerEntrypoints.js
module.exports = function addDevServerEntrypoints(webpackOptions, devServerOptions, listeningApp) {
if (devServerOptions.inline !== false) {
...
const devClient = [`${require.resolve('../../client/')}?${domain}`];
if (devServerOptions.hotOnly) { devClient.push('webpack/hot/only-dev-server'); }
else if (devServerOptions.hot) { devClient.push('webpack/hot/dev-server'); }
...
}
};
3. 监听 webpack 编译结束
修改好入口配置后,又调用了setupHooks
方法,用来注册监听事件的,监听每次 webpack
时间编译完成
javascript
// node_modules/webpack-dev-server/lib/Server.js
// 绑定监听事件
setupHooks() {
const { node } = compiler.hooks;
// 监听 webpack 的 done 钩子,tapple 提供的监听方法
done.tap('webpack-dev-server', (stats) => {
this._sendStats(this.sockets, this.getStats(stats));
this._stats = stats;
})
}
当监听到一次 webpack
编译结束,就会调用 _sendStaus
方法通过 websocket
给浏览器发送通知,ok
和 hash
事件,这样浏览器就可以拿到最新的 hash
值了,做检查更新逻辑。
javascript
// 通过 websocket 给客户端发消息
_sebdStats() {
this.sockWrite(sockets, 'hash', stats.hash);
this.sockWrite(sockets, 'ok');
}
4. webpack 监听文件变化
每次修改代码就会触发编译,说明我们还需要监听本地代码的变化,主要通过 setupDevMiddleware
方法实现的
这个方法主要执行了 webpack-dev-middleware
库。很多人分不清webpack-dev-middleware
和 webpack-dev-server
的区别。其实就是因为webpack-dev-server
只负责启动服务和前置准备工作,所有文件相关的操作都抽离到webpack-dev-middleware
库了,主要是本地文件的编译 和输出 以及监听,无非就是职责的划分更清晰了
scss
// node_modules/webpack-dev-middleware/index.js
compiler.watch(options.watchOptions, (err) = {
if(err) {/*错误处理*/}
})
// 通过memory-fs" 库将打包后的文件写入内存
setFs(context, compiler);
(1)调用了 comiler.watch 方法
- 首先对本地文件代码进行编译打包,也就是
webpack
的一系列编译流程。 - 其次编译结束后,开启对本地文件的监听,当文件发生变化,重新编译,编译完成之后继续监听
为什么代码的改动保存会自动编译,重新打包?这一系列的重新检测编译就归功于compiler.watch
这个方法了。监听本地文件的变化主要是通过文件的生成时间是否有变化。
(2)执行 setFs 方法
这个方法主要目的就是将编译后的文件打包到内存 。这就是为什么在开发的过程中,你会发现 dist 目录没有打包后的代码,因为都在内存里。存在内存的原因在于访问内存中的代码比访问文件系统中的文件更快,而且也减少了代码写入文件的开销,这一切都归功于memory-fs
。
5. 浏览器接收到热更新的通知
我们已经可以监听到文件的变化了,当文件发生变化,就触发重新编译。同时还监听了每次编译结束的事件。当监听到一次webpack
编译结束,_sendStats
方法就通过websoket
给浏览器发送通知,检查下是否需要热更新。下面重点讲的就是_sendStats
方法中的ok
和hash
事件都做了什么。
在上面提到的client/index.js文件中创建的socket就是为了接受服务端更新文件的通知。
ini
var onSocketMsg = {
...
progress: function progress(_progress) {
if (typeof document !== 'undefined') {
useProgress = _progress;
}
},
hash: function hash(_hash) {
currentHash = _hash;
},
ok: function ok() {
sendMsg('Ok');
if (useWarningOverlay || useErrorOverlay) overlay.clear();
if (initial) return initial = false; // eslint-disable-line no-return-assign
reloadApp();
},
...
};
浏览器在接收到type为'ok'的socket消息,会去调用reloadApp
方法
php
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' + currentHash, '*');
}
}
...
}
reloadApp方法会使用hotEmitter
去触发webpackHotUpdate
方法。
var hotEmitter = require('webpack/hot/emitter');
这里指的注意一下,一开始我很疑惑,EventEmitter明明是Nodejs提供的一个API,为何能在浏览器中使用。因为在浏览器中使用的是browserify/events ,旨在没有Node的环境中实现Node的事件模块,例如在浏览器中。那么这里的webpackHotUpdate
方法是什么呢,记得上文说的addDevServerEntrypoints
方法吗,在addDevServerEntrypoints方法中引入了wbpack/hot/dev-server.js
文件,在这个文件中注册了webpackHotUpdate
方法:
javascript
//webpack/hot/dev-server.js
if(module.hot) {
...
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();
}
});
log("info", "[HMR] Waiting for update signal from WDS...");
} else {
throw new Error("[HMR] Hot Module Replacement is disabled.");
}
可以看到在webpackHotUpdate方法中会调用check()
,那么check
从何而来,那就是接下来要说的HotModuleReplacementPlugin。
6. 不得不说的HotModuleReplacementPlugin
我们可以对比下:配置热更新和不配置的时候 bundle.js
的区别。直接执行 webpack
命令就可以看到生成的 bundle.js
文件,不要用 webpack-dev-server
启动
(1)没有配置的
(2)配置了HotModuleReplacementPlugin
或--hot
的。
显而易见,moudle
新增了一个属性为 hot
,再看 hotCreateModule
方法这不就找到module.hot.check
是哪里冒出来的。
经过对比打包后的文件,webpack_require
中的moudle
以及代码行数的不同。我们都可以发现HotModuleReplacementPlugin
原来也是默默的塞了很多代码到bundle.js
中。这和第 2 步骤很是相似哦!为什么,因为检查更新是在浏览器中操作,这些代码必须在运行时的环境。
我们也可以直接看浏览器Sources
下的代码,会发现webpack
和plugin
偷偷加的代码都在,在这里调试也很方便。
由此可知 5 最后的check() 其实就是HotModuleReplacementPlugin
注入的hotCheck
方法 。
7. moudle.hot.check 开始热更新、
前面可以说都是准备工作,真正热更新从此开始。 hotCheck 方法中通过hotDownloadManifest
获取到了一个h变量,这个是根据文件代码内容生成的hash值,通过这一步webpack会去和上一次的hash值进行比对,如果不一样则会进行后续的热更新,否则就不会有其他操作。
而hotDownloadManifest其实使用了XHR对象请求了HMR Server服务器,返回当前最新的hash值
这里可以顺便看一下修改了文件和不修改文件保存触发热更新的不同:
改动文件的热更新在请求json后,会继续通过jsonp请求一个js文件 而文件没有改动过的则只会有个json请求
继续回到hotCheck,在获取到新的hash值之后会进入hotEnsureUpdateChunk
方法,这个方法使用hotDownloadUpdateChunk
以jsonp格式获取js文件,而通过jsonp目的是为了能够直接执行获取的js文件。
可以看到hot-update.js返回即是一个可执行js文件
获取到了js文件,最后一步就是hotUpdateDownloaded
方法,在此方法中最终通过hotApply实现文件的替换
8. hotApply 热更新模块替换
热更新的核心逻辑就在这了!!
🔸(1)删除过期的模块,就是需要替换的模块
通过 hotUpdate
可以找到旧模块
ini
js
复制代码
var queue = outdatedModules.slice();
while (queue.length > 0) {
moduleId = queue.pop();
// 从缓存中删除过期的模块
module = installedModules[moduleId];
// 删除过期的依赖
delete outdatedDependencies[moduleId];
// 存储了被删掉的模块id, 便于更新代码
outdateSelfAcceptedModules.push({
module: moduleId
})
}
🔸(2)将新的模块添加到 modules 中
ini
js
复制代码
appliedUpdate[moduleId] = hotUpdate[moduleId];
for (moduleId in appliedUpdate) {
if (Object.prototype.hasOwnProperty.call(appliedUpdate, moduleId)) {
modules[moduleId] = appliedUpdate[moduleId];
}
}
🔸(3)通过__webpack_require__执行相关模块的代码
ini
js
复制代码
for (i = 0; i < outdatedSelfAcceptedModules.length; i++) {
var item = outdatedSelfAcceptedModules[i];
moduleId = item.module;
try {
// 执行最新的代码
__webpack_require__(moduleId);
} catch (err) {
// ...容错处理
}
}
hotApply
的确比较复杂,知道大概流程就好了,这一小节,要求你对 webpack
打包后的文件如何执行的有一些了解,大家可以自去看下。
至此wepback完成一次热更新。
vite热更新机制
关于vite热更新,"即时代码热更新,vite 热更新背后的原理"这篇文章写的已经非常完善了,可以直接参考。
两者热更新机制的区别
了解完两者的热更机制,说说我理解的两者最主要区别:
webpack:webpack由于会把文件先打包成bundle机制的原因,会把改动的文件模块打包编译完成之后通知客户端去获取文件,并且用jsonp的格式推送给客户端一个可执行文件。
vite:而vite则是会去进行模块的依赖分析,收集依赖当前模块的其他模块,清除掉依赖信息,最终告诉客户端的是修改文件的路径,最终文件的获取是依靠浏览器原生的ESM模块用过import动态导入文件。
由此也能分析出为什么vite的编译为什么会比webpack快。