項(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 來復(fù)制文本在網(wǎng)頁(yè)應(yīng)用里十分常見岂却,比如 github 里復(fù)制 remote 地址的功能:
今天就來帶大家一起寫一個(gè) JS 復(fù)制文本的輪子吧~
從零開始
關(guān)于 JS 做復(fù)制功能的文章還挺多的瞒御,這里列舉一篇 阮一峰的《剪貼板操作 Clipboard API 教程》 作為例子。
大部分文章的做法是這樣:創(chuàng)建一個(gè)輸入框(input 或者 textarea)碘橘,將復(fù)制文本賦值到元素的 value 值弊攘,JS 選中文本內(nèi)容笆载,最后使用 document.exec('copy')
完成復(fù)制蜈漓。
這里的問題是蛹锰,在某些環(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è)面縮放的問題零聚,如果沒有對(duì)這個(gè)進(jìn)行處理,調(diào)用
select()
方法時(shí)(其實(shí)就是讓標(biāo)簽處于focus狀態(tài))會(huì)出現(xià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ù)制文本捕犬。這里有人就問了:為啥不用 innerText
呢?他們有什么區(qū)別呢酵镜?區(qū)別詳見 Stackoverflow: Difference between textContent vs innerText碉碉。
好的我知道你不會(huì)看的,這里就簡(jiǎn)單列一下吧:
- 首先
innerText
是非標(biāo)準(zhǔn)的淮韭,textContent
是標(biāo)準(zhǔn)的 -
innerText
非常容易受 CSS 的影響垢粮,textContent
則不會(huì):innerText
只返回可見的文本,而textContent
返回全文本缸濒。比如 "Hello Wold" 文本足丢,用 display: none 把 "Hello" 變成看不見了,那么innerText
會(huì)返回 "World"庇配,而textContent
返回 "Hello World"斩跌。 -
innerText
性能差一點(diǎn),因?yàn)樾枰鹊戒秩就炅酥笸ㄟ^頁(yè)面布局信息來獲取文本 -
innerText
通過 HTMLElement 拿到捞慌,而textContent
可以通過所有 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 你看不見而已。
這時(shí)它被標(biāo)記為 Collapsed敲才,這表示選區(qū)被壓縮至一點(diǎn)裹纳,即光標(biāo)位置择葡。—— Selection
長(zhǎng)度為 0 好像也沒什么問題嘛痊夭,剛剛代碼不是 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ù)制不成功的問題:
- 當(dāng)鼠標(biāo)無(wú)意地點(diǎn)擊到頁(yè)面時(shí)(比如按鈕)她我,Selection 會(huì)加入一個(gè)看不見的 Range(變成光標(biāo)的位置虹曙,而不是一個(gè)選中的區(qū)域了)
- 在我們代碼中
selection.addRange
后并不會(huì)把 span 里的選中文本作為新的 Range 加入 Selection - 執(zhí)行
document.exec('copy')
的時(shí)候,由于選區(qū)是個(gè)光標(biāo)位置番舆,復(fù)制了個(gè)寂寞酝碳,粘貼板還是原來的復(fù)制內(nèi)容,不會(huì)改變恨狈,如果原來是空疏哗,那粘貼出來的還是空 - 既然執(zhí)行了個(gè)寂寞,為啥 success 不為
false
呢禾怠?因?yàn)?MDN 說了執(zhí)行成功或者失敗和返回值毛關(guān)系沒有返奉,只有document.exec
不被瀏覽器支持或未被啟用才會(huì)返回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() // 移除調(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ù)制的問題污尉,但是會(huì)把原來選中的區(qū)域也整沒了。比如用戶選了一段文字往产,執(zhí)行了 copy
導(dǎo)致原來的文字沒有選中了被碗。copy
函數(shù)就會(huì)有 side-effect 了,對(duì)應(yīng)用不友好仿村。
解決方法也很簡(jiǎn)單:執(zhí)行 copy
前移除當(dāng)前選區(qū)锐朴,執(zhí)行過后再恢復(fù)原來選區(qū)。
export const deselectCurrent = () => {
const selection = document.getSelection()
// 當(dāng)前沒有選中
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()
}
// 沒有選中蔼囊,就把之前的 ranges 加回來
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 回來。
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() // 去掉當(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)聽。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ì)問了:這個(gè) setData
好理解缀皱,不就設(shè)置復(fù)制文本嘛斗这,那要這個(gè) “text/plain" 干嘛用?
DataTransfer 里的 format
不知道大家有沒有關(guān)注過 clipboardData
類型呢啤斗?它其實(shí)是一個(gè) DataTransfer
的類型表箭,那 DataTransfer
又是干啥的?一般是拖拽時(shí)钮莲,用于存放拖拽內(nèi)容的免钻。復(fù)制也算是數(shù)據(jù)轉(zhuǎn)移的一種,所以 clipboardData
也為 DataTransfer 類型崔拥。
復(fù)制本質(zhì)上是復(fù)制內(nèi)容而非單一的文本极舔,也有格式的。我們可能學(xué)時(shí)一般就復(fù)制幾個(gè)文字握童,但是在一些情況下姆怪,比如復(fù)制一個(gè)鏈接、一個(gè) <h1>
標(biāo)簽的元素澡绩、甚至一張圖片后稽揭,當(dāng)粘貼到 docs 文件的時(shí)候,會(huì)發(fā)現(xiàn)這些元素的樣式和圖片全都帶過來了肥卡。
為什么發(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)
來復(fù)制內(nèi)容。
兼容 IE
前端工程師們都會(huì)有一個(gè)共通的一生之?dāng)场狪E俏讹。目前查了文檔当宴,有以下兼容問題:
- 在 IE 11 下,format 這里只有
Text
和Url
兩種 - 在 IE 下泽疆,copy 事件中
e.clipboardData
為undefined
户矢,但是會(huì)有window.clipboardData
- 在 IE 9 以下,
document.execCommand
可能不被支持(有些貼子說可以殉疼,有些貼子說有問題)
針對(duì)上面的問題梯浪,我們要為 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"
}
接下來是 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)不生效的問題哈肖,以及 execCommand
不支持的問題吻育,為了應(yīng)對(duì) IE 下絕大多的問題,我們可以祭出 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
漩绵,有問題走 window.clipboardData.setData
的方式來復(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ù)原來選區(qū)
- 提供 onCopy,調(diào)用方可自己定義復(fù)制 listener
- 提供 format吟孙,可多格式復(fù)制
- 兼容了 IE
- 對(duì)樣式做了兼容澜倦,在不對(duì)頁(yè)面產(chǎn)生副作用情況下完成復(fù)制功能
最后
JS 復(fù)制這個(gè)需求應(yīng)該不少人都會(huì)遇到過。然而真正研究起來杰妓,要考慮的東西還是很多的藻治。
如果僅僅只是掃一眼源碼可能只會(huì)做出”從零開始“這一版,后面的兼容巷挥、format桩卵、回調(diào)等功能真的特別難想到。
最后再來說一下 Clipboard API倍宾。Clipboard API 是下一代的剪貼板操作方法雏节,比傳統(tǒng)的 document.execCommand() 方法更強(qiáng)大、更合理高职。它的所有操作都是異步的钩乍,返回 Promise 對(duì)象,不會(huì)造成頁(yè)面卡頓怔锌。而且寥粹,它可以將任意內(nèi)容(比如圖片)放入剪貼板。
不過埃元,目前還是 document.execCommand
使用的比較廣泛涝涤。雖然上面也說了 IE 對(duì) document.execCommand
不好,但是 Clipboard API 的兼容性更差亚情,F(xiàn)ireFox 和 Chome 在某些版本可能都會(huì)有問題妄痪。另外還有一個(gè)問題,使用 clipboard API 需要從權(quán)限 Permissions API 獲取權(quán)限之后楞件,才能訪問剪貼板內(nèi)容衫生,這樣會(huì)嚴(yán)重影響用戶體驗(yàn)。用戶:你讓我開權(quán)限土浸,是不是又想偷我密碼罪针??黄伊?