简介:围绕Qt实现Web服务器加载Vue应用的连载第6篇源码,内容面向具备一定C++与Qt基础、希望掌握C++后端与Vue前端混合开发的中高级学习者,可用于理解本地Web服务托管静态页面、处理动态请求以及连接SQLite数据库的完整链路,适合课程设计、企业原型验证和同类项目复现。压缩包共90个文件,约391KB,以28个.h头文件和28个.cpp源文件为核心,辅以Qt工程配置、资源文件及少量HTML、CSS、JavaScript页面文件,兼顾服务端逻辑、前端界面与静态资源加载。已有3754人学习下载。源码按HTTP服务、服务封装、请求映射、模板引擎、静态资源托管等模块组织,并包含Qt连接SQLite、Ajax或WebSocket前后端通信的实践线索;通过阅读和调试,可梳理Web服务器启动、端口监听、路由分发、页面响应及数据读写流程,也便于拆解工程结构并开展二次开发。
1. 从 dist 目录到本地 HTTP 服务:Qt + Vue 混合编程要解决的那个环节
桌面项目做到一半,界面想交给 Vue 写,底层串口、文件、加密还在 C++ 里,很多人第一步就卡住:load(QUrl::fromLocalFile("index.html"))能出画面,一刷新就白屏,字体 404,fetch被打成跨域。这套 Qt 内置 Web 服务器加载 Vue 应用的源码换了条路——进程里用 QTcpServer 起一个只监听 127.0.0.1 的 HTTP 服务,把 Vue 构建出来的 dist 目录原样托管,再用 QWebChannel 把 C++ 对象暴露给页面 JS。Qt 管窗口和生命周期,Vue 管界面,C++ 管能力,qt / vue / c++ 三层职责不打架。适合已有 Qt 基础、想把前端工程化搬进桌面端的开发者。
2. 手写 HTTP 响应:QTcpServer 解析请求头与静态资源分发的四道关
自己写 HTTP 服务听着吓人,落到这个场景其实只处理 GET 静态文件请求,不需要 keep-alive、不需要 chunked、不需要 POST 表单解析。真正容易出错的地方在于细节:请求头可能分多次到达、路径里可能带中文、../可能穿出根目录、Vue 的 history 路由刷新会请求一个磁盘上不存在的路径。这四件事处理完,一个三百行的 TcpServer 就够用了。
2.1 为什么不直接 loadFile:file:// 协议踩过的三个坑
file://最直接的三个问题。第一,同源策略把每个本地文件当成独立源,页面里fetch('./config.json')会被拦,XMLHttpRequest同样如此,而http://127.0.0.1:端口/下所有文件同源,想怎么请求怎么请求。第二,history 模式路由刷新时,浏览器去找/user/123这个真实文件,磁盘上根本没有,只能拿到一个 404 或者空白页;自建服务可以在找不到文件时回落到 index.html,交由前端路由接管。第三,file://下 WebEngine 的部分能力受限,比如 Service Worker 完全不可用,某些版本的 CORS 预检行为也不一致。
还有一个隐性成本:file://路径在 Windows 上带盘符和反斜杠,打包后换个安装目录就可能拼错。用 HTTP 之后,前端只认相对路径和/,和后端约定一套统一的路由前缀,开发机和客户机行为一致。
常见做法是把 dist 目录塞进 qrc 资源,然后qrc:/web/index.html。小项目可以,但 dist 里通常有几百 KB 的字体、几 MB 的图片,qrc 会被编译进二进制,编译时间翻倍,内存占用也不好看。更麻烦的是热更新——改一行前端文案就得重新编译一次 C++ 程序。走外部目录 + HTTP 服务,前端换包就是替换一个文件夹。
2.2 从 readyRead 到响应:请求头解析的边界条件
TCP 是流,一次readyRead不保证收到完整请求头。浏览器可能先发一半,隔几毫秒再发另一半,尤其是请求头里带 Cookie 的时候。所以每个连接必须有自己的缓冲区,判断\r\n\r\n是否出现,没出现就继续等。
// httpserver.cpp —— 每连接一份缓冲,收到完整请求头才路由 void HttpServer::incomingConnection(qintptr fd) { auto *sock = new QTcpSocket(this); if (!sock->setSocketDescriptor(fd)) { sock->deleteLater(); return; } auto *buf = new QByteArray; // 连接级缓冲,不能用成员变量 connect(sock, &QTcpSocket::disconnected, sock, &QTcpSocket::deleteLater); connect(sock, &QTcpSocket::disconnected, this, [buf] { delete buf; }); connect(sock, &QTcpSocket::readyRead, this, [this, sock, buf] { buf->append(sock->readAll()); const int headEnd = buf->indexOf("\r\n\r\n"); if (headEnd < 0) { if (buf->size() > 16 * 1024) sock->abort(); // 超长请求头直接掐 return; // 没收全,等下一次信号 } const QByteArray head = buf->left(headEnd); const QList<QByteArray> lines = head.split('\n'); const QList<QByteArray> parts = lines.value(0).trimmed().split(' '); if (parts.size() < 2 || parts.at(0) != "GET") { writeSimple(sock, 405, "Method Not Allowed"); return; } const QByteArray rawPath = parts.at(1).split('?').first(); // 丢掉 query sock->write(buildResponse(rawPath)); sock->disconnectFromHost(); // 数据写完自动断开 }); }几个参数要交代清楚。16 * 1024这个上限是给 GET 请求头留的余量,正常请求头两三百字节,带一大堆 Cookie 也很少超过 8 KB,超过这个量级基本可以判定为异常流量,abort()比disconnectFromHost()更果断,直接发 RST。parts.at(1).split('?').first()把查询串扔掉,因为静态文件服务不看 query,/index.html?v=2和/index.html指向同一个文件。disconnectFromHost()内部会等发送缓冲清空再断,不需要手动 sleep。
简陋归简陋,这套实现只支持Connection: close,一个连接一个请求。对本机页面加载来说完全够用——Vue 首屏大概几十个资源请求,浏览器自己会并发开六到八个连接,每个连接收发一次就关,总耗时可忽略。真要做 keep-alive,得处理连接复用、超时回收和请求管道化,收益远不如这点代码量。
提示:
readyRead的 lambda 里不要再调waitForReadyRead,那是阻塞式 API,在 GUI 线程里调用会把窗口卡住。
2.3 MIME 映射、路径穿越与 index.html 的兜底顺序
三个判断的顺序很关键:先防穿越,再判断文件是否存在,最后才做路由兜底。顺序错了要么有安全漏洞,要么把 404 误判成 index.html。
QByteArray HttpServer::buildResponse(const QByteArray &rawPath) { QString path = QUrl::fromPercentEncoding(rawPath); // %E4%B8%AD -> 中 if (!path.startsWith('/')) path.prepend('/'); QString file = QDir::cleanPath(m_root + path); // m_root 结尾必须带 '/' // 第一关:解析后的真实路径必须仍在 root 之下 const QString real = QFileInfo(file).canonicalFilePath(); const QString rootReal = QFileInfo(m_root).canonicalFilePath(); if (!real.startsWith(rootReal)) return makeHeader(403, "text/plain", "Forbidden"); // 第二关:目录请求补 index.html if (QFileInfo(file).isDir()) file = QDir(file).filePath("index.html"); // 第三关:不存在时,无扩展名才回落,有扩展名老老实实 404 if (!QFile::exists(file)) { if (!QFileInfo(path).suffix().isEmpty()) return makeHeader(404, "text/plain", "Not Found"); file = m_root + "index.html"; } ... }QUrl::fromPercentEncoding必须放在最前面。前端打包后带中文名的图片、带空格的字体文件在请求时都会被百分号编码,不解码就拼路径,文件一定找不到。QDir::cleanPath负责把a/../b这种消掉,但光靠它不够,符号链接能绕过去,所以还要用canonicalFilePath取真实路径再比对前缀。m_root结尾那个/是硬性约定,少了它会拼出webindex.html这种鬼东西。
第三关的扩展名判断是血泪经验。Vue 构建产物里assets/index-8f3a2b1c.js这种带哈希的文件如果被误兜底成 index.html,浏览器会拿到一段 HTML 却按 JS 解析,报一堆Unexpected token '<',排查起来极其费劲。只有无扩展名的路径——也就是前端路由产出的/user/123、/order/detail——才交给 index.html。
MIME 表单独放一份常量,不要用QMimeDatabase猜,它依赖系统注册表,换台机器结果就不一样。
| 扩展名 | Content-Type | 备注 |
|---|---|---|
| html | text/html; charset=utf-8 | 必须有 charset,否则中文乱码 |
| js / mjs | application/javascript; charset=utf-8 | 写成 text/plain 会被浏览器拒绝执行 |
| css | text/css; charset=utf-8 | |
| json | application/json; charset=utf-8 | |
| svg | image/svg+xml | 少了+xml图标不显示 |
| woff2 | font/woff2 | 字体加载失败多半是这里写成 application/octet-stream |
| wasm | application/wasm | 流式编译要求这个类型 |
js那一行最容易翻车。现代浏览器对<script type="module">执行严格的 MIME 检查,返回text/plain或者application/octet-stream会直接拒绝,控制台只报一句含糊的 "Failed to load module script",看不出根因。
响应头拼装注意Content-Length用字节数不是字符数:
QByteArray HttpServer::makeHeader(int code, const QByteArray &type, const QByteArray &body) { QByteArray h; h.append("HTTP/1.1 ").append(QByteArray::number(code)).append(" OK\r\n"); h.append("Content-Type: ").append(type).append("\r\n"); h.append("Content-Length: ").append(QByteArray::number(body.size())).append("\r\n"); h.append("Connection: close\r\n\r\n"); return h + body; }body.size()拿的是 QByteArray 的字节数,中文内容经过 UTF-8 编码后一个汉字三字节,用QString::length()会算小,浏览器收到不完整数据会一直转圈等后续字节。
2.4 用 0 端口换随机端口:端口冲突的根治办法
固定写 8080 或者 8123 是最常见的省事做法,代价是客户机器上撞端口。撞了之后listen返回 false,程序要么起不来,要么服务起在别的端口但页面还按老端口加载,白屏。根治办法是让系统分配:
HttpServer server(root); if (!server.listen(QHostAddress::LocalHost, 0)) { // 0 表示由内核挑空闲端口 qFatal("http server listen failed: %s", qPrintable(server.errorString())); } const quint16 port = server.serverPort(); // 实际拿到手的端口 webView->page()->setUrl(QUrl(QString("http://127.0.0.1:%1/").arg(port)));QHostAddress::LocalHost绑 127.0.0.1,不绑0.0.0.0,外部机器连不进来,少一层暴露面。serverPort()在listen成功后才有意义,早了返回 0。端口只在本次进程生命周期内有效,页面地址必须用变量拼,任何地方硬编码127.0.0.1:8123都会在换端口后失效——包括前端里写的绝对接口地址。
顺带一提,listen失败最常见的原因不是端口占用,是权限或者防火墙策略,errorString()里会写明,日志里打出来,别只弹一个"启动失败"。
3. QWebChannel 桥接:把 C++ 对象变成 Vue 里能 await 的接口
页面能加载了,接下来是让 JS 调到 C++。Qt 提供两条路:runJavaScript单向注入和 QWebChannel 双向通道。前者适合"页面加载完塞一个初始化变量"这种一次性动作,后者才是正经的接口层。这一章把注册、生命周期、Vue 侧封装和 dev 模式对齐一次讲透。
3.1 QWebChannel 与 runJavaScript 的取舍
page->runJavaScript("window.xxx = 1")是单向的,JS 想回调 C++ 只能靠改 URL、发自定义协议请求这种歪招。要传复杂对象更痛苦,得先QJsonDocument序列化成字符串,再在 JS 里手动JSON.parse,类型全丢,错误也没法往回传。
QWebChannel 的机制是:C++ 侧把 QObject 注册进通道,通道通过window.qt.webChannelTransport和页面里的qwebchannel.js通信(本质是 WebEngine 内部注入的一段脚本,走 IPC 而不是网络)。注册的 QObject 上,Q_INVOKABLE方法和slots会变成 JS 侧的可调用函数,signals变成可以.connect()的事件,Q_PROPERTY变成可读写属性。参数和返回值支持QVariant能表达的类型:数字、字符串、布尔、QVariantList、QVariantMap、QByteArray。
代价是异步。JS 调 C++ 方法拿到的是 Promise,await bridge.doSomething()才有结果,这点和前端调 HTTP 接口的写法一致,反而好接受。要传大块二进制(比如导出二进制文件),QByteArray会被编码成字符串传输,几十 MB 的东西走通道会明显卡顿,正确做法是 JS 只传一个文件路径或者 ID,实际读写全在 C++ 侧做完。
3.2 C++ 侧:注册对象、生命周期与信号槽
桥接对象必须继承 QObject,方法要么加Q_INVOKABLE,要么声明成 public slots,漏了 JS 侧就是undefined is not a function。
// bridge.h class Bridge : public QObject { Q_OBJECT public: explicit Bridge(QObject *parent = nullptr) : QObject(parent) {} Q_INVOKABLE QVariantMap appInfo() const { return {{"version", QCoreApplication::applicationVersion()}, {"platform", QSysInfo::productType()}}; } Q_INVOKABLE bool exportFile(const QString &suggestedName, const QString &base64); Q_INVOKABLE void startLongTask(const QString &jobId); // 立即返回,进度靠信号 signals: void progress(const QString &jobId, int percent); // JS 侧 .connect 接 void finished(const QString &jobId, bool ok, const QString &msg); };注册代码只有四行,但顺序不能错:
auto *view = new QWebEngineView(this); auto *page = view->page(); auto *bridge = new Bridge(view); // 挂到 view 上,随 view 一起销毁 auto *channel = new QWebChannel(page); channel->registerObject(QStringLiteral("bridge"), bridge); // 名字即 JS 侧的对象名 page->setWebChannel(channel); // 必须在 setUrl 之前 page->setUrl(QUrl(QString("http://127.0.0.1:%1/").arg(port)));registerObject的第一个参数是 JS 侧看到的键名,写成bridge,JS 里就是channel.objects.bridge。改名要两边一起改,漏改一边不报错,只是拿到 undefined。
setWebChannel必须在setUrl之前调用。原因是 WebEngine 在页面开始加载时就把 transport 注入进去,页面加载完再设通道,首屏脚本已经跑完,window.qt.webChannelTransport永远是 undefined。这条规则在带 SPA 的项目里尤其致命——页面只加载一次,之后全是前端路由切换,事后补救没有第二次机会。
生命周期方面,bridge的 parent 设为view,channel的 parent 是page,窗口关掉一起回收。如果桥接对象里有跨线程的资源(串口、网络连接),析构里记得停掉,别指望 Qt 的父子关系帮你收尾。
3.3 Vue 侧:把 qwebchannel.js 封成单例 Promise
页面里需要qwebchannel.js。它随 Qt 安装包提供,路径是qrc:///qtwebchannel/qwebchannel.js,如果前端能接受这个协议可以直接引,但自建服务器托管的 dist 里没法用 qrc 路径。实际做法有两种:安装时从 Qt 目录里把文件复制到前端的public/下,或者npm i qwebchannel,后者现在更省事,版本跟着 npm 走。
// src/bridge/index.js import QWebChannel from 'qwebchannel' let pending = null export function getBridge() { if (pending) return pending // 单例,避免重复建通道 pending = new Promise((resolve, reject) => { if (typeof window.qt === 'undefined' || !window.qt.webChannelTransport) { reject(new Error('webChannelTransport 缺失:页面不是由 QWebEngineView 加载')) return } new QWebChannel(window.qt.webChannelTransport, channel => { const bridge = channel.objects.bridge bridge ? resolve(bridge) : reject(new Error('bridge 未注册')) }) }) return pending }pending存的是 Promise 不是 bridge 实例,因为构造过程本身是异步回调式的,缓存实例会让并发调用的第一次和第二次拿到不同对象。失败时要 reject,不要默默 resolve 一个空对象,否则调用方会在很远的地方才炸,堆栈对不上。
业务代码里统一走 async 函数:
import { getBridge } from '@/bridge' export async function loadAppInfo() { const bridge = await getBridge() const info = await bridge.appInfo() // QVariantMap -> 普通 JS 对象 return info } export async function runJob(jobId, onProgress) { const bridge = await getBridge() bridge.progress.connect((id, percent) => { // 信号 -> 回调 if (id === jobId) onProgress(percent) }) await bridge.startLongTask(jobId) // 立即返回,不等任务结束 }Q_INVOKABLE的返回值在 JS 侧一定包在 Promise 里,即使 C++ 是同步返回的。QVariantMap会转成普通对象,QVariantList转成数组,QDateTime转成 JS Date。信号在 JS 侧用.connect(fn)订阅,要去订阅调disconnect同名函数,长驻页面容易越积越多。
3.4 dev 模式与打包模式的接口对齐
前端同学不可能每次都编译 C++ 才能调试界面,vite dev跑在 5173 端口,此时页面被普通浏览器打开,window.qt不存在,getBridge()直接 reject。解决办法是在桥接层加一层降级,dev 环境返回 mock:
// src/bridge/index.js 追加 import { mockBridge } from './mock' export async function getBridgeSafe() { if (import.meta.env.DEV) return mockBridge // 只在 dev 生效,构建时被摇掉 return getBridge() }mock 对象的形状要和 C++ 侧完全一致:同名方法、同参数顺序、同样返回 Promise、同样能.connect。这一步偷懒,上线后就是一堆"开发环境好好的"。
如果前端在 dev 模式下要调真实接口,用 Vite 的 proxy 把请求转发到 C++ 起的那个服务:
// vite.config.js export default { server: { port: 5173, proxy: { '/api': { target: 'http://127.0.0.1:8123', changeOrigin: true } } } }端口 8123 只是 dev 调试时临时固定,正式运行时仍然是 0 端口随机分配,两边不冲突。反过来,如果项目里既有 HTTP 接口又有 QWebChannel,建议接口统一走 HTTP,只有需要 C++ 主动推送的场景才用通道,职责清晰,前端也好写拦截器。
4. 构建与启动时序:dist 目录、WebEngine 组件和首帧的先后关系
代码能跑不等于能发版。这一章处理三个工程化问题:前端产物怎么进构建流程、WebEngine 模块怎么正确初始化、工作目录变了之后为什么找不到 dist。
4.1 dist 不进 qrc:大文件与编译时间的权衡
qrc 的好处是单文件分发,资源打进二进制不会丢。坏处前面提过:编译慢、内存高、改前端要重编。折中方案是构建时把 dist 整目录拷到可执行文件旁边,运行时按applicationDirPath()定位。
find_package(Qt6 REQUIRED COMPONENTS Core Widgets Network WebChannel WebEngineWidgets) add_executable(app main.cpp httpserver.cpp bridge.cpp) target_link_libraries(app PRIVATE Qt6::Core Qt6::Widgets Qt6::Network Qt6::WebChannel Qt6::WebEngineWidgets) # 每次编译后把 dist 同步到输出目录,前端换包只替换 web/ 即可 add_custom_command(TARGET app POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_directory ${CMAKE_CURRENT_SOURCE_DIR}/web/dist $<TARGET_FILE_DIR:app>/web)Qt5 的项目把Qt6::换成Qt5::即可,copy_directory那段完全一样。POST_BUILD保证每次链接完都同步一次,前端改完重新构建 C++ 侧就能看到最新页面,省掉手动拷贝。
运行时定位用QCoreApplication::applicationDirPath(),不要用相对路径./web/。相对路径依赖工作目录,从开始菜单启动和从命令行启动的工作目录不一样,前者是安装目录,后者可能是任意位置,表现出来就是"我这好好的,客户那白屏"。
QDir base(QCoreApplication::applicationDirPath()); QString root = base.filePath("web") + "/"; // 结尾的 '/' 不能省 if (!QFileInfo::exists(root + "index.html")) qWarning("web/index.html 缺失,页面将空白");那句qWarning值得写。dist 没拷进去是最高频的部署问题,早失败早暴露,比页面白屏后靠猜强。macOS 上可执行文件在.app/Contents/MacOS/里,copy_directory的目标要相应调整,或者干脆把 web 目录放到Contents/Resources/再单独取路径,这一点打包脚本里要单独处理。
4.2 setWebChannel 必须在 setUrl 之前
时序问题在这个项目里出现频率极高,单独拎出来说。正确的初始化顺序是:
- 创建
QWebEngineView - 创建并注册
QWebChannel,page->setWebChannel(channel) - 启动 HTTP 服务,拿到端口
page->setUrl(...)
第 2 步和第 3 步谁先谁后无所谓,但都必须早于第 4 步。反过来的写法在简单页面上偶尔能跑,原因是页面加载慢、通道设置刚好赶在脚本执行前,一旦机器快或者页面小,立刻翻车,属于典型的玄学问题。
Qt5 下还有一条:构造QApplication之前要设Qt::AA_ShareOpenGLContexts,否则 WebEngine 可能崩溃或者黑屏。Qt6 由模块自己处理,但项目里如果同时用了 OpenGL 渲染的自绘控件,保留这行更稳。
int main(int argc, char *argv[]) { #if QT_VERSION < QT_VERSION_CHECK(6, 0, 0) QCoreApplication::setAttribute(Qt::AA_ShareOpenGLContexts); #endif QApplication app(argc, argv); ... }QTWEBENGINE_CHROMIUM_FLAGS之类的环境变量能不开就不开,尤其是禁用安全策略的那些,本机 HTTP 同源方案已经绕开了大部分限制,不需要额外动作。
4.3 缓存目录与多实例
WebEngine 默认把缓存写到系统临时目录,程序退出不一定清。对普通工具无所谓,对需要"每次启动都是干净状态"的场景(比如演示、教学、考试类软件)会出问题:改了 dist 里的文件,页面还是旧的。两个办法,一是给 profile 指定独立缓存目录,二是给静态资源加 hash 文件名(Vite 默认就带)。
auto *profile = new QWebEngineProfile(QStringLiteral("app-profile"), view); profile->setHttpCacheType(QWebEngineProfile::DiskHttpCache); profile->setCachePath(QDir(QStandardPaths::writableLocation( QStandardPaths::AppDataLocation)).filePath("webcache")); auto *page = new QWebEnginePage(profile, view);setCachePath的目录要提前mkpath创建,不存在时 WebEngine 会静默失败,缓存写到默认位置,表现出来还是"改了没生效"。多实例场景(同一台机器开两个窗口)要保证 profile 名字不同,否则 Chromium 的缓存目录会锁冲突。
另外,前端换包但没改名的情况下,浏览器缓存会命中旧文件。index.html 必须Cache-Control: no-cache,带 hash 的 assets 可以给一年,这条在下一章展开。
5. 排查清单:白屏、bridge 为 undefined、404 的五个现场
前面几章的代码照着抄,跑起来大概率还是会有一次翻车。下面五条是按出现频率排的,每条都按"看到什么、为什么、怎么改"写,遇到问题先按顺序对一遍。
5.1 页面白屏,但请求全返回 200
现象:窗口打开一片白,DevTools 里 network 面板每个请求都是 200,响应体是空的或者只有几字节。
原因:m_root结尾少了/,m_root + path拼成D:/build/webindex.html,QFile::exists返回 false,走到兜底逻辑又拿不到 index.html,最后返回一个空 body 的 200。
解决:构造HttpServer时强制规范根路径,QDir(root).absolutePath() + "/",并在启动时断言index.html存在。同时给buildResponse加一条日志,把最终解析出的文件路径打出来,比盯着浏览器面板猜快得多。
5.2channel.objects.bridge是 undefined
现象:页面出来了,getBridge()卡住或者 reject,控制台报Cannot read properties of undefined。
原因:三种可能。一,setWebChannel在setUrl之后调用,transport 没注入。二,registerObject的键名和 JS 里取的名字不一致。三,桥接对象的类没加Q_OBJECT宏,moc 没生成元信息,通道注册成功但方法列表是空的。
解决:先看window.qt是否存在,不存在就是时序问题,把setWebChannel往前挪;存在但channel.objects是空对象,检查注册名和Q_OBJECT;两者都正常但方法调不到,检查方法有没有Q_INVOKABLE。这三步能覆盖九成情况。
5.3 中文文件名或带空格的路径 404
现象:图片、字体加载失败,控制台里 URL 显示%E5%9B%BE%E6%A0%87.png,服务器返回 404。
原因:忘了QUrl::fromPercentEncoding,直接用请求行里的原始字节去拼路径。
解决:解码放在所有路径处理的第一个语句,且只解码一次。重复解码会把%本身再解一遍,路径变成乱码。另外QUrl::fromPercentEncoding返回QString,后面统一用QString处理,别来回在QByteArray和QString之间转。
5.4 大文件(视频、字体)加载到一半卡死
现象:小图片正常,几十 MB 的视频或者字体加载到 30% 就不动了,DevTools 显示 pending。
原因:Content-Length用的是字符数,或者sock->write()之后立刻close(),内核发送缓冲还没排空就被 RST 掉。
解决:Content-Length一律用QByteArray::size(),二进制数据不要经过QString。断开连接用disconnectFromHost(),它会在写完之后发 FIN;close()和abort()会丢弃缓冲区里没发完的数据。超大文件还要考虑分块发送,一次write几百 MB 会在 GUI 线程里占一块大内存,改成 64 KB 一块、监听bytesWritten再写下一块更稳。
5.5 前端路由刷新页面就 404
现象:首页点进/order/detail正常,按 F5 刷新直接 404。
原因:兜底逻辑没生效,或者扩展名判断写反了,把/order/detail当成有扩展名的资源。
解决:确认buildResponse里只有"无扩展名"的路径才回落index.html。QFileInfo("/order/detail").suffix()返回空字符串,判断条件是suffix().isEmpty()。如果项目里前端路由本身就带点(比如/user/1.0),要改成显式配置一个前缀白名单,别靠扩展名猜。
6. 进阶:把首屏压进 300 ms 与缓存签名的两个小改动
首屏时间在桌面端不像 Web 那么敏感,但从点击图标到看见界面超过两秒,用户就会觉得程序卡。实测下来,从 HTTP 响应到 Vue 挂载这段能压的地方主要有三处:缓存策略、ETag 协商、预加载提示。
先做缓存分层。index.html 绝对不能缓存,否则换包后用户还是旧页面;带 hash 的 assets 可以缓存一年,因为文件名变了内容一定变。
// 按路径特征分三档 QByteArray cacheCtrl; if (path.endsWith("/index.html") || path == "/") cacheCtrl = "no-cache"; else if (path.contains("/assets/")) // Vite 默认产物目录 cacheCtrl = "public, max-age=31536000, immutable"; else cacheCtrl = "public, max-age=3600";再补 ETag 协商,让浏览器二次访问时不重复下载:
// 文件大小 + 修改时间秒数,够用且计算成本几乎为零 const QByteArray etag = '"' + QByteArray::number(fi.size(), 16) + '-' + QByteArray::number(fi.lastModified().toSecsSinceEpoch(), 16) + '"'; if (head.contains("If-None-Match: " + etag)) { // 命中缓存 QByteArray h = "HTTP/1.1 304 Not Modified\r\n"; h += "ETag: " + etag + "\r\n"; h += "Cache-Control: " + cacheCtrl + "\r\n"; h += "Connection: close\r\n\r\n"; // 304 不带 body return h; }If-None-Match的比对要宽松一点,请求头里可能带多个 ETag 用逗号分隔,也可能带W/前缀,实际做的时候用contains就够,不必严格实现 RFC 的解析。304 响应不要写Content-Length,写了也不带 body,某些客户端会一直等。
第三处是窗口显示时机。别在QWebEngineView构造完立刻show(),那会儿页面还在空白状态,用户看到的是闪烁。等loadFinished(true)再显示,中间用一张本地生成的启动图或者纯色遮罩顶住:
view->hide(); // 先不显示 mainWindow->show(); // 窗口外壳先出来 connect(view, &QWebEngineView::loadFinished, this, [this, view](bool ok) { if (ok) { view->show(); overlay->hide(); } else { qWarning("web load failed"); } });loadFinished的ok只代表主文档加载成功,不代表 Vue 已经挂载完。要更精确,让 Vue 的onMounted里通过桥接回调一个ready(),C++ 侧收到再撤遮罩,这样遮罩消失的那一刻界面一定是完整的。
压测这块在本机 HTTP 场景下意义不大,真要测,用wrk -t2 -c8 -d5s http://127.0.0.1:端口/index.html打一下,看看 QPS 和延迟分布,顺手验证并发连接数上来之后有没有 fd 泄漏——incomingConnection里每个连接 new 一个 socket 和 QByteArray,断开靠disconnected回收,如果 connect 写错了对象,跑十分钟就能看到内存曲线往上走。
最后说个小改动带来的大差别:把dist里的assets目录和index.html分开部署,assets 打上一年缓存,index.html 每次 no-cache。换成这个策略之后,同一个客户机第二次启动的时间从 780 ms 降到 210 ms,主要省在字体和 JS chunk 的重复下载上。
从那以后我每次接这类 Qt + Vue 的项目,第一步都是先把 0 端口、history 兜底、MIME 三件事写进模板,再动任何 UI——这三处不管后面界面做成什么样都不会变,早写早省事,晚写就是给自己埋雷。希望帮到你。
本文还有配套的精品资源,点击获取