如果你使用计算机已经有一段时间了,你可能知道剪贴板可以存储多种类型的数据(图像、富文本内容、文件等)。作为一个软件开发者,我对自己对剪贴板如何存储和组织不同类型数据缺乏良好理解感到沮丧。
我最近决定揭开剪贴板的神秘面纱,并用我的学习成果写了这篇文章。我们将重点关注网络剪贴板及其API,尽管我们也会触及它如何与操作系统剪贴板交互。
我们将从探索网络剪贴板API及其历史开始。剪贴板API在数据类型方面有一些有趣的限制,我们将看看一些公司是如何绕过这些限制的。我们还将看看一些旨在解决这些限制的提议(最值得注意的是,Web Custom Formats)。
如果你曾经好奇网络剪贴板是如何工作的,这篇文章适合你。
使用异步剪贴板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();
// 处理HTML...
}
}
通过 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数据写入剪贴板:
// 创建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]);
运行这个时,抛出了一个异常:
Failed to execute 'write' on 'Clipboard':
Type application/json not supported on write.
嗯,这是怎么回事?好吧,write
的规范告诉我们,除了 text/plain
、text/html
和 image/png
之外的数据类型必须被拒绝:
如果 type 不在强制数据类型列表中,则拒绝[…]并中止这些步骤。
有趣的是,application/json
MIME类型从2012到2021都在强制数据类型列表中,但在w3c/clipboard-apis#155中从规范中被移除了。在那个变化之前,从剪贴板读取的强制数据类型列表有16个,写入剪贴板的有8个。变化之后,只剩下 text/plain
、text/html
和 image/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"));
//=> "e.isTrusted is false"
让我们看看剪贴板事件,看看它们是否允许我们将任意数据类型写入剪贴板。
剪贴板事件API
ClipboardEvent
为复制、剪切和粘贴事件分派,它包含一个类型为DataTransfer
的clipboardData
属性。DataTransfer
对象由剪贴板事件API用来保存数据的多种表示形式。
在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年的W3C剪贴板API草案定义了clipboardData
及其setData
和getData
方法(这表明当时还没有使用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()的类型参数,但建议坚持使用常见类型。
[问题] 我们应该列出一些“常见类型”吗?
即使在今天,使用任何字符串进行setData
和getData
仍然有效。这完全有效:
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!"
}
});
如果你将这段代码片段粘贴到你的DevTools中,然后执行复制粘贴,你将看到消息“Hello, world”被记录到你的控制台中。
剪贴板事件API的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没有真正限制可以写入和读取剪贴板的数据类型。然而,它只能在由用户代理触发的复制和粘贴事件处理程序中使用(即当
isTrusted
为true时)。
看起来,如果你想要的是将不仅仅是纯文本、HTML或图像的数据类型写入剪贴板,使用剪贴板事件API是唯一的出路。在这方面,它限制较少。
但是,如果你想构建一个复制按钮来写入非标准数据类型呢?看来如果你没有触发复制事件,你将无法使用剪贴板事件API,对吧?
构建一个复制按钮来写入任意数据类型
我去尝试了不同的网络应用程序中的复制按钮,并检查了写入剪贴板的内容。结果很有趣。
Google文档有一个复制按钮,可以在它们的右键菜单中找到。
这个复制按钮将三种表示形式写入剪贴板:
text/plain
,text/html
,和application/x-vnd.google-docs-document-slice-clip+wrapped
注意:第三种表示形式包含JSON数据。
他们将自定义数据类型写入剪贴板,这意味着他们没有使用异步剪贴板API。他们是如何通过点击处理程序做到这一点的?
我运行了分析器,点击了复制按钮,并检查了结果。原来,点击复制按钮会触发对document.execCommand("copy")
的调用。
这让我感到惊讶。我的第一个想法是"execCommand
不是过时的、弃用的将文本复制到剪贴板的方法吗?"。
是的,它是,但Google有它的理由。execCommand
是特别的,因为它允许你以编程方式分派一个受信任的复制事件,就好像用户自己调用了复制命令一样。
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文档中的粘贴按钮,看看它做了什么。
在我的Macbook上,我得到了一个弹出窗口,告诉我需要安装一个扩展来使用粘贴按钮。
但在我Windows笔记本上,粘贴按钮就可以直接工作。
奇怪。不一致性来自哪里呢?好吧,是否粘贴按钮会工作可以通过运行queryCommandSupported("paste")
来检查:
document.queryCommandSupported("paste");
在我的Macbook上,我在Chrome和Firefox上得到了false
,但在Safari上得到了true
。
Safari注重隐私,要求我确认粘贴操作。我认为这是个非常好的想法。它非常明确地表明网站将从你的剪贴板读取内容。
在我的Windows笔记本上,我在Chrome和Edge上得到了true
,但在Firefox上得到了false
。Chrome的不一致性令人惊讶。为什么Chrome允许在Windows上使用execCommand("paste")
,而在macOS上不允许?我找不到任何关于这个的信息。
我发现Google没有尝试在execCommand("paste")
不可用时回退到异步剪贴板API,这让我感到惊讶。即使他们无法使用它来读取application/x-vnd.google-[...]
表示形式,HTML表示形式包含内部ID,这些ID可以被使用。
<!-- 清理过的HTML表示形式 -->
<meta charset="utf-8">
<b id="docs-internal-guid-[guid]" style="...">
<span style="...">复制的文本</span>
</b>
另一个具有粘贴按钮的网络应用程序是Figma,他们采取了完全不同的方法。让我们看看他们在做什么。
Figma中的复制和粘贴
Figma是一个基于网络的应用程序(他们的原生应用程序使用Electron)。让我们看看他们的复制按钮将什么写入剪贴板。
Figma的复制按钮将两种表示形式写入剪贴板:text/plain
和text/html
。一开始这让我很惊讶。Figma如何用纯HTML表示他们的各种布局和样式特性?
但看了HTML后,我们看到两个空的span
元素,带有data-metadata
和data-buffer
属性:
<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())
得到:
{
"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-kiwi
作为线索——我发现这是Kiwi消息格式(由Figma的联合创始人和前CTO,Evan Wallace创建),用于编码.fig
文件。
由于Kiwi是一个基于模式的格式,似乎我们无法在不了解模式的情况下解析这些数据。然而,幸运的是,Evan创建了一个公共.fig
文件解析器。让我们尝试将缓冲区插入其中!
要将缓冲区转换为.fig
文件,我编写了一个小型脚本来生成Blob URL:
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的好处
一开始我觉得这有点傻,但这种方法有一个很强的优势。为了理解为什么,考虑网络基础的剪贴板API如何与各种操作系统剪贴板API交互。
Windows、macOS和Linux都提供了不同的格式来将数据写入剪贴板。如果你想将HTML写入剪贴板,Windows有CF_HTML
和macOS有NSPasteboard.PasteboardType.html
。
所有操作系统都为“标准”格式(纯文本、HTML和PNG图像)提供了类型。但是,当用户尝试将像application/foo-bar
这样的任意数据类型写入剪贴板时,浏览器应该使用哪种操作系统格式呢?
没有好的匹配,所以浏览器不会将该表示形式写入操作系统剪贴板的常见格式。相反,该表示形式只存在于操作系统剪贴板上的自定义浏览器特定剪贴板格式中。这使得可以在浏览器标签之间复制和粘贴任意数据类型,但不能跨应用程序。
这就是为什么使用常见的数据类型text/plain
、text/html
和image/png
如此方便。它们被映射到常见的操作系统剪贴板格式,因此可以被其他应用程序轻松读取,这使得复制/粘贴可以在应用程序之间工作。在Figma的情况下,使用text/html
使得可以从浏览器中的figma.com
复制Figma元素,然后将其粘贴到原生Figma应用程序中,反之亦然。
浏览器为自定义数据类型写入剪贴板的内容是什么?
我们已经了解到,我们可以在浏览器标签之间复制和粘贴自定义数据类型,但不能跨应用程序。但是,当我们将自定义数据类型写入网络剪贴板时,浏览器到底在本地操作系统剪贴板上写了什么?
我在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
,我们看到了我们复制的数据:
我猜想带重音的“î”和不一致的换行符是由于某些分隔符显示不正确的结果。
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允许在源URL(域名)相同的情况下,在浏览器标签之间复制和粘贴自定义数据类型,但在不同域名之间则不行。这种限制似乎在Chrome或Firefox中不存在(尽管Chrome存储了源URL)。
网络的原始剪贴板访问
2019年,提出了一个原始剪贴板访问提议,该提议提出了一个API,为网络应用程序提供对本地操作系统剪贴板的原始读写访问权限。
这个Motivation部分的摘录在chromestatus.com上关于原始剪贴板访问功能简洁地突出了其好处:
没有原始剪贴板访问[…]网络应用程序通常限于一小部分格式,并且无法与格式的长尾进行互操作。例如,Figma和Photopea无法与大多数图像格式互操作。
然而,由于安全问题,原始剪贴板访问提议最终没有进一步发展,比如远程代码执行在本地应用程序中的漏洞。
关于将自定义数据类型写入剪贴板的最新提议是Web Custom Formats提议(通常称为pickling)。
Web Custom Formats(Pickling)
2022年,Chromium在异步剪贴板API中实现了对Web Custom Formats的支持。
它允许网络应用程序通过在数据类型前缀"web "
来写入自定义数据类型:
// 创建JSON blob
const json = JSON.stringify({ message: "Hello, world" });
const jsonBlob = new Blob([json], { type: "application/json" });
// 将JSON blob作为Web Custom Format写入剪贴板
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();
// 处理JSON...
}
}
更有趣的是,写入Web Custom Formats时,以下内容被写入本地操作系统剪贴板:
- 从数据类型到剪贴板条目名称的映射
- 每个数据类型的剪贴板条目
在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[索引]
键对应于包含blob中未清理数据的操作系统剪贴板条目。这允许本地应用程序查看映射,看看是否有给定的表示形式,然后从相应的剪贴板条目中读取未清理的内容。
注意:Windows和Linux使用不同的命名约定用于映射和剪贴板条目。
这避免了原始剪贴板访问的安全问题,因为网络应用程序不能将未清理的数据写入他们想要的任何操作系统剪贴板格式。这带来了一个互操作性权衡,这在Pickling for Async Clipboard API规范中明确列出:
非目标
允许与旧版本地应用程序互操作,无需更新。这在原始剪贴板提议中已经探讨过,并且将来可能会进一步探讨,但带来了重大的安全挑战(在系统本地应用程序中远程代码执行)。
这意味着本地应用程序需要更新,以便在使用自定义数据类型时与网络应用程序进行剪贴板互操作。
Web Custom Formats自2022年以来已经在基于Chromium的浏览器中可用,但其他浏览器尚未实现这一提议。
结语
截至目前,还没有一个很好的方法可以将自定义数据类型写入剪贴板,使其在所有浏览器中都能工作。Figma的方法是将base64字符串放入HTML表示中,这种方法虽然粗糙但有效,因为它规避了剪贴板API周围的众多限制。如果你需要通过剪贴板传输自定义数据类型,这似乎是一个不错的方法。
我认为Web Custom Formats提议是有希望的,我希望它能够被所有主要浏览器实现。它似乎能够以安全和实用的方式将自定义数据类型写入剪贴板。