前端实现复制文字和图片,原来这么简单!

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个参数

  1. aCommandName:一个字符串类型的参数,是命令的名称,比如复制用到的copy,剪切用到的cut
  2. aShowDefaultUI:一个布尔类型的参数,表示是否展示用户界面,一般为false,Mozilla 没有实现
  3. 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个问题需要优化

  1. 复制的内容必须是页面上的DOM元素,能不能是自己设定的?
  2. 拖蓝的效果不是很好看,如何复制文字不显示选中效果?

这时就要用到一个新的属性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字典,它可能会指定如下两个属性:

  1. type,默认值为 "",它代表了将会被放入到 blob 中的数组内容的 MIME 类型。
  2. endings,默认值为"transparent",用于指定包含行结束符\n的字符串如何被写入。它是以下两个值中的一个:"native",代表行结束符会被更改为适合宿主操作系统文件系统的换行符,或者 "transparent",代表会保持 blob 中保存的结束符不变

 

根据文档中显示,我们需要先准备一个对应的数组,然后才能构造Blob对象,也就是要把图片转成二进制数据。

分步骤实现把图片转换成Blob

  1. 把img图像画在canvas画布上
  2. 调用canvas的toDataURL方法,获取图片的base64编码
  3. 调用atob方法,把base64编码的数据转换成二进制数据
  4. 根据转换后的二进制数据,创建一个视图,此视图将把缓冲内的数据格式化为一个 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>

 

 

posted @ 2023-08-29 11:14  Chenkai_Zhou  阅读(3470)  评论(4编辑  收藏  举报