
項(xiàng)目代碼:https://github.com/Haixiang6123/my-copy-to-clipboard
預(yù)覽地址:http://yanhaixiang.com/my-copy-to-clipboard/
參考輪子:https://www.npmjs.com/package/copy-to-clipboard
用 JS 來(lái)復(fù)制文本在網(wǎng)頁(yè)應(yīng)用里十分常見(jiàn),比如 github 里復(fù)制 remote 地址的功能:

今天就來(lái)帶大家一起寫(xiě)一個(gè) JS 復(fù)制文本的輪子吧~
從零開(kāi)始
關(guān)于 JS 做復(fù)制功能的文章還挺多的,這里列舉一篇 阮一峰的《剪貼板操作 Clipboard API 教程》 作為例子。
大部分文章的做法是這樣:創(chuàng)建一個(gè)輸入框(input 或者 textarea),將復(fù)制文本賦值到元素的 value 值,JS 選中文本內(nèi)容,最后使用 document.exec('copy') 完成復(fù)制。
這里的問(wèn)題是,在某些環(huán)境下文本輸入框會(huì)存在一些怪異的行為,比如:
- 如果不是文本輸入標(biāo)簽,需要主動(dòng)創(chuàng)建一個(gè)可輸入文本的標(biāo)簽(input和textarea)然后將待復(fù)制的文本賦值給這個(gè)標(biāo)簽,再調(diào)用.select()方法選中這個(gè)標(biāo)簽才能繼續(xù)執(zhí)行
document.execCommand('copy')去復(fù)制。 - 如果是文本輸入標(biāo)簽,標(biāo)簽不可以賦予 disable 或者 readonly,這會(huì)影響
select()方法。 - 移動(dòng)端 iOS 在選中輸入框的時(shí)候會(huì)有自動(dòng)調(diào)整頁(yè)面縮放的問(wèn)題,如果沒(méi)有對(duì)這個(gè)進(jìn)行處理,調(diào)用
select()方法時(shí)(其實(shí)就是讓標(biāo)簽處于focus狀態(tài))會(huì)出現(xiàn)同樣的問(wèn)題。
聽(tīng)起來(lái)就很麻煩。為了去掉這些兼容問(wèn)題,可以使用 <span> 元素作為復(fù)制文本的容器,那先按上面的思路,造一個(gè)最簡(jiǎn)單的輪子吧。
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('復(fù)制成功')
} else {
alert('復(fù)制失敗')
}
if (mark) {
document.body.removeChild(mark)
}
}
這里用到 Selection 和 Range 兩個(gè)對(duì)象。關(guān)于 Selection 表示用戶選擇的文本范圍或插入符號(hào)的當(dāng)前位置。它代表頁(yè)面中的文本選區(qū),可能橫跨多個(gè)元素;而 Range 表示一個(gè)包含節(jié)點(diǎn)與文本節(jié)點(diǎn)的一部分的文檔片段。一個(gè) Selection 可以有多個(gè) Range 對(duì)象。
上面邏輯很簡(jiǎn)單,創(chuàng)建 span 元素,從 textContent 加入復(fù)制文本。這里有人就問(wèn)了:為啥不用 innerText 呢?他們有什么區(qū)別呢?區(qū)別詳見(jiàn) Stackoverflow: Difference between textContent vs innerText。
好的我知道你不會(huì)看的,這里就簡(jiǎn)單列一下吧:
- 首先
innerText是非標(biāo)準(zhǔn)的,textContent是標(biāo)準(zhǔn)的 -
innerText非常容易受 CSS 的影響,textContent則不會(huì):innerText只返回可見(jiàn)的文本,而textContent返回全文本。比如 "Hello Wold" 文本,用 display: none 把 "Hello" 變成看不見(jiàn)了,那么innerText會(huì)返回 "World",而textContent返回 "Hello World"。 -
innerText性能差一點(diǎn),因?yàn)樾枰鹊戒秩就炅酥笸ㄟ^(guò)頁(yè)面布局信息來(lái)獲取文本 -
innerText通過(guò) HTMLElement 拿到,而textContent可以通過(guò)所有 Node 拿到,獲取范圍更廣一些
回到代碼,把創(chuàng)建好的 span 放入 document.body 里,并選中元素,把 range 加入 selection 中,document.exec 執(zhí)行復(fù)制操作,最后一步把 mark 元素移除,收工了。
復(fù)制時(shí)好時(shí)壞
如果你弄了個(gè)按鈕并綁定 copy('Hello'),點(diǎn)擊后會(huì)發(fā)現(xiàn):咦?怎么時(shí)好時(shí)壞的?一會(huì)可以復(fù)制一會(huì)又不行了。
剛剛提到 Selection 有可能是插入符號(hào)的當(dāng)前位置,啥意思?想一想鼠標(biāo)點(diǎn)一下算不算選區(qū)呢?算的,只是長(zhǎng)度為 0 你看不見(jiàn)而已。
這時(shí)它被標(biāo)記為 Collapsed,這表示選區(qū)被壓縮至一點(diǎn),即光標(biāo)位置。—— Selection
長(zhǎng)度為 0 好像也沒(méi)什么問(wèn)題嘛,剛剛代碼不是 addRange 了么?然而 addRange 并不會(huì)添加新 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()
總結(jié)一下復(fù)制不成功的問(wèn)題:
- 當(dāng)鼠標(biāo)無(wú)意地點(diǎn)擊到頁(yè)面時(shí)(比如按鈕),Selection 會(huì)加入一個(gè)看不見(jiàn)的 Range(變成光標(biāo)的位置,而不是一個(gè)選中的區(qū)域了)
- 在我們代碼中
selection.addRange后并不會(huì)把 span 里的選中文本作為新的 Range 加入 Selection - 執(zhí)行
document.exec('copy')的時(shí)候,由于選區(qū)是個(gè)光標(biāo)位置,復(fù)制了個(gè)寂寞,粘貼板還是原來(lái)的復(fù)制內(nèi)容,不會(huì)改變,如果原來(lái)是空,那粘貼出來(lái)的還是空 - 既然執(zhí)行了個(gè)寂寞,為啥 success 不為
false呢?因?yàn)?MDN 說(shuō)了執(zhí)行成功或者失敗和返回值毛關(guān)系沒(méi)有,只有document.exec不被瀏覽器支持或未被啟用才會(huì)返回false。
Note:
document.execCommand()only returnstrueif 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() // 移除調(diào)用前已經(jīng)存在 Range
selection.addRange(range)
const success = document.execCommand('copy')
if (success) {
console.log('復(fù)制成功')
} else {
console.log('復(fù)制失敗')
}
if (mark) {
document.body.removeChild(mark)
}
}
上面使用 selection.removeAllRanges 移除當(dāng)前的 Range,這樣就可以把要復(fù)制的 Range 加入到 Selection 中了。
toggle-selection
上面雖然解決了不能復(fù)制的問(wèn)題,但是會(huì)把原來(lái)選中的區(qū)域也整沒(méi)了。比如用戶選了一段文字,執(zhí)行了 copy 導(dǎo)致原來(lái)的文字沒(méi)有選中了。copy 函數(shù)就會(huì)有 side-effect 了,對(duì)應(yīng)用不友好。
解決方法也很簡(jiǎn)單:執(zhí)行 copy 前移除當(dāng)前選區(qū),執(zhí)行過(guò)后再恢復(fù)原來(lái)選區(qū)。
export const deselectCurrent = () => {
const selection = document.getSelection()
// 當(dāng)前沒(méi)有選中
if (selection.rangeCount === 0) {
return () => {}
}
let $active = document.activeElement
// 獲取當(dāng)前選中的 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()
}
// 沒(méi)有選中,就把之前的 ranges 加回來(lái)
if (selection.rangeCount === 0) {
ranges.forEach(range => {
selection.addRange(range)
})
}
}
}
deselectCurrent 函數(shù)將當(dāng)前選區(qū)存在 ranges 里,最后返回一個(gè)函數(shù),該函數(shù)可用于恢復(fù)當(dāng)前選區(qū)。
另外,我們還要考慮到如果 activeElement 為 input 或 textarea 的情況,deselect 時(shí)要 blur,reselect 時(shí)則要 focus 回來(lái)。
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 回來(lái)
if ($active) {
($active as HTMLInputElement | HTMLTextAreaElement).focus()
}
}
}
在 copy 里就可以愉快 deselect 和 reselect 了:
const copy = (text: string) => {
const reselectPrevious = deselectCurrent() // 去掉當(dāng)前選區(qū)
...
const success = document.execCommand('copy')
if (mark) {
document.body.removeChild(mark)
}
reselectPrevious() // 恢復(fù)以前的選區(qū)
return success
}
onCopy
復(fù)制的時(shí)候?qū)⒂|發(fā) copy 事件,因此這里還可以給調(diào)用方提供 onCopy 的回調(diào),自定義 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" 事件的監(jiān)聽(tīng)。e.stopPropagation 阻止 copy 事件冒泡,e.prevenDefault 禁止默認(rèn)響應(yīng),然后用 onCopy 函數(shù)接管復(fù)制事件的響應(yīng)。同時(shí),onCopy 里傳入 e.clipbaordData,調(diào)用方可以隨意處理復(fù)制的數(shù)據(jù)。
比如:
$myCopy.onclick = () => {
const myText = 'my text'
copy('xxx', {
onCopy: (clipboardData) => clipboardData.setData('text/plain', myText), // 復(fù)制 'my-text'
})
}
有人就會(huì)問(wèn)了:這個(gè) setData 好理解,不就設(shè)置復(fù)制文本嘛,那要這個(gè) “text/plain" 干嘛用?
DataTransfer 里的 format
不知道大家有沒(méi)有關(guān)注過(guò) clipboardData 類(lèi)型呢?它其實(shí)是一個(gè) DataTransfer 的類(lèi)型,那 DataTransfer 又是干啥的?一般是拖拽時(shí),用于存放拖拽內(nèi)容的。復(fù)制也算是數(shù)據(jù)轉(zhuǎn)移的一種,所以 clipboardData 也為 DataTransfer 類(lèi)型。
復(fù)制本質(zhì)上是復(fù)制內(nèi)容而非單一的文本,也有格式的。我們可能學(xué)時(shí)一般就復(fù)制幾個(gè)文字,但是在一些情況下,比如復(fù)制一個(gè)鏈接、一個(gè) <h1> 標(biāo)簽的元素、甚至一張圖片后,當(dāng)粘貼到 docs 文件的時(shí)候,會(huì)發(fā)現(xiàn)這些元素的樣式和圖片全都帶過(guò)來(lái)了。
為什么發(fā)生這樣的事?因?yàn)樵趶?fù)制的時(shí)候系統(tǒng)會(huì)設(shè)定 format,而 World 正好可以識(shí)別這些 format,所以可以直接展示出帶樣式的復(fù)制內(nèi)容。
目前我們的函數(shù)僅支持純文本的復(fù)制,應(yīng)該再加一個(gè) format,讓調(diào)用方自定義復(fù)制的格式。
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();
// 帶格式去復(fù)制內(nèi)容
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
}
在剛剛代碼基礎(chǔ)上,我們可以在 copy 事件里判斷是否有 format,如果有則直接接管 copy listener,clearData 清除復(fù)制內(nèi)容,然后 setData(format, text) 來(lái)復(fù)制內(nèi)容。
兼容 IE
前端工程師們都會(huì)有一個(gè)共通的一生之?dāng)场狪E。目前查了文檔,有以下兼容問(wèn)題:
- 在 IE 11 下,format 這里只有
Text和Url兩種 - 在 IE 下,copy 事件中
e.clipboardData為undefined,但是會(huì)有window.clipboardData - 在 IE 9 以下,
document.execCommand可能不被支持(有些貼子說(shuō)可以,有些貼子說(shuō)有問(wèn)題)
針對(duì)上面的問(wèn)題,我們要為 format、e.clipboardData 和 document.execCommand 做好兜底兼容操作。
首先是 format,提供一個(gè) format 的轉(zhuǎn)換 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"
}
接下來(lái)是 e.clipboardData 做兼容,這里有個(gè)知識(shí)點(diǎn)是在 IE 下,window 會(huì)有一個(gè) clipboardData,我們可以把要復(fù)制的內(nèi)容存到 window.clipboardData。注意:這個(gè)全局變量只有 IE 下才會(huì)有,普通情況下還是使 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 要轉(zhuǎn)為 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)
}
})
...
}
最后一步是對(duì) document.execCommand 做兼容。目前我自己搜到的是會(huì)出現(xiàn)不生效的問(wèn)題,以及 execCommand 不支持的問(wèn)題,為了應(yīng)對(duì) IE 下絕大多的問(wèn)題,我們可以祭出 try-catch 大法,只要有 error,通通走 IE 的老路子去做復(fù)制。
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 的時(shí)候輸入
window.prompt('輸入需要復(fù)制的內(nèi)容', text)
}
} finally {
if (selection.removeRange) {
selection.removeRange(range)
} else {
selection.removeAllRanges()
}
if (mark) {
document.body.removeChild(mark)
}
reselectPrevious()
}
return success
}
上面加了好幾個(gè) try-catch,第一個(gè)兼容 document.execCommand,有問(wèn)題走 window.clipboardData.setData 的方式來(lái)復(fù)制。第二個(gè)為兜底方案,使用 window.prompt 作為兜底。
最后 finally 里對(duì) selection.removeRange 做了兼容,優(yōu)先使用 removeRange,失敗再使用 removeAllRanges 清除所有 Range。
兼容樣式
在創(chuàng)建和添加 mark 時(shí)還要對(duì)其樣式進(jìn)行處理,防止頁(yè)面出現(xiàn) side-effect,比如:
- 添加和刪除 mark 不能造成頁(yè)面滾動(dòng)
- span 元素的 space 和 line-break 要為
pre,復(fù)制時(shí)可以把換行等特殊符號(hào)也帶上 - 外部有可能會(huì)被設(shè)置成 "none",所以 user-select 一定要為 "text",不然連選都選不中
const updateMarkStyles = (mark: HTMLSpanElement) => {
// 重置用戶樣式
mark.style.all = "unset";
// 放在 fixed,防止添加元素后觸發(fā)滾動(dòng)行為
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',因此這里設(shè)置為 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) => {
...
})
...
}
在創(chuàng)建 span 元素之后應(yīng)該馬上更新樣式,確保不會(huì)有頁(yè)面變化(副作用)。
總結(jié)
目前已經(jīng)完成 copy-to-clipboard 這個(gè)庫(kù)的所有功能了,主要做了以下幾件事:
- 完成復(fù)制功能
- 復(fù)制后會(huì)恢復(fù)原來(lái)選區(qū)
- 提供 onCopy,調(diào)用方可自己定義復(fù)制 listener
- 提供 format,可多格式復(fù)制
- 兼容了 IE
- 對(duì)樣式做了兼容,在不對(duì)頁(yè)面產(chǎn)生副作用情況下完成復(fù)制功能
最后
JS 復(fù)制這個(gè)需求應(yīng)該不少人都會(huì)遇到過(guò)。然而真正研究起來(lái),要考慮的東西還是很多的。
如果僅僅只是掃一眼源碼可能只會(huì)做出”從零開(kāi)始“這一版,后面的兼容、format、回調(diào)等功能真的特別難想到。
最后再來(lái)說(shuō)一下 Clipboard API。Clipboard API 是下一代的剪貼板操作方法,比傳統(tǒng)的 document.execCommand() 方法更強(qiáng)大、更合理。它的所有操作都是異步的,返回 Promise 對(duì)象,不會(huì)造成頁(yè)面卡頓。而且,它可以將任意內(nèi)容(比如圖片)放入剪貼板。
不過(guò),目前還是 document.execCommand 使用的比較廣泛。雖然上面也說(shuō)了 IE 對(duì) document.execCommand 不好,但是 Clipboard API 的兼容性更差,F(xiàn)ireFox 和 Chome 在某些版本可能都會(huì)有問(wèn)題。另外還有一個(gè)問(wèn)題,使用 clipboard API 需要從權(quán)限 Permissions API 獲取權(quán)限之后,才能訪問(wèn)剪貼板內(nèi)容,這樣會(huì)嚴(yán)重影響用戶體驗(yàn)。用戶:你讓我開(kāi)權(quán)限,是不是又想偷我密碼???