本篇以 webpack4 版本作为讲解。
1、什么是 webpack 热更新呢?
相信做过前端的同学应该不陌生,热更新就是 webpack 其中的一个重要功能。
最初写 HTML 的时候,每次代码写完之后,都要在页面自己手动去刷新一遍,有没有觉得很蛋疼。自从用上了 webpack 的这个热更新功能之后,简直爽得飞天啊。不用手动刷新,直接 ctrl + s ,页面就会自动更新,这就是热更新。
2、webpack 的编译构建过程
在了解 webpack 热更新原理之前,我们先来看看 webpack 的编译构建过程。
设置 devServer.stats 的值为 normal,即可在控制台看到输出日志过程。
当配置了 quiet 或 noInfo 时,该配置不起作用。需要关闭 quiet 或 noInfo
当我们 npm run dev 启动的时候,这时候我们可以在控制台看到输出的日志:
这里有一个Hash值:b6db0705ddcf5a433075,代表着这次webpack Compiling 的标识符。
当我们修改了文件后,再次ctrl + s时,可以看到控制打印出的日志:
这里的Hash值改变了:480ff6313164fc01043e
但是下面多了两份文件:hot-update.js 以及 hot-update.json,注意看这两份文件的前缀的hash值:b6db0705ddcf5a433075, 这不就是我们上次的hash值么??
这时候同学们可能就会发现了:上一次编译的hash值,作为这次编译新生成文件的标识。而这次的编译hash值就会作为下次编译新生成文件的标示。
同时,我们可以在浏览器控制台中看到,新加载了两份文件:
首先看json文件:h 代表本次编译生成的hash值。
c表示当前要热更新的文件对应的是index模块(看到下面js文件hash前面还有一个0么?如果前面是index的话,那么这里就是index: true)
而 js 文件就是我们这次修改完后,重新编译打包后的。
还有一种情况:我们本次没有修改,只是重新保存了一下代码然后重新编译时:
可以看到控制台只有一个json文件:里面的C是空的,代表本次没有需要更新的代码。
3、webpack 的热更新原理
3.1 webpack-dev-server
我们通过 webpack-dev-server 来启动本地服务的,看一下这里的源码:
// node_modules/webpack-dev-server/bin/webpack-dev-server.js
// 生成 webpack 编译主引擎 compiler
let compiler = webpack(webpackOptions);
// 启动本地服务
let server = new Server(compiler, options);
server.listen(options.port, options.host, (err) => {
if (err) throw err;
if (options.bonjour) broadcastZeroconf(options);
const uri = createDomain(options, server.listeningApp) + suffix;
reportReadiness(uri, options);
});
function Server(compiler, options) {
if (!options) options = {};
// Init express server
const app = this.app = new express(); // eslint-disable-line
this.listeningApp = http.createServer(app);
}
Server.prototype.listen = function (port, hostname, fn) {
const returnValue = this.listeningApp.listen(port, hostname, (err) => {
// 启动websocket服务
const sockServer = sockjs.createServer({
});
if (fn) {
fn.call(this.listeningApp, err);
}
});
return returnValue;
};
上面的代码中,先是启动webpack,生成compiler实例。接着启动本地server,这样浏览器就可以请求本地的静态资源了。然后再启动websocket服务。通过websocket可以建立本地服务器与浏览器的双向通信的功能,当本地文件变化时就可以通知浏览器做热更新的操作。
3.2.修改webpack.config.js的entry配置
在启动本地服务前,调用了 server.addDevServerEntrypoints
// lib/util/addDevServerEntrypoints.js
// 获取websocket客户端代码
const domain = createDomain(options, app);
const clientEntry = `${require.resolve(
'../../client/'
)}?${domain}${sockHost}${sockPath}${sockPort}`;
// 根据配置获取热更新代码
if (options.hotOnly) {
hotEntry = require.resolve('webpack/hot/only-dev-server');
} else if (options.hot) {
hotEntry = require.resolve('webpack/hot/dev-server');
}
修改后webpack入口配置如下:
// 修改后的entry入口
// 在入口新增两个文件,就会一起打包到bundle文件中去,在线上运行。
{
entry: {
main: [
// 上面获取的clientEntry
'xxx/node_modules/webpack-dev-server/client/index.js?http://localhost:8080',
// 上面获取的hotEntry
'xxx/node_modules/webpack/hot/dev-server.js',
// 开发配置的入口
'./src/main.js'
]
}
}
webpack-dev-server/client/index.js: 这个文件是用于websocket的,需要双向通信,我们在前面启动server的时候,我们本地启动了websocket,但是浏览器还有没有和我们本地的服务端通信。但是浏览器上又没有事先就有websocket的代码,所以需要我们这边把websocket浏览器通信的代码也塞进我们的代码中(打包进bundle)。
webpack/hot/dev-server.js: 这个文件主要是用于检测更新的逻辑的。
3.3 监听webpack编译结束
修改好入口配置后,注册监听事件,监听每次webpack编译完成。
// node_modules/webpack-dev-server/lib/Server.js
const { compile, invalid, done } = compiler.hooks;
...
done.tap('webpack-dev-server', (stats) => {
this._sendStats(this.sockets, this.getStats(stats));
this._stats = stats;
});
// 通过websoket给客户端发消息
_sendStats() {
...
this.sockWrite(sockets, 'hash', stats.hash);
if (stats.errors.length > 0) {
this.sockWrite(sockets, 'errors', stats.errors);
} else if (stats.warnings.length > 0) {
this.sockWrite(sockets, 'warnings', stats.warnings);
} else {
this.sockWrite(sockets, 'ok');
}
}
当监听到一次webpack编译结束,调用_sendStates()方法通过websocket给浏览器发通知:ok和hash事件。这样浏览就拿到最新的hash值了。
3.4 webpack监听文件变化
每次我们本地修改完代码保持,都会触发编译,那证明webpack中间还有监听文件的变化,主要是通过setupDevMiddleware方法实现的。
webpack-dev-server:负责启动服务和前置准备工作
webpack-dev-middleware:负责本地文件的编译和输出以及监听。
// node_modules/webpack-dev-server/lib/server.js
setupDevMiddleware() {
// middleware for serving webpack bundle
this.middleware = webpackDevMiddleware(
this.compiler,
Object.assign({}, this.options, { logLevel: this.log.options.level })
);
}
这个方法主要是执行了 webpack-dev-middleware库,看一下
// node_modules/webpack-dev-middleware/index.js
compiler.watch(options.watchOptions, (err) => {
if (err) { /*错误处理*/ }
});
// 通过“memory-fs”库将打包后的文件写入内存
setFs(context, compiler);
(1) 调用compiler.watch方法,开启对本地文件的监听,当文件发生变化,重新编译,编译完成后继续监听。
(2) 执行setFs方法,这个方法的目的是将编译后的文件打包到内存。开发过程中我们发现并没有dist目录,因为代码都在内存中。因为访问内存中的代码比访问文件系统中的文件要快,而且也减少了代码写入文件的开销。
3.5浏览器收到更新通知
上面说到,当监听到文件发生变化,就会给浏览器发送ok和hash事件。
那浏览器是怎么接受websocket的消息的呢?
还记得我们上面说的修改entry入口么?这个文件会被打包到bundle.js中,并运行在浏览器中。
'xxx/node_modules/webpack-dev-server/client/index.js?http://localhost:8080'
看一下这个核心代码:
// webpack-dev-server/client/index.js
var socket = require('./socket');
var onSocketMessage = {
hash: function hash(_hash) {
// 更新currentHash值
status.currentHash = _hash;
},
...
...
ok: function ok() {
sendMessage('Ok');
// 进行更新检查等操作
reloadApp(options, status);
},
}
socket(socketUrl, onSocketMessage);
// webpack-dev-server/client/util/reloadApp.js
function reloadApp() {
if (hot) {
log.info('[WDS] App hot update...');
// hotEmitter其实就是EventEmitter的实例
var hotEmitter = require('webpack/hot/emitter');
hotEmitter.emit('webpackHotUpdate', currentHash);
}
}
socket 方法建立了websocket和服务端的连接,并注册了很多个监听事件。我们主要看ok 和hash 事件。
- hash 事件:更新最后一次打包的hash值。
- ok事件:调用reloadApp()方法,进行热更新检测。
reloadApp()方法中,利用了node.js的EventEmitter,发出webpackHotUpdate消息,发出了这个消息之后,webpack接下来会做什么呢?
我们之前entry入口中,除了新增 'xxx/node_modules/webpack-dev-server/client/index.js?http://localhost:8080', 还有新增了一个文件:'xxx/node_modules/webpack/hot/dev-server.js'
那我们看看这个文件的内容:
// node_modules/webpack/hot/dev-server.js
var check = function check() {
module.hot.check(true)
.then(function(updatedModules) {
// 容错,直接刷新页面
if (!updatedModules) {
window.location.reload();
return;
}
// 热更新结束,打印信息
if (upToDate()) {
log("info", "[HMR] App is up to date.");
}
})
.catch(function(err) {
window.location.reload();
});
};
var hotEmitter = require("./emitter");
hotEmitter.on("webpackHotUpdate", function(currentHash) {
lastHash = currentHash;
check();
});
可以看到这里webpack监听到了 webpackHotUpdate 这个事件,获取到最新的hash值,然后进行检测更新。
但是module.hot.check这个是来自哪里呢
3.6 HotModuleReplacementPlugin
首先对比一次,配置了热更新和不配置热更新时bundle.js的区别。
没有配置的:
配置了的:
对比发现配置了热更新的文件中,多了个hot: hotCreateModule(moduleId)的配置。
当我们在继续往下找hotCreateModule()方法,就可以找到module.hot.check这个来源了。
在浏览器环境中,webpack和plugin会偷偷的加上一些代码,为了检查更新,也为了方便调试。主要是利用了tapable。
3.7 module.hot.check
从上面我们可以知道 module.hot.check 是从 HotModuleReplacementPlugin 中来的。具体做了:
- 利用上一次保存的hash值,调用 hotDownloadManifest 发送 xxx/hash-update.json的ajax请求
- 请求结果获取热更模块,以及下次热更新的Hash标识,并进入热更新准备阶段。
调用hotDownloadUpdateChunl 发送 xxx/hash.hot-update.js请求(JSONP)
function hotDownloadUpdateChunk(chunkId) {
var head = document.getElementsByTagName("head")[0];
var script = document.createElement("script");
script.type = "text/javascript";
script.charset = "utf-8";
script.src = __webpack_require__.p + "" + chunkId + "." + hotCurrentHash + ".hot-update.js";
head.appendChild(script);
}
为什么使用JSONP获取最新的代码?
新编译后的代码在一个webpackHotUpdate函数体内部的,也就是立即执行webpackHotUpdate这个方法。
// webpackHotUpdate
window["webpackHotUpdate"] = function (chunkId, moreModules) {
hotAddUpdateChunk(chunkId, moreModules);
} ;
hotAddUpdateChunk 方法会把更新的模块 moreModules 赋值给全局变量 hotUpdate。
hotUpdateDownloaded 方法会调用 hotApply 进行代码的替换。
function hotAddUpdateChunk(chunkId, moreModules) {
// 更新的模块moreModules赋值给全局全量hotUpdate
for (var moduleId in moreModules) {
if (Object.prototype.hasOwnProperty.call(moreModules, moduleId)) {
hotUpdate[moduleId] = moreModules[moduleId];
}
}
// 调用hotApply进行模块的替换
hotUpdateDownloaded();
}
3.8 hotApply 热更新模块替换
热更新的核心在 hotApply 这个函数中。具体做了:
- 删除过期的模块,就是需要替换的模块 (通过hotUpdate可以找到旧模块)
var queue = outdatedModules.slice();
while (queue.length > 0) {
moduleId = queue.pop();
// 从缓存中删除过期的模块
module = installedModules[moduleId];
// 删除过期的依赖
delete outdatedDependencies[moduleId];
// 存储了被删掉的模块id,便于更新代码
outdatedSelfAcceptedModules.push({
module: moduleId
});
}
// 将新的模块添加到modules中
appliedUpdate[moduleId] = hotUpdate[moduleId];
for (moduleId in appliedUpdate) {
if (Object.prototype.hasOwnProperty.call(appliedUpdate, moduleId)) {
modules[moduleId] = appliedUpdate[moduleId];
}
}
// 通过__webpack_require__执行相关模块的代码
for (i = 0; i < outdatedSelfAcceptedModules.length; i++) {
var item = outdatedSelfAcceptedModules[i];
moduleId = item.module;
try {
// 执行最新的代码
__webpack_require__(moduleId);
} catch (err) {
// ...容错处理
}
}
4、总结
作者:周桂麟
来源-微信公众号:三七互娱技术团队
出处:https://mp.weixin.qq.com/s/irlwRYsXwS9vmsjqli5M_Q
本文暂时没有评论,来添加一个吧(●'◡'●)