項目代碼: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。
好的我知道你不會看的,這裏就簡單列一下吧:
- 首先
innerText
是非標準的,textContent
是標準的 -
innerText
非常容易受 CSS 的影響,textContent
則不會:innerText
只返回可見的文本,而textContent
返回全文本。比如 "Hello Wold" 文本,用 display: none 把 "Hello" 變成看不見了,那麼innerText
會返回 "World",而textContent
返回 "Hello World"。 -
innerText
性能差一點,因爲需要等到渲染完了之後通過頁面佈局信息來獲取文本 -
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()
總結一下複製不成功的問題:
- 當鼠標無意地點擊到頁面時(比如按鈕),Selection 會加入一個看不見的 Range(變成光標的位置,而不是一個選中的區域了)
- 在我們代碼中
selection.addRange
後並不會把 span 裏的選中文本作爲新的 Range 加入 Selection - 執行
document.exec('copy')
的時候,由於選區是個光標位置,複製了個寂寞,粘貼板還是原來的複製內容,不會改變,如果原來是空,那粘貼出來的還是空 - 既然執行了個寂寞,爲啥 success 不爲
false
呢?因爲 MDN 說了執行成功或者失敗和返回值毛關係沒有,只有document.exec
不被瀏覽器支持或未被啓用纔會返回false
。
Note:
document.execCommand()
only returnstrue
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, nesteddocument.execCommand()
calls will always returnfalse
. —— 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 這裏只有
Text
和Url
兩種 - 在 IE 下,copy 事件中
e.clipboardData
爲undefined
,但是會有window.clipboardData
- 在 IE 9 以下,
document.execCommand
可能不被支持(有些貼子說可以,有些貼子說有問題)
針對上面的問題,我們要爲 format
、e.clipboardData
和 document.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 這個庫的所有功能了,主要做了以下幾件事:
- 完成複製功能
- 複製後會恢復原來選區
- 提供 onCopy,調用方可自己定義複製 listener
- 提供 format,可多格式複製
- 兼容了 IE
- 對樣式做了兼容,在不對頁面產生副作用情況下完成複製功能
最後
JS 複製這個需求應該不少人都會遇到過。然而真正研究起來,要考慮的東西還是很多的。
如果僅僅只是掃一眼源碼可能只會做出”從零開始“這一版,後面的兼容、format、回調等功能真的特別難想到。
最後再來說一下 Clipboard API。Clipboard API 是下一代的剪貼板操作方法,比傳統的 document.execCommand() 方法更強大、更合理。它的所有操作都是異步的,返回 Promise 對象,不會造成頁面卡頓。而且,它可以將任意內容(比如圖片)放入剪貼板。
不過,目前還是 document.execCommand
使用的比較廣泛。雖然上面也說了 IE 對 document.execCommand
不好,但是 Clipboard API 的兼容性更差,FireFox 和 Chome 在某些版本可能都會有問題。另外還有一個問題,使用 clipboard API 需要從權限 Permissions API 獲取權限之後,才能訪問剪貼板內容,這樣會嚴重影響用戶體驗。用戶:你讓我開權限,是不是又想偷我密碼???