玩转 Web 剪贴板


如果你使用电脑已有一段时间,你可能知道剪贴板可以存储多种类型的数据(图像、富文本内容、文件等)。作为一名软件开发人员,我开始感到困扰,因为我对剪贴板如何存储和组织不同类型的数据没有很好的理解。

最近,我决定揭开剪贴板的神秘面纱,并根据我的学习撰写了这篇文章。我们将重点关注网页剪贴板及其API,但也会简要讨论它与操作系统剪贴板的交互。

我们将从探索网页的剪贴板API及其历史开始。剪贴板API在数据类型方面有一些有趣的限制,我们将看到一些公司如何绕过这些限制。我们还将看看一些旨在解决这些限制的提案(最显著的是 Web Custom Formats)。

如果你曾经对网页剪贴板的工作原理感到好奇,这篇文章适合你。

使用异步Clipboard API

如果我从一个网站复制一些内容并粘贴到Google Docs中,一些格式会保留,例如链接、字体大小和颜色。

但是,如果我打开VS Code并粘贴内容,只有纯文本被粘贴。

剪贴板通过允许以与MIME类型关联的多种表示形式存储信息来服务这两种用例。W3C剪贴板规范规定,在向剪贴板写入和从剪贴板读取时,必须支持以下三种数据类型:

  • text/plain 用于纯文本。
  • text/html 用于HTML。
  • image/png 用于PNG图像。

因此,当我粘贴到Google Docs时,它读取了 text/html 表示形式,并使用它保留了富文本格式。而VS Code只关心原始文本,并读取了 text/plain 表示形式。这很合理。

通过异步Clipboard API的 read 方法读取特定表示形式非常简单:

1
2
3
4
5
6
7
8
9
const items = await navigator.clipboard.read();

for (const item of items) {
if (item.types.includes("text/html")) {
const blob = await item.getType("text/html");
const html = await blob.text();
// 对HTML进行处理...
}
}

通过 write 将多个表示形式写入剪贴板稍微复杂一些,但仍然相对简单。首先,我们为每个要写入剪贴板的表示形式构建 Blob

1
2
const textBlob = new Blob(["Hello, world"], { type: "text/plain" });
const htmlBlob = new Blob(["Hello, <em>world<em>"], { type: "text/html" });

一旦我们有了 Blob,我们将它们传递给一个新的 ClipboardItem,并以数据类型为键、Blob 为值的键值对存储它们:

1
2
3
4
const clipboardItem = new ClipboardItem({
[textBlob.type]: textBlob,
[htmlBlob.type]: htmlBlob,
});

注意:我喜欢 ClipboardItem 接受键值存储的方式。它很好地符合使用数据结构避免非法状态表示的理念,就像在 “Parse, don’t validate” 中讨论的那样。

最后,我们调用 write,传入我们新构建的 ClipboardItem

1
await navigator.clipboard.write([clipboardItem]);

其他数据类型呢?

HTML和图像很酷,但像JSON这样的通用数据交换格式呢?如果我在编写支持复制粘贴的应用程序,我可以想象希望将JSON或某些二进制数据写入剪贴板。

让我们尝试将JSON数据写入剪贴板:

1
2
3
4
5
6
7
// 创建JSON blob
const json = JSON.stringify({ message: "Hello" });
const blob = new Blob([json], { type: "application/json" });

// 将JSON blob写入剪贴板
const clipboardItem = new ClipboardItem({ [blob.type]: blob });
await navigator.clipboard.write([clipboardItem]);

运行后,会抛出一个异常:

1
2
无法在 'Clipboard' 上执行 'write': 
不支持 application/json 类型的写入。

嗯,这是什么情况?根据 write 规范,除了 text/plaintext/htmlimage/png 之外的其他数据类型必须被拒绝:

如果类型不在强制性数据类型列表中,则拒绝 […] 并中止这些步骤。

有趣的是,application/json MIME类型曾经在2012年到2021年的强制数据类型列表中,但在 w3c/clipboard-apis#155 中被移除。在此之前,读取剪贴板的强制性数据类型有16种,写入剪贴板的有8种。变更之后,只剩下 text/plaintext/htmlimage/png

此变更是由于浏览器出于安全考虑而选择不支持许多强制性类型导致的。这一点在规范中的强制数据类型部分通过警告得到了反映。

警告!作为安全预防措施,不受信任的脚本只能将有限的数据类型写入剪贴板。
不受信任的脚本可能会试图通过在剪贴板上放置已知会触发漏洞的数据来利用本地软件中的安全漏洞。

好的,所以我们只能将有限的数据类型写入剪贴板。但是,关于“不受信任的脚本”是怎么回事呢?我们能否以某种方式运行“受信任”的脚本,允许我们将其他数据类型写入剪贴板?

isTrusted 属性

也许“受信任”的部分指的是事件的 isTrusted 属性。isTrusted 是一个只读属性,仅当事件由用户代理分发时,其值才会被设置为 true

1
2
3
4
5
document.addEventListener("copy", (e) => {
if (e.isTrusted) {
// 该事件由用户代理触发
}
});

“由用户代理分发”意味着它是由用户触发的,例如用户按下 Command + C 时触发的复制事件。这与通过 dispatchEvent() 程序化调度的合成事件形成对比:

1
2
3
4
5
6
document.addEventListener("copy", (e) => {
console.log("e.isTrusted is " + e.isTrusted);
});

document.dispatchEvent(new ClipboardEvent("copy"));
// => "e.isTrusted is false"

让我们看看剪贴板事件,并看看它们是否允许我们将任意数据类型写入剪贴板。

剪贴板事件 API

当发生复制、剪切和粘贴事件时,会调度一个 ClipboardEvent,该事件包含一个类型为 DataTransferclipboardData 属性。DataTransfer 对象由剪贴板事件 API 使用,用于保存多种数据表示形式。

在复制事件中写入剪贴板非常简单:

1
2
3
4
5
6
document.addEventListener("copy", (e) => {
e.preventDefault(); // 阻止默认的复制行为

e.clipboardData.setData("text/plain", "Hello, world");
e.clipboardData.setData("text/html", "Hello, <em>world</em>");
});

在粘贴事件中读取剪贴板内容同样很简单:

1
2
3
4
5
6
7
8
document.addEventListener("paste", (e) => {
e.preventDefault(); // 阻止默认的粘贴行为

const html = e.clipboardData.getData("text/html");
if (html) {
// 对HTML进行处理...
}
});

现在,大问题来了:我们能将JSON写入剪贴板吗?

1
2
3
4
5
6
document.addEventListener("copy", (e) => {
e.preventDefault();

const json = JSON.stringify({ message: "Hello" });
e.clipboardData.setData("application/json", json); // 没有错误
});

没有抛出异常,但这真的将JSON写入了剪贴板吗?让我们通过编写一个粘贴处理程序,遍历剪贴板中的所有条目并将其打印出来来验证:

1
2
3
4
5
6
7
8
9
10
document.addEventListener("paste", (e) => {
for (const item of e.clipboardData.items) {
const { kind, type } = item;
if (kind === "string") {
item.getAsString((content) => {
console.log({ type, content });
});
}
}
});

添加这些处理程序并执行复制粘贴后,会记录如下内容:

1
{ "type": "application/json", "content": "{\"message\":\"Hello\"}" }

成功了!看来 clipboardData.setData 并不像异步 write 方法那样对数据类型进行限制。

但为什么呢?为什么我们可以使用 clipboardData 读写任意数据类型,而使用异步剪贴板API时却不行?

clipboardData 的历史

相对较新的异步剪贴板API是在2017年添加到规范中的,而 clipboardData 早在那之前就已经存在。2006年关于剪贴板API的W3C草案定义了 clipboardData 及其 setDatagetData 方法(这表明当时MIME类型还未被使用):

  • setData() 接受一个或两个参数。第一个必须设置为 ‘text’ 或 ‘URL’(不区分大小写)。
  • getData() 接受一个参数,允许目标请求特定类型的数据。

但是,clipboardData 实际上比2006年草案还要早。请看该文档状态部分的这段话:

该文档大部分描述了在Internet Explorer中实现的功能…

这表明该文档的目的是为了描述当前浏览器中实际可用的功能,以提高互操作性,而不是添加新功能。

一篇2003年的文章详细介绍了在Internet Explorer 4及以上版本中,如何使用 clipboardData 在未经用户同意的情况下读取用户的剪贴板内容。鉴于Internet Explorer 4发布于1997年,这表明 clipboardData 接口至少已经有26年的历史了。

MIME类型在2011年进入规范:

dataType 参数是一个字符串,例如但不限于MIME类型…

如果脚本调用 getData('text/html')

当时,规范还没有确定应该使用哪些数据类型:

虽然可以为 setData() 的类型参数使用任意字符串,但建议坚持使用常见类型。

今天,setDatagetData 仍然可以使用任意字符串。这完全没有问题:

1
2
3
4
5
6
7
8
9
10
11
document.addEventListener("copy", (e) => {
e.preventDefault();
e.clipboardData.setData("foo bar baz", "Hello, world");
});

document.addEventListener("paste", (e) => {
const content = e.clipboardData.getData("foo bar baz");
if (content) {
console.log(content); // 输出 "Hello, world!"
}
});

如果你将这个代码片段粘贴到你的开发者工具中,然后执行复制粘贴操作,你会看到控制台输出“Hello, world!”。

剪贴板事件API的 clipboardData 允许我们使用任意数据类型的原因似乎是历史原因:“不要破坏网络”。

再次探讨 isTrusted

让我们重新考虑一下强制性数据类型部分的这句话:

作为安全预防措施,不受信任的脚本只能将有限的数据类型写入剪贴板。

那么,如果我们在合成(不受信任的)剪贴板事件中尝试写入剪贴板会发生什么?

1
2
3
4
5
6
7
8
document.addEventListener("copy", (e) => {
e.preventDefault();
e.clipboardData.setData("text/plain", "Hello");
});

document.dispatchEvent(new ClipboardEvent("copy", {
clipboardData: new DataTransfer(),
}));

这段代码成功运行,但它并没有修改剪贴板。这是预期的行为,正如规范中所解释的:

合成的剪切和复制事件不得修改系统剪贴板上的数据。

合成的粘贴事件不得给予脚本访问真实系统剪贴板数据的权限。

因此,只有由用户代理分发的复制和粘贴事件才能修改剪贴板。这完全合理——我不希望网站可以随意读取我的剪贴板内容并窃取我的密码。

  • 引入于2017年的异步剪贴板API限制了可以写入和读取剪贴板的数据类型。然而,只要用户授予权限(且文档处于焦点状态),它可以随时读取和写入剪贴板。
  • 较旧的剪贴板事件API对写入和读取剪贴板的数据类型没有实质性的限制。然而,它只能在由用户代理触发的复制和粘贴事件处理程序中使用(即当 isTrustedtrue 时)。

如果你想向剪贴板写入不仅仅是纯文本、HTML或图像的数据类型,那么使用剪贴板事件API似乎是唯一的方法。在这方面限制要少得多。
但是,如果您想构建一个复制按钮,将非标准数据类型写入剪贴板,该怎么办?如果用户没有触发复制事件,您似乎无法使用剪贴板事件API。对吗?”

构建一个写入任意数据类型的复制按钮


这个复制按钮将三种表示形式写入剪贴板:

  • text/plain
  • text/html
  • application/x-vnd.google-docs-document-slice-clip+wrapped

注意:第三种表示形式包含 JSON 数据。

他们将自定义数据类型写入剪贴板,这意味着他们没有使用异步剪贴板 API。那么他们是如何通过点击处理程序做到这一点的呢?

我运行了分析器,点击了复制按钮,并检查了结果。事实证明,点击复制按钮会触发 document.execCommand("copy") 的调用。

这让我感到惊讶。我的第一个想法是:“execCommand 不是旧的、已弃用的复制文本到剪贴板的方法吗?”

是的,它是,但谷歌这样使用是有原因的。execCommand 的特殊之处在于,它允许你以编程方式调度一个受信任的复制事件,就像用户自己执行了复制命令一样。

1
2
3
4
5
6
document.addEventListener("copy", (e) => {
console.log("e.isTrusted is " + e.isTrusted);
});

document.execCommand("copy");
// => "e.isTrusted is true"

注意:Safari 需要一个有效的选区来让 execCommand("copy") 调度复制事件。可以通过向 DOM 添加一个非空的输入元素并在调用 execCommand("copy") 之前选择它来伪造该选区,然后再将该输入元素从 DOM 中移除。

好的,所以使用 execCommand 可以让我们在响应点击事件时将任意数据类型写入剪贴板。很酷!

那么粘贴呢?我们可以使用 execCommand("paste") 吗?

构建一个粘贴按钮

让我们试一下 Google Docs 中的粘贴按钮,看看它做了什么。

在我的 Macbook 上,我收到一个弹窗,提示我需要安装一个扩展程序才能使用粘贴按钮。

但奇怪的是,在我的 Windows 笔记本电脑上,粘贴按钮却可以正常工作。

很奇怪。这种不一致性来自哪里?嗯,是否粘贴按钮能够正常工作,可以通过运行 queryCommandSupported("paste") 检查:

1
document.queryCommandSupported("paste");

在我的 Macbook 上,我在 Chrome 和 Firefox 上得到了 false,但在 Safari 上得到了 true

Safari 出于隐私考虑,要求我确认粘贴操作。我认为这是一个非常好的主意。它明确表示该网站将从你的剪贴板中读取数据。

在我的 Windows 笔记本电脑上,我在 Chrome 和 Edge 上得到了 true,但在 Firefox 上得到了 false。Chrome 的这种不一致性让人感到惊讶。为什么 Chrome 在 Windows 上允许 execCommand("paste"),但在 macOS 上不允许?对此我找不到任何相关信息。

让我感到意外的是,谷歌并没有在 execCommand("paste") 不可用时回退到异步剪贴板 API。虽然他们无法使用它读取 application/x-vnd.google-[...] 表示形式,但 HTML 表示形式包含内部 ID,可以使用这些 ID。

1
2
3
4
5
<!-- 清理后的 HTML 表示形式 -->
<meta charset="utf-8">
<b id="docs-internal-guid-[guid]" style="...">
<span style="...">复制的文本</span>
</b>

另一个具有粘贴按钮的 Web 应用程序是 Figma,他们采取了完全不同的方式。让我们看看他们在做什么。

Figma 中的复制和粘贴

Figma 是一个基于 Web 的应用程序(他们的原生应用使用 Electron)。让我们看看他们的复制按钮将什么写入剪贴板。

Figma 的复制按钮将两种表示形式写入剪贴板:text/plaintext/html。一开始这让我感到惊讶。Figma 如何用普通 HTML 表示他们的各种布局和样式功能呢?

但查看 HTML 后,我们看到两个带有 data-metadatadata-buffer 属性的空 span 元素:

1
2
3
4
5
6
<meta charset="utf-8">
<div>
<span data-metadata="<!--(figmeta)eyJma[...]9ifQo=(/figmeta)-->"></span>
<span data-buffer="<!--(figma)ZmlnL[...]P/Ag==(/figma)-->"></span>
</div>
<span style="white-space:pre-wrap;">文本</span>

注意:data-buffer 字符串对于一个空框架大约有 26,000 个字符。在此之后,data-buffer 的长度似乎随着复制的内容量线性增长。

看起来像是 base64 编码。eyJ 开头的部分清楚地表明 data-metadata 是一个 base64 编码的 JSON 字符串。对 data-metadata 运行 JSON.parse(atob()) 后得到:

1
2
3
4
5
{
"fileKey": "4XvKUK38NtRPZASgUJiZ87",
"pasteID": 1261442360,
"dataType": "scene"
}

注意:我替换了真实的 fileKeypasteID

那么庞大的 data-buffer 属性呢?对其进行 base64 解码后得到如下内容:

1
fig-kiwiF\x00\x00\x00\x1CK\x00\x00µ½\v\x9CdI[...]\x197Ü\x83\x03

看起来像是一个二进制格式。经过一番调查——利用“fig-kiwi”这个线索——我发现这是 Kiwi 消息格式(由 Figma 的联合创始人兼前 CTO Evan Wallace 创建),用于编码 .fig 文件。

由于 Kiwi 是一个基于模式的格式,没有模式我们似乎无法解析这些数据。不过,幸运的是,Evan 创建了一个公开的 .fig 文件解析器。让我们尝试将缓冲区插入其中!

为了将缓冲区转换为 .fig 文件,我编写了一个小脚本来生成一个 Blob URL:

1
2
3
4
5
const base64 = "ZmlnL[...]P/Ag==";
const blob = base64toBlob(base64, "application/octet-stream");

console.log(URL.createObjectURL(blob));
// => blob:<origin>/1fdf7c0a-5b56-4cb5-b7c0-fb665122b2ab

然后我下载了生成的 Blob 作为 .fig 文件,并将其上传到 .fig 文件解析器,结果就是这样:

因此,Figma 的复制操作实际上是通过创建一个小型的 Figma 文件,将其编码为 base64,然后将生成的 base64 字符串放入空 HTML span 元素的 data-buffer 属性中,并将其存储在用户的剪贴板中。

复制粘贴 HTML 的好处

一开始这看起来有些可笑,但这种方法有一个很大的好处。为了理解这个好处,先看看 Web 剪贴板 API 如何与各种操作系统的剪贴板 API 交互。

Windows、macOS 和 Linux 都为将数据写入剪贴板提供了不同的格式。如果你想将 HTML 写入剪贴板,Windows 有 CF_HTML,而 macOS 有 NSPasteboard.PasteboardType.html

所有操作系统都提供“标准”格式(纯文本、HTML 和 PNG 图像)的类型。但是,当用户尝试将 application/foo-bar 这样的任意数据类型写入剪贴板时,浏览器应该使用哪种操作系统格式呢?

没有一个好的匹配项,所以浏览器不会将该表示写入操作系统剪贴板中的常见格式。相反,该表示只存在于操作系统剪贴板上的自定义浏览器特定格式中。这导致能够跨浏览器标签复制和粘贴任意数据类型,但不能跨应用程序使用。

这就是为什么使用常见的数据类型 text/plaintext/htmlimage/png 如此方便。它们映射到操作系统剪贴板的常见格式,因此可以很容易地被其他应用程序读取,使复制/粘贴能够在不同的应用程序之间正常工作。对于 Figma 来说,使用 text/html 使得可以在浏览器中的 figma.com 复制一个 Figma 元素,然后将其粘贴到本地 Figma 应用程序中,反之亦然。

浏览器将什么写入剪贴板作为自定义数据类型?

我们了解到可以跨浏览器标签向剪贴板写入和读取自定义数据类型,但不能跨应用程序使用。但是,当我们将自定义数据类型写入 Web 剪贴板时,浏览器究竟将什么写入本地操作系统剪贴板呢?

我在我的 Macbook 上的每个主要浏览器中运行了以下代码,设置了一个复制监听器:

1
2
3
4
5
6
7
document.addEventListener("copy", (e) => {
e.preventDefault();
e.clipboardData.setData("text/plain", "Hello, world");
e.clipboardData.setData("text/html", "<em>Hello, world</em>");
e.clipboardData.setData("application/json", JSON.stringify({ type: "Hello, world" }));
e.clipboardData.setData("foo bar baz", "Hello, world");
});

然后我使用 Pasteboard Viewer 检查剪贴板内容。Chrome 向剪贴板添加了四个条目:

  • public.html 包含 HTML 表示。
  • public.utf8-plain-text 包含纯文本表示。
  • org.chromium.web-custom-data 包含自定义表示。
  • org.chromium.source-url 包含执行复制操作的网页 URL。

查看 org.chromium.web-custom-data,我们看到我们复制的数据:

Firefox 也创建了 public.htmlpublic.utf8-plain-text 条目,但将自定义数据写入 org.mozilla.custom-clipdata。它不像 Chrome 那样存储源 URL。

Safari(如你所料)也创建了 public.htmlpublic.utf8-plain-text 条目。它将自定义数据写入 com.apple.WebKit.custom-pasteboard-data,并且有趣的是,它还在那里存储了完整的表示形式列表(包括纯文本和 HTML)以及源 URL。

注意:Safari 允许跨浏览器标签复制粘贴自定义数据类型,如果源 URL(域名)相同,但不能跨不同域名。这种限制似乎在 Chrome 或 Firefox 中不存在(尽管 Chrome 存储源 URL)。

Web 原始剪贴板访问

2019 年提出了一个原始剪贴板访问的提案,提出了一个 API,允许 Web 应用程序对本地操作系统剪贴板进行原始读写访问。

这个摘录来自 chromestatus.com 上的原始剪贴板访问功能的动机部分,简洁地突出了它的好处:

“没有原始剪贴板访问 […] Web 应用程序通常只能访问少数格式,无法与大多数格式互操作。例如,Figma 和 Photopea 无法与大多数图像格式互操作。”

然而,由于安全问题(例如在本地应用程序中远程代码执行),原始剪贴板访问提案最终没有进一步推进。

最新的自定义数据类型写入剪贴板的提案是 Web 自定义格式提案(通常称为 pickling)。

Web 自定义格式(Pickling)

2022 年,Chromium 在异步剪贴板 API 中实现了对 Web 自定义格式的支持。

它允许 Web 应用程序通过异步剪贴板 API 写入自定义数据类型,方法是为数据类型添加前缀“web”:

1
2
3
4
5
6
7
8
9
// 创建 JSON Blob
const json = JSON.stringify({ message: "Hello, world" });
const jsonBlob = new Blob([json], { type: "application/json" });

// 将 JSON Blob 写入剪贴板作为 Web 自定义格式
const clipboardItem = new ClipboardItem({
[`web ${jsonBlob.type}`]: jsonBlob,
});
navigator.clipboard.write([clipboardItem]);

这些数据可以像其他数据类型一样使用异步剪贴板 API 读取:

1
2
3
4
5
6
7
8
const items = await navigator.clipboard.read();
for (const item of items) {
if (item.types.includes("web application/json")) {
const blob = await item.getType("web application/json");
const json = await blob.text();
// 处理 JSON 数据...
}
}

更有趣的是,写入本地剪贴板时会发生什么。当写入 Web 自定义格式时,以下内容会被写入本地操作系统剪贴板:

  • 数据类型到剪贴板条目名称的映射
  • 每种数据类型的剪贴板条目

在 macOS 上,映射被写入 org.w3.web-custom-format.map,其内容如下:

1
2
3
4
{
"application/json": "org.w3.web-custom-format.type-0",
"application/octet-stream": "org.w3.web-custom-format.type-1"
}

org.w3.web-custom-format.type-[index] 键对应操作系统剪贴板中的条目,包含来自 Blob 的未经处理的数据。这使本地应用程序可以查看映射,以确定是否有可用的表示形式,然后从相应的剪贴板条目中读取未经处理的内容。

注意:Windows 和 Linux 使用不同的命名约定来表示映射和剪贴板条目。

这避免了围绕原始剪贴板访问的安全问题,因为 Web 应用程序无法将未经处理的数据写入它们想要的任何操作系统剪贴板格式。这伴随着一种明确列在异步剪贴板 API 规范中的互操作性权衡:

非目标:允许与不更新的旧版本地应用程序进行互操作。这个问题在原始剪贴板提案中被探索过,可能会在将来进一步探索,但它带来了重大安全挑战(系统本地应用程序中的远程代码执行)。

这意味着本地应用程序需要更新才能与 Web 应用程序在使用自定义数据类型时进行剪贴板互操作。

Web 自定义格式自 2022 年起在基于 Chromium 的浏览器中可用,但其他浏览器尚未实现该提案。

最后的话

截至目前,还没有一种能够跨所有浏览器写入自定义数据类型的理想方式。Figma 将 base64 字符串放入 HTML 表示的方式虽然粗糙,但有效,它绕过了剪贴板 API 周围的各种限制。如果你需要通过剪贴板传输自定义数据类型,这似乎是一种不错的方法。