前言

前端的复制功能第一次使用的时候还记忆犹新,那时根本不知道怎么操作,复制它到底是怎么实现的,该怎么去触发,百度查看了不知道多少所谓的复制功能文档,每篇都有不一样的用法,我总想着有一天能搞清楚这些,毕竟现在复制功能已经web功能开发中离不开了。

execCommand API

浏览器在document对象上暴露了execCommand方法,该方法允许使用者通过输入“命令”的方式来操作 可编辑内容区域 的元素。

可编辑内容区域被官方认为是contenteditable="true"的html元素,但按道理,input这些应该也是算可编辑内容区域的。

这些都不重要,我们这次关注的是该api提供的命令:copy

拷贝当前选中内容到剪贴板。启用这个功能的条件因浏览器不同而不同,而且不同时期,其启用条件也不尽相同。使用之前请检查浏览器兼容表,以确定是否可用。

我们的复制功能也就是使用了该命令,需要注意一点,复制的内容必须是选中的内容,所以我们如何选中就是一个重中之重。

从兼容度上看,execCommand支持到了安卓4.4,但是我个人测试发现copy命令是不支持的,可能是浏览器版本的问题。

execCommand函数有三个参数:

  1. aCommandName 一个string类型的命令文本;命令列表
  2. aShowDefaultUI 是否展示用户界面,一般为 false。Mozilla 没有实现。(非必填)
  3. aValueArgument 一些特殊命令的额外参数,比如命令insertImage需要提供图片url,这个参数就是url,默认为null(非必填)
const bool = document.execCommand(aCommandName, aShowDefaultUI, aValueArgument);

需要注意我们在调用execCommand之前需要先选中文本。

execCommand也是有返回值的,他会返回一个布尔值,表示该次操作是否成功,但是官方不推荐使用这个返回值来判断浏览器兼容性。

备注:在调用一个命令前,不要尝试使用返回值去校验浏览器的兼容性

所以,有一个专门的api用于判断浏览器的支持度:queryCommandSupported

queryCommandSupported Api

mdn:queryCommandSupported

它的支持度基本和execCommand差不多,所以这两个是可以配套使用的。

const isSupported = document.queryCommandSupported(command);

queryCommandSupported支持一个参数,这个参数就是string类型的命令文本,比如我们需要检测浏览器是否支持copy,可以这么写:

const isSupported = document.queryCommandSupported("copy");
if (isSupported ) {
  //支持
} else {
  //不支持
}

它返回一个布尔值,用于确定浏览器是否支持指定的编辑指令。

如何选中文本

上面已经介绍了我们改如何正确使用execCommand api,下面就是我们的大头,如果正确的选中文本。

纯文本

纯文本的选中是项目开发最常见的一种方式,我们通过某种方式拿到了需要复制的文本,然后需要将其进行复制,然后让用户自行粘贴到想要地方,那么常见的做法如下:

/** 选中文本 */
function select(value: string) {
  const textarea = document.createElement("textarea");
  textarea.value = value;
  //只读防止选中而产生的页面跳动
  textarea.readOnly = true;
  //不让元素展示出来
  textarea.style.position = "absolute";
  textarea.style.left = "-9999px";
  textarea.style.top = "-9999px";
  textarea.style.opacity = "0";
  document.body.appendChild(textarea);
  //选中元素的文本
  textarea.select();
  textarea.setSelectionRange(0, value.length); //这个应该是兼容作用
  //用完删除
  textarea.remove();
}

可以从代码中看出,常见的做法就是创建一个textarea元素,然后让其无法被用户察觉的情况下,通过dom的select方法进行选中文本。

选中完还需要触发copy命令,但是我们这只讲怎么选中,所以代码并不是完整的。

更新补充:23年测试的时候发现textarea.style.visibility = "hidden";加上后会导致无法复制,估计又是什么标准更了导致无法select选中了。

dom元素的选中

有时候我们不一定是使用string的文本,我们希望他能选中我们指定dom的文本进行复制操作,那么我们就需要另寻方法。

dom中input元素和textarea就按照上面string的方式选中即可。

select元素

我们需要获取select元素的value值,然后再调用纯文本的选中方法。

function select(dom: HTMLElement) {
  let text = "";
  if (dom instanceof HTMLSelectElement) {
    text = dom.value;
  }
  const textarea = document.createElement("textarea");
  textarea.value = text;
  //只读防止选中而产生的页面跳动
  textarea.readOnly = true;
  //不让元素展示出来
  textarea.style.position = "absolute";
  textarea.style.left = "-9999px";
  textarea.style.top = "-9999px";
  textarea.style.opacity = "0";
  document.body.appendChild(textarea);
  //选中元素的文本
  textarea.select();
  textarea.setSelectionRange(0, text.length); //这个应该是兼容作用
  //用完删除
  textarea.remove();
}

select方法只能选中没有被display:none;的元素,所以这点需要注意了。

其他元素

对于其他元素,比如我们想复制一个div元素中的内容,有2种办法:

  1. innerText 获取到该元素的所有文本
  2. 通过SelectionRange实现选中元素文本

innerText

这种方式其实就是走的老路子了

function select(dom: HTMLElement) {
  const text = dom.innerText;
  const textarea = document.createElement("textarea");
  textarea.value = text;
  //只读防止选中而产生的页面跳动
  textarea.readOnly = true;
  //不让元素展示出来
  textarea.style.position = "absolute";
  textarea.style.left = "-9999px";
  textarea.style.top = "-9999px";
  textarea.style.opacity = "0";
  document.body.appendChild(textarea);
  //选中元素的文本
  textarea.select();
  textarea.setSelectionRange(0, text.length); //这个应该是兼容作用
  //用完删除
  textarea.remove();
}

由于一些兼容上的问题,部分设备无法通过select()方法来选中文本,这也是该种方式的一个缺陷。

创建选区

Selection对象表示页面的文本选区,我们如果需要将一个dom元素的文本选中,需要通过该对象的addRange方法传入一个Range对象;Range表示一个区域,也就是一个包含节点与文本节点的一部分的文档片段,这个对象有一个selectNodeContents方法,它接受一个node节点,也就是dom元素。

const div = document.getElementById("box");

//获取Selection对象
const selection = window.getSelection();
//创建区域
const range = document.createRange();
range.selectNodeContents(div);
//先移除所有选区
selection.removeAllRanges(); 
//传入区域
selection.addRange(range);
//此时div的文本会被选中
//省略复制操作
//取消选中
selection.removeAllRanges();

removeAllRanges会移除所有的range对象,还有一个精准移除的方法removeRange,它接收一个range对象,触发后便会移除该range对象。

selection.removeRange(range);

效果也是一样,看自己业务需求。

由于选区的方式不需要使用select()来进行选中文本,所以它也是各大第三方库首选的一种方法,但是他也是有一个缺陷的。

比如我有一个contenteditable属性元素,他就会导致在可编辑模式下,如果元素被用户focus了一次,就会导致在使用这种方式时,再次被focus,哪怕我们最后移除了range对象。

contenteditable 开头有提到过,是一个html元素的属性,它可以接受一个布尔值或者无值空值,不写默认就是允许编辑,所以他会有以下4种写法。

<div id="box" contenteditable>可编辑</div>

<div id="box" contenteditable="">可编辑</div>

<div id="box" contenteditable="true">可编辑</div>

<div id="box" contenteditable="false">不可编辑</div>

不过现在很少使用该属性,所以其实也不用考虑这种情况,如果真的有,特殊处理下就行了,比如暂时关闭元素的contenteditable属性。

let isEditable = false;
if (box.hasAttribute("contenteditable") && box.getAttribute("contenteditable") !== "false") {
  isEditable = true;
}
//暂时移除,用完回复
if (isEditable) {
  box.removeAttribute("contenteditable");
}

...选中操作

//恢复
if (isEditable) {
  box.setAttribute("contenteditable", "true");
}

警告:非常重要的一点

不管上述的逻辑怎么完善,最终我们直接通过js触发复制操作,而不是用户去触发的,那么我们得到的结果永远是复制失败。

因为浏览器为了安全考虑,防止脚本恶意操作剪贴板,要求剪贴板的操作,必须是由用户真实的操作触发的,比如用户点击一个按钮,我们可以在click事件回调中触发copy操作,此时复制是成功的,如果用户没有任何操作,通过js脚本自行触发copy,得到的结果将是false。

事实上这个用户真实操作比较广义,比如我页面有一个5秒的定时器,它自动运行,其回调就是复制操作,用户只需要在页面点击啥的,操作一次,哪怕不再事件回调里触发,我们的复制操作也是可以成功的。这一点在最新的火狐和谷歌浏览器测试都是相同表现。

完整代码

基于以上所有的知识,我们就可以拼凑一个相对晚上的复制函数。补充了下对于Shadow DOM的兼容。

/**
 * @description: 复制函数
 * @param {string | HTMLElement} val
 * @Date: 2022-09-04 23:26:12
 * @Author: mulingyuer
 */
export function copy(val: string | HTMLElement | ShadowRoot): Promise<boolean> {
  return new Promise((resolve, reject) => {
    /** 是否允许复制 */
    const isSupported = document.queryCommandSupported("copy");
    if (!isSupported) {
      console.warn("当前设备不支持copy功能");
      return reject(false);
    }

    /** 确认需要选中的内容 */
    let copyVal: string | HTMLElement | ShadowRoot = "";
    if (typeof val === "string") {
      copyVal = val;
    } else if (
      val instanceof HTMLInputElement ||
      val instanceof HTMLTextAreaElement ||
      val instanceof HTMLSelectElement
    ) {
      copyVal = val.value;
    } else {
      copyVal = val;
    }

    /** 文本复制与dom的复制 */
    let status: boolean;
    if (typeof copyVal === "string") {
      status = copyString(copyVal);
    } else {
      status = copyNodeText(copyVal);
    }

    if (status) {
      return resolve(true);
    } else {
      return reject(false);
    }
  });
}

/**
 * @description: 复制纯文本
 * @param {string} val
 * @Date: 2022-09-04 23:42:46
 * @Author: mulingyuer
 */
function copyString(val: string): boolean {
  const textarea = document.createElement("textarea");
  textarea.value = val;
  //只读防止选中而产生的页面跳动
  textarea.readOnly = true;
  //不让元素展示出来
  textarea.style.position = "absolute";
  textarea.style.left = "-9999px";
  textarea.style.top = "-9999px";
  textarea.style.opacity = "0";
  document.body.appendChild(textarea);
  //选中元素的文本
  textarea.select();
  textarea.setSelectionRange(0, val.length); //这个应该是兼容作用
  //复制命令
  const copyFlag = document.execCommand("copy");
  //用完删除
  textarea.remove();

  return copyFlag;
}

/**
 * @description: 复制节点文本
 * @param {HTMLElement} val
 * @Date: 2022-09-04 23:44:52
 * @Author: mulingyuer
 */
function copyNodeText(node: HTMLElement | ShadowRoot): boolean {
  let isEditable = false;
  if (node instanceof HTMLElement) {
    if (node.hasAttribute("contenteditable") && node.getAttribute("contenteditable") !== "false") {
        isEditable = true;
    }
    //暂时移除,用完回复
    if (isEditable) {
        node.removeAttribute("contenteditable");
    }
  }

  //选中元素的文本
  //获取Selection对象
  const selection = window.getSelection()!;
  //创建区域
  const range = document.createRange();
  range.selectNodeContents(node);
  //先移除所有选区
  selection.removeAllRanges();
  //传入区域
  selection.addRange(range);
  //复制
  const copyFlag = document.execCommand("copy");
  //取消选中
  selection.removeAllRanges();
  //恢复
  if (isEditable) {
    (node as HTMLElement).setAttribute("contenteditable", "true");
  }

  return copyFlag;
}

使用:

document.body.addEventListener("click", function () {
  copy("我是复制的内容")
    .then((res) => {
      console.log(res);
    })
    .catch((err) => {
      console.log(err);
    });
});

如果是Shadow DOM,你需要提供 ShadowRoot对象。

document.body.addEventListener("click", function () {
  //假设div元素里面有shadowRoot
  copy(div.shadowRoot)
    .then((res) => {
      console.log(res);
    })
    .catch((err) => {
      console.log(err);
    });
});

额外的知识

ios低版本兼容问题

低版本的ios系统(10.2吧,应该是?)可能无法通过元素的select()方法来选中文本,我们可能需要通过SelectionRange的方式来选中文本,由于我没有该测试环境,大家就自行测试了。

创建textarea的一些兼容项

clipboard.js 第三方复制库中,对创建textarea是这样的:

/**
 * Creates a fake textarea element with a value.
 * @param {String} value
 * @return {HTMLElement}
 */
export default function createFakeElement(value) {
  const isRTL = document.documentElement.getAttribute('dir') === 'rtl';
  const fakeElement = document.createElement('textarea');
  // Prevent zooming on iOS
  fakeElement.style.fontSize = '12pt';
  // Reset box model
  fakeElement.style.border = '0';
  fakeElement.style.padding = '0';
  fakeElement.style.margin = '0';
  // Move element out of screen horizontally
  fakeElement.style.position = 'absolute';
  fakeElement.style[isRTL ? 'right' : 'left'] = '-9999px';
  // Move element to the same position vertically
  let yPosition = window.pageYOffset || document.documentElement.scrollTop;
  fakeElement.style.top = `${yPosition}px`;

  fakeElement.setAttribute('readonly', '');
  fakeElement.value = value;

  return fakeElement;
}

他会对textarea元素设置字体和流向,以及部分样式的重置,还有一个比较特殊的就是控制了top的值为当前scrollTop,虽然不懂为啥,如果碰到一些创建textarea的问题,可以来这取取经。

未来的 clipboard API

execCommandqueryCommandSupported已经属于弃用状态了,不确定未来什么时候会放弃支持,不过从目前浏览器的支持来看,短期内应该是不可能不可用的,但是我们还是需要了解下未来的复制操作该用什么。

clipboard是一个基于promise的异步api,通过navigator调用,从语义上更加明确,剪切板的功能属于浏览器提供的。

mdn文档:clipboard

从支持度上来讲现在还是比较惨淡,但是api的使用非常简洁,期待它的完善。

结语

经过几个小时的编写才有了该文章,第一是能让自己能对复制功能有更加清晰的了解,第二也是想将完整的知识分享给需要的人,一起成长。

我相信,完整看完这篇文章后,以后对于复制功能应当是手到擒来了。

分类: 前端功能 标签: copyclipboard复制execCommandqueryCommandSupported

评论

全部评论 1

  1. 安琪拉的通话
    安琪拉的通话
    Google Chrome Windows 10
    测试的测试

目录