yyzTools 的 WebView2 桥接实现细节:把 C++ 函数挂到 JS 可调用
承上篇。上篇讲了 yyzTools 桥接层分
BindSync/BindAsync两种模式和返回值归一化。这篇往下钻一层:BindManager内部到底怎么把一个 C++ 函数变成 JS 能直接调的东西,以及异步回调怎么安全地回到 UI 线程。
一、WebView2 的 JS <-> C++ 通道
WebView2 提供两种让 JS 调 C++ 的机制:
AddHostObjectToScript:注册一个 COM 对象到 JS,前端通过window.chrome.webview.hostObjects.xxx访问。强类型,但要写 IDL。AddScriptToExecuteOnDocumentCreated+postMessage:往页面注入一段 JS 桥垫层,前端用postMessage发消息,C++ 侧WebMessageReceived收消息。灵活,但自己管协议。
yyzTools 的桥接层走的是偏第二种的路子,核心是:C++ 侧维护一个「方法名 -> 可调用对象」的注册表,前端调用时通过桥垫层把方法名和参数传过来,C++ 查表执行后把结果送回。
二、注册表:方法名到可调用对象的映射
BindSync 和 BindAsync 的本质是往这个注册表里塞东西:
// 概念性伪代码,说明原理
void BindManager::BindSync(const std::string& name, SyncFn fn) {
m_syncBindings[name] = fn;
}
void BindManager::BindAsync(const std::string& name, AsyncFn fn) {
m_asyncBindings[name] = fn;
}
yyzTools 的 SetupBindings() 集中注册所有方法(节选):
m_bindManager->BindSync("getConfig",
std::bind(&NativeApi::GetConfig, this, std::placeholders::_1));
m_bindManager->BindSync("searchApp",
std::bind(&NativeApi::SearchApp, this, std::placeholders::_1));
m_bindManager->BindAsync("getAppInfo",
std::bind(&NativeApi::GetAppInfo, this,
std::placeholders::_1, std::placeholders::_2));
注意 std::bind 把 this 绑定进去,这样注册的就是成员方法。_1 是入参(前端传来的参数),_2 是异步专有的回调参数。
三、同步调用:直接返回
前端调 window.Zen.getConfig(),桥垫层把方法名 getConfig 和参数打包传给 C++。C++ 侧:
- 在
m_syncBindings查getConfig - 取出可调用对象,传入参数执行
- 拿到返回值(JSON 字符串),通过
postMessage或返回值机制送回前端 - 前端桥垫层 resolve 对应的 Promise
同步调用的关键是:执行发生在 C++ 线程,前端 await 等结果。因为绝大多数操作(配置读写、文件查询)是毫秒级,阻塞可接受。
四、异步调用:后台线程 + 回调
异步绑定多了第二个参数--回调。这是处理耗时操作的关键:
// 概念性伪代码
void NativeApi::GetAppInfo(const nlohmann::json& args, ResultCallback cb) {
// 把耗时活丢后台线程
std::thread([this, args, cb]() {
// 1. 等索引就绪(冷启动可能要等几百毫秒)
WaitForInitialization();
// 2. 做 map::find 查询
auto result = m_appIndex.Lookup(args);
// 3. 通过回调把结果送回
cb(BuildResult(result));
}).detach();
}
4.1 为什么 yyzTools 里 getAppInfo 是异步
getAppInfo 内部要 WaitForInitialization() 等后台索引线程就绪。应用索引在 Win32Manager::Init 时由后台线程生成,CmdManager::Init 一返回就置 m_initialized 标志,但冷启动时前端立即查会拿到空表。
如果做成同步,前端会卡住等待。做成异步,前端发起后不阻塞,索引就绪后通过回调送回结果。
4.2 回调的线程安全
后台线程不能直接碰 WebView2--WebView2 的操作必须在 UI 线程。所以回调里不能直接 postMessage,要走 PostToUiThread 之类的机制把结果 marshal 回 UI 线程再发。
这是异步绑定比同步绑定复杂的地方:同步在调用线程直接返回,异步要跨线程传结果,必须经过线程 marshaling。
五、前端桥垫层
yyzTools 前端侧的 zen_api.js 封装类隐藏了同步/异步的差异:
class ZenAPI {
static isOk(res) {
return !!res && String(res.error) === '0';
}
static async getConfig() {
const res = await window.Zen.getConfig();
return res;
}
static async getAppInfo(ids) {
const res = await window.Zen.getAppInfo(ids);
return res;
}
}
对业务代码来说,无论底层是同步还是异步,调用方式都是 await ZenAPI.xxx()。差异被桥垫层吸收。
实际实现里同步方法也可能包成 Promise 返回,统一成异步接口,这样前端不用区分「这个方法是同步还是异步」。
六、一个真实的类型陷阱案例
结合上篇的类型陷阱,这里给个 yyzTools 里完整的踩坑场景。
C++ 有个方法返回文件信息:
// ptree 路径(error 是字符串)
json NativeApi::GetFileInfo(...) {
boost::property_tree::ptree pt;
pt.put("error", "0");
pt.put("exists", fileExists ? "true" : "false");
pt.put("isDirectory", isDir ? "true" : "false");
return SaveToString(pt);
}
前端如果这样写就踩坑:
// 错误:exists 是字符串 "false",truthy 判断永远为真
const res = await ZenAPI.getFileInfo(path);
if (res.exists) {
// 即使文件不存在也进来
}
正确写法:
if (res.exists === "true") { ... }
error 字段有 ZenAPI.isOk(res) 兜底所以不会踩坑,但每个布尔字段都得自己显式比较。这是 yyzTools 桥接层的工程债:类型一致性靠人肉约定,没类型系统兜底。
七、为什么不用强类型 COM HostObject
WebView2 的 AddHostObjectToScript 能提供强类型 COM 对象,IDL 定义接口,类型安全。但 yyzTools 没用它,原因:
- IDL 要为每个方法写接口定义,新增方法成本高
- COM 的类型系统在 JS 侧表现为 VARIANT,布尔/数字还是可能被装箱成字符串,治标不治本
- 现有
BindSync/BindAsync模板已经很轻,一个std::bind就注册一个方法
取舍是:接受类型陷阱,用 ZenAPI.isOk 在高频字段兜底,其余布尔字段靠前端显式比较。工程债明确,文档化,不假装它不存在。
八、小结
yyzTools 桥接层的实现核心:一个 方法名 -> 可调用对象 的注册表 + 同步/异步两种执行路径。同步直接返回,异步走后台线程 + 回调 + 线程 marshaling。前端用 zen_api.js 统一封装,隐藏同步异步差异。
最大的工程债是类型一致性--两条 JSON 序列化路径产出不同类型,靠归一化和显式比较兜底,没类型系统约束。这是这套架构要持续治理的地方。
(本文为 yyzTools 架构实现复盘,基于实际项目源码。文中伪代码用于说明原理,非逐行源码。)

浙公网安备 33010602011771号