Http2 带来的好处毋庸置疑,最直接的就是能够多路复用且不会出现队头阻塞的情况,一般在本地开发的时候并不需要 http2,除非你的项目中有大量并发的请求。
本文主要针对于在 webpack 项目中添加 http2 特性,但整体思路在其它项目中通用。
webpack 官方文档中有提供一种方式开启 http2,即在 devServer
中调整配置如下:
js
module.exports = {
//...
devServer: {
server: "spdy",
},
};
上述配置参考 webpack devServer.server
其中,将 devServer.server
指定为 spdy
则会在 webpack 中使用 spdy 库来开启 http2,但在配置下面有一行警告,大概意思是:
该选项在 Node 15.0.0 及以上版本中被忽略,因为对于这些版本,spdy 是无效的。一旦 Express 支持,devServer 也将迁移到 Node 的内置HTTP/2。
我尝试了 Node 14,依旧会出现一些问题,例如某些请求会出现内容被截断导致请求体解析报错 (failed)net::ERR_CONTENT_DECODING_FAILED
:
并且我查看了 Express 的官方文档,发现其并不支持 http2,最终我放弃了 webpack 内置的这种方案。
另辟蹊径
既然 Webpack 内置的选项支持有问题,那我们就通过代理的方式自己来实现,其原理很简单,大概思路如下:
- 独立启动一个 http2 服务
- 将 http2 服务收到的请求转发到 devServer
原来的 devServer 端口还是保持不变,我们自己手动启动一个 http2 服务器,但由于 http2 是必须建立在 https 之上,因此我们首先得准备一个本地开发的 ssl 证书,可以选择一些开源库来生成,例如 selfsigned,但每次启动都生成不一样的证书会导致浏览器每次都出现证书警告提醒:
如果出现上面这种提示,则需要点【高级】按钮,然后点继续才可以通过。
但我更倾向于直接一次性生成证书并保存到本地,这样就避免了每次启动都收到证书警告提醒的情况。
生成本地开发的 https 证书
可以参考以下方式一次性生成证书,在 bash 或者 git-bash 中:
- 在项目内创建一个 ssl 目录用来存放 ssl 证书
mkdir ssl
- 生成秘钥
openssl genrsa 1024 > ssl/key.pem
- 生成证书
openssl req -x509 -new -key key.pem > ssl/key-cert.pem
启动 http2 服务
js
const http2 = require("http2");
const http2Server = http2.createSecureServer({
allowHTTP1: true, // 必须提供,否则 websocket 无法被代理
key: fs.readFileSync("./ssl/key.pem"),
cert: fs.readFileSync("./ssl/key-cert.pem"),
});
使用 http2-proxy
来代理 http2 请求
js
const proxy = require("http2-proxy");
const finalhandler = require("finalhandler");
http2Server.on("request", (req, res) => {
proxy.web(
req,
res,
{
hostname: "localhost",
port: targetPort, // devServer 的端口
// protocol: "https", // 默认为 http,如果 devServer 开启了 https 则需要显式指定 https
},
// 错误处理
(err, req, res) => {
if (err) {
console.error("proxy error", err);
finalhandler(req, res)(err); // 为了简化逻辑,我们使用 finalhandler 去处理错误的情况
}
}
);
});
使用 http2-proxy
来代理 websocket 请求的 upgrade
消息
js
http2Server.on("upgrade", (req, socket, head) => {
proxy.ws(
req,
socket,
head,
{
hostname: "localhost",
port: targetPort, // devServer 的端口
// protocol: "https", // 默认为 http,如果 devServer 开启了 https 则需要显式指定 https
},
// 错误处理
(err, req, socket, head) => {
if (err) {
console.error("proxy websocket upgrade error", err);
socket.destroy();
}
}
);
});
接下来我们需要在某个端口启动 http2 服务器,但这个端口尽量不要写死,因为可能已经被其它程序占用,我们需要获取到一个可用的端口号,例如使用 find-free-port
来查找可用端口:
js
const fp = require("find-free-port");
const http2Port = await fp(process.env.http2_port || 12345); // 从参数指定的端口开始查找,如果端口被占用则递增,直到找到一个可用端口
http2Server.listen(http2Port); // 启动 http2 服务器
我们可以先将上面启动 http2 服务的逻辑封装成函数 startProxyServer(targetPort)
,其中 targetPort
通过参数传入。接下来就是如何获取 targetPort
。
targetPort
就是 devServer 启动的端口,由于可能出现默认端口被占用导致实际端口产生变化的情况,我们最好也不要把 targetPort 写死,还是通过动态获取的方式来获取它比较好。
在 webpack devServer 中有一个回调函数 onListening
:
js
const isDev = process.env.NODE_ENV === "development";
module.exports = {
devServer: {
onListening(devServer) {
const devServerPort = devServer.server.address().port;
// 一定要 dev 环境才启动,否则可能会因为启动的服务让进程无法结束,从而导致 ci 卡住
if (isDev) {
startProxyServer(devServerPort); // 启动 http2 服务
}
},
},
};
到目前为止,你已经可以在开发环境使用 http2 了。如果端口 12345 且没有被占用的话,那访问链接就是 https://localhost:12345
,注意是 https
!!!
其它配置调整
调整 HMR 的 ws 协议和端口
webpack 并不知道我们是使用代理的方式启动了 http2,生成的资源中的端口还是 devServer 的端口,例如 HMR 使用的 ws 的协议和端口都还是 devServer 的协议和端口,如果 devServer 是 http 的话,在打开的 https 页面中可能会被认为是不安全的,从而被浏览器阻止导致 HMR 失效,为了避免被浏览器阻止掉,我们需要调整成以我们代理服务器的协议和端口为准:
js
module.exports = {
devServer: {
client: {
webSocketURL: "auto://0.0.0.0:0/ws", // 表示协议和端口都以打开的网页为准
},
},
};
至此,你的本地开发也已经可以支持 http2 了:
写在最后
devServer 在每次编译完后会提示当前的服务地址:
我们也可以写一个简单的 webpack plugin 来给与提示(毕竟如果同时开发的项目太多可能会混淆端口):
js
const chalk = require("chalk");
module.exports = {
plugins: [
{
apply(compiler) {
compiler.hooks.done.tap("done", async (stats) => {
if (stats.compilation.errors.length === 0) {
await Promise.resolve(); // 延迟打印日志,将消息放在其它编译消息之后
console.log(
chalk.green.bold(`Http2 running at: https://localhost:${HTTP2_PORT}\n`) // 使用 chalk 美化一下输出颜色,使得提醒更加醒目
);
}
});
},
},
],
};
谨以此文给与新手朋友们一些参考思路。
startProxyServer 完整代码
js
const http2 = require("http2");
const proxy = require("http2-proxy");
const fp = require("find-free-port");
const finalhandler = require("finalhandler");
async function startProxyServer(targetPort) {
const http2Server = http2.createSecureServer({
allowHTTP1: true, // 必须提供,否则 websocket 无法被代理
key: fs.readFileSync("./ssl/key.pem"),
cert: fs.readFileSync("./ssl/key-cert.pem"),
});
// 代理 http 请求
http2Server.on("request", (req, res) => {
proxy.web(
req,
res,
{
hostname: "localhost",
port: targetPort, // devServer 的端口
// protocol: "https", // 默认为 http,如果 devServer 开启了 https 则需要显式指定 https
},
// 错误处理
(err, req, res) => {
if (err) {
console.error("proxy error", err);
finalhandler(req, res)(err); // 为了简化逻辑,我们使用 finalhandler 去处理错误的情况
}
}
);
});
// 代理 ws upgrade 请求
http2Server.on("upgrade", (req, socket, head) => {
proxy.ws(
req,
socket,
head,
{
hostname: "localhost",
port: targetPort, // devServer 的端口
// protocol: "https", // 默认为 http,如果 devServer 开启了 https 则需要显式指定 https
},
// 错误处理
(err, req, socket, head) => {
if (err) {
console.error("proxy websocket upgrade error", err);
socket.destroy();
}
}
);
});
const http2Port = await fp(process.env.http2_port || 12345); // 从参数指定的端口开始查找,如果端口被占用则递增,直到找到一个可用端口
http2Server.listen(http2Port); // 启动 http2 服务器
}