跳至主要內容
Skip to content

剪貼簿操作 (Clipboard API)

在網頁應用程式中,「複製到剪貼簿」是最常見的互動需求之一。從複製優惠碼、分享連結,到程式碼片段的取用,這個看似簡單的功能背後,其實經歷了瀏覽器 API 的重大演進。

本篇將帶你深入了解現代瀏覽器的 Clipboard API,並實作一個相容性極佳的 Vue 3 useClipboard 組合函式。


一、 剪貼簿 API 的演進

在過去,我們依賴 document.execCommand('copy') 來實作複製功能。雖然它相容性極佳,但它是同步操作(可能阻塞主執行緒),且使用起來相當繁瑣(需要先選取文字)。

現代瀏覽器推出了全新的 Async Clipboard API,提供更安全、非同步的剪貼簿存取方式。

1.1 新舊對比

特性execCommand (舊)Clipboard API (新)
語法document.execCommand('copy')navigator.clipboard.writeText()
執行緒同步 (Synchronous)非同步 (Asynchronous / Promise)
安全性較低需 HTTPS (localhost 除外)
權限自動允許 (由使用者操作觸發)寫入自動允許,讀取需授權
狀態已棄用 (Deprecated)推薦使用

1.2 為什麼需要降級方案 (Fallback)?

雖然 Clipboard API 是未來趨勢,但在某些舊版瀏覽器或非安全情境(HTTP)下可能無法使用。為了確保功能在所有環境下都能運作,我們的 useClipboard 必須具備「自動降級」的能力。


二、 設計 Composable 介面

我們參考 VueUse 的設計理念,定義 useClipboard 的介面:

typescript
// types/useClipboard.ts
import type { Ref, ComputedRef } from "vue";

export interface UseClipboardOptions {
  /**
   * 複製後的重置時間 (毫秒)
   * @default 1500
   */
  copiedDuring?: number;
}

export interface UseClipboardReturn {
  /** 是否支援 Clipboard API */
  isSupported: ComputedRef<boolean>;
  /** 當前剪貼簿內容 */
  text: Ref<string>;
  /** 是否剛剛完成複製 (用於顯示成功提示) */
  copied: Ref<boolean>;
  /** 複製函式 */
  copy: (text: string) => Promise<void>;
  /** 讀取剪貼簿函式 */
  read: () => Promise<void>;
}

三、 實作 useClipboard Composable

3.1 核心實作

typescript
// composables/useClipboard.ts
import { ref, computed } from "vue";
import type {
  UseClipboardOptions,
  UseClipboardReturn,
} from "@/types/useClipboard";

export function useClipboard(
  options: UseClipboardOptions = {},
): UseClipboardReturn {
  const { copiedDuring = 1500 } = options;

  const isSupported = computed(
    () => typeof navigator !== "undefined" && "clipboard" in navigator,
  );

  const text = ref("");
  const copied = ref(false);

  // 計時器參考
  let timer: any = null;

  // 處理複製成功後的狀態重置
  function handleCopied(content: string) {
    text.value = content;
    copied.value = true;

    // 清除舊的計時器
    if (timer) clearTimeout(timer);

    // 設定新的計時器重置狀態
    timer = setTimeout(() => {
      copied.value = false;
    }, copiedDuring);
  }

  // 舊版 API 實作 (execCommand)
  function legacyCopy(content: string) {
    const textArea = document.createElement("textarea");
    textArea.value = content;

    // 避免影響版面
    textArea.style.position = "fixed";
    textArea.style.left = "-9999px";
    textArea.style.top = "0";

    document.body.appendChild(textArea);
    textArea.focus();
    textArea.select();

    try {
      document.execCommand("copy");
      handleCopied(content);
    } catch (e) {
      console.error("Legacy copy failed", e);
      throw e;
    } finally {
      document.body.removeChild(textArea);
    }
  }

  // 複製函式
  async function copy(content: string) {
    if (isSupported.value) {
      try {
        await navigator.clipboard.writeText(content);
        handleCopied(content);
      } catch (e) {
        // 如果新 API 失敗,嘗試降級到舊 API
        console.warn("Clipboard API failed, falling back to execCommand", e);
        legacyCopy(content);
      }
    } else {
      legacyCopy(content);
    }
  }

  // 讀取函式
  async function read() {
    if (!isSupported.value) return;

    try {
      const content = await navigator.clipboard.readText();
      text.value = content;
    } catch (e) {
      console.error("Failed to read clipboard", e);
    }
  }

  return {
    isSupported,
    text,
    copied,
    copy,
    read,
  };
}

NOTE

HTTPS 限制navigator.clipboard 只能在安全環境(HTTPS 或 localhost)下使用。如果您的應用部署在 HTTP 環境,程式會自動降級使用 legacyCopy


四、 實際應用範例

4.1 一鍵複製按鈕

這是最常見的使用情境,使用者點擊按鈕後將文字複製,並顯示「已複製!」提示。

vue
<script setup lang="ts">
import { useClipboard } from "@/composables/useClipboard";

const source = "npm install @vueuse/core";
const { copy, copied, isSupported } = useClipboard();
</script>

<template>
  <div class="copy-box">
    <code>{{ source }}</code>

    <button @click="copy(source)">
      <!-- 根據狀態切換顯示圖示/文字 -->
      <span v-if="!copied">📋 複製</span>
      <span v-else class="success">✅ 已複製!</span>
    </button>
  </div>

  <p class="hint">
    API 支援度:
    {{ isSupported ? "支援 (Clipboard API)" : "降級 (execCommand)" }}
  </p>
</template>

<style scoped>
.copy-box {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 1rem;
  background: #f5f5f5;
  border-radius: 8px;
}
.success {
  color: #10b981;
  font-weight: bold;
}
</style>

4.2 讀取剪貼簿 (貼上並解析)

除了寫入,有時我們也需要讀取剪貼簿內容,例如「貼上分享碼」功能。

WARNING

權限要求readText() 會觸發瀏覽器的權限詢問視窗,使用者必須明確「允許」後才能讀取內容。

vue
<script setup lang="ts">
import { useClipboard } from "@/composables/useClipboard";

const { text, read, isSupported } = useClipboard();

async function handlePaste() {
  await read();
  // text.value 自動更新為剪貼簿內容
  console.log("剪貼簿內容:", text.value);
}
</script>

<template>
  <div v-if="isSupported">
    <button @click="handlePaste">📋 從剪貼簿貼上</button>
    <div v-if="text" class="preview">讀取到的內容: {{ text }}</div>
  </div>
</template>

總結

概念說明
Navigator.clipboard現代瀏覽器的非同步剪貼簿 API
writeText()寫入文字,通常不需要額外權限(需由使用者觸發)
readText()讀取文字,必須經過使用者授權
降級處理 (Fallback)在 API 不可用時,退回使用 document.execCommand
使用者體驗複製後應提供視覺回饋 (如 1.5秒後復原狀態)

TIP

權限最佳實踐:永遠不要在頁面載入時直接呼叫 readText(),這會導致糟糕的使用者體驗(突兀的權限請求)。請務必將其綁定在明確的按鈕點擊事件上(例如「貼上」按鈕)。


進階挑戰

  1. 複製圖片:研究 clipboard.write() 方法,嘗試複製圖片 Blob 到剪貼簿。
  2. 富文本複製:實作複製 HTML 格式的內容(如帶有樣式的表格)。
  3. 剪貼簿監聽:使用 paste 事件監聽器,實作攔截使用者的貼上行為並過濾內容。

延伸閱讀與資源