前端实现复制文字和图片,原来这么简单!
1.功能需求
实习工作中,遇到一个需求,需要完成点击复制的功能,当文字过长的时候,让用户手拖再ctrl+c这种方式体验就不是很好了,如果可以点击一下直接复制就是一种不错的优化用户体验的方式。
经过查阅文档,网络上完成这个功能大多使用两大类方法
第一种是以document.execCommand() 方法为主,无论是手写还是使用clipboard.js插件都是依赖的这个方法,但是在MDN 文档中已经显示过时了。
第二种是用了navigator.clipboard的方法,避免了过时问题,但是在复制图片的时候会有一定的浏览器兼容性问题
2.document.execCommand('copy')
这个方法其实就是在模拟用户选择元素然后右键复制的动作。尽管MDN已经显示这个方法过时了,但是仅针对copy这个指令,大部分主流浏览器都可以支持,所以这个方法仍然可以作为一种实现问题的方案。
2.1 基本用法
根据MDN文档学习本方法的传参和返回值
语法
bool = document.execCommand(aCommandName, aShowDefaultUI, aValueArgument)
这个方法可以传3个参数,并且会返回一个布尔值
返回值
先从返回值开始,返回值相对比较简单,如果返回的值是false就表示浏览器不支持使用这个操作,反之浏览器支持该操作就返回true。
虽然这个返回值看似可以用来提前判断浏览器兼容性,但是文档中不推荐在调用一个命令前,尝试使用返回值去校验浏览器的兼容性
参数值
参数一共可以传3个,但是使用复制命令的时候只需要传第一个参数就可以。这里简单介绍一下3个参数
- aCommandName:一个字符串类型的参数,是命令的名称,比如复制用到的copy,剪切用到的cut
- aShowDefaultUI:一个布尔类型的参数,表示是否展示用户界面,一般为false,Mozilla 没有实现
- aValueArgument:一些命令(例如 insertImage)需要额外的参数(insertImage 需要提供插入 image 的 url),默认为 null。
简单举例
以本文主要讲的复制命令为例子:document.execCommand('copy')
指令兼容性问题
前文讲到,MDN不推荐在调用一个命令前,尝试使用返回值去校验浏览器的兼容性,那么就需要用另外的方法去检测浏览器是否支持某个指令,浏览器为我们提供了一个方法叫document.queryCommandSupported(),使用这个方法可以检测浏览器是否支持某个指令,这个方法比较简单,只有1个参数,参数就传指令字符串,方法的返回值是一个布尔值表示当前浏览器是否支持这个指令。
举例如下:
if(document.queryCommandSupported && document.queryCommandSupported('copy')){
//先检测是否支持document.queryCommandSupported和copy指令
//如果都支持直接执行指令
document.execCommand('copy')
}
MDN文档中提到,document.queryCommandSupported也被弃用了,但是为了兼容性依然保留可用,当我们使用document.execCommand的时候仍然可以用document.queryCommandSupported来检测是否支持。同时,它的浏览器兼容性也是比较好的,大部分主流浏览器都支持。
2.2 Selection Api
复制文本这个操作对比复制图片是相对比较简单的,一共包含2大步
一是选中要复制的元素
二是执行复制指令。
执行复制指令在前面的基本语法里已经讲到了,直接调用document.execCommand('copy')就可以了。剩下要做的便是先选中元素了。下面便介绍一下和选中元素相关的selection api
MDN文档上写道:Selection
对象表示用户选择的文本范围或插入符号的当前位置。它代表页面中的文本选区,可能横跨多个元素。文本选区由用户拖拽鼠标经过文字而产生。如果要获取用于检查或修改的 Selection
对象,可以调用 window.getSelection()
方法。
这看起来就十分的官方和抽象,简单的来说Selection 对象所对应的是用户所选择的 ranges
(区域),俗称 拖蓝。上图中的拖蓝就是selection对象中的一个区域。
通过getRangeAt方法可以获取到具体的选中区域
let selection = window.getSelection() //获取selection对象
let range = selection.getRangeAt(0) //获取第一个选中的区域
除了获取选区中的区域之外,我们还可以通过 document.createRange()创建一个新的区域,然后将该区域添加到选区中
<body>
<div id="hello">你好</div>
<div id="yes">是的</div>
</body>
<script>
let selection = window.getSelection() //获取selection对象
const hello = document.querySelector('#hello')
if(selection.rangeCount > 0){
//如果有已经选中的区域,直接全部去除
selection.removeAllRanges()
}
let range = document.createRange() //创建range
range.selectNode(hello) //range选中hello
selection.addRange(range) //加入到选区中
</script>
效果如下,当代码执行后,你好这个元素被直接选中
加入区域的api包括range.selectNode和range.selectNodeContents。其中selectNode表示选中整个节点而selectNodeContents表示选中节点中的内容,针对文字的复制需要选中节点的内容,而图片的复制需要选中节点本身。
用法如下
<body>
<div id="hello">你好</div>
<div id="yes">是的</div>
<button class="btn">点击复制</button>
</body>
<script>
const yes = document.querySelector('#yes')
const selection = window.getSelection()
const range= document.createRange()
range.selectNode(yes)
range.selectNode(yes)
</script>
2.3复制文字
通过以上的selection api可以完成 创建selection对象-->选中节点内容-->添加到区域-->执行一下copy指令就可以完成复制文字了
<body>
<div id="hello">你好</div>
<div id="yes">是的</div>
<button class="btn">点击复制</button>
</body>
<script>
const btn = document.querySelector('.btn')
const hello = document.querySelector('#hello')
btn.addEventListener('click', () => {
let range = document.createRange() //创建range
range.selectNodeContents(hello) //range选中hello
let selection = window.getSelection() //获取selection对象
if (selection.rangeCount > 0) {
//如果有已经选中的区域,直接全部去除
selection.removeAllRanges()
}
selection.addRange(range) //加入到选区中
if (document.queryCommandSupported && document.queryCommandSupported('copy')) {
//先检测是否支持document.queryCommandSupported和copy指令
//如果都支持直接执行指令
document.execCommand('copy')
//去除选中区域,取消拖蓝效果
selection.removeAllRanges()
}
})
</script>
2.4复制图像
复制图像的操作是和复制文字基本相同的,只是需要在加入区域时选中整个节点,也就是把selectNodeContents方法换成selectNode
<body>
<div id="hello">你好</div>
<div id="yes">是的</div>
<img src="./test.png" alt="">
<button class="btn">点击复制</button>
</body>
<script>
const btn = document.querySelector('.btn')
const hello = document.querySelector('#hello')
const img = document.querySelector('img')
btn.addEventListener('click', () => {
let range = document.createRange() //创建range
range.selectNode(img) //range选中图像节点
let selection = window.getSelection() //获取selection对象
if (selection.rangeCount > 0) {
//如果有已经选中的区域,直接全部去除
selection.removeAllRanges()
}
selection.addRange(range) //加入到选区中
if (document.queryCommandSupported && document.queryCommandSupported('copy')) {
//先检测是否支持document.queryCommandSupported和copy指令
//如果都支持直接执行指令
document.execCommand('copy')
//去除选中区域,取消拖蓝效果
selection.removeAllRanges()
}
})
</script>
3.clipboard.js
clipboard.js是一个第三方库,也是使用了前文所讲到的document.execCommand('copy')来实现的点击复制,使用方便,但是只能用于文本的复制。
3.1安装和引入clipboard.js
使用npm安装
npm install clipboard --save
安装后在html文件内引入
<script src="dist/clipboard.min.js"></script>
或者使用CDN引入(这里只写了一种CDN引入方式,可以选择多种不同CDN方,具体请看https://github.com/zenorocha/clipboard.js/wiki/CDN-Providers)
<script src="https://cdn.jsdelivr.net/npm/clipboard@2.0.11/dist/clipboard.min.js"></script>
使用import的方式引入
import Clipboard from "clipboard";
3.2基本使用
初始化
直接创建一个ClipboardJS对象,传的参数可以是选择器字符串或者是DOM元素或者是DOM元素列表
new ClipboardJS('.btn') // import方式为 new Clipboard('.btn')
实现点击复制文字功能
初始化完后,可以到要绑定的对应元素下添加data-clipboard-target属性,属性值是要复制的元素的选择器,这里要复制的元素是 ‘是的’ 那个div,所以属性值就写#yes。不进行其他配置时,我们点击按钮,触发点击事件后,就可以完成复制文字 ‘是的’ 了。
<body>
<div id="hello" >你好</div>
<div id="yes">是的</div>
<button class="btn" data-clipboard-target="#yes">点击复制</button>
</body>
<script src="https://cdn.jsdelivr.net/npm/clipboard@2.0.11/dist/clipboard.min.js"></script>
<script>
new ClipboardJS('.btn') // import方式为 new Clipboard('.btn')
</script>
点击后,是的这个元素被选中(拖蓝),使用ctrl+v可以完成文字的复制,效果已经达到。
此时有2个问题需要优化
- 复制的内容必须是页面上的DOM元素,能不能是自己设定的?
- 拖蓝的效果不是很好看,如何复制文字不显示选中效果?
这时就要用到一个新的属性data-clipboard-text,属性值就是希望动态复制的内容。对ClipboardJS绑定的元素设置这个属性就可以动态复制自己设定的内容,此时就不需要再设置data-clipboard-target属性了(如果同时写2个属性,data-clipboard-text优先)。以下代码是一个写死的简单展示,真实使用的时候属性值要用js设置成需要复制的值。
<body>
<div id="hello" >你好</div>
<div id="yes">是的</div>
<button class="btn" data-clipboard-text="动态设置的内容">点击复制</button>
</body>
<script src="https://cdn.jsdelivr.net/npm/clipboard@2.0.11/dist/clipboard.min.js"></script>
<script>
new ClipboardJS('.btn') // import方式为 new Clipboard('.btn')
</script>
上图显示点击之后,复制内容成功,这样没有选中元素的效果,不会拖蓝,交互效果更好的同时又能动态设置内容
3.3更多用法
data-clipboard-action属性
data-clipboard-action属性可以决定执行的操作,这个属性有2个可选值copy或者是cut,默认是copy也就是复制,前面的所有代码中都没有出现这个属性,是直接使用的默认值copy。cut剪切,只能在input和textarea标签中使用,显然之前的div标签是无法使用的。使用方法仍是对ClipboardJS绑定的元素设置这个属性。
<button class="btn" data-clipboard-text="动态设置的内容" data-clipboard-action="copy">点击复制</button>
事件处理
事件处理可以让用户设置复制或剪切成功或者失败的回调,事件名分别是success和error。可以通过on在ClipboardJS实例对象身上绑定success和error事件处理的回调。以下示例写了最简单alert打印成功和失败
const clipboard = new ClipboardJS('.btn') // import方式为 new Clipboard('.btn')
clipboard.on('success',function(){
alert('复制成功')
})
clipboard.on('error',function(){
alert('复制失败')
})
纯JS写法
如果不想改HTML,加入过多的属性,可以直接使用纯JS写法来初始化ClipboardJS对象构造函数中传入第二个参数,第二个参数为对象,如下的示例中仅用完成js就完成了动态设置复制内容。设置配置对象的text方法,返回值就是要复制的内容
new ClipboardJS('.btn',{
text: function(){
return '动态复制的内容'
}
})
设置配置对象的target方法,返回值就是要复制的元素
new ClipboardJS('.btn',{
target: function (){
return document.querySelector('#hello')
}
})
经过测试,当html中设置属性的同时,又在构造函数里加入配置项,以js构造函数配置项为准(优先级高)
<body>
<div id="hello">你好</div>
<div id="yes">是的</div>
<button class="btn" data-clipboard-target="#hello">点击复制</button>
</body>
<script src="https://cdn.jsdelivr.net/npm/clipboard@2.0.11/dist/clipboard.min.js"></script>
<script>
new ClipboardJS('.btn',{
target(){
return document.querySelector('#yes')
}
})
</script>
销毁对象
如果使用的是单页应用程序,可能希望更精确地管理DOM的生命周期。可以使用destroy方法销毁对象
var clipboard = new ClipboardJS('.btn');
clipboard.destroy();
3.4源码分析
看了之前的api,想了解一下这个所谓的简单的复制库是如何实现的,于是打开了源码开始分析一下
源码地址 https://github.com/zenorocha/clipboard.js
初始化
构造函数里面传2个参数,第一个trigger即触发点击的元素对象,第二个options配置项。从最简单的例子来看,只需要传一个trigger参数就可以实现功能,那就先不管options,直接看与trigger有关的listenClick方法。
listenClick方法调用了一个第三方库的listen方法绑定了click事件和对应的回调函数this.onClick,在onclick方法中,调用了ClipboardActionDefault方法,并且传了对应的几个配置项参数,action container,target,text,这些值都是this.xxx方法,这几个方法又是在哪定义的呢?
找了一下类内部,定义这些方法的地方是在前文构造函数里的this.resolveOptions方法里
resolveOptions方法里的defaultAction,defaultText等等方法都是类似的,都是调用了一个getAttributeValue方法去获取html模板上的属性值
getAttributeValue方法如下,比较简单
ClipboardActionDefault
上面跳了这么多方法虽然不难,但是也有点绕,主要还是在干一件事,那就是通过定义来准备好ClipboardActionDefault这个方法的参数。这时候就要看一下ClipboardActionDefault这个方法在干什么。
简单来看,这个方法主要分4个if判断,前2个if就是一些条件的判断,判断action只能是复制或者剪切,还有就是判断要复制的目标节点的节点类型和readonly问题等等,此处不展开去研究,有兴趣者可以点击本部分开始处的源码链接下载。
后2个if判断中的内容如下,分别用于判断是否有text值和target值,这2个值也是通过本库的核心属性data-clipboard-text和data-clipboard-target在html中获取的(或者在js配置项里获取)。判断完后就调用了ClipboardActionCopy或者ClipboardActionCut方法去实现复制或者剪切功能。
ClipboardActionCopy
这个方法就开始进行文本的复制了,首先判断要复制的目标是普通的字符串(通过data-clipboard-text设置)还是节点(通过data-clipboard-target设置),如果是文本或者不是普通的输入元素,直接调用fakeCopyAction方法执行复制操作。
fakeCopyAction先创建了虚拟元素,然后把这个元素插入dom里,最后执行选中+复制操作
创建虚拟元素的方法也比较简单,先通过原生方法createElement创建了一个textarea元素,然后把它隐藏。创建这种输入类元素的好处就是可以直接去修改它的value,最后一步操作就是把文本text赋值给textarea
创建完虚拟元素就要处理选中问题了,这里调用了select方法,方法内部根据3种元素类型设置了不同的处理对策,select元素只要focus后赋值就好。输入元素可以调用原生的select方法来选中元素,而普通元素就需要使用之前讲到的selection api去获取range和添加range了
function select(element) {
var selectedText;
if (element.nodeName === 'SELECT') {
//针对select元素的处理
element.focus();
selectedText = element.value;
}
else if (element.nodeName === 'INPUT' || element.nodeName === 'TEXTAREA') {
//选中输入元素
var isReadOnly = element.hasAttribute('readonly');
if (!isReadOnly) {
element.setAttribute('readonly', '');
}
element.select();
element.setSelectionRange(0, element.value.length);
if (!isReadOnly) {
element.removeAttribute('readonly');
}
selectedText = element.value;
}
else {
//普通元素选中
if (element.hasAttribute('contenteditable')) {
element.focus();
}
var selection = window.getSelection();
var range = document.createRange();
range.selectNodeContents(element);
selection.removeAllRanges();
selection.addRange(range);
selectedText = selection.toString();
}
return selectedText;
}
最后的command('copy')也就是对执行复制指令这个方法的简单封装,做了一下兼容性的处理。
4. navigator.clipboard
前面的document.execCommand和第三方库clipboard.js都非常的好用,但是他们可能面临被弃用的风险,那么该怎么解决复制粘贴这个问题呢? H5新推出的clipboard api是 处理复制粘贴相关的api,可以很好的解决这个问题。用promise的方式把数据写入剪贴板,避免了页面的卡顿。
4.1 复制文字
Clipboard对象
使用Clipboard api时我们不需要手动创建Clipboard对象,而是通过navigator.clipboard来获取
打印出Clipboard对象后可以看出,这个对象有4个方法,分为两大类,write和read类。其中与复制相关的是write类表示把数据写入剪贴板,和粘贴相关的是read类表示从剪贴板里面读取数据
writeText方法
Clipboard对象中的writeText方法可以用于复制文字,也是非常简单易用的一个方法。
参数:传一个字符串参数,即要复制的内容
返回值: 一个promise对象,如果成功复制则是成功的promise,如果写入剪贴板失败(复制失败)则是失败的promise
示例如下:先创建了一个clipboard对象,然后直接调用writeText方法复制文字123
navigator.clipboard.writeText('123')
根据之前的html结构,使用Clipboard api完成文字的复制
默认情况下,会为当前的激活的页面自动授予剪贴板的写入权限。出于安全方面考虑,这里我们还是先主动向用户请求剪贴板的写入权限,如果被授权,就可以调用上面的方法直接完成复制了。
<body>
<div id="hello">你好</div>
<div id="yes">是的</div>
<img src="./test.png" alt="">
<button class="btn">点击复制</button>
</body>
<script>
const btn = document.querySelector('.btn')
const hello = document.querySelector('#hello')
const img = document.querySelector('img')
btn.addEventListener('click', async () => {
const { state } = await navigator.permissions.query({
// 出于安全方面考虑,这里我们还是主动向用户请求剪贴板的写入权限
name: "clipboard-write",
});
if (state == 'granted') {
navigator.clipboard.writeText(hello.innerHTML)
}
})
</script>
4.2 复制图像
write方法
write方法除了支持文本数据之外,还支持将图像数据写入到剪贴板,调用该方法后会返回一个 Promise 对象。
以下是简单的使用案例,先通过 Blob API 创建 Blob 对象,然后使用该 Blob 对象来构造 ClipboardItem 对象,最后再通过 write 方法把数据写入到剪贴板,复制了文字(当前页面的地址)
<button onclick="copyPageUrl()">拷贝当前页面地址</button>
<script>
async function copyPageUrl() {
const text = new Blob([location.href], {type: 'text/plain'});
try {
await navigator.clipboard.write(
new ClipboardItem({
"text/plain": text,
}),
);
console.log("页面地址已经被拷贝到剪贴板中");
} catch (err) {
console.error("页面地址拷贝失败: ", err);
}
}
</script>
复制图片案例
了解了write的基本用法,那使用Clipboard对象复制图片也有办法了,只要先把图像变成Blob对象,然后构造 ClipboardItem 对象,最后再调用write方法就好。
可是,如何把一个img标签里的图片转换成Blob对象呢?
首先从Blob对象的构造函数开始,MDN文档写了Blob构造函数所需要的参数
var aBlob = new Blob( array, options );
array 是一个由ArrayBuffer, ArrayBufferView, Blob, DOMString 等对象构成的 Array ,或者其他类似对象的混合体,它将会被放进 Blob。DOMStrings 会被编码为 UTF-8。
options 是一个可选的BlobPropertyBag字典,它可能会指定如下两个属性:
- type,默认值为 "",它代表了将会被放入到 blob 中的数组内容的 MIME 类型。
- endings,默认值为"transparent",用于指定包含行结束符\n的字符串如何被写入。它是以下两个值中的一个:"native",代表行结束符会被更改为适合宿主操作系统文件系统的换行符,或者 "transparent",代表会保持 blob 中保存的结束符不变
根据文档中显示,我们需要先准备一个对应的数组,然后才能构造Blob对象,也就是要把图片转成二进制数据。
分步骤实现把图片转换成Blob
- 把img图像画在canvas画布上
- 调用canvas的toDataURL方法,获取图片的base64编码
- 调用atob方法,把base64编码的数据转换成二进制数据
- 根据转换后的二进制数据,创建一个视图,此视图将把缓冲内的数据格式化为一个 8 位无符号整数数组,也就是获得了一个ArrayBufferView数组(关于ArrayBuffer和ArrayBufferView的内容详细可查阅 JavaScript 类型化数组)
下方代码完成了基本的功能实现:
微信输入框显示,可以完成复制
这个方法在浏览器兼容性上仍存在一些问题,比如火狐可能就不支持ClipboardItem对象,此时只能用前文写的document.execCommand方法了。
<body>
<div id="hello">你好</div>
<div id="yes">是的</div>
<img style="width: 400px; height: 200px" src="./test.png" alt="">
<button class="btn">点击复制</button>
</body>
<script>
const btn = document.querySelector('.btn')
const hello = document.querySelector('#hello')
const img = document.querySelector('img')
btn.addEventListener('click', async () => {
const {
state
} = await navigator.permissions.query({
// 出于安全方面考虑,这里我们还是主动向用户请求剪贴板的写入权限
name: "clipboard-write",
});
if (state == 'granted') {
//创建canvas对象
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
const image = new Image()
image.src = img.src
image.onload = async () => {
canvas.width = image.width;
canvas.height = image.height;
context.drawImage(image, 0, 0, image.width, image.height); // img图片转成canvas
const imageDataUrl = canvas.toDataURL() //通过canvas获取base64编码
const binary = atob(imageDataUrl.split(',')[1]); // base64编码转二进制数据
const array = [];
for (let i = 0; i < binary.length; i++) {
array.push(binary.charCodeAt(i));
}
//二进制数据转Blob对象
const blob = new Blob([new Uint8Array(array)], {
type: 'image/png'
});
// 判断浏览器是否有ClipboardItem对象,有些浏览器不支持本方法
if (typeof ClipboardItem !== 'undefined') {
//把blob数据写入剪贴板
await navigator.clipboard.write([
new ClipboardItem({
[blob.type]: blob
})
])
}
}
}
})
</script>