ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

WebView2实战:老C++程序多入口资源加载与虚拟主机名映射方案

2026/9/15 5:53:21 拓冰建站 浏览量
WebView2实战:老C++程序多入口资源加载与虚拟主机名映射方案 给一个跑了十年的 C 老程序换界面补一层现代 UI我最后选的是 WebView2。这个方案的思路很直接后端继续用 C 顶着业务逻辑前端用 HTML/CSS/JS 重新画界面两边通过 WebView2 的通信机制对接。真正落地时最容易卡住的反而不是界面本身而是怎么把一堆 HTML、JS、CSS 资源有序地加载到 WebView2 里。这篇文章就把我踩过几次坑之后形成的多入口资源加载方案和虚拟主机名实践完整拆一遍给同样在给老项目补 UI 的人做个参考。1. 为什么老C程序值得上WebView21.1 MFC老项目的改造困境先交代下背景。我手上这个程序是 MFC 时代的老产品界面里密密麻麻排着各种 CButton、CListCtrl、CTreeCtrl业务逻辑和界面代码几乎揉在一起。这些年用户越来越不买账反馈最多的一句话就是“界面太老了像个上个世纪的软件”。可要把这个程序的业务逻辑全部重写风险太大、周期太长老板和客户都等不起。于是摆在面前的选择只有两个一是在旧框架上继续修补 UI二是找一个能力强的现代渲染引擎把界面层整个替换掉。前者治标不治本换汤不换药后者才是能让产品重新拥有竞争力的路。但问题来了老程序是 C 写的市面上能嵌入的渲染引擎无非就那几种选择本身就得反复权衡。1.2 WebView2和CEF的抉择很多人在这个环节第一反应是 CEFChromium Embedded Framework。CEF 确实成熟网上资料多我自己也用过。但 CEF 有几个硬伤Chromium 内核整体打包安装包体积动辄上百 MB内存占用非常夸张每次升级内核得自己维护一整套构建链。对一个小团队来说这个维护成本不是一般的高。WebView2 的出现给了另一个解法。它底层就是 Edge 的 Chromium 内核但由系统统一管理和更新不需要你打包一个浏览器进去。开发库本身只有头文件和一段小库文件集成成本比 CEF 低一个量级。更重要的是它和桌面应用无缝集成消息通信、窗口句柄绑定、资源拦截这些能力都做得比较完善。实测下来如果只是做 UI 层替换WebView2 是最省心的选择。有人担心系统没有 WebView2 Runtime 怎么办。这个问题确实存在不过现在 Windows 10/11 系统普遍把 Edge 作为内置组件大多数机器都有。退一步说就算目标环境比较老也可以用离线安装包在安装程序里顺便装上后面我会专门写这个问题的处理。1.3 架构分层C管业务Web管界面选用 WebView2 之后我的架构变成了这样C 层维持原有业务模块、数据处理、网络请求等逻辑同时作为宿主进程承载 WebView2 控件。Web 层HTML/CSS/JS 负责所有界面交互、视觉表现。通信层WebView2 的 PostWebMessageAsJson / WebMessageReceived 通道负责 C 和 JS 之间的消息交换。这样做的好处很明显。第一C 团队不用再和界面样式死磕UI 层面完全交给前端栈。第二以后想换皮肤、改布局、加动效不再需要重新编译 C只要替换静态资源文件。第三也是我后面会重点讲的WebView2 提供了比较宽松的资源映射机制让资源和程序本身解耦。这套架构跑起来之后我甚至觉得与其说是给老程序补 UI不如说是给老程序配了个高效的页面容器。2. 多入口资源加载的技术选型2.1 程序里的“多入口”从哪来给老程序做 UI 的过程中最容易被忽略的往往是一个细节真实业务程序几乎不止有一个界面入口。以我改造的这款工具软件为例它至少有三个界面入口主仪表盘程序启动后默认展示的主页面放核心数据和快捷操作。设置面板配置运行参数、用户偏好独立于主页面存在。帮助与诊断包含用户手册、版本信息、环境自检通常从主菜单调起。如果只把主界面替换成 HTML其他弹窗还是 MFC 原生窗口用户会觉得非常割裂。所以目标是把所有界面全部迁到 WebView2 里。这个需求延伸开来就是“多入口资源加载”程序里有多个页面入口每个入口对应一组 HTML/JS/CSS 资源这些资源要能被 WebView2 稳定、高效地加载。更麻烦的是不同入口之间还会有公共依赖比如共用的 JS 库、图标、字体不可能每个页面目录都复制一份。2.2 资源来源不止磁盘一条路再往深一层想资源加载的“入口”不仅指页面入口还指资源来源。我在实际项目中遇到的情况是主界面和设置页的静态资源从程序安装目录的 assets 文件夹读取帮助页面里有一部分内容需要从服务端动态获取还有几个工具模块需要用到由 C 层实时生成的内部数据。比如用户点击“导出报告”后页面上要弹一个预览窗口报告内容是由 C 生成的 JSON 数据不能等编译期打包成文件。所以当时的资源来源实际上有三个入口资源入口典型内容加载方式安装目录静态资源HTML、JS、CSS、图片磁盘直接读取程序内置资源默认配置、兜底页面从资源段解压动态生成内容报表、诊断信息内存流实时生成如果每个入口都手工拼路径、拼 URL、处理编码代码很快会变成一团乱麻。这时候引入虚拟主机名把所有资源统一到一个逻辑域名空间下是最能控制复杂度的做法。2.3 file协议为什么不能打天下我想过不用虚拟主机名直接让 WebView2 加载打包好的 HTML 文件也就是 file:// 协议。试过几天之后放弃了原因还真不是图省事。file 协议在 Chromium 内核里的限制很多。最典型的是跨目录访问一个 file:///D:/assets/dashboard/index.html 页面如果想引用 file:///D:/assets/shared/lib.js会被同一目录策略挡住浏览器根本不允许直接加载。JS 的 ES Module 用起来更是处处受阻fetch 接口直接失效。而现代前端项目几乎离不开这些能力。虚拟主机名解决了两个核心问题一是让页面拥有正常的 HTTP(S) Origin从而支持模块化加载、fetch 和其他 Web 标准能力二是可以把多个物理目录映射到不同的逻辑域名下让每一个入口页面都站在自己独立的 origin 上互不干扰。比如 app.dashboard 和 app.settings 就是两个不同的 origin页面之间的隔离关系一目了然权限控制也好做。3. 虚拟主机名从API到落地3.1 初始化环境与版本问题要使用虚拟主机名映射先要保证 WebView2 版本不能太老因为 SetVirtualHostNameToFolderMapping 是在 ICoreWebView2_3 接口上提供的老 SDK 里根本没有这个接口。如果你在写代码时发现智能提示里没有这个方法先检查一下项目引用的 WebView2 SDK 是不是太旧。初始化 WebView2 环境的代码标准写法是这样#include windows.h #include wrl/client.h #include wrl/callback.h #include webview2.h using namespace Microsoft::WRL; bool g_initialized false; ComPtrICoreWebView2Environment g_env; ComPtrICoreWebView2Controller g_controller; ComPtrICoreWebView2 g_webview; void SetupWebView(HWND hwnd) { HRESULT hr CreateCoreWebView2EnvironmentWithOptions( nullptr, // browserExecutableFoldernullptr 表示用系统 runtime nullptr, // userDataFolder用默认即可 nullptr, // additionalBrowserArguments CallbackICoreWebView2CreateCoreWebView2EnvironmentCompletedHandler( [hwnd](HRESULT result, ICoreWebView2Environment* env) - HRESULT { if (FAILED(result)) { // 环境创建失败多半是 runtime 没装 return S_OK; } g_env env; return env-CreateCoreWebView2Controller( hwnd, CallbackICoreWebView2CreateCoreWebView2ControllerCompletedHandler( [env](HRESULT result, ICoreWebView2Controller* controller) - HRESULT { if (FAILED(result)) { return S_OK; } g_controller controller; g_webview controller-GetCoreWebView2(); SetupVirtualHosts(); NavigateToMainEntry(); return S_OK; }) .Get()); }) .Get()); }这里有几个细节。CreateCoreWebView2EnvironmentWithOptions 的第一个参数传 nullptr 表示使用本机已有 Runtime。如果程序想捆绑一个自定义的固定版本内核可以在这里传一个浏览器可执行文件目录。第三参数通常也传 nullptr因为附加参数不是必须的除非你要强制打开远程调试端口。3.2 SetVirtualHostNameToFolderMapping 参数解析拿到 ICoreWebView2 之后第一步是 QueryInterface 到 ICoreWebView2_3void SetupVirtualHosts() { ComPtrICoreWebView2_3 webview3; HRESULT hr g_webview.As(webview3); if (FAILED(hr)) { // 如果走不到 ICoreWebView2_3说明 SDK 或运行库版本过低 return; } std::wstring assetsDir GetAssetsRootPath(); // 自己封装返回安装目录下的 assets 路径 webview3-SetVirtualHostNameToFolderMapping( Lapp.dashboard, (assetsDir Ldashboard).c_str(), COREWEBVIEW2_HOST_RESOURCE_ACCESS_KIND_ALLOW); webview3-SetVirtualHostNameToFolderMapping( Lapp.settings, (assetsDir Lsettings).c_str(), COREWEBVIEW2_HOST_RESOURCE_ACCESS_KIND_ALLOW); webview3-SetVirtualHostNameToFolderMapping( Lapp.shared, (assetsDir Lshared).c_str(), COREWEBVIEW2_HOST_RESOURCE_ACCESS_KIND_ALLOW); }逐个说下参数。hostName你自定义的逻辑域名。官方示例喜欢用 appassets.example 这种形式。我建议一定带一个点因为不带点会被当成局域网主机名有些网络环境下容易触发奇怪的处理逻辑。这里我用 app.dashboard、app.settings、app.shared 三个分别对应三个入口和公共资源。folderPath必须是绝对路径相对路径不会生效。文件夹目录必须存在映射建立时不会自动创建目录。Windows 路径里的反斜杠可以直接传但注意 C 字符串里要写双反斜杠。accessKind资源访问模式最常用的就是 ALLOW。它有三种取值差异我放在下一节说。3.3 accessKind 三种取值怎么选COREWEBVIEW2_HOST_RESOURCE_ACCESS_KIND 有三个值用错场景会有一些隐性坑COREWEBVIEW2_HOST_RESOURCE_ACCESS_KIND_DENY拒绝通过这个虚拟主机名访问任何资源。这个值实际上用得很少一般用来在历史遗留代码里显式关闭某个映射。COREWEBVIEW2_HOST_RESOURCE_ACCESS_KIND_ALLOW允许访问。绝大多数场景用这个让页面可以引用映射目录里的任何文件。COREWEBVIEW2_HOST_RESOURCE_ACCESS_KIND_DENY_CORS允许访问但不允许其他 origin 通过 CORS 机制读取这里的资源。简单来说资源你可以加载但如果你想把这里面的数据 fetch 给别的域名会被 CORS 策略拦下来。看起来 ALLOW 最好用为什么还有 DENY_CORS 这种看似半吊子的选项我理解是公共共享目录这种跨页面引用场景如果用 ALLOW 且页面里还有 JS 在运行时动态请求数据那相当于给所有虚拟主机名都开了免跨域权限对于做安全审计的人来说是不可接受的。如果希望共享目录能引用但严格限制运行时跨域读取DENY_CORS 是更稳的选择。我自己的配置策略是页面入口目录用 ALLOW公共资源目录用 DENY_CORS。页面自身要能自由加载同目录下的 JS/CSS/图片而 shared 共享目录理论上只需要作为静态资源被引用不需要被 JS 通过 fetch 读内容。3.4 HTML/JS 侧怎么引用资源映射完成之后页面里的资源引用不需要任何特殊处理直接写正常的 URL 就行。比如主仪表盘的 index.html 里!DOCTYPE html html langzh-CN head meta charsetUTF-8 titleDashboard/title link relstylesheet hrefhttps://app.shared/css/common.css script srchttps://app.shared/lib/vue.runtime.min.js/script /head body div idappLoading.../div script src./js/dashboard.js/script /body /html设置面板的 index.html 里引用的公共库路径完全相同。好处就是资源路径在多入口之间保持一致该走的缓存机制也会正常生效。CSS 里写的背景图只要用相对路径浏览器会自动以当前页面的 origin虚拟主机名为基准去请求。例如.logo { background-image: url(https://app.shared/img/logo.png); }到这里多入口资源的加载骨架已经出来了。每个入口一个域名公共资源一个域名页面开发时就当正常网站开发完全不用关心 C 层是怎么把磁盘目录映射过来的。3.5 入口切换的路由控制程序里可能有多个 WebView 实例比如主窗口和设置窗口各一个也可能只有一个 WebView 通过导航切换入口。我建议如果界面模块比较多优先用单 WebView 多导航的模式。原因有两个每个 WebView2 都会额外创建多个渲染进程实例越多内存占用越夸张单实例模式下资源缓存、消息监听都只要维护一份。切换入口只需要一行调用g_webview-Navigate(Lhttps://app.settings/index.html);如果希望像前端路由那样带参数直接在 URL 后面拼 query 就行std::wstring url Lhttps://app.settings/index.html?modeadvancedfromdashboard; g_webview-Navigate(url.c_str());页面里的 JS 通过 location.search 就能拿到参数。我一直用这个方式在 C 和前端之间做轻量级状态传递比每次都走 WebMessage 通道简单也不容易把消息顺序搞乱。还有一个注意点SetVirtualHostNameToFolderMapping 不是全局配置它是挂在某个 ICoreWebView2 实例上的。也就是说如果程序里开了两个 WebView2 窗口需要分别在两个实例上调用映射。在同步两个窗口的资源时经常容易漏掉其中一个我在多窗口改造时就被自己坑过。4. 进阶拦截请求把资源入口接到内存和网络4.1 为什么光有目录映射还不够虚拟主机名映射确实解决了静态资源加载的问题但我的项目里有几个场景是它覆盖不了的。第一个场景是资源加密。安装目录里的静态文件不能是明文我们用了简单的 XOR 加密混淆HTML/JS 文件只有程序启动后才在内存里解密。SetVirtualHostNameToFolderMapping 只能指向磁盘目录没办法动态返回解密后的内容。第二个场景是运行期动态生成。前面提到的报告预览内容由 C 层生成根本不存在磁盘上。这种情况下与其临时写文件再加载不如直接拦截资源请求在内存里把响应构造好。4.2 WebResourceRequested 拦截的基本流程ICoreWebView2 提供了一套标准的资源请求拦截机制用 AddWebResourceRequestedFilter 声明要拦截的 URL 范围然后挂一个 WebResourceRequested 事件处理器。void RegisterResourceInterceptor() { // 拦截所有指向 app.shared 的资源请求 g_webview-AddWebResourceRequestedFilter( Lhttps://app.shared/*, COREWEBVIEW2_WEB_RESOURCE_CONTEXT_ALL); g_webview-add_WebResourceRequested( CallbackICoreWebView2WebResourceRequestedEventHandler( [](ICoreWebView2* sender, ICoreWebView2WebResourceRequestedEventArgs* args) - HRESULT { ComPtrICoreWebView2WebResourceRequest request; args-get_Request(request); LPWSTR uriRaw nullptr; request-get_Uri(uriRaw); std::wstring uri(uriRaw ? uriRaw : L); CoTaskMemFree(uriRaw); // 命中约定路径自己构造响应 if (uri.find(L/dynamic/report) ! std::wstring::npos) { std::string json GenerateReportJson(); // C 生成内容 return CreateJsonResponse(args, json); } // 其余请求放行交给默认逻辑 return S_OK; }) .Get()); }这里有个细节AddWebResourceRequestedFilter 的第二个参数是资源上下文类型COREWEBVIEW2_WEB_RESOURCE_CONTEXT_ALL 表示所有类型都拦截。实际使用中如果你知道只要拦截脚本或 XHR可以收窄范围减少性能开销。路径匹配支持简单的通配符 *但只支持一个 *别写复杂正则。4.3 内存流构造响应的关键点在拦截回调里构造响应最容易翻车的是 IStream 的创建和响应头设置。我用的方法是 CreateStreamOnHGlobalHRESULT CreateJsonResponse(ICoreWebView2WebResourceRequestedEventArgs* args, const std::string payload) { ComPtrICoreWebView2Environment env; g_webview-get_Environment(env); // 把 string 拷贝进 HGLOBAL HGLOBAL hMem GlobalAlloc(GMEM_MOVEABLE, payload.size()); if (!hMem) return E_OUTOFMEMORY; void* pDst GlobalLock(hMem); memcpy(pDst, payload.data(), payload.size()); GlobalUnlock(hMem); ComPtrIStream stream; HRESULT hr CreateStreamOnHGlobal(hMem, TRUE, stream); // 第二个参数 TRUE 表示流释放时自动释放 hMem if (FAILED(hr)) return hr; ComPtrICoreWebView2WebResourceResponse response; hr env-CreateWebResourceResponse( stream.Get(), 200, // 状态码 LOK, // 状态文本 LContent-Type: application/json\r\n LCache-Control: no-store\r\n, response); if (FAILED(hr)) return hr; return args-put_Response(response.Get()); }CreateWebResourceResponse 的状态文本如果传了非标准值某些版本会有兼容问题建议直接写 OK。响应头用 \r\n 分隔注意这个格式是 HTTP 原始头不能写成 JSON 对象。另外如果响应内容要让页面的 fetch 拿到必须为每个请求构造独立的 IStream同一个 IStream 不能被两个请求复用否则第二个请求打开流时位置已经在末尾内容读出来是空的。4.4 把请求转发给本地 HTTP 服务还有一个灵活用法就是把虚拟主机名下的部分请求转发到本地已经跑起来的 HTTP 服务。比如 C 进程里嵌了一个小型 HTTP 接口用来查询实时状态前端页面不愿处理 CORS最简单的方式就是在 WebResourceRequested 里把这个请求手动转成对 127.0.0.1 的请求。这一步可以用 ICoreWebView2WebResourceRequest 的 put_Uri 和 put_Method 重新调整请求然后一路透传。实际操作中直接改请求对象转发有时候会因为跨源问题失败我更推荐直接在 C 侧用 WinHTTP 发请求拿到响应再走上面的 CreateJsonResponse 流程。这样 C 侧还能在转发之前做鉴权、日志复杂场景下更可控。5. 实操中踩过的坑5.1 最常见的 errorCould not find the WebView2 Runtime在我把程序分发给客户后最常见的报错就是 “Could not find the WebView2 Runtime ”。这个错误文案分好几种但本质都是环境找不到 WebView2 Runtime。排查思路其实很清晰先确认系统是不是真没装。Windows 10/11 自带 Edge通常有 Runtime。真正出问题的是那些被精简过的系统或者被安全软件把相关组件删掉的机器。其次是检查代码里是不是给 CreateCoreWebView2EnvironmentWithOptions 传了 browserExecutableFolder 参数如果传了一个不存在的目录系统会认为你要用固定版本 Runtime于是直接报找不到。解决办法有三个我按推荐程度排安装包内置 Evergreen Bootstrapper在安装阶段静默安装。这是最省事的在线下载安装后自动更新。使用离线安装包 Evergreen Standalone Installer体积比 Bootstrapper 大一点但适合内网环境。把下载地址写进安装程序安装完再启动主程序。如果客户环境彻底隔离连离线包都不方便塞那就选 Fixed Version Runtime直接把整个运行时目录放进安装目录打包体积会增加 100 到 200 MB部署最保险但体积代价也最大。我最后选了第二方案。在安装程序里判断注册表是否已有 Runtime没有就先装离线包再继续安装主程序。这样既保证了用户体验又不至于把安装包撑到几百 MB。检测 Runtime 是否安装不一定要在 C 里读注册表。简单做法是让安装脚本比如 Inno Setup 或者 NSIS提前检查 WebView2 Runtime 的注册表路径// 检测 Evergreen Runtime 是否存在注册表路径可参考文档 bool IsWebView2RuntimeInstalled() { HKEY hKey nullptr; LPCWSTR subKey LSOFTWARE\\WOW6432Node\\Microsoft\\EdgeUpdate\\Clients\\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}; LONG result RegOpenKeyExW(HKEY_LOCAL_MACHINE, subKey, 0, KEY_READ, hKey); if (result ERROR_SUCCESS) { RegCloseKey(hKey); return true; } return false; }注意注册表路径在 32 位和 64 位系统上会有差异64 位系统上要看 WOW6432Node32 位系统上直接看 SOFTWARE 节点。这个函数写完后我用大量测试机验证过判断逻辑还是比较稳的。5.2 路径和目录为什么映射就是不生效虚拟主机名映射最常见的坑都出在路径上。我总结过几个高频踩雷点路径必须是绝对路径。传相对路径时接口调用不报错但导航到对应 URL 就是 404这非常迷惑人。路径尾部不要带反斜杠。虽然多数情况下没问题但某些版本的 WebView2 对尾部反斜杠处理不一致导航时资源会少一层目录表现为 JS 加载不出来。我直接约定统一去掉尾部反斜杠。目录必须真实存在且对进程有访问权限。程序运行在普通权限下映射到 Program Files 下需要写权限的目录文件读不出来。中文路径和空格路径虽然官方说支持但一旦 HTML 内部又做了 encodeURIComponent 操作很容易出现双重编码问题排查起来非常头疼。我最初的版本把资源目录放在带中文的路径下面调试了一上午最后把资源挪到纯英文路径才解决。所以建议资源文件夹路径只用英文字母、数字和下划线。5.3 多入口切换时的页面状态问题这是我在多入口实践里额外发现的问题。用单 WebView 切换入口时默认行为是页面整个重新加载。如果上一个入口里填了一半的表单切走再切回来内容全没了。有两个处理思路。一是页面侧做状态记忆把临时数据缓存在 sessionStorage 里进入页面时自动恢复。二是 C 侧保留多个 WebView2 实例不让入口切换触发重新加载。两种方案我都试过。最终我的选择是高频切换、低内存占用的入口用方案一低频但重型的独立工具窗口用方案二单独开一个 WebView2 实例。方案一实现简单性能好。方案二内存开销大多一个 WebView2 实例大概多占用 60 到 80 MB 内存这在压力测试时能明显看到。所以结论是不要为了图省事把所有入口都做成独立 WebView优先考虑状态恢复。5.4 UI 卡顿的系统级因素页面本身没多少复杂逻辑UI 却总觉得不跟手这种问题通常不在 CSS 而在宿主侧。我遇到过的典型原因有三个。第一创建 WebView2 的线程和操作它的线程不一致。WebView2 要求创建它的线程负责消息循环如果你把 WebView 对象跨线程直接用界面会频繁卡顿。解决办法是始终在主消息线程里创建和调用 WebView 相关接口跨线程调用用消息投递绕过去。第二GPU 加速没开。老机器的集成显卡上如果没有正确配置浏览器参数页面滚动和动画会掉帧。可以根据运行环境设置开关// 通过环境变量在程序启动时设置可以控制是否开启 GPU 合成 putenv(WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS--enable-gpu);第三渲染进程数量设计不合理。每个 WebView2 实例都有独立的渲染进程如果程序同时开了七八个 WebView2 窗口再好的配置也会卡。宁可单窗口多导航也别堆实例。5.5 DevTools 和调试技巧调试这块我推荐在开发版本里直接调用 OpenDevToolsWindow不用去翻日志。g_webview-OpenDevToolsWindow();也可以给 CreateCoreWebView2EnvironmentWithOptions 的 additionalBrowserArguments 传 --remote-debugging-port9222然后在 Chrome 或 Edge 里打开对应调试地址。这种方式对跨入口调试特别有用可以同时看多个页面。一个技巧在加载页面的 JS 里加一段调试日志把当前 location.origin 打印出来能最快确认请求到底走没走虚拟主机名console.log([entry] origin , location.origin, path , location.pathname);如果 origin 不是你预期的 app.dashboard而是 file:// 或者 404 页面的 origin那就说明映射没生效或者导航路径写错了先去查路径映射别浪费时间在页面代码上。6. 一点实际经验多入口资源加载这件事表面上是技术选型实际上是对整个 UI 层资源组织方式的重构。虚拟主机名的方法帮我把原本散落在不同地方、靠各种 hack 才能加载的资源统一到了一个可维护的映射体系里。它不复杂但确实需要一开始就把目录结构、域名规划和 accessKind 策略定清楚不然入口一多映射关系很容易乱。最后分享一个小技巧我建议在 C 层维护一个版本号放在 URL query 里这样前端资源更新时导航 URL 会变化能有效避免缓存导致的旧资源问题。比如std::wstring version L20240415; std::wstring url Lhttps://app.dashboard/index.html?v version; g_webview-Navigate(url.c_str());这样部署新 UI 时改一个版本号就能让所有客户端拉到新页面。如果你也在给老 C 程序做类似的 UI 改造可以先从一个小入口试点把虚拟主机名映射跑通再逐步铺开到其他页面。