yyzTools 是 C++(Win32 + WebView2)加 Vite 前端的混合架构:原生层负责窗口、系统钩子、文件操作,UI 全在 WebView2 里跑。这种架构有个绕不开的痛点------你想让用户从资源管理器把文件拖进网页,可 Chromium 沙箱下的 Web 端收不到系统级的 OLE 拖放(CF_HDROP)。这活只能原生层干,再桥给前端。
1.0.7.1700 把这套桥接做实了:六个批量模块、程序坞、右键菜单都接上了拖放。本文只拆前端这边看到的那一层------web/lib/drop-zone.js,以及它背后的注册时序。
整体链路

注意 ExecuteScript 这一步:原生层不解析文件、不过滤类型,只负责把绝对路径数组塞进页面全局函数。业务逻辑全留在前端,原生层保持"哑"。
全局入口与单 handler 设计
drop-zone.js 顶部就这么几行:
ini
let handler = null;
window.__zenDropFiles = paths => {
if (Array.isArray(paths) && handler) handler(paths);
};
export function registerDropFiles(cb) {
handler = cb;
}
两个设计点值得说。
一是 window.__zenDropFiles 作为原生注入锚点。 C++ 那边硬编码调这个函数名,前端把它挂在 window 上,原生层不需要知道页面是哪个模块、用的什么框架。耦合点只有这一个字符串。
二是 registerDropFiles 是单 handler(后注册覆盖前者)。 宿主窗口只有一个拖放目标,同一时刻也只有一个模块在前台接收拖入,所以不需要事件总线、不需要多订阅。模块挂载时 registerDropFiles(myCb),卸载或切换时再注册新的即可。简单,且不会有两份回调同时触发导致的双处理。
expandDropPaths:一级展开 + accept 过滤
真正有意思的是展开逻辑:
ini
export async function expandDropPaths(paths, accept = null) {
const out = [];
for (const p of paths) {
let isDir = false;
try {
const info = await ZenAPI.getFileInfo(p);
isDir = ZenAPI.isOk(info) && info.isDirectory === true;
} catch (e) { /* ignore */ }
if (!isDir) {
if (!accept || accept(p)) out.push(p);
continue;
}
try {
const r = await ZenAPI.readDir(p);
if (ZenAPI.isOk(r) && Array.isArray(r.files)) {
const dir = p.replace(/\/g, '/').replace(//+$/, '');
for (const name of r.files) {
const f = dir + '/' + name;
if (!accept || accept(f)) out.push(f);
}
}
} catch (e) { /* ignore */ }
}
return out;
}
几个关键点:
判目录用原生布尔,不是字符串比较。 注释里写了"自研 ptree 已输出原生布尔,直接 === true 判断"。很多桥接方案图省事用字符串 "true" 或 1,一旦后端序列化出错就翻车。这里 info.isDirectory === true 是强类型判断,拿不到明确布尔就当文件处理,安全。
只展开一级。 目录走 readDir 拿子项,但子项不再递归判断。前面提过,整树递归既卡又容易误吞。一级是刻意的产品取舍。
readDir 返回的是文件名,不是全路径。 所以代码里做了拼接:dir + '/' + name,并把反斜杠统一成斜杠。这个细节前端同学容易忽略------原生层返回的是裸名,路径组装得自己补。
accept 过滤放在最后。 文件直接过 accept(p),目录展开后每个子文件再过 accept(f)。传 null 则不过滤。图片模块就传一个按扩展名判的函数,文件夹里混着的 txt、docx 自动被滤掉。
pathExt 这个小工具也顺带导出了,给 accept 实现复用:
bash
export function pathExt(p) {
return p.replace(/\/g, '/').split('/').pop().split('.').pop().toLowerCase();
}
抢注时序:为什么不是"注册一下就完事"
原生层还有个反直觉的细节。WebView2 本身能收 OLE 拖放,但你要抢在它之前拦截 CF_HDROP,就得自己 RegisterDragDrop。可 WebView2 的 AllowExternalDrop 必须保持 TRUE------置 FALSE 会让 Chromium 拒绝一切 OLE 拖放,你自己的目标也收不到。
于是做法是:全局低级鼠标钩子在左键按下 时才 RegisterDragDrop,抢在 WebView2 之前;左键松开 后延迟 150ms 才 RevokeDragDrop。为什么要等 150ms?因为低级钩子的松键回调早于 OLE 的 Drop 派发,你要是立刻撤销,拖放在最后一刻会落空,文件就"掉"不进去了。
这 150ms 是桥接层最隐蔽的一个坑,纯前端视角的同学看到 drop-zone.js 会觉得"不就是个回调吗",但真正让这个回调能稳定被调到,靠的是原生层这套钩子时序。
另一条路径:程序坞的文件夹拖入不走这个桥
有个对照值得记一下:程序坞(tabdock)导航条的"拖文件夹进来建分类",没有 走 drop-zone.js。
它在 native 侧就结束了------WinDockNav.cpp 把 AcceptFileDrop 打开,OnFilesDropped 收到路径后直接 DockDrop::ListFolderEntries 列目录、MakeId("category_") 建分类、重排 order、写回配置 CONF_DOCK_NAV,最后只把结果脚本(BuildDropResultScript)丢给页面渲染。单个分类最多收 30 条(DockDropHelper.h 的 MAX_ITEMS_PER_CATEGORY),超出截断。
区别在哪?批量模块要的是"这些文件的内容",得交给页面去处理,所以走桥接把路径递进去;程序坞要的是"这个目录变成一条配置",本质是配置写回,native 干完就行,页面只负责显示。判断标准其实很简单:谁消费数据,就由谁接这一棒。
顺带说下这个能力对用户意味着什么:把一堆软件快捷方式丢进一个文件夹,整个拖到导航栏上,一次就变成一个分类,桌面就此清空------整理成本从"一个一个加"变成"先归堆,再拖一次"。
小结
drop-zone.js 的代码量不大,但把混合架构里最别扭的一块(系统拖放 → 网页)收得很干净:原生层只传路径,不做业务;前端用单 handler + 一级展开 + accept 过滤,把"拖文件夹进来意味着什么"定义清楚。下次你要在 WebView2 里接原生拖放,这套"哑原生 + 薄桥 + 聪明前端"的分层值得直接抄。