跳转到内容

无后端 PWA 接收 WhatsApp 分享的 PDF

发布于

2026-10-09,dev.to 账号 Shahzaib 发表了一个系列的第五部分,该系列讲的是 PdfWord,一个该系列 描述为免费且无后端的 PDF 工具站。这篇文章报告了为该站点新增的三处平台集成,文章写明这三处全部 是客户端实现、零后端:注册进 Android 分享面板,使得从 WhatsApp、Gmail 或 Files 分享出来的 PDF 会在应用中预加载打开;三个 manifest 快捷方式,长按已安装的图标即可到达;以及向 iOS 用户显示的 安装提示 (该文章)。

文章写明这项分享集成分为两部分,并先给出 manifest 这一半。它的声明把 action 设为 /share.html,method 设为 POST,enctype 设为 multipart/form-data,并给出一个 params.files 条目,其 name 为 pdfs,其 accept 列表为 application/pdf 与 .pdf (该文章)。

MDN 记录了上述每一个键。它把 share_target 描述为允许已安装的 Progressive Web App 被注册为 系统分享对话框中的分享目标的那个 manifest 成员,并写明一旦注册并安装,使用 Web Share Target API 的 PWA 就会与典型的系统分享目标(例如邮件、通讯软件以及其他能接收分享内容的原生应用)并列, 充当内容分享目标。在该成员的属性中,MDN 把 action 与 params 标为必需,并把 method 描述为 要使用的 HTTP 请求方法,取 GET 或 POST,若分享的数据包含图片之类的二进制数据,或者会改变 目标应用,则应使用 POST。MDN 写明要接收分享的文件,HTTP 方法必须是 POST,enctype 必须是 multipart/form-data,并且必须提供一个定义所接受文件类型的 files 条目;文件必须具备 name 属性,而 accept 属性必须指明所接受的 MIME 类型或文件扩展名。MDN 补充说,两者都定义大概是个好 主意,因为操作系统可能在偏好哪一种上有所不同 (MDN,share_target)。

MDN 还记录了该成员上的两项条件,而文章没有重述它们。第一项关于适用范围:MDN 写明只有在已安装的 情况下,PWA 才能充当 web 分享目标。第二项关于支持情况:MDN 把该成员标注为 “Limited availability”,并写明该特性不属于 Baseline,因为它在一些使用最广泛的浏览器中并不工作 (MDN,share_target)。

文章里的第二部分是 Service Worker 中的一个 fetch 监听器。文章为把解析放在那里给出的理由是: 静态主机无法解析 multipart 表单,而 Service Worker 可以。它的处理器以 e.request.method === 'POST' 配合 /share.html 这一 pathname 作为匹配条件,等待 e.request.formData(),用 fd.getAll('pdfs') 取出表单字段名 pdfs 下的各个条目,并过滤掉 任何 size 不为正数的东西 (该文章)。

MDN 记录了处理 POST 分享数据的同一条路径。它写明你可以用服务器端代码来处理 POST 分享数据, 或者为了给离线用户提供更好的体验,使用一个 fetch 事件监听器来拦截该 HTTP 请求,这让你可以在 Service Worker 中访问这些数据。它自己针对这条路径的示例在 event.respondWith() 内部调用 event.request.formData() (MDN,share_target)。

MDN 关于该调用的参考文档说明了它返回什么以及它如何失败。它把 Request 接口上的 formData() 描述为读取请求体并以一个 promise 的形式返回它的方法,该 promise 会以一个 FormData 对象兑现; 并列出了抛出 TypeError 的各种原因:请求体已被扰动或被锁定,解码请求体内容时出错,请求体的 MIME 类型无法从请求所含的 Content-Type 头中确定、或者既不是 application/x-www-form-urlencoded 也不是 multipart/form-data,或者请求体无法被解析为一个 FormData 对象 (MDN,Request.formData())。

已经解析完表单的 Service Worker 还得把字节送到一个文档里。

文章的处理器打开一个它命名为 pdfword-shared-v1 的缓存,并以键 shared-0 调用 cache.put(),把第一个文件包进一个 Response,其头部携带由 encodeURIComponent 生成的 X-Filename 值。随后它返回 Response.redirect('/share.html?shared=1', 303) (该文章)。

这两半都有文档可依。关于重定向,MDN 写明理想情况下应以一个 HTTP 303 See Other 重定向来回应该 POST 请求,以避免在用户发起页面刷新时提交多次 POST 请求。关于存储,MDN 写明为了把文件从 Service Worker 上下文送到客户端上下文,一种解决办法是把文件临时写入 Cache 或 IndexedDB,然后 用 Client.postMessage() 通知它的客户端 (MDN,share_target)。

MDN 的缓存指南解释了为什么一条缓存条目可以当作这两个上下文之间的通道。它把 Cache 接口描述为 为 Request/Response 对提供持久化存储,带有增删这些对、以及查找与给定 Request 匹配的已缓存 Response 的方法;并写明该缓存在应用主线程与 Service Worker 中都可用,因此一个线程往那里添加 一个响应、另一个线程把它取出来是可能的 (MDN,Caching)。

文章描述了重定向之后发生的事。/share.html 这个页面从缓存中读出文件,把它展示出来,并提供 工具按钮,文章点名了其中的 Compress、Merge 与 PDF to Word。点击其中一个会把文件塞进 IndexedDB 并导航到工具页,在那里,站点共享的 common.js 里的一小段脚本通过 DataTransfer 把它注入文件 输入框,文章说这意味着该工具原有的上传处理逻辑直接就能用,不需要逐个工具改动。文章对结果的总结 是:用户再也不用把同一个文件挑第二次 (该文章)。

MDN 记录了最后这一步点名的那个对象。它把 DataTransfer 描述为用来保存在上下文之间传输的任何 数据的对象,例如一次拖放操作或一次剪贴板读写;写明它可以保存一个或多个数据项,每一项有一种或 多种数据类型;并列出了一个 DataTransfer() 构造函数,用于创建并返回一个新的 DataTransfer 对象。它的只读 files 属性包含该数据传输上所有可用的本地文件的列表。MDN 还指出 DataTransfer 最初主要是为 HTML 拖放 API 设计的,并且仍然在 HTML 拖放章节中被规范,但现在也被其他 API 使用 (MDN,DataTransfer)。

除了分享注册之外,文章还报告了另外两项新增:一个 manifest 成员,以及针对某个不触发安装按钮所 依赖事件的浏览器作出的 UI 处置 (该文章)。

文章称其向 manifest 添加了三个快捷操作,把它们命名为 Merge PDF、Compress PDF 与 PDF to Word。 展示的 manifest 片段为 Merge PDF 列出了 name、url 和一个 192x192 图标,随后以省略号代替其余 对象。它写明的行为是长按已安装的应用图标 会直接跳到对应工具,它写明的成本是五分钟的工作量 (该文章)。

MDN 把该成员描述为用于指定指向你的 Web 应用内关键任务或页面的链接,并写明浏览器可以用这些信息 创建一个上下文菜单,该菜单通常在用户与 Web 应用的图标交互时显示。它写明快捷方式通常在用户与 Web 应用图标交互时(例如右键点击或长按)以上下文菜单的形式呈现给用户,并写明每个对象的属性中 只有 name 与 url 是必需的。MDN 对作者能控制什么作了限定:它写明呈现方式以及向用户显示的 快捷方式数量由浏览器与操作系统决定,并写明浏览器可能截断所声明的快捷方式列表,以与宿主操作系统 的惯例或限制保持一致 (MDN,shortcuts)。

文章写明 iOS Safari 不触发 beforeinstallprompt,所以它的安装按钮在那里什么也不做;并写明 iOS 用户现在也能拿到这个按钮:点击它会显示一段文字,指示读者点击 Share,然后点击 Add to Home Screen 来安装 PdfWord。文章对这项改动的定性是:它很小且诚实,比一个死按钮要好 (该文章)。

MDN 描述了那个按钮所依赖的事件。它写明 beforeinstallprompt 在浏览器检测到某个网站可以被安装 为 Progressive Web App 时触发,写明该事件没有保证的触发时间,但通常发生在页面加载时,并写明其 典型用途是 Web 应用提供自己的应用内 UI 来邀请用户安装应用,而不是用浏览器提供的那个通用 UI。 MDN 自己对该模式的指导是从处理器中展开应用内安装 UI,并直接给出了理由:该 UI 应该默认隐藏, 因为并非所有浏览器都支持安装 (MDN,beforeinstallprompt event)。

文章中可复用的部分是静态主机与 Service Worker 之间的分工。MDN 写明接收分享的文件需要 POST 配合 multipart/form-data,并写明在一个 fetch 处理器中拦截该请求,是用服务器端代码处理那个 请求体之外有文档可依的替代做法 (MDN,share_target)。

交接是有文档可依的下一步。MDN 点名 Cache 与 IndexedDB 作为临时写入文件、以便客户端上下文 能读到它们的地方,并推荐用一个 303 重定向来回应分享请求 (MDN,share_target)。

MDN 的缓存指南写明该缓存从主线程与 Service Worker 都可以到达 (MDN,Caching)。

有两条附带条件挂在分享目标本身上。MDN 写明只有在已安装之后,PWA 才能充当 web 分享目标,所以在 安装之前它不会被注册为分享目标;而 MDN 把 share_target 标注为不属于 Baseline,因为它在一些使用 最广泛的浏览器中并不工作 (MDN,share_target)。

安装按钮自己也带着一条有文档可依的附带条件。MDN 默认隐藏应用内安装 UI 的理由是,并非所有浏览器 都会支持安装 (MDN,beforeinstallprompt event)。

最后,MDN 给整个机制附上了一条文章没有讨论的安全提示:与 HTML 表单提交类似,你应当对经由分享 目标发送到你的应用的数据保持谨慎,并确保在使用之前验证传入的数据——这覆盖任何把分享来的文件 写入缓存的处理器 (MDN,share_target)。

  • 能力:在比较本文所引来源对分享目标、manifest 快捷方式与安装提示 事件写明的支持限制时,可继续阅读这一条目。
  • 存储:在追踪文章所述从 Cache 到 IndexedDB 的交接时,可继续阅读这一 条目。
  • 性能:在评估文章所述目标——让分享来的文件预加载打开,而不要求 用户再次选择文件——时,可继续阅读这一条目。
  • 分享面板的行为是在哪个 Android 浏览器与版本上观察到的,文章并未说明,因此该说法无法钉到某个 引擎上。
  • 安装提示是针对哪个 iOS 版本测试的,并未说明,因此文章关于 beforeinstallprompt 在那里不触发 的报告无法钉到某个发布版本上。
  • 文件输入框是通过赋值接收 DataTransfer 对象的 files 列表,还是通过别的步骤,文章并未说明; MDN 把 files 记录为 DataTransfer 的一个只读属性。
  • 键为 shared-0 的缓存条目在文件被消费之后是否会被删除,未被涉及,因此第二次分享会对一次 待处理的分享造成什么并未得到确认。
  • 是否处理多于一个分享文件未得到确认:文章的过滤收集了每一个 size 为正数的条目,但它的 cache.put() 只存了第一个。
  • 分享来的文件在使用之前是否经过验证,也就是 MDN 为分享目标输入所建议的那件事,文章未涉及。