给 C++ 老程序补现代 UI:WebView2 多入口资源加载与虚拟主机名实践

手头有一坨跑了多年的 C++ Win32 程序,逻辑稳得一批,UI 却停留在 XP 审美。重写得不偿失,硬着头皮用 MFC 改又心累。yyzTools 走的就是另一条路:保留 C++ 内核,用 WebView2 把前端页面塞进原生窗口,靠多入口资源加载和虚拟主机名把几十个功能模块管起来。本文复盘这套资源加载方案踩过的坑。

一、起点:不是重做,是「套壳」

yyzTools 内核是一套 C++ Win32 程序,负责文件索引、进程管理、剪贴板、OCR 这些系统级活儿。前端部分用 Alpine.js + 原生 JS,构建用 Vite 多入口。架构上它和 Electron 应用长得很像------前端做 UI,原生层做系统能力------区别是它不打包 Chromium,借的是 Windows 自带的 Edge 内核(WebView2)。

「给老程序补 UI」这件事,一开始我们的设想很天真:把 WebView2 当成一个能显示网页的控件,往里 Navigate 一个本地 HTML 不就完事了?等真把 40+ 功能模块铺开,才发现资源怎么加载、路径怎么解析、页面之间怎么隔离,比写页面本身麻烦得多。

二、为什么是 WebView2 而不是 Electron / CEF

选型时纠结过三个方案:

  • Electron:生态成熟,但每个实例拖一个 Chromium,内存吃了个饱。一个效率工具集常驻后台,扛不起。
  • CEF(Chromium Embedded Framework):能定制,但接入成本和二进制体积都不小,分发时要带着一大包运行时。
  • WebView2:Windows 10 1809+ 自带,没装的话首次运行自动拉取 Evergreen 运行时。内存占用低一个量级,和系统集成更深。

代价我们也认了:WebView2 强绑 Windows,跨不了平台;首屏冷启动要等 Edge 运行时就绪,比纯原生控件慢几百毫秒。对一个本地效率工具来说,这个取舍能接受。

三、多入口:每个功能页是一个独立 HTML

最大的架构决定,是把「单页应用」否了,改走多入口。

最早想用一个 index.html 通吃所有功能,靠 JS 路由切换。试了一版就弃了------理由很实在:命令面板唤起 OCR、文件预览唤起 PDF 阅读、截图唤起编辑器,这些常常是各自独立的原生窗口,每个窗口就是一个 WebView2 实例。强行单页,意味着每个窗口都要加载全部 JS 和路由表,冷启动慢、内存翻倍、互相干扰。

后来定下的规则是:一个功能模块 = 一个入口 = 一份独立 index.htmlGetBaseUrl(){page} 拼出对应入口:

cpp 复制代码
// 示意:按模块名拼出该模块的入口地址
std::wstring NativeApi::GetBaseUrl(const std::wstring& page) {
    // tabdock(程序坞)统一走虚拟主机名,路径形如 /{page}/index.html
    return L"https://yyztools.localhost/" + page + L"/index.html";
}

// 唤起命令面板窗口时
std::wstring url = GetBaseUrl(L"cmdplate");
// -> https://yyztools.localhost/cmdplate/index.html
webview->Navigate(url.c_str());

Vite 这边对应多入口配置,每个模块一个 index.html 作为打包入口,产物互不耦合:

js 复制代码
// vite.config.js 多入口示意
import { resolve } from 'path';

const pages = ['cmdplate', 'ocr', 'translate', 'filepreview', 'sdk_sample'];

export default {
  build: {
    rollupOptions: {
      input: Object.fromEntries(
        pages.map(p => [p, resolve(__dirname, `Web/${p}/index.html`)])
      ),
      output: {
        // 每个入口独立 chunk,改一个模块不会让其他模块全量重打
        entryFileNames: 'assets/[name].[hash].js',
        chunkFileNames: 'assets/[name].[hash].js',
      }
    }
  }
};

多入口带来的直接好处是故障隔离:某个模块的 JS 抛了未捕获异常,最多崩自己那个窗口,不会拖垮整个工具集。构建上也舒服------改 OCR 模块只重打 OCR 的 chunk,CI 时间砍掉一大截。

代价同样明确:公共依赖(Alpine.js、ZenAPI 封装)不能随便放进每个入口,否则每个 chunk 都带一份。要靠 Vite 的 manualChunks 或者把稳定库提到共享 chunk,否则磁盘和内存都浪费。这块我们调过好几轮才压住重复体积。

四、虚拟主机名:绕开 file:// 的那些坑

资源放哪、用啥协议加载,是第二个大坑。

最早的实验版直接 Navigate("file:///C:/.../Web/cmdplate/index.html")。本地跑没问题,一上真机就踩雷:

  • file:// 下 ES Module 的 import 直接被浏览器策略拦掉,CORS 报错,模块化的前端根本起不来;
  • fetch 本地 JSON、动态 import() 统统受限file:// origin 是 null,啥都得放行;
  • localStorage / IndexedDBfile:// 下行为诡异,多个入口共用一个 null origin,数据串味;
  • Cookie、Service Worker 在 file:// 下基本不可用,将来想做离线缓存没戏。

这些限制单靠前端 hack 是补不齐的。WebView2 给的解法是虚拟主机名映射(Virtual Host Name Mapping):把本地一个目录映射成一个假的 https 域名,WebView2 内部把它当成正经的网络资源来加载。

cpp 复制代码
// 示意:初始化 WebView2 时建立虚拟主机名映射
void NativeApi::SetupWebView(ICoreWebView2* webview) {
    // 把本地 Web 目录映射成 https://yyztools.localhost/
    webview->SetVirtualHostNameToFolderMapping(
        L"yyztools.localhost",                          // 虚拟主机名
        L"C:\\Program Files\\yyzTools\\Web",            // 本地目录
        COREWEBVIEW2_HOST_RESOURCE_ACCESS_KIND_ALLOW);  // 允许访问

    // 之后所有页面用 https 协议加载,file:// 的坑全部消失
    webview->Navigate(L"https://yyztools.localhost/cmdplate/index.html");
}

映射到 https://yyztools.localhost/ 之后,前面那些问题一次性解决:ES Module 正常加载、fetch 本地资源不再报 CORS、localStorage 按真实 origin 隔离、每个模块窗口有自己的存储命名空间。tabdock(程序坞)就是统一走这个虚拟主机名,所有模块页面挂载在同一个 yyztools.localhost 之下,路径按 {page}/index.html 区分。

这里有个坑得单独说:虚拟主机名不要用真实存在的公网域名 。曾经随手用过真实域名做映射,结果某次该域名真能解析、还返回了内容,WebView2 优先走了网络,本地资源反而加载不上,排查了半天才反应过来。用 .localhost 这种保留后缀最稳,永远不会被公网抢答。

另一个细节是访问权限。SetVirtualHostNameToFolderMapping 的第三个参数控制资源访问范围,给 ALLOW 是全开,给 DENY 是全禁,还有个 ALLOW_CORS_REQUESTS_ONLY 只对带 CORS 头的交叉请求放行。我们默认用 ALLOW,但模块 SDK 开放给第三方后,这里是个需要警惕的攻击面------第三方网页能读到映射目录下的文件,所以宿主目录和模块目录的边界划分不能马虎。

五、桥接和加载是同一件事的两面

资源加载讲完,顺带说下它和 JS↔C++ 桥接的关系。前端不直接碰 window.Zen,统一走 ZenAPI 封装(位于 web/lib/zen_api.js):

js 复制代码
// zen_api.js 封装示意
class ZenAPI {
    static isOk(res) {
        return String(res?.error) === '0';   // error 恒为数字 0 表成功
    }
    static call(method, ...args) {
        return window.Zen[method](...args);
    }
}

// 取配置
const res = await ZenAPI.call('getConfig');
if (ZenAPI.isOk(res)) {
    render(res.data);
}

C++ 侧在 NativeApi::SetupBindings() 里用 BindSync / BindAsync 注册方法:同步的(getConfig、读剪贴板这类毫秒级操作)直接返回结果;真正耗时的(OCR、全盘搜索)才走 BindAsync,避免阻塞 UI 线程。

桥接和加载在这里咬合上了:虚拟主机名解决「页面怎么来」,桥接解决「页面能干什么」。两者都得建立在 WebView2 这套运行时之上,缺一个,要么页面起不来,要么页面是死的。

还有个类型契约的坑值得提一句。C++ 侧现在用类型化 ptree 生成返回:put<bool> 出原生布尔、put<int> 出原生数字、字符串才带引号。这套机制下,前端 if (res.enabled) 直接拿到的是真布尔,不用再 === 'true' 那种恶心判断。唯一的例外是配置值 ------历史上前端写配置时一直用 '1'/'0' 字符串,C++ 侧为了兼容没动它。新接模块的同事第一次遇到「这个字段是数字、那个字段是字符串」时都懵过,所以 ZenAPI.isOk 这种归一化判断是必须保留的护城河,别想着简化掉。

六、取舍:这套方案不是银弹

复盘下来,多入口 + 虚拟主机名的组合解决了加载和隔离,但它不万能:

  • 强绑 Windows。要跨 macOS/Linux 得另起炉灶,WebView2 不在那俩平台存在。
  • 运行时依赖。目标机器没装 Edge WebView2 运行时,首次启动要联网拉取;纯内网环境得提前随安装包预置。
  • 冷启动开销。每个独立窗口都是一个 WebView2 实例,开得越多,Edge 运行时共享虽好,但首批实例的初始化延迟还是比原生控件明显。命令面板那种「按一下立马出」的场景,我们用预创建 + 隐藏窗口的方式兜底,代价是常驻一点内存。
  • 虚拟主机名是全局的 。同一个 yyztools.localhost 下挂了所有模块,模块之间的 origin 相同,想做严格隔离得靠路径约定和宿主侧鉴权,没法靠浏览器同源策略天然隔开。

这些是架构层面就定下的债,不是调调参数能抹平的。写之前就得想清楚接不接得住。

小结

给 C++ 老程序补现代 UI,WebView2 是个性价比很高的选项:不重写内核、不打包浏览器、内存可控。落地时记住两件事------多入口按 {page}/index.html 拆,用 GetBaseUrl() 统一拼地址,换来求稳的故障隔离和可控的构建体积;资源加载别用 file://,用 SetVirtualHostNameToFolderMapping 映射到 https://yyztools.localhost/ 这种保留域名,把模块化的前端从 CORS 和存储隔离的坑里捞出来。桥接走 ZenAPI 统一封装、靠 error 数字契约判成功,类型化 ptree 让返回更干净,但历史遗留的 '1'/'0' 配置字符串得留好兼容。代价是平台绑定、运行时依赖和冷启动延迟,接之前先掂量。

yyzTools 当前 v1.0.6,40+ 内置功能、379 个命令模块、12 语言,本地优先、永久免费。想看多入口和虚拟主机名在真实工程里怎么落,官网 yyztools.com 的开发者页有完整样例可以扒。

相关推荐
打呵欠的猫1 小时前
让 AI 帮你写 Git Commit Message:从"fix bug"到语义化提交只需一个 Hook
前端·ai编程
labixiong1 小时前
CSS 锚点定位实测:零 JS 实现气泡跟随,自动翻转很香但暗藏 3 个致命坑
前端·css
qibmz1 小时前
用 DeepSeek 给 GitHub 仓库加 PR 自动 Review
前端
计算机魔术师1 小时前
幻觉率从 4.2% 降到 2% 又退回,GPT-6 Astra 到底在藏什么
前端
梨想橙汁1 小时前
Git 常用命令大全:提交、查看日志、版本回退、文件撤销
前端·javascript
Amos_Web1 小时前
Rspack 源码解析(三):从入口到依赖图,读懂 Make 阶段的 Rust 任务循环
前端·rust
晴天161 小时前
浏览器中ESM与AMD模块共存的解决方案
前端·node.js
芳心粽伙饭2 小时前
CSS第一章 CSS引入
前端·css
雪芽蓝域zzs2 小时前
第三十二节:部门组织管理(el‑tree 组织树
前端·javascript·vue.js