了解前端的复制功能
前言
前端的复制功能第一次使用的时候还记忆犹新,那时根本不知道怎么操作,复制它到底是怎么实现的,该怎么去触发,百度查看了不知道多少所谓的复制功能文档,每篇都有不一样的用法,我总想着有一天能搞清楚这些,毕竟现在复制功能已经web功能开发中离不开了。
execCommand API
浏览器在document对象上暴露了execCommand方法,该方法允许使用者通过输入“命令”的方式来操作 可编辑内容区域 的元素。
可编辑内容区域被官方认为是contenteditable="true"
的html元素,但按道理,input这些应该也是算可编辑内容区域的。
这些都不重要,我们这次关注的是该api提供的命令:copy
拷贝当前选中内容到剪贴板。启用这个功能的条件因浏览器不同而不同,而且不同时期,其启用条件也不尽相同。使用之前请检查浏览器兼容表,以确定是否可用。
我们的复制功能也就是使用了该命令,需要注意一点,复制的内容必须是选中的内容,所以我们如何选中就是一个重中之重。
从兼容度上看,execCommand支持到了安卓4.4,但是我个人测试发现copy命令是不支持的,可能是浏览器版本的问题。
execCommand函数有三个参数:
aCommandName
一个string类型的命令文本;命令列表aShowDefaultUI
是否展示用户界面,一般为 false。Mozilla 没有实现。(非必填)aValueArgument
一些特殊命令的额外参数,比如命令insertImage需要提供图片url,这个参数就是url,默认为null(非必填)
const bool = document.execCommand(aCommandName, aShowDefaultUI, aValueArgument);
需要注意我们在调用execCommand之前需要先选中文本。
execCommand也是有返回值的,他会返回一个布尔值,表示该次操作是否成功,但是官方不推荐使用这个返回值来判断浏览器兼容性。
备注:在调用一个命令前,不要尝试使用返回值去校验浏览器的兼容性
所以,有一个专门的api用于判断浏览器的支持度:queryCommandSupported
queryCommandSupported Api
它的支持度基本和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种办法:
innerText
获取到该元素的所有文本- 通过
Selection
和Range
实现选中元素文本
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()
方法来选中文本,我们可能需要通过Selection
、Range
的方式来选中文本,由于我没有该测试环境,大家就自行测试了。
创建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
execCommand
和queryCommandSupported
已经属于弃用状态了,不确定未来什么时候会放弃支持,不过从目前浏览器的支持来看,短期内应该是不可能不可用的,但是我们还是需要了解下未来的复制操作该用什么。
clipboard是一个基于promise的异步api,通过navigator
调用,从语义上更加明确,剪切板的功能属于浏览器提供的。
mdn文档:clipboard
从支持度上来讲现在还是比较惨淡,但是api的使用非常简洁,期待它的完善。
结语
经过几个小时的编写才有了该文章,第一是能让自己能对复制功能有更加清晰的了解,第二也是想将完整的知识分享给需要的人,一起成长。
我相信,完整看完这篇文章后,以后对于复制功能应当是手到擒来了。
本文系作者 @木灵鱼儿 原创发布在木灵鱼儿站点。未经许可,禁止转载。
全部评论 1
安琪拉的通话
Google Chrome Windows 10