在后台管理系统中,通过点击按钮复制文本的需求很是普遍,使用ClipboardJs实现也是比较常用的实现方案。当遇到复制失败的情况,我们如何处理呢?
1. 起源
bug群有客户反馈,项目中某个复制文本按钮点完提示复制失败,尝试多次都没成功。详细了解后,发现此处复制的数据和日期有关,只是在某个日期下复制失败,尝试的其它日期下复制成功。
2. 复现
由于各种原因,没办法在生产环境调试,尝试在预生产复现。同一天的数据,预生产未复现复制失败的问题。
经过对比后发现,点击复制按钮,查询一个接口,在接口成功返回后执行复制操作。在问题日期下,该接口在预生产环境的响应速度为2s左右,而在生产环境响应速度长达7s。难道复制失败跟接口响应速度有关???尝试调慢预生产的网速,发现在接口响应时长超过5s后,复制失败了。
为了进一步确定这个问题,尝试使用setTimeout模拟业务代码中的异步请求,从而最大程度还原业务代码中的场景。通过控制延时时长,发现:在延时时间设置在4s时,可以正常复制成功。而把延时时间设置成5s或者更长时,复制失败了。示例代码如下:
<template>
<div class="home">
<div @click="handleClick">click me</div>
<div
class="copy"
@click="handleCopy"
:data-clipboard-text="copyData"
data-clipboard-action="copy"
>
复制按钮
</div>
</div>
</template>
<script setup>
import Clipboard from "clipboard";
let copyData = "xxxxx";
const handleCopy = () => {
const clipboard = new Clipboard(".copy");
clipboard.on("success", () => {
alert("复制成功");
clipboard.destroy();
});
clipboard.on("error", () => {
alert("复制失败");
clipboard.destroy();
});
};
// 发现复制成功与否和延时时长有关
const handleClick = () => {
setTimeout(() => {
document.querySelector(".copy").click();
}, 5000);
};
</script>
3. 为甚呢
解决问题的关键就是先找到关键的问题...扒下源码瞅瞅呢...
从源码中可看到,决定执行success还是error回调的关键是在succeeded,即:document.execCommand的返回值,复制失败时,document.execCommand返回了false。
// 部分源码截取v2.0.4,clipboard\src\clipboard-action.js
...
/**
* Executes the copy operation based on the current selection.
*/
copyText() {
let succeeded;
try {
succeeded = document.execCommand(this.action);
}
catch (err) {
succeeded = false;
}
this.handleResult(succeeded);
}
/**
* Fires an event based on the copy operation result.
* @param {Boolean} succeeded
*/
handleResult(succeeded) {
this.emitter.emit(succeeded ? 'success' : 'error', {
action: this.action,
text: this.selectedText,
trigger: this.trigger,
clearSelection: this.clearSelection.bind(this)
});
}
...
4. 肿么办
既然找到了关键的问题,那就解决一下它咯
官方文档提示document.execCommand已弃用。至于为啥会产生上面的问题,查询无果 官方文档戳这里
尝试用下navigator.clipboard,由于该api比较新,且需要用户授权。所以选择在document.execCommand复制失败后,用它做一个重试操作。代码如下:
import { Message } from "element-ui";
/**
* 处理复制
* @param {String} text
* @returns Promise
*/
export default async function copyForExecCommand(text) {
try {
// 创建临时 textarea 元素
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.setAttribute('readonly', '');
textarea.style.position = 'absolute';
textarea.style.left = '-9999px';
document.body.appendChild(textarea);
// 选择并复制内容到剪贴板
textarea.select();
const result = document.execCommand('copy');
// 移除临时元素
document.body.removeChild(textarea);
if (result) {
console.log('复制成功[document.execCommand]');
return Promise.resolve();
}
// 复制失败,尝试使用navigator.clipboard复制
const queryOpts = { name: 'clipboard-read' }
const permissionStatus = await navigator.permissions.query(queryOpts)
if (permissionStatus.state == 'denied') {
Message({
message: '请前往浏览器设置允许剪贴板权限',
type: "error",
duration: 5 * 1000
});
return Promise.reject('无权限')
}
if (!navigator.clipboard) {
return Promise.reject('不支持复制')
}
// 尝试写入剪贴板
return navigator.clipboard.writeText(text);
} catch (e) {
console.err('复制失败[document.execCommand]:', e)
return Promise.reject(e)
}
}
使用示例:
async copyClick() {
try {
await copyForExecCommand(this.copyText)
this.$message({
message: "复制成功",
type: "success",
});
} catch (e) {
console.log('复制失败:' + e)
this.$message({
message: "复制文本失败,请重试!",
type: "error"
});
} finally {
this.copyText = "";
}
}
5. 其它
在使用clipboardJs的过程中发现:实例化Clipboard时,传入dom和选择器事件监听是不一样的。如果以当前点击的元素为实例化Clipboard传入的target,当直接传入dom时,会监听当前元素的点击事件;而传入选择器时,会监听body的点击事件。
源码中如下:
// good-listener\src\listen.js
...
function listen(target, type, callback) {
...
if (is.node(target)) {
return listenNode(target, type, callback);
}
...
else if (is.string(target)) {
return listenSelector(target, type, callback);
}
...
}
function listenNode(node, type, callback) {
node.addEventListener(type, callback);
return {
destroy: function() {
node.removeEventListener(type, callback);
}
}
}
function listenSelector(selector, type, callback) {
// 传入body
return delegate(document.body, selector, type, callback);
}
...
// delegate\src\delegate.js
function delegate(elements, selector, type, callback, useCapture) {
// Handle the regular Element usage
if (typeof elements.addEventListener === 'function') {
return _delegate.apply(null, arguments);
}
...
}
// useCapture为false,默认冒泡
function _delegate(element, selector, type, callback, useCapture) {
var listenerFn = listener.apply(this, arguments);
// 将事件绑定到body上
element.addEventListener(type, listenerFn, useCapture);
return {
destroy: function() {
element.removeEventListener(type, listenerFn, useCapture);
}
}
}
在这个前提下,在当前元素的点击事件中实例化Clipboard,传入当前dom,监听了当前元素的点击事件,首次点击复制不会生效,第二次才会生效。如果传入的是选择器,监听了body的点击事件,由于事件冒泡,body点击事件触发,首次点击当前元素就会复制成功。
示例代码如下:
<template>
<div class="home">
<div @click="handleCopy" class="copy">click me</div>
</div>
</template>
<script lang="ts" setup>
import Clipboard from "clipboard";
let copyData = "xxxxx";
const handleCopy = () => {
// 传入选择器
const clipboard = new Clipboard(".copy", {
text: () => copyData,
});
// 传入dom
// const clipboard = new Clipboard(document.querySelector(".copy"), {
// text: () => copyData,
// });
clipboard.on("success", () => {
alert("复制成功");
clipboard.destroy();
});
clipboard.on("error", () => {
alert("复制失败");
clipboard.destroy();
});
};
</script>
简言之,就是在当前元素的点击事件中监听当前元素的点击事件,监听的点击事件的回调首次不生效。而在当前元素的点击事件中监听父级元素的点击事件,由于事件冒泡,回调首次就会生效。用代码描述如下:
<template>
<div class="home">
<div @click="handleCopy" class="copy">click me</div>
</div>
</template>
<script setup>
let clickNum = 0;
const handleCopy = () => {
clickNum++;
const currentDom = document.querySelector(".copy");
currentDom?.addEventListener("click", () => {
console.log("点击了当前元素:" + clickNum);
});
document.querySelector(".home").addEventListener("click", () => {
console.log("点击了home元素:" + clickNum); // 由于事件冒泡,首次点击会输出
});
document.body.addEventListener("click", () => {
console.log("点击了body元素:" + clickNum); // 由于事件使用捕获传播,首次点击不会输出
}, true);
};
</script>