剪贴板如何存储不同类型的数据

前言

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

本文决定揭开剪贴板的神秘面纱,关注网页剪贴板及其 API,同时也会提及它与操作系统剪贴板的交互方式。

我们将从研究 Web 剪贴板的 API 及其历史开始。剪贴板 API 在数据类型方面有一些有趣的限制,我们将看看一些公司是如何绕过这些限制的。此外,我们还会探讨一些旨在解决这些限制的提案(特别是 Web 自定义格式)。

使用异步剪贴板 API

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

但如果我打开 VS Code 并将其粘贴在那里,则只会粘贴原始的文本内容。

剪贴板通过允许将信息以与 MIME 类型相关的多种表示形式存储来满足这两种使用场景。W3C 剪贴板规范规定在剪贴板的读写操作中,必须支持以下三种数据类型:

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

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

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

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();
     }
 }

通过 write 写入多个表示形式稍微复杂一些,但还是相对比较简单的。首先,我们为每种需要写入剪贴板的表示形式构建 Blob:

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

一旦有了这些 blob 后,我们将它们以键值对的形式传递给新的 ClipboardItem,其中数据类型作为键,blob 作为值:

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

注意:我喜欢 ClipboardItem 接受键值对的方式,它很好地与 “使用数据结构来避免非法状态” 的理念相契合,正如在解析,不验证中所讨论的那样。

最后,我们使用构造好的 ClipboardItem 调用 write

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

其他数据类型呢?

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

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

 const json = JSON.stringify({ message: "Hello" });
 const blob = new Blob([json], { type: "application/json" });
 const clipboardItem = new ClipboardItem({ [blob.type]: blob });
 await navigator.clipboard.write([clipboardItem]);

运行后抛出异常:

 Failed to execute 'write' on 'Clipboard':
 Type application/json not supported on write.

这是怎么回事?根据 规范,除了 text/plaintext/htmlimage/png 之外,必须拒绝其他数据类型:

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

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

此更改是因为浏览器出于安全考虑选择不支持许多强制类型。规范的强制数据类型部分反映了这一点,并有如下警告:

警告!为防范安全问题,不受信任的脚本只能将有限的数据类型写入剪贴板。
不可信脚本可能会尝试利用本地软件中的安全漏洞,通过将已知会触发这些漏洞的数据放入剪贴板。

好吧,我们只能将有限的数据类型写入剪贴板。但那个 “不可信脚本” 是指什么呢?我们能否在某种 “可信” 的脚本中运行代码,允许将其他数据类型写入剪贴板呢?

isTrusted 属性

或许这里的 “可信” 是指事件的 isTrusted 属性。isTrusted 是一个只读属性,只有当事件由用户代理分派时才会被设置为 true

document.addEventListener("copy", (e) => {
     if (e.isTrusted) {
         // 做点什么
     }
 });

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

 document.addEventListener("copy", (e) => {
     console.log("e.isTrusted is " + e.isTrusted);
 });

 document.dispatchEvent(new ClipboardEvent("copy"));

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

剪贴板事件 API

ClipboardEvent 是在复制、剪切和粘贴事件中触发的事件,包含一个 clipboardData 属性,该属性的类型为 DataTransferDataTransfer 对象用于存储数据的多种表示形式。

copy 事件中将数据写入剪贴板非常简单:

 document.addEventListener("copy", (e) => {
     e.preventDefault();
     e.clipboardData.setData("text/plain", "Hello, world");
     e.clipboardData.setData("text/html", "Hello, <em>world</em>");
 });

同样地,在 paste 事件中从剪贴板读取数据也很简单:

 document.addEventListener("paste", (e) => {
     e.preventDefault();
     const html = e.clipboardData.getData("text/html");
     if (html) {
         // 处理 html 数据
     }
 });

现在最大的问题是:我们能将 JSON 数据写入剪贴板吗?

 document.addEventListener("copy", (e) => {
     e.preventDefault();
     const json = JSON.stringify({ message: "Hello" });
     e.clipboardData.setData("application/json", json);
 });

虽然没有抛出异常,但是否真正将 JSON 写入了剪贴板呢?让我们通过编写一个粘贴处理程序来验证:

 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 });
             });
         }
     }
 });

当我们添加这些处理程序并执行复制粘贴后,控制台打印出以下内容:

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

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

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

clipboardData 的历史

相对较新的异步剪贴板 API 于 2017 年被添加到规范中,而 clipboardData 则要比这早得多。2006 年的剪贴板 API 草案定义了 clipboardData 及其 setDatagetData 方法,当时并没有使用 MIME 类型。

setData() 需要一到两个参数。第一个参数必须设置为 'text''URL'(大小写不敏感)。
getData() 只接受一个参数,允许目标请求特定类型的数据。

但事实证明,《Web 应用框架指南》甚至比 2006 年的草案还要古老。请看下面这段来自 “本文档状态” 部分的引述:

这份文件在很大程度上描述了在 Internet Explorer 中实现的功能…
本文档的目的是... 明确当前浏览器中实际可行的功能,或者作为它们提高互操作性的简单目标,而不是添加新的功能。

实际上,clipboardData 甚至比 2006 年的草案还要早。2003 年一篇文章详细说明了当时如何在 Internet Explorer 4 及以上版本中,未经用户同意读取用户剪贴板内容。由于 Internet Explorer 4 于 1997 年发布,说明 clipboardData 至少有 26 年的历史了(截至本文撰写时)。

至今仍可以使用任意字符串作为 setDatagetData 的类型参数。以下代码可以正常工作:

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” 被记录到控制台中。

clipboardData 允许使用任意数据类型,原因似乎是历史原因 ——“不要破坏网络”。

重访 isTrusted

让我们回顾一下强制数据类型部分中的这句话:

为防范安全问题,不可信脚本只能将有限的数据类型写入剪贴板。

如果我们在不可信的合成剪贴板事件中尝试写入剪贴板会发生什么呢?

document.addEventListener("copy", (e) => {
     e.preventDefault();
     e.clipboardData.setData("text/plain", "Hello");
 });

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

代码运行成功,但并未修改剪贴板。这是预期的行为,正如规范中解释的那样:

合成的剪切和复制事件不得修改系统剪贴板中的数据。
合成的粘贴事件不得使脚本访问系统剪贴板中的数据。

因此,只有用户代理程序发送的事件才允许修改剪贴板。这很合理 —— 我们不希望网站随意读取我们的剪贴板内容并窃取密码。

总结一下我们目前的发现:

  • 2017 年引入的异步剪贴板 API 限制了可写入和读取剪贴板的数据类型。然而,前提是用户授予了权限并且文档处于焦点,异步 API 可以随时读取和写入剪贴板。

  • 较旧的剪贴板事件 API 对于可写入和读取剪贴板的数据类型几乎没有限制,但只能在由用户触发的复制和粘贴事件处理程序中使用(即 isTrusted 为 true)。

如果你想要将非标准数据类型写入剪贴板,使用剪贴板事件 API 似乎是唯一的选择。

但是如果你想创建一个 “复制” 按钮,并将非标准的数据类型写入剪贴板,该怎么办呢?如果用户没有触发复制事件,似乎就不能使用 “剪贴板事件” API,对吧?

创建一个可复制任意数据类型的按钮

我在不同的 web 应用中测试了复制按钮,并检查了它们向剪贴板写入的内容,发现了一些有趣的现象。

Google 文档在右键菜单中提供了一个复制按钮。

该按钮将以下三种表示形式写入剪贴板:

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

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

他们将自定义数据类型写入剪贴板,表明并没有使用异步剪贴板 API。那么他们是如何通过点击事件实现这一操作的呢?

我使用分析器跟踪点击该按钮的过程,发现它调用了 document.execCommand("copy")

这让我感到惊讶。我的第一反应是:"execCommand 不是已经弃用的 API 吗?"

确实如此,但 Google 使用它是有原因的。execCommand 的特殊之处在于,它允许您通过编程的方式触发一个可信的 “复制” 事件,就好像用户亲自执行了 “复制” 命令一样。

document.addEventListener("copy", (e) => {
     console.log("e.isTrusted 是 " + e.isTrusted);
 });

document.execCommand("copy");
 

注意:在 Safari 中,execCommand("copy") 需要有一个活动的文本选择才能触发复制事件。可以通过在 DOM 中添加一个非空输入元素并选择它来模拟该选择,调用 execCommand("copy") 后再将该输入元素移除。

好了,使用 execCommand 可以通过点击事件将任意数据类型写入剪贴板,非常酷!

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

创建一个粘贴按钮

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

在我的 Macbook 上,点击粘贴按钮后,出现了一个弹窗,提示我需要安装扩展程序才能使用该粘贴功能。

然而,奇怪的是,在我的 Windows 笔记本电脑上,粘贴按钮竟然直接有效。

这似乎很奇怪,这种不一致性是怎么回事呢?我们可以通过运行 queryCommandSupported("paste") 来检查是否支持粘贴按钮:

 document.queryCommandSupported("paste");

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

Safari 非常注重隐私,因此它要求我确认粘贴操作。我觉得这是一种非常好的做法,明确告知用户网站将从剪贴板中读取内容。

在我的 Windows 笔记本电脑上,Chrome 和 Edge 上的返回值为 true,而 Firefox 返回了 false。Chrome 的这种不一致让我很意外,为什么它在 Windows 上允许 execCommand("paste"),而在 macOS 上却不允许呢?我没能找到相关的解释。

我觉得奇怪的是,Google 没有尝试在 execCommand("paste") 不可用的情况下回退到异步剪贴板 API。虽然它无法读取 application/x-vnd.google-[...] 这种自定义数据类型的内容,但 HTML 表示形式中包含的内部 ID 完全可以派上用场,例如:

<meta charset="utf-8">
 <b id="docs-internal-guid-[guid]" style="...">
     <span style="...">复制的文本</span>
 </b>

另一个支持粘贴按钮的 Web 应用是 Figma,它采取了与 Google Docs 完全不同的方式。让我们看看他们是如何处理的。

Figma 中的复制和粘贴功能

Figma 是一个基于 Web 的应用程序(他们的桌面应用程序是基于 Electron 的)。我们来看看 Figma 的复制按钮向剪贴板写入了什么内容。

Figma 的复制按钮将两个表示形式写入了剪贴板:text/plaintext/html。这让我感到有些意外,Figma 如何使用普通的 HTML 来表示其复杂的布局和样式呢?

查看 HTML 后,我发现两个空的 span 元素,其中包含了 data-metadatadata-buffer 属性:

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

注意:data-buffer 字符串大约有 26,000 个字符,对于一个空的框架来说显得过长。而且随着被复制内容的增多,data-buffer 的长度似乎会线性增长。

这些内容看起来像 base64 编码。eyJ 开头表明 data-metadata 是一个 base64 编码的 JSON 字符串。通过使用 JSON.parse(atob()) 来解析该字符串,结果如下:

{
     "fileKey": "4XvKUK38NtRPZASgUJiZ87",
     "pasteID": 1261442360,
     "dataType": "scene"
 }

注意:这里的 fileKey 和 pasteID 都是我替换后的示例数据。

那么 data-buffer 中的大块数据又是什么呢?当我们对其进行 base64 解码后,结果如下:

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

看起来像是某种二进制格式。经过一些研究,我发现这个 fig-kiwiFigma 联合创始人和前 CTO Evan Wallace 创建的 Kiwi 消息格式,它用于编码 .fig 文件。

由于 Kiwi 是一种基于 schema 的格式,因此我们无法直接解析这些数据。不过,幸运的是,Evan 创建了一个公开的 .fig 文件解析器。我们可以试试将这些缓冲数据传入解析器中!

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

const base64 = "ZmlnL[...]P/Ag==";
 const blob = base64toBlob(base64, "application/octet-stream");
 console.log(URL.createObjectURL(blob));

然后我下载了生成的 blob 文件,并将其上传到 .fig 文件解析器中,成功读取了其中的内容。

因此,Figma 的复制操作实际上是通过创建一个小的 Figma 文件,将其编码为 base64 字符串,并将生成的 base64 字符串嵌入到 HTML 的 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 应用之间无缝复制粘贴元素。

浏览器会将自定义数据类型写入剪贴板中吗?

我们已经了解到,可以在浏览器标签页之间复制粘贴自定义数据类型,但不能跨应用程序。那么,当我们将自定义数据类型写入 Web 剪贴板时,浏览器具体会向操作系统剪贴板写入哪些内容呢?

我在 Macbook 上的各大浏览器中运行了以下代码并检查了剪贴板内容:

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,我们可以看到我们复制的数据:

我推测这里显示的带重音符号的 "î" 和不一致的换行符,可能是某些分隔符显示不正确导致的。

îapplication/json{"type":"Hello, world"} foo bar baz Hello, world

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

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

注意:Safari 允许在相同域的浏览器标签页之间复制粘贴自定义数据类型,但不允许跨域操作。而 Chrome 和 Firefox 似乎不存在这种限制(尽管 Chrome 也会存储来源 URL)。

Web 的原始剪贴板访问

原始剪贴板访问提案于 2019 年提出,该提案旨在为 Web 应用程序提供对操作系统剪贴板的原始读写访问权限。

chromestatus.com 上关于原始剪贴板访问功能的动机部分简明扼要地总结了其优势:

没有原始剪贴板访问权限,Web 应用程序通常只能处理有限的一小部分数据格式,无法与大量格式互操作。例如,Figma 和 Photopea 无法处理大多数图像格式。

然而,由于安全问题(如本地应用程序中的远程代码执行漏洞),原始剪贴板访问提案未能得到进一步推进。

最近关于向剪贴板写入自定义数据类型的提议是 Web 自定义格式提案(通常被称为 “序列化”)。

Web 自定义格式(Pickling)

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

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

const json = JSON.stringify({ message: "Hello, world" });
const jsonBlob = new Blob([json], { type: "application/json" });
const clipboardItem = new ClipboardItem({
 [`web ${jsonBlob.type}`]: jsonBlob,
});
navigator.clipboard.write([clipboardItem]);

这些自定义数据类型可以像其他数据类型一样通过异步剪贴板 API 读取:

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();
     }
 }

有趣的是,这些自定义格式会被写入到操作系统的剪贴板中。写入时,会生成以下内容:

  • 一个从数据类型到剪贴板条目的映射
  • 每个数据类型的剪贴板条目

在 macOS 上,映射会写入到 org.w3.web-custom-format.map,内容类似如下:

{
     "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 应用程序不能随意写入未经处理的数据到操作系统的剪贴板格式中。这种做法虽然解决了安全问题,但带来了互操作性方面的妥协,在 Pickling for Async Clipboard API 规范中明确指出:

非目标
允许与未更新的传统本地应用程序互操作。这在原始剪贴板访问提案中曾被探讨过,未来可能会进一步研究,但存在严重的安全挑战(如系统本地应用程序中的远程代码执行问题)。

这意味着本地应用程序需要进行更新,才能与使用 Web 自定义格式的 Web 应用程序进行剪贴板互操作。

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

最后的话

目前还没有一种能在所有浏览器中编写自定义数据类型并将其复制到剪贴板的有效方法。 Figma 采用将 base64 字符串放入 HTML 表示法的方法,虽然粗略但有效,因为它绕过了剪贴板 API 的众多限制。如果您需要通过剪贴板传输自定义数据类型,这种方法似乎是一个不错的选择。

我觉得 “Web Custom Formats” 提案很有前途,希望所有主流浏览器都能实现它。这似乎能让我们以安全且实用的方式在剪贴板中创建自定义数据类型。

关于我
loading