<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Electron on Lance.Moe</title><link>https://www.lance.moe/tags/electron/</link><description>Recent content in Electron on Lance.Moe</description><generator>Hugo -- gohugo.io</generator><language>zh-Hans</language><lastBuildDate>Fri, 28 Aug 2026 05:14:00 +0900</lastBuildDate><atom:link href="https://www.lance.moe/tags/electron/index.xml" rel="self" type="application/rss+xml"/><item><title>在 Electron 中实现拖拽到本地自动下载</title><link>https://www.lance.moe/archives/electron-external-drop-target-discovery/</link><pubDate>Fri, 28 Aug 2026 05:14:00 +0900</pubDate><guid>https://www.lance.moe/archives/electron-external-drop-target-discovery/</guid><description>&lt;p&gt;最近在开发一个 SFTP 客户端，发现在云盘、网盘客户端、远程文件管理器（如 FTP/SFTP 工具）以及即时通讯软件中，有一个非常符合直觉的高频交互：&lt;/p&gt;&#10;&#10; &lt;blockquote&gt;&#10; &lt;p&gt;用户在软件里看中了一个尚未下载的远程文件，直接用鼠标把它拖出应用窗口，甩到 Windows 资源管理器（或者桌面）的某个文件夹里；松开鼠标后，应用自动把该文件下载到这个目录中。&lt;/p&gt;&#10;&#10; &lt;/blockquote&gt;&#10;&lt;p&gt;这个需求看似简单，大部分 Native 的应用都支持这种操作，但如果你尝试用 Electron 现有的 API 去实现，很快就会发现一个尴尬的现实：&lt;strong&gt;根本做不到。&lt;/strong&gt;&lt;/p&gt;&#10;&lt;h2 id="为什么原生-api-走不通"&gt;为什么原生 API 走不通？&#10;&lt;/h2&gt;&lt;p&gt;在 Electron 中处理拖出操作，官方提供的核心 API 是 &lt;code&gt;webContents.startDrag()&lt;/code&gt;。但这个 API 有两个硬伤：&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;&lt;strong&gt;必须传入一个真实存在的本地文件路径&lt;/strong&gt;：&lt;code&gt;startDrag({ file: filePath, icon: ... })&lt;/code&gt; 要求该文件在拖拽发生前就已经躺在硬盘上了。可远程文件在用户松手之前根本还没下载，如果是几个 GB 的大文件，显然不可能在鼠标拖拽前就预先下载好。&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;拖放目标对 Electron 完全不可见&lt;/strong&gt;：当用户把鼠标移动到 Windows 资源管理器并松开时，接管拖放的是操作系统的 Explorer 进程。Electron 只能知道“拖拽开始了”，却拿不到任何关于“用户到底把它放到了哪个文件夹”的回调或事件。&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;换句话说：&lt;strong&gt;应用知道用户拖了什么，却不知道用户放到了哪里。&lt;/strong&gt;&lt;/p&gt;&#10;&lt;p&gt;没有目标路径，下载就无从谈起。要在不引入复杂 C++ 原生扩展（如 Windows &lt;code&gt;IDataObject&lt;/code&gt; / 虚拟流传输）的前提下解决这个问题，我们需要换一种逆向思维。&lt;/p&gt;&#10;&lt;h2 id="核心思路把文件系统当作通信信道"&gt;核心思路：把文件系统当作“通信信道”&#10;&lt;/h2&gt;&lt;p&gt;既然系统 API 不愿意主动把目标目录传回给应用，那我们能不能让系统资源管理器“帮我们写出来”？&lt;/p&gt;&#10;&lt;p&gt;思路其实非常巧妙：&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;&lt;strong&gt;生成零字节占位符&lt;/strong&gt;：当用户在界面上按住某个远程文件准备拖拽时，应用在临时目录生成一个唯一的 0 字节占位文件，文件名带上随机 UUID，例如：&#10;&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;&#10;&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;&#10;&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1&#10;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&#10;&lt;td class="lntd"&gt;&#10;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;.__app-drop-8e2f3cf0-3e3f-4785-b7a0-1bd359b55d9d.placeholder&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;&#10;&lt;/div&gt;&#10;&lt;/div&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;把占位文件交给系统拖拽&lt;/strong&gt;：调用 &lt;code&gt;webContents.startDrag()&lt;/code&gt;，把这个 0 字节的占位文件路径传给它。对于 Windows 资源管理器来说，这就是一次普通的本地文件复制。&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;捕获落点&lt;/strong&gt;：用户把鼠标松在 &lt;code&gt;D:\Archive\&lt;/code&gt;，资源管理器就会把这个几微秒就能复制完的占位文件拷贝到 &lt;code&gt;D:\Archive\&lt;/code&gt; 目录下。&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;文件系统监听器命中&lt;/strong&gt;：我们在后台运行的文件监听器（File Watcher）立刻捕获到 &lt;code&gt;D:\Archive\&lt;/code&gt; 下新增了一个名为 &lt;code&gt;.__app-drop-8e2f3cf0-3e3f-4785-b7a0-1bd359b55d9d.placeholder&lt;/code&gt; 的文件。&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;提取路径并触发下载&lt;/strong&gt;：&#10;&lt;ul&gt;&#10;&lt;li&gt;提取目录：&lt;code&gt;dirname(eventPath)&lt;/code&gt; $\to$ &lt;code&gt;D:\Archive&lt;/code&gt;&lt;/li&gt;&#10;&lt;li&gt;拼出最终文件路径：&lt;code&gt;D:\Archive\report.pdf&lt;/code&gt;&lt;/li&gt;&#10;&lt;li&gt;删掉临时的占位文件&lt;/li&gt;&#10;&lt;li&gt;把下载任务提交给既有的下载队列&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;整个流程的时序如下图所示：&lt;/p&gt;&#10;&lt;pre class="mermaid" style="visibility:hidden"&gt;sequenceDiagram&#10; autonumber&#10; participant UI as 渲染进程 (UI)&#10; participant Main as 主进程 (Main)&#10; participant Watcher as 文件监听器 (Watcher)&#10; participant Explorer as Windows 资源管理器&#10; participant Queue as 下载传输队列&#10;&#10; UI-&gt;&gt;Main: pointerdown / 准备拖拽 (元数据)&#10; Main-&gt;&gt;Watcher: 启动并就绪本地盘符监听&#10; Watcher--&gt;&gt;Main: Ready&#10; UI-&gt;&gt;Main: 触发 dragstart&#10; Main-&gt;&gt;Main: 生成 0 字节 UUID 占位文件&#10; Main-&gt;&gt;Explorer: webContents.startDrag(占位文件路径)&#10; Explorer-&gt;&gt;Explorer: 用户松开鼠标，系统将占位文件复制到目标目录&#10; Watcher--&gt;&gt;Main: 捕获到目标目录新增了精确的 UUID 文件&#10; Main-&gt;&gt;Main: 静默删除该占位文件&#10; Main-&gt;&gt;Queue: 提交正式下载任务 (目标: D:\Archive\report.pdf)&#10; Queue--&gt;&gt;UI: 像普通下载一样更新进度条&lt;/pre&gt;&lt;p&gt;通过这种方式，我们把一个原本无解的“跨进程 UI 状态获取”问题，转换成了一次确定性的“文件系统事件监听”。&lt;/p&gt;&#10;&lt;h2 id="实现细节与生命周期设计"&gt;实现细节与生命周期设计&#10;&lt;/h2&gt;&lt;p&gt;原理看似简单，但真正写成稳健的生产级代码时，有几个非常关键的细节需要处理好。&lt;/p&gt;&#10;&lt;h3 id="1-时序与竞态为什么监听器必须提前预热"&gt;1. 时序与竞态：为什么监听器必须提前预热？&#10;&lt;/h3&gt;&lt;p&gt;千万不要等 &lt;code&gt;dragstart&lt;/code&gt; 触发或者 &lt;code&gt;startDrag&lt;/code&gt; 调用后再异步去启动文件监听。&lt;/p&gt;&#10;&lt;p&gt;如果用户的操作非常快（比如从应用窗口一甩手就扔到了旁边的文件夹），占位文件落地的瞬间，异步监听器可能根本还没初始化完成，导致漏掉文件新增事件，拖拽直接失效。&lt;/p&gt;&#10;&lt;p&gt;正确的做法是：&lt;strong&gt;提前预热&lt;/strong&gt;。&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;在文件列表项的 &lt;code&gt;pointerdown&lt;/code&gt; 阶段，就可以通知主进程启动监听器；&lt;/li&gt;&#10;&lt;li&gt;监听到 watcher 的 &lt;code&gt;ready&lt;/code&gt; 事件后，标记就绪状态；&lt;/li&gt;&#10;&lt;li&gt;在前端 &lt;code&gt;dragstart&lt;/code&gt; 事件发生时，确认监听器已处于 ready 状态才调用 &lt;code&gt;startDrag&lt;/code&gt;。如果此时因为系统繁忙等原因未就绪，应有明确的降级提示，而不是盲目启动系统拖拽。&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;&#10;&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;&#10;&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1&#10;&lt;/span&gt;&lt;span class="lnt"&gt;2&#10;&lt;/span&gt;&lt;span class="lnt"&gt;3&#10;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&#10;&lt;td class="lntd"&gt;&#10;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;pointerdown (预热监听器并 await ready) &#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ↳ dragstart (生成占位文件 -&amp;gt; startDrag) &#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ↳ 捕获事件 -&amp;gt; 删除占位符 -&amp;gt; 入队下载 -&amp;gt; 关闭监听器&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;&#10;&lt;/div&gt;&#10;&lt;/div&gt;&lt;h3 id="2-监听范围别搞全盘常驻按需启停"&gt;2. 监听范围：别搞全盘常驻，按需启停&#10;&lt;/h3&gt;&lt;p&gt;有的开发者第一反应是把 &lt;code&gt;homedir&lt;/code&gt;（用户目录）或者全盘挂载一个永久运行的 Chokidar / 监听器。这通常不可取：&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;只监听用户主目录&lt;/strong&gt;：无法覆盖用户将文件拖到 D 盘、E 盘、移动硬盘或自定义工作目录的情况。&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;全盘永久常驻监听&lt;/strong&gt;：会导致大量的文件句柄占用、不必要的 CPU 消耗，并且会收到海量的无关系统事件噪声。&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;最合理的折中方案是&lt;strong&gt;按需动态监听&lt;/strong&gt;：&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;仅在检测到拖拽意图（如鼠标按下）时启动；&lt;/li&gt;&#10;&lt;li&gt;枚举当前系统已挂载的固定本地驱动器盘符（如 &lt;code&gt;C:\&lt;/code&gt;, &lt;code&gt;D:\&lt;/code&gt;, &lt;code&gt;E:\&lt;/code&gt;）作为根目录进行非深度扫描式监听；&lt;/li&gt;&#10;&lt;li&gt;一旦命中匹配、或者用户取消拖拽超时，立刻销毁监听器，释放资源。&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;h3 id="3-状态流转与匹配逻辑"&gt;3. 状态流转与匹配逻辑&#10;&lt;/h3&gt;&lt;p&gt;不要随便拿一个数组存文件名去模糊比对。整个拖拽过程应该有清晰的状态机控制，确保每次拖拽事件唯一且可追溯。&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;&#10;&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;&#10;&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1&#10;&lt;/span&gt;&lt;span class="lnt"&gt;2&#10;&lt;/span&gt;&lt;span class="lnt"&gt;3&#10;&lt;/span&gt;&lt;span class="lnt"&gt;4&#10;&lt;/span&gt;&lt;span class="lnt"&gt;5&#10;&lt;/span&gt;&lt;span class="lnt"&gt;6&#10;&lt;/span&gt;&lt;span class="lnt"&gt;7&#10;&lt;/span&gt;&lt;span class="lnt"&gt;8&#10;&lt;/span&gt;&lt;span class="lnt"&gt;9&#10;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&#10;&lt;td class="lntd"&gt;&#10;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-typescript" data-lang="typescript"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;PendingDrop&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;id&lt;/span&gt;: &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// 任务唯一 ID&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;remotePath&lt;/span&gt;: &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// 远程文件原始路径&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;remoteName&lt;/span&gt;: &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// 真实文件名 (如 report.pdf)&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;placeholderName&lt;/span&gt;: &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// 占位文件名 (带 UUID)&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;placeholderPath&lt;/span&gt;: &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// 本地临时占位文件完整路径&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;arming&amp;#39;&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;ready&amp;#39;&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;dragging&amp;#39;&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;matched&amp;#39;&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;expired&amp;#39;&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;cancelled&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;expiresAt&lt;/span&gt;: &lt;span class="kt"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// 超时时间戳&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;&#10;&lt;/div&gt;&#10;&lt;/div&gt;&lt;p&gt;当文件监听器收到变动通知时，核心处理逻辑如下：&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;&#10;&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;&#10;&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1&#10;&lt;/span&gt;&lt;span class="lnt"&gt; 2&#10;&lt;/span&gt;&lt;span class="lnt"&gt; 3&#10;&lt;/span&gt;&lt;span class="lnt"&gt; 4&#10;&lt;/span&gt;&lt;span class="lnt"&gt; 5&#10;&lt;/span&gt;&lt;span class="lnt"&gt; 6&#10;&lt;/span&gt;&lt;span class="lnt"&gt; 7&#10;&lt;/span&gt;&lt;span class="lnt"&gt; 8&#10;&lt;/span&gt;&lt;span class="lnt"&gt; 9&#10;&lt;/span&gt;&lt;span class="lnt"&gt;10&#10;&lt;/span&gt;&lt;span class="lnt"&gt;11&#10;&lt;/span&gt;&lt;span class="lnt"&gt;12&#10;&lt;/span&gt;&lt;span class="lnt"&gt;13&#10;&lt;/span&gt;&lt;span class="lnt"&gt;14&#10;&lt;/span&gt;&lt;span class="lnt"&gt;15&#10;&lt;/span&gt;&lt;span class="lnt"&gt;16&#10;&lt;/span&gt;&lt;span class="lnt"&gt;17&#10;&lt;/span&gt;&lt;span class="lnt"&gt;18&#10;&lt;/span&gt;&lt;span class="lnt"&gt;19&#10;&lt;/span&gt;&lt;span class="lnt"&gt;20&#10;&lt;/span&gt;&lt;span class="lnt"&gt;21&#10;&lt;/span&gt;&lt;span class="lnt"&gt;22&#10;&lt;/span&gt;&lt;span class="lnt"&gt;23&#10;&lt;/span&gt;&lt;span class="lnt"&gt;24&#10;&lt;/span&gt;&lt;span class="lnt"&gt;25&#10;&lt;/span&gt;&lt;span class="lnt"&gt;26&#10;&lt;/span&gt;&lt;span class="lnt"&gt;27&#10;&lt;/span&gt;&lt;span class="lnt"&gt;28&#10;&lt;/span&gt;&lt;span class="lnt"&gt;29&#10;&lt;/span&gt;&lt;span class="lnt"&gt;30&#10;&lt;/span&gt;&lt;span class="lnt"&gt;31&#10;&lt;/span&gt;&lt;span class="lnt"&gt;32&#10;&lt;/span&gt;&lt;span class="lnt"&gt;33&#10;&lt;/span&gt;&lt;span class="lnt"&gt;34&#10;&lt;/span&gt;&lt;span class="lnt"&gt;35&#10;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&#10;&lt;td class="lntd"&gt;&#10;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-typescript" data-lang="typescript"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kr"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;basename&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;dirname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;join&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="kr"&gt;from&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;path&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kr"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;promises&lt;/span&gt; &lt;span class="kr"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="kr"&gt;from&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;fs&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kr"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nx"&gt;onPlaceholderAdded&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;eventPath&lt;/span&gt;: &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kr"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fileName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;basename&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;eventPath&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c1"&gt;// 1. 严格精确比对文件名，杜绝任何 includes 模糊匹配&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kr"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pending&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pendingDropsByPlaceholder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kr"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fileName&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;pending&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;pending&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;dragging&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c1"&gt;// 2. 命中后立即变更状态，并从待处理表中移除，防止二次触发&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;pending&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;matched&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;pendingDropsByPlaceholder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fileName&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c1"&gt;// 3. 计算最终目标目录与目标文件路径&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kr"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;targetDirectory&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;dirname&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;eventPath&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kr"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;finalLocalPath&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;targetDirectory&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pending&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;remoteName&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c1"&gt;// 4. 清理刚刚复制到目标目录的 0 字节占位文件&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;unlink&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;eventPath&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c1"&gt;// 5. 校验目标文件重名并接入正常下载队列&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;handleFileNameConflict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;finalLocalPath&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;downloadQueue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;add&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;remotePath&lt;/span&gt;: &lt;span class="kt"&gt;pending.remotePath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;localPath&lt;/span&gt;: &lt;span class="kt"&gt;finalLocalPath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;});&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c1"&gt;// 6. 销毁临时资源与监听器&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;cleanupPendingDrop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pending&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;&#10;&lt;/div&gt;&#10;&lt;/div&gt;&lt;h2 id="边界情况与避坑指南"&gt;边界情况与避坑指南&#10;&lt;/h2&gt;&lt;h3 id="1-复用既有下载通道不要重复造轮子"&gt;1. 复用既有下载通道，不要重复造轮子&#10;&lt;/h3&gt;&lt;p&gt;文件监听器的职责到“发现目标目录”这一步就已经全部结束了。&lt;/p&gt;&#10;&lt;p&gt;接下来的下载，一定要直接扔给应用既有的&lt;strong&gt;普通下载队列&lt;/strong&gt;。千万不要在拖放模块里单独写一套 &lt;code&gt;fetch&lt;/code&gt; 写入逻辑。&lt;/p&gt;&#10;&lt;p&gt;通过复用下载队列，可以自然继承整套成熟机制：&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;统一的下载进度条、速度统计和通知中心；&lt;/li&gt;&#10;&lt;li&gt;断点续传与网络重试；&lt;/li&gt;&#10;&lt;li&gt;写入临时文件（如 &lt;code&gt;report.pdf.part&lt;/code&gt;），下载成功后再重命名，避免在目标目录留下半截损坏文件；&lt;/li&gt;&#10;&lt;li&gt;目标路径已存在同名文件时的冲突弹窗（覆盖、自动重命名、跳过）。&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h3 id="2-拖拽取消与超时回收"&gt;2. 拖拽取消与超时回收&#10;&lt;/h3&gt;&lt;p&gt;Windows 的系统拖放并不总能向 Electron 返回准确的“用户已取消”事件（例如用户按了 &lt;code&gt;Esc&lt;/code&gt;，或者拖出后放到了任务栏等无效区域）。&lt;/p&gt;&#10;&lt;p&gt;因此，必须设置一个&lt;strong&gt;兜底超时时间&lt;/strong&gt;（建议 20~30 秒）：&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;超时未检测到占位文件落地，主动将任务标记为 &lt;code&gt;expired&lt;/code&gt;；&lt;/li&gt;&#10;&lt;li&gt;立即注销并关闭文件监听器；&lt;/li&gt;&#10;&lt;li&gt;清理本地临时目录中的源占位文件；&lt;/li&gt;&#10;&lt;li&gt;应用下次启动或退出时，顺便扫描并清理临时目录下可能残留的历史占位文件。&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h3 id="3-路径安全校验"&gt;3. 路径安全校验&#10;&lt;/h3&gt;&lt;p&gt;由于落点路径来源于文件系统事件通知，在拼接最终路径时必须做好边界防御：&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;校验文件名是否合法，过滤 Windows 保留名称（如 &lt;code&gt;CON&lt;/code&gt;, &lt;code&gt;PRN&lt;/code&gt;, &lt;code&gt;AUX&lt;/code&gt;, &lt;code&gt;NUL&lt;/code&gt;, &lt;code&gt;COM1&lt;/code&gt;~&lt;code&gt;COM9&lt;/code&gt; 等）；&lt;/li&gt;&#10;&lt;li&gt;杜绝 &lt;code&gt;../&lt;/code&gt; 路径穿越行为，确保最终下载路径不会逃逸出所发现的目标文件夹。&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h3 id="4-文件夹拖拽怎么扩展"&gt;4. 文件夹拖拽怎么扩展？&#10;&lt;/h3&gt;&lt;p&gt;如果用户拖拽的是一个远程文件夹呢？&lt;/p&gt;&#10;&lt;p&gt;逻辑基本一致，但细节稍有不同：&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;本地生成一个带 UUID 的空文件夹，交由 &lt;code&gt;startDrag&lt;/code&gt; 拖出；&lt;/li&gt;&#10;&lt;li&gt;监听到目标目录创建了该 UUID 文件夹后，获取其所在父目录；&lt;/li&gt;&#10;&lt;li&gt;删除占位空文件夹，并在该目录下建立与远程目录同名的真实文件夹；&lt;/li&gt;&#10;&lt;li&gt;递归拉取远程目录树，批量向下载队列提交子文件下载任务。&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;h2 id="方案权衡这套做法的边界在哪里"&gt;方案权衡：这套做法的边界在哪里？&#10;&lt;/h2&gt;&lt;p&gt;任何工程方案都有它的适用范围。这种基于“UUID 占位符 + 文件系统监听”的方案，本质上是一种在纯 JavaScript/Node.js 生态内低成本实现的&lt;strong&gt;尽力而为（Best-effort）方案&lt;/strong&gt;。&lt;/p&gt;&#10;&lt;h3 id="优势"&gt;优势&#10;&lt;/h3&gt;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;纯 JS 实现&lt;/strong&gt;：无需编译任何 C++ / Rust 原生 Node 扩展，跨 Electron 大版本升级时维护成本极低；&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;对既有架构侵入小&lt;/strong&gt;：发现路径后直接复用普通下载管道，核心业务逻辑高度内聚。&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h3 id="局限"&gt;局限&#10;&lt;/h3&gt;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;非本地文件系统可能漏报&lt;/strong&gt;：如果用户将文件拖入某些特殊的网络共享盘（SMB/NFS）、没有本地文件变更通知的云盘映射盘、压缩包内（如 WinRAR 窗口）或系统虚拟特殊目录（如“此电脑”、“回收站”），文件系统监听器可能无法收到新增事件；&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;极端竞态&lt;/strong&gt;：极少数情况下如果系统 I/O 严重堵塞，可能导致匹配延迟。&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&#10; &lt;blockquote&gt;&#10; &lt;p&gt;&lt;strong&gt;什么时候需要考虑原生方案？&lt;/strong&gt;&lt;/p&gt;&#10;&lt;p&gt;如果产品对拖拽成功率有 100% 的强诉求，或者必须支持拖入压缩包、虚拟目录等任意目标，则需要脱离 Electron 自带的 &lt;code&gt;startDrag&lt;/code&gt;，转向开发原生平台模块：&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;Windows&lt;/strong&gt;：实现 COM &lt;code&gt;IDataObject&lt;/code&gt; 接口，提供虚拟文件流（&lt;code&gt;CFSTR_FILEDESCRIPTOR&lt;/code&gt; / &lt;code&gt;CFSTR_FILECONTENTS&lt;/code&gt;），当 Explorer 请求文件内容时再实时下载写入管道；&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;macOS&lt;/strong&gt;：使用 &lt;code&gt;NSFilePromiseProvider&lt;/code&gt; 实现 File Promise 机制。&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;不过原生模块开发难度大、维护成本高。对于绝大多数常规桌面工具而言，这套“占位文件探测法”加上完善的“另存为”降级入口，已经足够以极小的成本提供相当出色的交互体验。&lt;/p&gt;&#10;&#10; &lt;/blockquote&gt;&#10;</description></item></channel></rss>