剪贴板与通知 API

深入理解 Clipboard API 和 Notification API 的完整功能、安全考虑、以及在现代 Web 应用中的实际应用。

剪贴板与通知 API(Clipboard and Notification APIs)

一、Clipboard API

1.1 剪贴板 API 概述

Clipboard API 提供了读写系统剪贴板的能力。现代浏览器出于安全考虑,要求剪贴板操作必须在用户交互(如点击事件)的上下文中触发,并且需要用户授权。

// 检测剪贴板 API 支持
if ('clipboard' in navigator) {
  console.log('Clipboard API supported');
}

1.2 读取剪贴板

// 读取文本
async function readClipboardText() {
  try {
    const text = await navigator.clipboard.readText();
    console.log('Clipboard text:', text);
    return text;
  } catch (error) {
    console.error('Failed to read clipboard:', error);
  }
}

// 读取图片(需要权限)
async function readClipboardImage() {
  try {
    const items = await navigator.clipboard.read();

    for (const item of items) {
      for (const type of item.types) {
        if (type.startsWith('image/')) {
          const blob = await item.getType(type);
          return URL.createObjectURL(blob);
        }
      }
    }
  } catch (error) {
    console.error('Failed to read image:', error);
  }
}

// 点击事件中读取
document.getElementById('pasteBtn').addEventListener('click', async () => {
  const text = await navigator.clipboard.readText();
  console.log('Pasted:', text);
});

1.3 写入剪贴板

// 写入文本
async function copyToClipboard(text) {
  try {
    await navigator.clipboard.writeText(text);
    console.log('Copied to clipboard');
  } catch (error) {
    console.error('Failed to copy:', error);
  }
}

// 写入富文本
async function copyRichText(html, plainText) {
  try {
    const blob = new Blob([html], { type: 'text/html' });
    const textBlob = new Blob([plainText], { type: 'text/plain' });

    const item = new ClipboardItem({
      'text/html': blob,
      'text/plain': textBlob
    });

    await navigator.clipboard.write([item]);
    console.log('Rich text copied');
  } catch (error) {
    console.error('Failed to copy rich text:', error);
  }
}

// 写入图片
async function copyImage(imageUrl) {
  try {
    const response = await fetch(imageUrl);
    const blob = await response.blob();

    const item = new ClipboardItem({
      [blob.type]: blob
    });

    await navigator.clipboard.write([item]);
    console.log('Image copied');
  } catch (error) {
    console.error('Failed to copy image:', error);
  }
}

二、Clipboard 事件

2.1 传统剪贴板事件

// 复制事件
document.addEventListener('copy', (e) => {
  console.log('Copy action detected');
  // 修改剪贴板内容
  e.preventDefault();  // 阻止默认行为后可以修改
  e.clipboardData.setData('text/plain', 'Custom copied text');
  e.clipboardData.setData('text/html', '<b>Custom copied text</b>');
});

// 剪切事件
document.addEventListener('cut', (e) => {
  console.log('Cut action detected');
  e.preventDefault();
  e.clipboardData.setData('text/plain', 'Cut text');
});

// 粘贴事件
document.addEventListener('paste', (e) => {
  console.log('Paste action detected');
  e.preventDefault();
  const text = e.clipboardData.getData('text/plain');
  console.log('Pasted text:', text);
});

2.2 与新 API 的对比

特性 传统事件 Async Clipboard API
数据类型 字符串 字符串、Blob、File
异步操作 不支持 完全支持
权限模型 需要明确授权
可靠性 同步但可能有延迟 Promise-based

三、Notification API

3.1 发送通知

// 检测支持
if ('Notification' in window) {
  console.log('Notifications supported');
}

// 检查权限
console.log('Permission:', Notification.permission);
// 'granted' - 已授权
// 'denied' - 已拒绝
// 'default' - 未决定

// 请求权限
async function requestNotificationPermission() {
  const permission = await Notification.requestPermission();
  console.log('Permission:', permission);
  return permission;
}

// 发送通知
function showNotification(title, options = {}) {
  if (Notification.permission !== 'granted') {
    console.error('Notification permission not granted');
    return;
  }

  const notification = new Notification(title, {
    body: options.body || '',
    icon: options.icon || '/icon.png',
    tag: options.tag || '',  // 用于分组和替换
    requireInteraction: options.requireInteraction || false,
    data: options.data || {},
    ...options
  });

  // 监听点击
  notification.addEventListener('click', (e) => {
    console.log('Notification clicked');
    // 打开窗口或聚焦
    window.focus();
    notification.close();
  });

  // 监听关闭
  notification.addEventListener('close', (e) => {
    console.log('Notification closed');
  });

  return notification;
}

3.2 通知选项详解

// 完整选项
const options = {
  body: '通知正文内容',
  icon: '/icon.png',
  badge: '/badge.png',  // 状态栏小图标
  tag: 'notification-id',  // 唯一标识,用于去重
  renotify: true,  // 是否在已有 tag 时重新通知
  requireInteraction: true,  // 是否保持直到用户交互
  silent: false,  // 是否静音
  vibrate: [200, 100, 200],  // 振动模式
  timestamp: Date.now(),  // 时间戳
  lang: 'zh-CN',  // 语言
  dir: 'ltr',  // 文字方向
  data: { url: '/some-page' }  // 自定义数据
};

四、Service Worker 中的通知

4.1 后台通知

// sw.js
self.addEventListener('push', (event) => {
  const data = event.data ? event.data.json() : {};

  const options = {
    body: data.body || 'New notification',
    icon: data.icon || '/icon.png',
    badge: data.badge || '/badge.png',
    tag: data.tag || 'default',
    data: data.data || {},
    actions: data.actions || [],
    requireInteraction: data.requireInteraction || false
  };

  event.waitUntil(
    self.registration.showNotification(data.title || 'Notification', options)
  );
});

// 点击通知
self.addEventListener('notificationclick', (event) => {
  event.notification.close();

  const url = event.notification.data.url || '/';

  event.waitUntil(
    clients.matchAll({ type: 'window', includeUncontrolled: true })
      .then((clientList) => {
        // 如果有窗口则聚焦,否则打开新窗口
        for (const client of clientList) {
          if (client.url === url && 'focus' in client) {
            return client.focus();
          }
        }
        return clients.openWindow(url);
      })
  );
});

4.2 定时通知

// 安排定时通知
async function scheduleNotification(title, options, delay = 0) {
  // 在 Service Worker 中使用
  const now = Date.now();
  const timestamp = now + delay;

  // 使用 Web Push 或后台同步来实现
  // 这里只是简单示例

  setTimeout(() => {
    showNotification(title, options);
  }, delay);
}

五、综合示例:剪贴板 + 通知

5.1 复制成功通知

class ClipboardNotifier {
  constructor() {
    this.init();
  }

  init() {
    document.addEventListener('click', (e) => {
      const copyButton = e.target.closest('[data-copy]');
      if (copyButton) {
        this.handleCopy(copyButton);
      }
    });
  }

  async handleCopy(element) {
    const text = element.dataset.copy || element.textContent;

    try {
      await navigator.clipboard.writeText(text);
      this.showNotification('已复制', {
        body: text.slice(0, 50) + (text.length > 50 ? '...' : ''),
        tag: 'copy-success',
        silent: true
      });
    } catch (error) {
      console.error('Copy failed:', error);
    }
  }

  showNotification(title, options) {
    if (Notification.permission === 'granted') {
      new Notification(title, options);
    } else if (Notification.permission !== 'denied') {
      Notification.requestPermission().then(permission => {
        if (permission === 'granted') {
          new Notification(title, options);
        }
      });
    }
  }
}

// 使用
// <button data-copy="要复制的内容">复制</button>
const notifier = new ClipboardNotifier();

六、安全考虑

6.1 权限请求

// 始终在用户交互后请求权限
async function safeRequestPermission() {
  // 不要在页面加载时自动请求
  // 等待用户点击或交互

  if (Notification.permission === 'default') {
    const btn = document.getElementById('enableNotifications');
    btn.addEventListener('click', async () => {
      const permission = await Notification.requestPermission();
      console.log('Permission:', permission);
    });
  }
}

6.2 恶意使用风险

// 恶意网站可能利用剪贴板读取敏感信息
// 传统 paste 事件可以被滥用

document.addEventListener('paste', (e) => {
  // 不要在这里记录或上传剪贴板内容
  // 这可能被用于钓鱼攻击
});

// 最佳实践:只读取用户明确触发的剪贴板操作

参考资料

延展阅读