手头有一坨跑了多年的 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.html。GetBaseUrl() 按 {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/IndexedDB在file://下行为诡异,多个入口共用一个 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 的开发者页有完整样例可以扒。