WebView2 原生拖放的桥接之道:拆解 yyzTools 的 drop-zone.js

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 之前;左键松开 后延迟 150msRevokeDragDrop。为什么要等 150ms?因为低级钩子的松键回调早于 OLE 的 Drop 派发,你要是立刻撤销,拖放在最后一刻会落空,文件就"掉"不进去了。

这 150ms 是桥接层最隐蔽的一个坑,纯前端视角的同学看到 drop-zone.js 会觉得"不就是个回调吗",但真正让这个回调能稳定被调到,靠的是原生层这套钩子时序。

另一条路径:程序坞的文件夹拖入不走这个桥

有个对照值得记一下:程序坞(tabdock)导航条的"拖文件夹进来建分类",没有drop-zone.js

它在 native 侧就结束了------WinDockNav.cppAcceptFileDrop 打开,OnFilesDropped 收到路径后直接 DockDrop::ListFolderEntries 列目录、MakeId("category_") 建分类、重排 order、写回配置 CONF_DOCK_NAV,最后只把结果脚本(BuildDropResultScript)丢给页面渲染。单个分类最多收 30 条(DockDropHelper.hMAX_ITEMS_PER_CATEGORY),超出截断。

区别在哪?批量模块要的是"这些文件的内容",得交给页面去处理,所以走桥接把路径递进去;程序坞要的是"这个目录变成一条配置",本质是配置写回,native 干完就行,页面只负责显示。判断标准其实很简单:谁消费数据,就由谁接这一棒

顺带说下这个能力对用户意味着什么:把一堆软件快捷方式丢进一个文件夹,整个拖到导航栏上,一次就变成一个分类,桌面就此清空------整理成本从"一个一个加"变成"先归堆,再拖一次"。

小结

drop-zone.js 的代码量不大,但把混合架构里最别扭的一块(系统拖放 → 网页)收得很干净:原生层只传路径,不做业务;前端用单 handler + 一级展开 + accept 过滤,把"拖文件夹进来意味着什么"定义清楚。下次你要在 WebView2 里接原生拖放,这套"哑原生 + 薄桥 + 聪明前端"的分层值得直接抄。

相关推荐
一位正在转型AI全栈的前端工程师1 小时前
AI 全栈学习之旅 -Week 11:从 CLI 到浏览器:用 FastAPI、SSE 和 Vue 3 做一个可审核的 ReAct Agent
前端·python
10share1 小时前
我为什么用 React 重写了一个 VitePress
前端·react.js
零基础的修炼1 小时前
CUDA知识汇总
开发语言·c++·编辑器
计算机魔术师1 小时前
GPT-6 Astra 发布后,Sebastian Raschka 解析 looped transformer 与隐藏推理链传闻
前端
不可能片场1 小时前
Electron 退化成 node:一个环境变量的锅
前端·electron
欧特克_Glodon1 小时前
OpenCV计算机视觉开发入门与实践<三十八>:图像添加数字水印
c++·人工智能·opencv·计算机视觉
楚楚河河1 小时前
js 变量声明
前端
Cicada1281 小时前
Web 服务器怎么选
运维·服务器·前端
不可能片场1 小时前
接口返回 200 不代表活着:catch-all 路由的陷阱
前端·electron