Clipboard API 实战:浏览器剪贴板读写与安全策略

技术架构(更新于 2026年6月3日)

Clipboard API:现代剪贴板交互标准

浏览器剪贴板操作经历了从 document.execCommandAsync Clipboard API 的演进:

对比项 execCommand('copy') Async Clipboard API
标准 已废弃 W3C 推荐
异步 否(同步阻塞) 是(Promise)
权限 隐式(需用户操作) 显式 Permissions API
富文本 不支持 支持(ClipboardItem)
自定义格式 不支持 支持(web custom formats)
图片读写 不支持 支持
可靠性 依赖选区 直接操作剪贴板

核心 API 详解

Clipboard API 接口

// Navigator.clipboard 主接口
interface Clipboard extends EventTarget {
  read(): Promise<ClipboardItem[]>;
  readText(): Promise<string>;
  write(data: ClipboardItem[]): Promise<void>;
  writeText(text: string): Promise<void>;
}

// ClipboardItem 表示一条剪贴板数据
interface ClipboardItem {
  readonly types: string[];
  getType(type: string): Promise<Blob>;
}

文本读写

// 写入文本
async function copyText(text: string) {
  try {
    await navigator.clipboard.writeText(text);
    showToast('已复制到剪贴板');
  } catch (err) {
    console.error('复制失败:', err);
    fallbackCopyText(text);
  }
}

// 读取文本
async function pasteText(): Promise<string> {
  try {
    const text = await navigator.clipboard.readText();
    return text;
  } catch (err) {
    console.error('读取失败:', err);
    return '';
  }
}

富文本与多格式写入

ClipboardItem 多格式

async function copyRichText(html: string, plainText: string) {
  const clipboardItem = new ClipboardItem({
    'text/html': new Blob([html], { type: 'text/html' }),
    'text/plain': new Blob([plainText], { type: 'text/plain' }),
  });
  await navigator.clipboard.write([clipboardItem]);
}

// 使用示例
copyRichText(
  '<strong>重要通知</strong>:请于 <em>周五</em> 前提交',
  '重要通知:请于周五前提交'
);

图片复制

async function copyImage(pngBlob: Blob) {
  await navigator.clipboard.write([
    new ClipboardItem({ 'image/png': pngBlob })
  ]);
}

// 从 Canvas 复制
async function copyCanvas(canvas: HTMLCanvasElement) {
  const blob = await new Promise<Blob>((resolve) => {
    canvas.toBlob((b) => resolve(b!), 'image/png');
  });
  await navigator.clipboard.write([
    new ClipboardItem({ 'image/png': blob })
  ]);
}

图片粘贴

document.addEventListener('paste', async (e) => {
  const items = e.clipboardData?.items;
  if (!items) return;

  for (const item of items) {
    if (item.type.startsWith('image/')) {
      const blob = item.getAsFile()!;
      const url = URL.createObjectURL(blob);
      const img = document.createElement('img');
      img.src = url;
      document.body.appendChild(img);
    }
  }
});

自定义 MIME 类型(Web Custom Formats)

async function copyCustomData(data: object) {
  const json = JSON.stringify(data);
  await navigator.clipboard.write([
    new ClipboardItem({
      'web application/json': new Blob([json], { type: 'application/json' }),
      'text/plain': new Blob([json], { type: 'text/plain' }),
    })
  ]);
}

async function pasteCustomData(): Promise<object | null> {
  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 text = await blob.text();
      return JSON.parse(text);
    }
  }
  return null;
}

web 前缀是自定义格式的安全要求,防止网页读取原生剪贴板格式。


权限与安全策略

Permissions API

async function checkClipboardPermission(): Promise<boolean> {
  try {
    const result = await navigator.permissions.query({
      name: 'clipboard-read' as PermissionName
    });
    return result.state === 'granted';
  } catch {
    // 部分浏览器不支持 clipboard-read 权限查询
    return false;
  }
}

安全限制一览

操作 要求 说明
writeText 用户激活 需 click/keydown 等用户手势
write 用户激活 同上
readText 用户激活 + 权限 需用户授权弹窗
read 用户激活 + 权限 需用户授权弹窗
焦点窗口 文档需聚焦 失焦窗口调用会拒绝

用户激活检查

// 确保在用户交互中调用
button.addEventListener('click', async () => {
  // ✅ 在 click 事件中,有用户激活
  await navigator.clipboard.writeText('copied!');
});

// ❌ 定时器中没有用户激活
setTimeout(async () => {
  await navigator.clipboard.writeText('fails'); // DOMException
}, 1000);

降级方案:execCommand

function fallbackCopyText(text: string): boolean {
  const textarea = document.createElement('textarea');
  textarea.value = text;
  textarea.style.position = 'fixed';
  textarea.style.left = '-9999px';
  document.body.appendChild(textarea);
  textarea.select();

  try {
    const success = document.execCommand('copy');
    document.body.removeChild(textarea);
    return success;
  } catch {
    document.body.removeChild(textarea);
    return false;
  }
}

// 统一封装
async function safeCopy(text: string) {
  if (navigator.clipboard?.writeText) {
    try {
      await navigator.clipboard.writeText(text);
      return true;
    } catch {
      // 降级
    }
  }
  return fallbackCopyText(text);
}

paste 事件深度处理

拦截与转换粘贴内容

editor.addEventListener('paste', (e) => {
  e.preventDefault();
  const clipboardData = e.clipboardData!;

  // 优先处理 HTML
  const html = clipboardData.getData('text/html');
  if (html) {
    const sanitized = sanitizeHTML(html);
    insertHTML(sanitized);
    return;
  }

  // 纯文本
  const text = clipboardData.getData('text/plain');
  if (text) {
    // 检测 URL 自动链接
    const linked = text.replace(
      /https?:\/\/\S+/g,
      (url) => `<a href="${url}">${url}</a>`
    );
    insertHTML(linked);
  }
});

剪贴板数据类型检测

editor.addEventListener('paste', (e) => {
  const data = e.clipboardData!;
  console.log('类型列表:', [...data.types]);

  // 常见类型优先级
  const priorityOrder = [
    'text/html',
    'application/json',
    'text/plain',
    'image/png',
  ];

  for (const type of priorityOrder) {
    if (data.types.includes(type)) {
      const content = data.getData(type);
      console.log(`粘贴 ${type}:`, content.slice(0, 100));
      break;
    }
  }
});

实战:复制按钮组件

class CopyButton {
  private timeoutId: number | null = null;

  constructor(private button: HTMLButtonElement, private getText: () => string) {
    button.addEventListener('click', () => this.handleCopy());
  }

  private async handleCopy() {
    const text = this.getText();
    const success = await safeCopy(text);

    if (success) {
      this.showFeedback('已复制!');
    } else {
      this.showFeedback('复制失败');
    }
  }

  private showFeedback(message: string) {
    const original = this.button.textContent;
    this.button.textContent = message;
    this.button.disabled = true;

    if (this.timeoutId) clearTimeout(this.timeoutId);
    this.timeoutId = window.setTimeout(() => {
      this.button.textContent = original;
      this.button.disabled = false;
    }, 2000);
  }
}

浏览器兼容性

API Chrome Firefox Safari Edge
writeText 66+ 63+ 13.1+ 79+
readText 66+ 63+ 13.1+ 79+
write (ClipboardItem) 76+ 87+ 13.1+ 79+
read 76+ 87+ 13.1+ 79+
Web Custom Formats 104+ 不支持 不支持 104+
图片剪贴板 76+ 87+ 13.1+ 79+

最佳实践

  1. 始终提供降级execCommand 作为 Clipboard API 不可用时的后备
  2. 用户激活内调用:复制操作必须在 click/keydown 等事件中触发
  3. 反馈必做:复制成功/失败都应给出视觉反馈
  4. 读取需谨慎read() 涉及隐私,仅在必要时请求
  5. sanitize 粘贴内容:HTML 粘贴必须过滤 XSS 风险

总结

Async Clipboard API 是现代浏览器剪贴板操作的标准方案,支持文本、富文本、图片和自定义格式的读写。相比 execCommand,它更可靠、更安全、功能更强大,但需注意用户激活要求和权限模型。

推荐使用 剪贴板查看器 调试剪贴板内容,文本替换 批量处理粘贴文本,大小写转换 规范化剪贴板文本。

本站提供浏览器本地工具,免注册即可试用 →

#Clipboard API#剪贴板#异步API#浏览器安全#用户交互