异步剪贴板 API

Safari 13.1 添加了对异步剪贴板 API 的支持。作为 Web 开发者,您可以使用此 API 从系统剪贴板读取和写入数据,并且与当前通过 DataTransfer 读取和写入剪贴板数据的技术相比,它提供了多项优势。让我们来看看这个新 API 是如何工作的。

API

剪贴板 API 引入了两个新对象

  • Clipboard,可通过 navigator.clipboard 访问,包含从系统剪贴板读取和写入数据的方法。
  • ClipboardItem,代表系统剪贴板上的一个单独项目,它可能有多种表示形式。目前,在读取和写入数据时,WebKit 支持四种 MIME 类型表示:"text/plain""text/html""text/uri-list""image/png"

从概念上讲,Clipboard 由一个或多个 ClipboardItem 的有序列表表示,每个 ClipboardItem 可以有多种类型表示形式。例如,当将多张 PNG 图像写入剪贴板时,您应该为每张图像创建一个 ClipboardItem,每个项目具有一个 "image/png" 类型表示。当写入一张带有某些 alt 文本的 PNG 图像时,您应该写入一个 ClipboardItem,它包含两种表示形式:"image/png""text/plain"

您可以使用 clipboard.read 从系统剪贴板提取数据;这会异步检索一个 ClipboardItem 数组,每个 ClipboardItem 都包含 MIME 类型到 Blob 的映射。类似地,clipboard.write 可用于将给定的 ClipboardItem 数组写入系统剪贴板。但是,如果您只想读取或写入纯文本,您可能会发现 clipboard.readTextclipboard.writeText 方法更符合人体工程学。

每个 ClipboardItem 还有一个 presentationStyle,它可能表示该项目最好表示为内联数据还是“附件”(即,文件状实体)。这种区分可能有助于区分网页上复制的文本选择和复制的 HTML 文件。

让我们深入了解下面的示例,看看如何以编程方式读取和写入数据。

写入数据

考虑这个基本示例,它实现了一个在点击时复制纯文本的按钮

<button id="new-copy">Copy text</button>
<script>
document.getElementById("new-copy").addEventListener("click", event => {
    navigator.clipboard.writeText("This text was copied programmatically.");
});
</script>

这比当前以编程方式复制文本的方法要简单得多,后者需要我们选择一个文本字段并执行“复制”命令

<button id="old-copy">Copy text</button>
<script>
document.getElementById("old-copy").addEventListener("click", event => {
    let input = document.createElement("input");
    input.style.opacity = "0";
    input.style.position = "fixed";
    input.value = "This text was also copied programmatically.";
    document.body.appendChild(input);

    input.focus();
    input.setSelectionRange(0, input.value.length);
    document.execCommand("Copy");

    input.remove();
});
</script>

当使用 clipboard.write 复制数据时,您需要创建一个 ClipboardItem 数组。每个 ClipboardItem 都使用 MIME 类型到 Promise 的映射进行初始化,该 Promise 可以解析为字符串或相同 MIME 类型的 Blob。以下示例使用 clipboard.write 复制一个同时包含纯文本和 HTML 表示的单个项目。

<button id="copy-html">Copy text and markup</button>
<div>Then paste in the box below:</div>
<div contenteditable spellcheck="false" style="width: 200px; height: 100px; overflow: hidden; border: 1px solid black;"></div>
<script>
document.getElementById("copy-html").addEventListener("click", event => {
    navigator.clipboard.write([
        new ClipboardItem({
            "text/plain": Promise.resolve("This text was copied using `Clipboard.prototype.write`."),
            "text/html": Promise.resolve("<p style='color: red; font-style: oblique;'>This text was copied using <code>Clipboard.prototype.write</code>.</p>"),
        }),
    ]);
});
</script>

使用现有 DataTransfer API 的类似实现将需要我们创建一个隐藏文本字段,在该文本字段上安装一个 copy 事件处理程序,使其获得焦点,触发编程复制,在 DataTransfer 上设置数据(在 copy 事件处理程序中),最后调用 preventDefault

请注意,clipboard.writeclipboard.writeText 都是异步的。如果您尝试在先前的剪贴板写入调用仍在挂起时写入剪贴板,则先前的调用将立即被拒绝,新内容将被写入剪贴板。

在 iOS 和 macOS 上,类型写入剪贴板的顺序也很重要。WebKit 按照指定的顺序将数据写入系统粘贴板——这意味着在其他类型之前的类型被系统视为具有“更高保真度”(即,保留更多原始内容)。macOS 和 iOS 上的原生应用程序在选择要读取的适当 UTI(通用类型标识符)时,可能会将此保真度顺序作为提示。

读取数据

数据提取遵循类似的流程。在以下示例中,我们

  1. 使用 clipboard.read 获取剪贴板项列表。
  2. 使用 clipboardItem.getType 将第一个项目的 "text/html" 数据解析为 Blob
  3. 使用 FileReader API 将 Blob 的内容作为文本读取。
  4. 通过设置容器 <div>innerHTML 来显示粘贴的标记。
<span style="font-weight: bold; background-color: black; color: white;">Select this text and copy</span>
<div><button id="read-html">Paste HTML below</button></div>
<div id="html-output"></div>
<script>
document.getElementById("read-html").addEventListener("click", async clickEvent => {
    let items = await navigator.clipboard.read();
    for (let item of items) {
        if (!item.types.includes("text/html"))
            continue;

        let reader = new FileReader;
        reader.addEventListener("load", loadEvent => {
            document.getElementById("html-output").innerHTML = reader.result;
        });
        reader.readAsText(await item.getType("text/html"));
        break;
    }
});
</script>

这里有几点值得注意

  • 与写入数据一样,读取数据也是异步的;获取每个 ClipboardItem 和从 ClipboardItem 提取 Blob 的过程都返回 Promise。
  • 读取数据时会保留类型保真度。这意味着写入类型(无论是使用 iOS 和 macOS 上的系统 API,还是异步剪贴板 API)的顺序与从剪贴板读取时它们暴露的顺序相同。

安全与隐私

异步剪贴板 API 是一个强大的 Web API,能够向剪贴板写入任意数据,并从系统剪贴板读取。因此,允许页面向剪贴板写入数据存在严重的安全隐患,而允许页面从剪贴板读取数据则存在隐私隐患。显然,不受信任的 Web 内容不应在未经用户明确同意的情况下提取敏感数据(例如密码或地址)。其他漏洞不那么明显;例如,考虑一个页面向粘贴板写入包含恶意脚本的 "text/html"。当粘贴到另一个网站时,这可能导致跨站点脚本攻击!

WebKit 对异步剪贴板 API 的实现通过多种机制缓解了这些问题。

  • 该 API 限于安全上下文,这意味着 navigator.clipboard 不适用于 http:// 网站。
  • 写入剪贴板的请求必须在用户手势期间触发。在用户手势(例如 "click""touch" 事件处理程序)范围之外调用 clipboard.writeclipboard.writeText 将导致 API 调用返回的 Promise 立即被拒绝。
  • "text/html""image/png" 数据在写入粘贴板之前都会进行清理。标记会在禁用 JavaScript 的单独文档中加载,然后仅从该页面提取可见内容。诸如 <script> 元素、注释节点、display: none; 元素和事件处理程序属性都将被删除。对于 PNG 图像,图像数据首先被解码为平台图像表示,然后再重新编码并发送到平台粘贴板进行写入。这确保了网站无法向粘贴板写入损坏或残缺的图像。如果图像数据无法解码,写入 Promise 将被拒绝。有关 WebKit 清理机制的更多信息,请参阅 剪贴板 API 改进博客文章。
  • 由于用户可能并非总是意识到敏感内容已被复制到粘贴板,因此对读取能力的限制比对写入能力的限制更严格。如果页面尝试在用户手势之外以编程方式从粘贴板读取,Promise 将立即被拒绝。如果用户在手势期间明确触发粘贴(例如,在 macOS 上使用键盘快捷键如 ⌘V,或在 iOS 上使用呼出栏上的“粘贴”操作进行粘贴),WebKit 将允许页面以编程方式读取剪贴板内容。如果系统剪贴板的内容是由具有相同安全来源的页面写入的,则也会自动授予编程剪贴板访问权限。如果上述两者都不成立,WebKit 将显示平台特定的 UI,用户可以与之交互以进行粘贴。在 iOS 上,这表现为带有单个粘贴选项的呼出栏;在 macOS 上,它是一个上下文菜单项。点击或单击页面中的任何位置(或执行任何其他操作,例如切换标签页或隐藏 Safari)将导致 Promise 被拒绝;仅当用户通过与平台特定 UI 交互手动选择粘贴时,页面才被授予对剪贴板的编程访问权限。
  • 类似于写入数据,从系统剪贴板读取数据涉及清理,以防止用户在不知情的情况下暴露敏感信息。从剪贴板读取的图像数据会剥离 EXIF 数据,其中可能包含位置信息和名称等详细信息。同样,从剪贴板读取的标记也会剥离隐藏内容,例如注释节点。

这些策略确保异步剪贴板 API 允许开发者提供出色的体验,而不会因滥用而损害用户的安全性或隐私。

未来工作

随着我们继续迭代异步剪贴板 API,我们将添加对自定义粘贴板类型的支持,并考虑支持其他 MIME 类型,例如 "image/jpeg""image/svg+xml"。一如既往,如果您遇到任何错误(或有未来增强功能的想法),请通过在 bugs.webkit.org 上提交错误来告知我们。