造一個 copy-to-clipboard 輪子

項目代碼:https://github.com/Haixiang6123/my-copy-to-clipboard
預覽地址:http://yanhaixiang.com/my-copy-to-clipboard/
參考輪子:https://www.npmjs.com/package/copy-to-clipboard

用 JS 來複制文本在網頁應用裏十分常見,比如 github 裏複製 remote 地址的功能:


今天就來帶大家一起寫一個 JS 複製文本的輪子吧~

從零開始

關於 JS 做複製功能的文章還挺多的,這裏列舉一篇 阮一峯的《剪貼板操作 Clipboard API 教程》 作爲例子。

大部分文章的做法是這樣:創建一個輸入框(input 或者 textarea),將複製文本賦值到元素的 value 值,JS 選中文本內容,最後使用 document.exec('copy') 完成複製。

這裏的問題是,在某些環境下文本輸入框會存在一些怪異的行爲,比如:

  • 如果不是文本輸入標籤,需要主動創建一個可輸入文本的標籤(input和textarea)然後將待複製的文本賦值給這個標籤,再調用.select()方法選中這個標籤才能繼續執行 document.execCommand('copy') 去複製。
  • 如果是文本輸入標籤,標籤不可以賦予 disable 或者 readonly,這會影響 select() 方法。
  • 移動端 iOS 在選中輸入框的時候會有自動調整頁面縮放的問題,如果沒有對這個進行處理,調用 select() 方法時(其實就是讓標籤處於focus狀態)會出現同樣的問題。

聽起來就很麻煩。爲了去掉這些兼容問題,可以使用 <span> 元素作爲複製文本的容器,那先按上面的思路,造一個最簡單的輪子吧。

const copy = (text: string) => {
  const range = document.createRange()
  const selection = document.getSelection()

  const mark = document.createElement('span')
  mark.textContent = text

  // 插入 body 中
  document.body.appendChild(mark)

  // 選中
  range.selectNodeContents(mark)
  selection.addRange(range)

  const success = document.execCommand('copy')

  if (success) {
    alert('複製成功')
  } else {
    alert('複製失敗')
  }

  if (mark) {
    document.body.removeChild(mark)
  }
}

這裏用到 Selection 和 Range 兩個對象。關於 Selection 表示用戶選擇的文本範圍或插入符號的當前位置。它代表頁面中的文本選區,可能橫跨多個元素;而 Range 表示一個包含節點與文本節點的一部分的文檔片段。一個 Selection 可以有多個 Range 對象。

上面邏輯很簡單,創建 span 元素,從 textContent 加入複製文本。這裏有人就問了:爲啥不用 innerText 呢?他們有什麼區別呢?區別詳見 Stackoverflow: Difference between textContent vs innerText

好的我知道你不會看的,這裏就簡單列一下吧:

  1. 首先 innerText 是非標準的,textContent 是標準的
  2. innerText 非常容易受 CSS 的影響,textContent 則不會:innerText 只返回可見的文本,而 textContent 返回全文本。比如 "Hello Wold" 文本,用 display: none 把 "Hello" 變成看不見了,那麼 innerText 會返回 "World",而 textContent 返回 "Hello World"。
  3. innerText 性能差一點,因爲需要等到渲染完了之後通過頁面佈局信息來獲取文本
  4. innerText 通過 HTMLElement 拿到,而 textContent 可以通過所有 Node 拿到,獲取範圍更廣一些

回到代碼,把創建好的 span 放入 document.body 裏,並選中元素,把 range 加入 selection 中,document.exec 執行復制操作,最後一步把 mark 元素移除,收工了。

複製時好時壞

如果你弄了個按鈕並綁定 copy('Hello'),點擊後會發現:咦?怎麼時好時壞的?一會可以複製一會又不行了。

剛剛提到 Selection 有可能是插入符號的當前位置,啥意思?想一想鼠標點一下算不算選區呢?算的,只是長度爲 0 你看不見而已。

這時它被標記爲 Collapsed,這表示選區被壓縮至一點,即光標位置。—— Selection

長度爲 0 好像也沒什麼問題嘛,剛剛代碼不是 addRange 了麼?然而 addRange 並不會添加新 Range 到 Selection 中!

Currently only Firefox supports multiple selection ranges, other browsers will not add new ranges to the selection if it already contains one. —— Selection.addRange()

總結一下複製不成功的問題:

  1. 當鼠標無意地點擊到頁面時(比如按鈕),Selection 會加入一個看不見的 Range(變成光標的位置,而不是一個選中的區域了)
  2. 在我們代碼中 selection.addRange 後並不會把 span 裏的選中文本作爲新的 Range 加入 Selection
  3. 執行 document.exec('copy') 的時候,由於選區是個光標位置,複製了個寂寞,粘貼板還是原來的複製內容,不會改變,如果原來是空,那粘貼出來的還是空
  4. 既然執行了個寂寞,爲啥 success 不爲 false 呢?因爲 MDN 說了執行成功或者失敗和返回值毛關係沒有,只有 document.exec 不被瀏覽器支持或未被啓用纔會返回 false

Note: document.execCommand() only returns true if it is invoked as part of a user interaction. You can't use it to verify browser support before calling a command. From Firefox 82, nested document.execCommand() calls will always return false. —— Document.execCommand()

解決方法是:使用 selection.removeAllRanges,在 selection.addRange 之前把原有的 Range 清乾淨就可以了。

const copy = (text: string) => {
  const range = document.createRange()
  const selection = document.getSelection()

  const mark = document.createElement('span')
  mark.textContent = text

  document.body.appendChild(mark)

  range.selectNodeContents(mark)
  selection.removeAllRanges() // 移除調用前已經存在 Range
  selection.addRange(range)

  const success = document.execCommand('copy')

  if (success) {
    console.log('複製成功')
  } else {
    console.log('複製失敗')
  }

  if (mark) {
    document.body.removeChild(mark)
  }
}

上面使用 selection.removeAllRanges 移除當前的 Range,這樣就可以把要複製的 Range 加入到 Selection 中了。

toggle-selection

上面雖然解決了不能複製的問題,但是會把原來選中的區域也整沒了。比如用戶選了一段文字,執行了 copy 導致原來的文字沒有選中了。copy 函數就會有 side-effect 了,對應用不友好。

解決方法也很簡單:執行 copy 前移除當前選區,執行過後再恢復原來選區。

export const deselectCurrent = () => {
  const selection = document.getSelection()

  // 當前沒有選中
  if (selection.rangeCount === 0) {
    return () => {}
  }

  let $active = document.activeElement

  // 獲取當前選中的 ranges
  const ranges: Range[] = []
  for (let i = 0; i < selection.rangeCount; i++) {
    ranges.push(selection.getRangeAt(i))
  }

  // deselect
  selection.removeAllRanges();

  return () => {
    // 如果是插入符則移除 ranges
    if (selection.type === 'Caret') {
      selection.removeAllRanges()
    }

    // 沒有選中,就把之前的 ranges 加回來
    if (selection.rangeCount === 0) {
      ranges.forEach(range => {
        selection.addRange(range)
      })
    }
  }
}

deselectCurrent 函數將當前選區存在 ranges 裏,最後返回一個函數,該函數可用於恢復當前選區。

另外,我們還要考慮到如果 activeElement 爲 input 或 textarea 的情況,deselect 時要 blur,reselect 時則要 focus 回來。

export const deselectCurrent = () => {
  const selection = document.getSelection()

  if (selection.rangeCount === 0) {
    return () => {}
  }

  let $active = document.activeElement

  const ranges: Range[] = []
  for (let i = 0; i < selection.rangeCount; i++) {
    ranges.push(selection.getRangeAt(i))
  }

  // 如果爲輸入元素先 blur 再 focus
  switch ($active.tagName.toUpperCase()) {
    case 'INPUT':
    case 'TEXTAREA':
      ($active as HTMLInputElement | HTMLTextAreaElement).blur()
      break
    default:
      $active = null
  }

  selection.removeAllRanges();

  return () => {
    if (selection.type === 'Caret') {
      selection.removeAllRanges()
    }
    if (selection.rangeCount === 0) {
      ranges.forEach(range => {
        selection.addRange(range)
      })
    }

    // input 或 textarea 要再 focus 回來
    if ($active) {
      ($active as HTMLInputElement | HTMLTextAreaElement).focus()
    }
  }
}

copy 裏就可以愉快 deselect 和 reselect 了:

const copy = (text: string) => {
  const reselectPrevious = deselectCurrent() // 去掉當前選區

  ...

  const success = document.execCommand('copy')

  if (mark) {
    document.body.removeChild(mark)
  }

  reselectPrevious() // 恢復以前的選區

  return success
}

onCopy

複製的時候將觸發 copy 事件,因此這裏還可以給調用方提供 onCopy 的回調,自定義 listener。

interface Options {
  onCopy?: (copiedText: DataTransfer | null) => unknown
}

const copy = (text: string, options: Options = {}) => {
  const {onCopy} = options

  const reselectPrevious = deselectCurrent()

  const range = document.createRange()
  const selection = document.getSelection()

  const mark = document.createElement('span')
  mark.textContent = text

  // 自定義 onCopy
  mark.addEventListener('copy', (e) => {
    if (onCopy) {
      e.stopPropagation()
      e.preventDefault()
      onCopy(e.clipboardData)
    }
  })

  document.body.appendChild(mark)

  range.selectNodeContents(mark)
  selection.addRange(range)

  const success = document.execCommand('copy')

  if (mark) {
    document.body.removeChild(mark)
  }

  reselectPrevious()

  return success
}

這裏添加了 "copy" 事件的監聽。e.stopPropagation 阻止 copy 事件冒泡,e.prevenDefault 禁止默認響應,然後用 onCopy 函數接管複製事件的響應。同時,onCopy 裏傳入 e.clipbaordData,調用方可以隨意處理複製的數據。

比如:

$myCopy.onclick = () => {
  const myText = 'my text'

  copy('xxx', {
    onCopy: (clipboardData) => clipboardData.setData('text/plain', myText), // 複製 'my-text'
  })
}

有人就會問了:這個 setData 好理解,不就設置複製文本嘛,那要這個 “text/plain" 幹嘛用?

DataTransfer 裏的 format

不知道大家有沒有關注過 clipboardData 類型呢?它其實是一個 DataTransfer 的類型,那 DataTransfer 又是幹啥的?一般是拖拽時,用於存放拖拽內容的。複製也算是數據轉移的一種,所以 clipboardData 也爲 DataTransfer 類型。

複製本質上是複製內容而非單一的文本,也有格式的。我們可能學時一般就複製幾個文字,但是在一些情況下,比如複製一個鏈接、一個 <h1> 標籤的元素、甚至一張圖片後,當粘貼到 docs 文件的時候,會發現這些元素的樣式和圖片全都帶過來了。

爲什麼發生這樣的事?因爲在複製的時候系統會設定 format,而 World 正好可以識別這些 format,所以可以直接展示出帶樣式的複製內容。

目前我們的函數僅支持純文本的複製,應該再加一個 format,讓調用方自定義複製的格式。

interface Options {
  onCopy?: (copiedText: DataTransfer | null) => unknown
  format?: Format
}

const copy = (text: string, options: Options = {}) => {
  const {onCopy} = options

  const reselectPrevious = deselectCurrent()

  const range = document.createRange()
  const selection = document.getSelection()

  const mark = document.createElement('span')
  mark.textContent = text

  mark.addEventListener('copy', (e) => {
    e.stopPropagation();

    // 帶格式去複製內容
    if (format) {
      e.preventDefault()
      e.clipboardData.clearData()
      e.clipboardData.setData(format, text)
    }

    if (onCopy) {
      e.preventDefault()
      onCopy(e.clipboardData)
    }
  })

  document.body.appendChild(mark)

  range.selectNodeContents(mark)
  selection.addRange(range)

  const success = document.execCommand('copy')

  if (mark) {
    document.body.removeChild(mark)
  }

  reselectPrevious()

  return success
}

在剛剛代碼基礎上,我們可以在 copy 事件裏判斷是否有 format,如果有則直接接管 copy listener,clearData 清除複製內容,然後 setData(format, text) 來複制內容。

兼容 IE

前端工程師們都會有一個共通的一生之敵——IE。目前查了文檔,有以下兼容問題:

  • 在 IE 11 下,format 這裏只有 TextUrl 兩種
  • 在 IE 下,copy 事件中 e.clipboardDataundefined,但是會有 window.clipboardData
  • 在 IE 9 以下,document.execCommand 可能不被支持(有些貼子說可以,有些貼子說有問題)

針對上面的問題,我們要爲 formate.clipboardDatadocument.execCommand 做好兜底兼容操作。

首先是 format,提供一個 format 的轉換 Mapper:

type Format = 'text/plain' | 'text/html' | 'default'
type IE11Format = 'Text' | 'Url'

const clipboardToIE11Formatting: Record<Format, IE11Format> = {
  "text/plain": "Text",
  "text/html": "Url",
  "default": "Text"
}

接下來是 e.clipboardData 做兼容,這裏有個知識點是在 IE 下,window 會有一個 clipboardData,我們可以把要複製的內容存到 window.clipboardData注意:這個全局變量只有 IE 下才會有,普通情況下還是使 e.clipboardData

const copy = (text: string, options: Options = {}) => {
  ...

  mark.addEventListener('copy', (e) => {
    e.stopPropagation();
    if (format) {
      e.preventDefault()
      if (!e.clipboardData) {
        // 只有 IE 11 裏 e.clipboardData 一直爲 undefined
        // 這裏 format 要轉爲 IE 11 裏指定的 format
        const IE11Format = clipboardToIE11Formatting[format || 'default']
        // @ts-ignore clearData 只有 IE 上有
        window.clipboardData.clearData()
        // @ts-ignore setData 只有 IE 上有
        window.clipboardData.setData(IE11Format, text);
      } else {
        e.clipboardData.clearData()
        e.clipboardData.setData(format, text)
      }
    }

    if (onCopy) {
      e.preventDefault()
      onCopy(e.clipboardData)
    }
  })

  ...
}

最後一步是對 document.execCommand 做兼容。目前我自己搜到的是會出現不生效的問題,以及 execCommand 不支持的問題,爲了應對 IE 下絕大多的問題,我們可以祭出 try-catch 大法,只要有 error,通通走 IE 的老路子去做複製。

const copy = (text: string, options: Options = {}) => {
  ...…

  try {
    // execCommand 有些瀏覽器可能不支持,這裏要 try 一下
    success = document.execCommand('copy')

    if (!success) {
      throw new Error("Can't not copy")
    }
  } catch (e) {
    try {
      // @ts-ignore window.clipboardData 這鬼玩意只有 IE 上有
      window.clipboardData.setData(format || 'text', text)
      // @ts-ignore window.clipboardData 這鬼玩意只有 IE 上有
      onCopy && onCopy(window.clipboardData)
    } catch (e) {
      // 最後兜底方案,讓用戶在 window.prompt 的時候輸入
      window.prompt('輸入需要複製的內容', text)
    }
  } finally {
    if (selection.removeRange) {
      selection.removeRange(range)
    } else {
      selection.removeAllRanges()
    }

    if (mark) {
      document.body.removeChild(mark)
    }
    reselectPrevious()
  }

  return success
}

上面加了好幾個 try-catch,第一個兼容 document.execCommand,有問題走 window.clipboardData.setData 的方式來複制。第二個爲兜底方案,使用 window.prompt 作爲兜底。

最後 finally 裏對 selection.removeRange 做了兼容,優先使用 removeRange,失敗再使用 removeAllRanges 清除所有 Range。

兼容樣式

在創建和添加 mark 時還要對其樣式進行處理,防止頁面出現 side-effect,比如:

  • 添加和刪除 mark 不能造成頁面滾動
  • span 元素的 space 和 line-break 要爲 pre,複製時可以把換行等特殊符號也帶上
  • 外部有可能會被設置成 "none",所以 user-select 一定要爲 "text",不然連選都選不中
const updateMarkStyles = (mark: HTMLSpanElement) => {
  // 重置用戶樣式
  mark.style.all = "unset";
  // 放在 fixed,防止添加元素後觸發滾動行爲
  mark.style.position = "fixed";
  mark.style.top = '0';
  mark.style.clip = "rect(0, 0, 0, 0)";
  // 保留 space 和 line-break 特性
  mark.style.whiteSpace = "pre";
  // 外部有可能 user-select 爲 'none',因此這裏設置爲 text
  mark.style.userSelect = "text";
}

const copy = (text: string, options: Options = {}) => {
  ...

  const mark = document.createElement('span')
  mark.textContent = text

  updateMarkStyles(mark)

  mark.addEventListener('copy', (e) => {
    ...
  })
  ...
}

在創建 span 元素之後應該馬上更新樣式,確保不會有頁面變化(副作用)。

總結

目前已經完成 copy-to-clipboard 這個庫的所有功能了,主要做了以下幾件事:

  1. 完成複製功能
  2. 複製後會恢復原來選區
  3. 提供 onCopy,調用方可自己定義複製 listener
  4. 提供 format,可多格式複製
  5. 兼容了 IE
  6. 對樣式做了兼容,在不對頁面產生副作用情況下完成複製功能

最後

JS 複製這個需求應該不少人都會遇到過。然而真正研究起來,要考慮的東西還是很多的。

如果僅僅只是掃一眼源碼可能只會做出”從零開始“這一版,後面的兼容、format、回調等功能真的特別難想到。

最後再來說一下 Clipboard API。Clipboard API 是下一代的剪貼板操作方法,比傳統的 document.execCommand() 方法更強大、更合理。它的所有操作都是異步的,返回 Promise 對象,不會造成頁面卡頓。而且,它可以將任意內容(比如圖片)放入剪貼板。

不過,目前還是 document.execCommand 使用的比較廣泛。雖然上面也說了 IE 對 document.execCommand 不好,但是 Clipboard API 的兼容性更差,FireFox 和 Chome 在某些版本可能都會有問題。另外還有一個問題,使用 clipboard API 需要從權限 Permissions API 獲取權限之後,才能訪問剪貼板內容,這樣會嚴重影響用戶體驗。用戶:你讓我開權限,是不是又想偷我密碼???

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章