如果您已经使用电脑一段时间,您可能知道剪贴板可以存储多种类型的数据(图像、富文本内容、文件等)。作为一名软件开发人员,我开始感到沮丧,因为我对剪贴板如何存储和组织不同类型的数据没有很好的理解。
我最近决定揭开剪贴板的神秘面纱,并利用我的学习成果写了这篇文章。我们将重点介绍网络剪贴板及其 API,但我们也会涉及它如何与操作系统剪贴板交互。
我们首先会探索 Web 的剪贴板 API 及其历史。剪贴板 API 在数据类型方面有一些有趣的限制,我们将了解一些公司如何解决这些限制。我们还将研究一些旨在解决这些限制的提案(最值得注意的是Web 自定义格式)。
如果你曾经好奇过网络剪贴板是如何工作的,那么这篇文章正适合你。
使用异步剪贴板 API
如果我从网站复制一些内容并将其粘贴到 Google Docs 中,则它的一些格式会保留,例如链接、字体大小和颜色。
但是如果我打开 VS Code 并将其粘贴到那里,则只会粘贴原始文本内容。
剪贴板通过允许将信息存储在与 MIME 类型相关的多种表示中来满足这两种用例。W3C 剪贴板规范规定,要写入和读取剪贴板,必须支持以下三种数据类型:
text/plain
用于纯文本。text/html
用于 HTML。image/png
对于 PNG 图像。
因此,当我之前粘贴时,Google Docs 会读取text/html
表示并使用它来保留富文本格式。VS Code 只关心原始文本并读取text/plain
表示,有道理。
通过异步剪贴板 API 的方法读取特定read
表示非常简单:
javascript
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();
// Do stuff with HTML...
}
}
通过 向剪贴板写入多个表示write
有点复杂,但仍然相对简单。首先,我们Blob
为要写入剪贴板的每个表示构造:
javascript
const textBlob = new Blob(["Hello, world"], { type: "text/plain" });
const htmlBlob = new Blob(["Hello, <em>world<em>"], { type: "text/html" });
一旦我们有了 Blob,我们就将它们传递给ClipboardItem
键值存储中的新对象,其中数据类型作为键,Blob 作为值:
javascript
const clipboardItem = new ClipboardItem({
[textBlob.type]: textBlob,
[htmlBlob.type]: htmlBlob,
});
注意:我喜欢ClipboardItem
接受键值存储。它与使用使非法状态无法表示的数据结构的想法非常吻合,如Parse, don't valid中所述。
最后,我们write
用新构造的来调用ClipboardItem
:
javascript
await navigator.clipboard.write([clipboardItem]);
那么其他数据类型怎么样?
HTML 和图像很酷,但像 JSON 这样的通用数据交换格式怎么样?如果我正在编写一个支持复制粘贴的应用程序,我可以想象想要将 JSON 或一些二进制数据写入剪贴板。
让我们尝试将 JSON 数据写入剪贴板:
javascript
// Create JSON blob
const json = JSON.stringify({ message: "Hello" });
const blob = new Blob([json], { type: "application/json" });
// Write JSON blob to clipboard
const clipboardItem = new ClipboardItem({ [blob.type]: blob });
await navigator.clipboard.write([clipboardItem]);
运行此程序时,会引发异常:
javascript
Failed to execute 'write' on 'Clipboard':
Type application/json not supported on write.
嗯,这是怎么回事?好吧,的规范write
告诉我们,除text/plain
、text/html
和之外的数据类型image/png
必须被拒绝:
如果_类型_不在强制数据类型列表中,则拒绝[...]并中止这些步骤。
有趣的是, MIME 类型在2012 年至2021 年application/json
期间一直位于强制数据类型列表中,但在w3c/clipboard-apis#155中从规范中删除。在此更改之前,强制数据类型列表要长得多,有 16 种强制数据类型用于从剪贴板读取,8 种强制数据类型用于写入剪贴板。更改后,只剩下、和。text/plain``text/html``image/png
由于安全问题,浏览器选择不支持许多强制类型,因此进行了此更改。规范中强制数据类型部分的警告反映了这一点:
警告!出于安全考虑,不受信任的脚本可以写入剪贴板的数据类型受到限制。
不受信任的脚本可以尝试通过将已知会触发漏洞的数据放在剪贴板上来利用本地软件中的安全漏洞。
_好的,所以我们只能将一组有限的数据类型写入剪贴板。但是"不受信任的_脚本"又是怎么回事呢?我们能否以某种方式运行"受信任"脚本中的代码,从而允许我们将其他数据类型写入剪贴板?
isTrusted 属性
也许"受信任"部分是指isTrusted事件的属性。isTrusted
是一个只读属性,只有当事件由用户代理分派时才设置为 true。
javascript
document.addEventListener("copy", (e) => {
if (e.isTrusted) {
// This event was triggered by the user agent
}
})
"由用户代理发送"意味着它是由用户触发的,例如用户按下命令 C
。这与通过以下方式编程分派的合成事件形成对比dispatchEvent()
:
javascript
document.addEventListener("copy", (e) => {
console.log("e.isTrusted is " + e.isTrusted);
});
document.dispatchEvent(new ClipboardEvent("copy"));
//=> "e.isTrusted is false"
让我们看看剪贴板事件,看看它们是否允许我们将任意数据类型写入剪贴板。
剪贴板事件 API
ClipboardEvent
为复制、剪切和粘贴事件调度A ,它包含一个clipboardData
类型的属性DataTransfer
。DataTransfer
剪贴板事件 API 使用该对象来保存数据的多种表示形式。
在事件中写入剪贴板copy
非常简单:
javascript
document.addEventListener("copy", (e) => {
e.preventDefault(); // Prevent default copy behavior
e.clipboardData.setData("text/plain", "Hello, world");
e.clipboardData.setData("text/html", "Hello, <em>world</em>");
});
在事件中从剪贴板读取也paste
同样简单:
javascript
document.addEventListener("paste", (e) => {
e.preventDefault(); // Prevent default paste behavior
const html = e.clipboardData.getData("text/html");
if (html) {
// Do stuff with HTML...
}
});
现在要回答一个大问题:我们可以将 JSON 写入剪贴板吗?
javascript
document.addEventListener("copy", (e) => {
e.preventDefault();
const json = JSON.stringify({ message: "Hello" });
e.clipboardData.setData("application/json", json); // No error
});
没有抛出异常,但这真的将 JSON 写入剪贴板了吗?让我们通过编写一个粘贴处理程序来验证这一点,该处理程序遍历剪贴板中的所有条目并将其记录下来:
javascript
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 });
});
}
}
});
添加这两个处理程序并调用复制粘贴将导致记录以下内容:
javascript
{ "type": "application/json", content: "{\"message\":\"Hello\"}" }
成功了!看起来它clipboardData.setData
不像异步方法那样限制数据类型write
。
但是...为什么?为什么我们可以用 读写任意数据类型,clipboardData
但使用异步剪贴板 API 时却不行?
历史clipboardData
相对较新的异步剪贴板 API 于2017 年添加到规范中,但比这clipboardData
要早_得多。__ _2006 年的剪贴板 API 的 W3C 草案定义了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
至今仍然有效。以下操作完全没问题:
javascript
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); // Logs "Hello, world!"
}
});
如果您将此代码片段粘贴到您的 DevTools 中,然后点击复制并粘贴,您将看到消息"Hello, world"记录到您的控制台中。
剪贴板事件 API 允许我们使用任何数据类型的原因clipboardData
似乎有历史原因。"不要破坏网络"。
重温 isTrusted
让我们再次考虑一下强制数据类型部分中的这句话:
为了安全预防措施,不受信任的脚本可以写入剪贴板的数据类型受到限制。
那么,如果我们尝试在合成(不受信任的)剪贴板事件中写入剪贴板,会发生什么?
javascript
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 或图像的数据类型写入剪贴板,似乎使用 Clipboard Events API 是唯一的出路。在这方面,它的限制要少得多。
但是,如果您想构建一个将非标准数据类型写入剪贴板的"复制"按钮,该怎么办?如果用户未触发复制事件,您似乎无法使用剪贴板事件 API。对吗?
构建一个可写入任意数据类型的复制按钮
我尝试了不同的 Web 应用程序中的"复制"按钮,并检查了写入剪贴板的内容。结果很有趣。
Google Docs 有一个复制按钮,可以在其右键菜单中找到。
此复制按钮将三种表示写入剪贴板:
text/plain
,text/html
, 和application/x-vnd.google-docs-document-slice-clip+wrapped
注意:第三种表示包含 JSON 数据。
他们正在向剪贴板写入自定义数据类型,这意味着他们没有使用异步剪贴板 API。他们如何通过点击处理程序做到这一点?
我运行了分析器,点击了复制按钮,然后检查了结果。结果发现,点击复制按钮会触发对 的调用document.execCommand("copy")
。
这让我很惊讶。我的第一个想法是_"这不是__execCommand_
旧的、过时的将文本复制到剪贴板的方法吗?"。
是的,但是谷歌使用它是有原因的。它execCommand
的特别之处在于它允许您以编程方式分派受信任的复制事件,_就好像_用户自己调用了复制命令一样。
javascript
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")
:
javascript
document.queryCommandSupported("paste");
在我的 Macbook 上,我使用的false
是 Chrome 和 Firefox,但true
使用的是 Safari。
Safari 非常注重隐私,要求我确认粘贴操作。我认为这是个好主意。它明确表明该网站将从您的剪贴板读取内容。
在我的 Windows 笔记本电脑上,我使用了true
Chrome 和 Edge,但false
使用的是 Firefox。Chrome 的不一致令人惊讶。为什么 ChromeexecCommand("paste")
在 Windows 上允许,但在 macOS 上却不允许?我找不到有关此问题的任何信息。
令我惊讶的是,Google 在不可用时不会尝试回退到异步 Clipboard API execCommand("paste")
。尽管他们无法application/x-vnd.google-[...]
使用该 API 读取表示,但 HTML 表示包含可以使用的内部 ID。
javascript
<!-- HTML representation, cleaned up -->
<meta charset="utf-8">
<b id="docs-internal-guid-[guid]" style="...">
<span style="...">Copied text</span>
</b>
另一个带有粘贴按钮的 Web 应用程序是 Figma,他们采用了完全不同的方法。让我们看看他们在做什么。
在 Figma 中复制和粘贴
Figma 是一个基于 Web 的应用程序(其原生应用程序使用Electron)。让我们看看他们的复制按钮会写入剪贴板的内容。
Figma 的复制按钮将两种表示形式写入剪贴板:text/plain
和text/html
。这起初让我很惊讶。Figma 如何用纯 HTML 表示它们的各种布局和样式功能?
但查看 HTML,我们看到两个具有和属性的空span
元素:data-metadata``data-buffer
javascript
<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;">Text</span>
注意:对于空框架,字符串data-buffer
约为 26,000 个字符。此后,长度data-buffer
似乎随着复制的内容量线性增长。
看起来像是 base64。eyJ
开头清楚地表明这data-metadata
是 base64 编码的 JSON 字符串。运行结果JSON.parse(atob())
为data-metadata
:
javascript
{
"fileKey": "4XvKUK38NtRPZASgUJiZ87",
"pasteID": 1261442360,
"dataType": "scene"
}
注意:我已替换真实的fileKey
和pasteID
。
但是大data-buffer
属性怎么办?Base64 解码后结果如下:
javascript
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:
javascript
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 字符串放入data-buffer
空 HTMLspan
元素的属性中,然后将其存储在用户的剪贴板中。
复制粘贴 HTML 的好处
乍一看,我觉得这有点愚蠢,但采用这种方法确实有很大好处。要理解原因,请考虑基于 Web 的剪贴板 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 元素figma.com
,然后将其粘贴到本机 Figma 应用程序中,反之亦然。
对于自定义数据类型,浏览器会将什么内容写入剪贴板?
我们已经知道,我们可以跨浏览器选项卡向剪贴板写入自定义数据类型,也可以从剪贴板读取自定义数据类型,但不能跨应用程序写入。但是,当我们将自定义数据类型写入网络剪贴板时,浏览器究竟会向本机操作系统剪贴板写入什么?
copy
我在 Macbook 上每个主流浏览器的监听器中运行了以下命令:
javascript
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 存储在那里。
注意:如果源 URL(域)相同,Safari 允许跨浏览器选项卡复制粘贴自定义数据类型,但不允许跨不同域复制粘贴。Chrome 或 Firefox 似乎不存在此限制(即使 Chrome 会存储源 URL)。
Web 的原始剪贴板访问
2019 年提出了一项关于原始剪贴板访问的提案,该提案提出了一种 API,用于授予 Web 应用程序对本机操作系统剪贴板的原始读写访问权限。
chromestatus.com 上"原始剪贴板访问"功能的动机部分的摘录相当简洁地强调了该功能的好处:
如果没有原始剪贴板访问 [...],Web 应用程序通常只能使用一小部分格式,无法与大量格式互操作。例如,Figma 和 Photopea 无法与大多数图像格式互操作。
然而,由于对本机应用程序中的远程代码执行等漏洞的安全担忧,原始剪贴板访问提案最终没有得到进一步实施。
将自定义数据类型写入剪贴板的最新提案是 Web 自定义格式提案(通常称为 pickling)。
Web 自定义格式(Pickling)
2022 年,Chromium在异步剪贴板 API 中实现了对Web 自定义格式的支持。
它允许 Web 应用程序通过异步剪贴板 API 编写自定义数据类型,方法是在数据类型前加上前缀"web "
:
javascript
// Create JSON blob
const json = JSON.stringify({ message: "Hello, world" });
const jsonBlob = new Blob([json], { type: "application/json" });
// Write JSON blob to clipboard as a Web Custom Format
const clipboardItem = new ClipboardItem({
[`web ${jsonBlob.type}`]: jsonBlob,
});
navigator.clipboard.write([clipboardItem]);
与任何其他数据类型一样,使用异步剪贴板 API 读取这些内容:
javascript
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();
// Do stuff with JSON...
}
}
更有趣的是写入本机剪贴板的内容。在编写 Web 自定义格式时,以下内容会写入本机 OS 剪贴板:
- 从数据类型到剪贴板条目名称的映射
- 每种数据类型的剪贴板条目
在 macOS 上,映射写入org.w3.web-custom-format.map
且其内容如下所示:
javascript
{
"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 应用程序无法将未清理的数据写入他们想要的任何 OS 剪贴板格式。这带来了互操作性的权衡,这在Pickling for Async Clipboard API 规范中明确列出:
非目标
允许与旧版本机应用程序互操作,无需更新。这在原始剪贴板提案中进行了探索,将来可能会进一步探索,但带来了重大的安全挑战(系统本机应用程序中的远程代码执行)。
这意味着当使用自定义数据类型时,本机应用程序需要进行更新,以便与 Web 应用程序进行剪贴板互操作。
自 2022 年起,基于 Chromium 的浏览器已经支持 Web 自定义格式,但其他浏览器尚未实现此提案。
最后的话
到目前为止,还没有一种适用于所有浏览器的将自定义数据类型写入剪贴板的好方法。 Figma 将 base64 字符串放入 HTML 表示中的方法虽然粗糙,但很有效,因为它绕过了剪贴板 API 的众多限制。如果您需要通过剪贴板传输自定义数据类型,这似乎是一种不错的方法。
我发现 Web 自定义格式提案很有前景,希望所有主流浏览器都能实现它。它似乎能够以安全实用的方式将自定义数据类型写入剪贴板。
谢谢阅读!希望这篇文章很有趣。
--- 亚历克斯·哈里
附录:unsanitized
选项
通过异步剪贴板 API 从剪贴板读取数据时,浏览器可能会对数据进行清理。例如,浏览器可能会从 HTML 中删除可能存在危险的脚本标签,并可能对 PNG 图像进行重新编码以避免zip 炸弹攻击。
因此,异步剪贴板 API 的read
方法包含一个unsanitized
选项,允许您请求未清理的数据。您可以在Thomas Steiner的这篇文章中了解有关此选项及其工作原理的更多信息。
目前,该unsanitized
选项仅在基于 Chromium 的浏览器中受支持(于 2023 年底添加)。其他浏览器将来可能会支持此选项(尽管 Safari 似乎不太可能这样做,请参阅利益相关者的反馈/反对意见)。
谢谢 Tom 提出这个unsanitized
选项!它完全符合这篇文章的范围。