关于ClipboardJs复制失败的一些尝试

842 阅读5分钟

在后台管理系统中,通过点击按钮复制文本的需求很是普遍,使用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>