封面

navigator.clipboard.writeText 静默失败:await 的隐藏陷阱

原文:https://dev.to/parsajiravand/the-await-that-silently-breaks-navigatorclipboardwritetext-10oe(作者 @parsajiravand)

你团队里有人上线了一个"复制邀请链接"按钮。它从 API 获取一个新的、一次性的链接,然后将其复制到剪贴板,以便用户粘贴到 Slack 中。代码审查没问题。QA 点了十几次,每次都正常。

两周后,一个工单来了:"我点击复制,粘贴到 Slack,结果得到的是昨天剪贴板里的内容,不是链接。"又一个工单,同样的情况,不同的用户。你无法复现。你连续点了四十次按钮,它复制了四十次链接。

关键细节在这里——如果你知道该找什么的话:每个遇到这个问题的用户,在点击复制和链接实际写入剪贴板之间的一两秒内,都切换到了另一个标签页或点击了另一个窗口。没有崩溃,没有错误到达 UI。navigator.clipboard.writeText() 只是静默地拒绝执行,而代码从未检查过。

那些不起作用的修复

直觉是把它当作普通的竞态条件或网络抖动来处理:

  • 加一个加载动画,让用户等 fetch 完成再点击。 没用——这个 bug 不是关于点击太早,而是关于点击之后发生了什么,此时你的代码还在 await 某个东西。
  • 用 `try/catch` 包裹写入操作,把错误吞掉。 现在它以同样的频率失败,只是从意外地静默变成了故意地静默。更糟,可以说——你删掉了自己的证据。
  • 稍后重试写入操作。 如果失败的原因仍然存在(文档仍然没有焦点),重试也会失败。如果你无限重试,你就构建了一个轮询器,去轮询一个与时间无关的权限。

这些方案都没有问那个唯一有用的问题:为什么一个"只是复制字符串"的浏览器 API 会拒绝运行?

真正的规则:API 只信任点击的那一瞬间

navigator.clipboard.writeText() 是 Async Clipboard API 的一部分,它强制执行的东西比"用户曾经点击过某个按钮"更严格。在你调用它的那一刻,浏览器要求两件事仍然为真:

  • 文档拥有焦点。 不是"点击发生时拥有焦点"——而是此刻,这个调用,这个 tick,拥有焦点。
  • 用户手势仍然活跃。 点击会授予一个短暂的"用户刚做了某事"的时间窗口,而这个窗口不会永远等待。

await 任何东西——一个 fetch() 获取链接、一个 Promise 链,甚至几次重新渲染——你就在点击和实际的 writeText() 调用之间插入了一个间隙。如果用户在这个间隙期间 alt-tab 切换、点击了浏览器界面元素,或者开发者工具面板抢走了焦点,调用到达时文档就没有焦点。浏览器不会排队它,不会警告用户,不会重试。它以 NotAllowedError 拒绝 Promise——在 Chrome 中,字面意思是 "Failed to execute 'writeText' on 'Clipboard': Document is not focused."——如果你的代码中没有读取这个拒绝,它就会消失在一条没人关注的生产环境 unhandled-promise-rejection 日志中。

// 看起来完全合理,但在真实时序下静默失败
async function copyInviteLink() {
  const res = await fetch("/api/invite-link"); // <- 间隙在这里打开
  const { url } = await res.json();
  await navigator.clipboard.writeText(url);    // <- 这就是掉进间隙的部分
}


这里没有任何语法错误。错的是时序——写入发生在网络恰好解析完成的时候,而不是在点击仍然新鲜的时候。

<!-- playground:start -->

自己动手试试

[打开交互式 playground →](https://bestpractic.org/blog/clipboard-writetext-focus-bug/playground)

_直接在浏览器中运行——动手试试,观察概念实时反应。_

<!-- playground:end -->

修复方案:立即把手势交给 API,数据稍后再给

writeText() 是一个便捷方法——它只接受你此刻已经手头有的字符串。但它的兄弟方法 navigator.clipboard.write() 接受一个 ClipboardItem,而 ClipboardItem 的数据不必是普通字符串或 Blob。它可以是一个稍后才 resolve 的 Promise。这才是"我此刻有用户的权限,但还没有文本"的真正工具:

// 在与点击相同的 tick 中同步调用 write()——
// *数据*可以在准备好之后的任何时候到达
function copyInviteLink() {
  const linkPromise = fetch("/api/invite-link")
    .then((res) => res.json())
    .then(({ url }) => new Blob([url], { type: "text/plain" }));

  return navigator.clipboard.write([
    new ClipboardItem({ "text/plain": linkPromise }),
  ]);
}


write() 调用本身仍然发生在点击处理函数内部,在任何 await 有机会让焦点溜走之前——所以权限检查在唯一一个保证为真的时刻通过。浏览器保持剪贴板槽位打开,等你的 Promise settle 后再填充它。你的 fetch 逻辑不需要改变任何东西;只需要改变你把最终字符串交给哪个方法

上线前值得了解的两个细节

  • 整个 API 要求安全上下文。 navigator.clipboard 在普通的 http:// 源(localhost 除外)上根本不存在——如果生产环境中是 undefined 但你本地没问题,几乎总是这个原因。
  • `document.execCommand("copy")` 已被废弃,根据 MDN 文档,未来不保证在每个浏览器中都能工作甚至存在。它仍然残留在旧代码中,因为它早于 Async Clipboard API 出现,且不存在上述焦点问题——它从选区同步复制,不涉及 Promise——但无论是否作为废弃方案的兜底,它都不是新代码该用的东西。

唯一值得记住的一点

一个在每次手动测试中都能正常工作、却对真实用户失效的复制按钮,不是"不稳定"——它是时序依赖的,而你的测试永远不会复现这种时序,因为你测试时不会在点击中途按 Alt-Tab 切窗口。规则一旦说破就很简单:让 Clipboard API 的调用与授权它的用户手势保持同步,如果数据还没准备好,就传给 API 一个 Promise,而不是让 API 去等待一个 Promise。

你是否上线过一个"偶尔就是没反应"的复制按钮,然后默默 catch 掉错误,却没有追问原因?你的 try/catch 长什么样?

原文:https://dev.to/parsajiravand/the-await-that-silently-breaks-navigatorclipboardwritetext-10oe(作者 @parsajiravand)

发布评论
全部评论(0)