Webpack 热刷新与热加载的原理分析

发布于:2019 年 11 月 1 日 11:50

Webpack热刷新与热加载的原理分析

Webpack 功能非常强大,一款优秀的脚手架可以给我们的工作省去众多繁琐且无意义的工作,其中热刷新、热加载相较于传统开发大大提高了开发效率。

首先简单介绍下热刷新与热加载:
热刷新:文件内动改动后,整个页面自动刷新,不保留任何状态,相当于 webpack 帮你摁了 F5 刷新;

热加载:文件改动后,以最小的代价改变被改变的区域。尽可能保留改动文件前的状态(修改 js 代码之后可以把页面输入的部分信息保留下来)

本文主要讲下我在配置热刷新、热加载时遇到的坑,边踩坑边成长,希望对初学者追查定位问题有帮助 (基于 Webpack4)。

内容

首先介绍下研究问题的项目场景,整个项目是基于前端 JS (使用 webpack 构建启动) + Node (中间服务器)+PHP 后端的开发模式,由于项目需要登录登录信息,故使用 Node 做登录验证,需要本地绑定 Host(127.0.0.1 test.link.lianjia.com ),使用域名 test.link.lianjia.com 在浏览器访问。我惊奇的发现前端居然未使用任何热更新,每次 js 代码修改之后需要手动刷新页面,强迫症的我开始给这个项目加热更新,就有了下述这篇文章。

1.Webpack 热刷新

简单来说 Webpack 热刷新就是 Webpack-dev-server 会启动一个 Socket 服务,来监听 webpack 打包文件变化,有文件更新时 webpack-dev-server 通知浏览器某个文件变化了,浏览器就会刷新当前页面。

在项目中我使用了以下配置:

复制代码
devServer: {
contentBase: './dist',
port: '7008',
inline: true,
host: '0.0.0.0'
}

在项目中使用以上配置时发现当代码更新时,浏览器并不会自动刷新页面,感觉很奇怪,配置貌似没有问题。为了避免其它因素干扰,自己写了一个小 Demo(只有前端,无 Node 服务),用了相同的配置 (仅修改了端口号) 去尝试热更新,在浏览器内使用 IP 127.0.0.1:7008 访问,发现配置是没问题的,可以实现正常的热刷新。(伏笔:在小 Demo 中使用的是 IP 去访问) 但是本项目在浏览器中却发现收不到 Websocket 文件。

问题原因
此时认为配置文件是没有问题的,质疑热刷新失败是否与 Node 服务有关系,然后就是各种源码阅读尝试,后来发现问题与 Node 无关,这里就不细说排查 Node 的过程。接下来质疑是否与 Host 文件有关系,因为之前在 Demo 使用 IP 去访问并没有问题,而本项目中是通过域名来访问。再次通过域名访问 Demo 项目,发现无法访问了,报错如下:

Webpack热刷新与热加载的原理分析

原因历经千辛万苦终于定位到了问题在哪里,那么究竟是 Webpack-dev-server 的问题,还是我系统配置的 Host 问题呢?接下来查看了 Webpack-dev-server 源码。

2.Webpack-dev-server 源码分析

在 webpack-dev-server\lib\Server.js 中,看到以下代码:

复制代码
Server.prototype.checkHost = function (headers) {
if (this.disableHostCheck) return true; // 1
const hostHeader = headers.host;
if (!hostHeader) return false; //2
const hostname = url.parse(`//${hostHeader}`, false, true).hostname;
if (ip.isV4Format(hostname) || ip.isV6Format(hostname)) return true; //3
if (hostname === 'localhost') return true; //3
if (this.allowedHosts && this.allowedHosts.length) { // 4
for (let hostIdx = 0; hostIdx < this.allowedHosts.length; hostIdx++) {
const allowedHost = this.allowedHosts[hostIdx];
if (allowedHost === hostname) return true;
if (allowedHost[0] === '.') {
if (hostname === allowedHost.substring(1)) return true;
if (hostname.endsWith(allowedHost)) return true;
}
}
}
if (hostname === this.listenHostname) return true; //5
if (typeof this.publicHost === 'string') { // 6
const idxPublic = this.publicHost.indexOf(':');
const publicHostname = idxPublic >= 0 ? this.publicHost.substr(0, idxPublic) : this.publicHost;
if (hostname === publicHostname) return true;
}
return false; // 7
};

而调用 checkHost 的代码如下:

复制代码
app.all('*', (req, res, next) => {
if (this.checkHost(req.headers)) { return next(); }
res.send('Invalid Host header');
});

可以看到 如果 checkHost 返回了 fasle, 那就只会返回 Invalid Host header,与我们看到的现象一致。

再来看看 checkHost 函数的返回值会受到哪些参数影响:

1)如果使用了 disableHostCheck,关闭校验,则直接返回 true;

2)如果请求头的 host 是空,返回 false;

3)如果请求网站时用的 host 是 localhost,返回 true;

4)启用白名单,如果访问的 host 地址在白名单中,返回 true;

5)访问的 host 与 webpack 配置的 host 相同时 返回 true;

6)返回的 host 与设置的 publicHost 相同时,返回 true;

7)不满足以上规则, 返回 false
根据本项目的情况, hostname 是 test.link.lianjia.com , 设置的 host 是 0.0.0.0(即本地 IP 或者 127.0.0.1),不满足上述所有规则,因此最终返回了 false。待我设置了白名单后,就可以愉快的开启 webpack 的自动刷新功能了。

3. 更进一步,开启热加载

自动刷新成功了,现在想要更进一步开启热加载。参考新版官网,新的配置如下:

复制代码
devServer: {
contentBase: './dist',
port: '7008',
inline: true,
host: '0.0.0.0',
disableHostCheck: true,// 不使用白名单的原因是多人开发,每个人都需要绑定 Host 不方便,因此关闭 Host 检查
hot: true, // 开启热更新
},
plugins: [
new MiniCssExtractPlugin({
filename: "css/[name].css",
}),
new HtmlWebpackPlugin({
title: 'Output Management'
}),
new webpack.HotModuleReplacementPlugin() // 开启热更新
],
// 在 webpack 入口 js 底部加上以下代码
if (module.hot) {
module.hot.accept();
}

晴天霹雳,又报错了,具体如下图:

Webpack热刷新与热加载的原理分析

遇见了平时后端常见的跨域问题,这是因为 webpack-dev-server 实质上是用 express 起了一个 Node 服务。看见这个问题,脑子一热上来就使用了 proxy 代理,配置如下:

复制代码
proxy: {
'/': {
target: 'http:// 我的 IP 地址',
// target: 'http://127.0.0.1',
// target: 'http://localhost',
changeOrigin: true
}
}

发现怎么弄都不成功,后来查阅资料才知道 proxy 是用于向其他服务器请求的代理,而不是对于 webpack-dev-server 自身启动的服务。
在浏览官方文档后发现其提供了 Before API,用于在启动服务前处理事物,我在其中添加 CORS 尝试能否解决问题。

Webpack热刷新与热加载的原理分析

按照官方的 API 我们加上简单的 CORS 配置:

复制代码
before: (app) => {
app.use('*', (req, res, next) => {
res.header("Access-Control-Allow-Origin", "*");
res.header("Access-Control-Allow-Methods", "POST,GET");
res.header("Access-Control-Allow-Headers", "Origin,Accept,Content-Type,Content-Length, Authorization, Accept,X-Requested-With");
next();
});
}

再保存更改的文件试试,热更新终于成功,终于可以愉快的 Coding 了。

4. 源码分析

问题解决了,但我们再去看看 webpack 相关的源码看看上述配置都做了些什么。
文件在 webpack-server-dev\client\index.js

复制代码
ok: function ok() {
sendMsg('Ok');
if (useWarningOverlay || useErrorOverlay) overlay.clear();
if (initial) return initial = false;
reloadApp();
}

这是监听到文件变化后 socket 调用的函数,可以看到它最主要功能就是调用 reloadAPP(),我们再来看看 reloadApp

复制代码
1function reloadApp() {
2 if (isUnloading || !hotReload) {
3 return;
4 }
5 if (_hot) {
6 log.info('[WDS] App hot update...');
7 var hotEmitter = require('webpack/hot/emitter');
8 hotEmitter.emit('webpackHotUpdate', currentHash);
9 if (typeof self !== 'undefined' && self.window) {
10 self.postMessage('webpackHotUpdate' + currentHash, '*');
11 }
12 } else {
13 ...// 非重要代码省略
14 }

可以看到如果开启热更新后,会用 webpack/hot/emitter 触发一个名为 webpackHotUpdate 的事件,找到这个事件,位置在 webpack\hot\dev-server.js

复制代码
1var upToDate = function upToDate() {
2 return lastHash.indexOf(__webpack_hash__) >= 0;
3};
4
5var check = function check() {
6 module.hot
7 .check(true)
8 .then(function(updatedModules) {
9 if (!updatedModules) {
10 log("warning", "[HMR] Cannot find update. Need to do a full reload!");
11 log(
12 "warning",
13 "[HMR] (Probably because of restarting the webpack-dev-server)"
14 );
15 window.location.reload();
16 return;
17 }
18
19 if (!upToDate()) {
20 check();
21 }
22
23 require("./log-apply-result")(updatedModules, updatedModules);
24
25 if (upToDate()) {
26 log("info", "[HMR] App is up to date.");
27 }
28 })
29 .catch(function(err) {
30 var status = module.hot.status();
31 if (["abort", "fail"].indexOf(status) >= 0) {
32 log(
33 "warning",
34 "[HMR] Cannot apply update. Need to do a full reload!"
35 );
36 log("warning", "[HMR] " + err.stack || err.message);
37 window.location.reload();
38 } else {
39 log("warning", "[HMR] Update failed: " + err.stack || err.message);
40 }
41 });
42};

upToDate 是判断最后一次前端的 hash 与 webpack 打包后的文件 hash 是否相同,相同返回 true(即表示最新的文件)。再看 module.hot 里的几个判断,updatedModules 是更新的模块,如果找不到更新的模块,则直接刷新网页。接下来通过判断文件 hash,如果两个文件 hash 相同了已经更新完了,调用

复制代码
require("./log-apply-result")(updatedModules, updatedModules);

打印 log,可以看看这个代码。

复制代码
1module.exports = function(updatedModules, renewedModules) {
2 //renewedModules 表示要更新的模块,updatedModules 表示已更新的模块
3 var unacceptedModules = updatedModules.filter(function(moduleId) {
4 return renewedModules && renewedModules.indexOf(moduleId) < 0;
5 });
6 var log = require("./log");
7
8 if (unacceptedModules.length > 0) { // 不需要热更新的模块
9 log(
10 "warning",
11 "[HMR] The following modules couldn't be hot updated: (They would need a full reload!)"
12 );
13 unacceptedModules.forEach(function(moduleId) {
14 log("warning", "[HMR] - " + moduleId);
15 });
16 }
17
18 if (!renewedModules || renewedModules.length === 0) { // 没有模块可以更新
19 log("info", "[HMR] Nothing hot updated.");
20 } else {
21 log("info", "[HMR] Updated modules:");
22 renewedModules.forEach(function(moduleId) { // 热更新模块
23 if (typeof moduleId === "string" && moduleId.indexOf("!") !== -1) {
24 var parts = moduleId.split("!");
25 log.groupCollapsed("info", "[HMR] - " + parts.pop());
26 log("info", "[HMR] - " + moduleId);
27 log.groupEnd("info");
28 } else {
29 log("info", "[HMR] - " + moduleId);
30 }
31 });
32 var numberIds = renewedModules.every(function(moduleId) {
33 return typeof moduleId === "number";
34 });
35 if (numberIds) // 如果 renewedModules 中都是数字,建议使用 NamedModulesPlugin
36 log(
37 "info",
38 "[HMR] Consider using the NamedModulesPlugin for module names."
39 );
40 }
41};

最后,简单梳理下一下热更新的流程图:

Webpack热刷新与热加载的原理分析

总结

如果是配置需求问题,先看官方文档,很多问题可以在文档中找到答案。如果在文档中找不到,可以花时间一步一步源码追踪,重点关注报错信息,按报错内容一步一步查找,终究能找到原因,从而解决问题。最后附上完整的 webpack4 配置代码。

在 webpack4.0 下热刷新配置:

复制代码
devServer: {
contentBase: './dist',
port: '7008',
inline: true,
host: '0.0.0.0'
disableHostCheck: true, // 关闭 Host 检查
// allowedHosts: ['你的域名'] // 白名单
}

热加载完整配置:

复制代码
// webpack config 文件
devServer: {
contentBase: './dist',
port: '7008',
inline: true,
host: '0.0.0.0',
disableHostCheck: true,
hot: true,
before: (app) => {
app.use('*', (req, res, next) => {
res.header("Access-Control-Allow-Origin", "*");
res.header("Access-Control-Allow-Methods", "POST,GET");
res.header("Access-Control-Allow-Headers", "Origin,Accept,Content-Type,Content-Length, Authorization, Accept,X-Requested-With");
next();
});
}
},
plugins: [
new MiniCssExtractPlugin({
filename: "css/[name].css",
}),
new HtmlWebpackPlugin({
title: 'Output Management'
}),
new webpack.HotModuleReplacementPlugin()
]
// webpack 配置的入口 js 文件,在末尾加上
if (module.hot) {
module.hot.accept();
}

见解有限,如有描述不当之处,请帮忙指出。

作者介绍:
张名攀,贝壳前端开发工程师,目前负责 NTS-APP 前端研发工作。

本文转载自公众号贝壳产品技术(ID:gh_9afeb423f390)。

原文链接:

https://mp.weixin.qq.com/s/NsQjFfDL_PR_gtmaeZkYHA

阅读数:638 发布于:2019 年 11 月 1 日 11:50

更多 文化 & 方法、Web框架、方法论 相关课程,可下载【 极客时间 】App 免费领取 >

评论

发布
暂无评论