Bootstrap

JS前端实现网页复制文本(兼容IOS端、微信浏览器 源码解析)

在这段时间前端网页开发中,遇到点击按钮,实现复制文本到剪切板中的需求,记录一下实现过程和遇到的坑:

首先介绍一下常用的2种复制方法:

1. 使用 document.execCommand() 方法:

function copyToClipboard(text) {
    const textArea = document.createElement('textArea')
    textArea.value = val
    
    //添加到dom节点中
    textArea.style.width = 0
    textArea.style.position = 'fixed'
    textArea.style.left = '-999px'
    textArea.style.top = '10px'
    textArea.setAttribute('readonly', 'readonly')
    document.body.appendChild(textArea)

    textArea.select()
    document.execCommand('copy')
    
    // 移除元素
    document.body.removeChild(textArea)
 }
 
 copyToClipboard("复制的内容")

该方法相对来说比较老,特别是 execCommand 已经弃用了,所以尽可能使用其他方法。

2. 使用 navigator.clipboard 方法:

function copyToClipboard(text) {
   navigator.clipboard
    .writeText(text)
    .then(() => {
      console.log("复制成功")
    })
    .catch((err) => {
      console.log("复制失败,请重试");
    });
 }

copyToClipboard("复制的内容")

该方法在 ios浏览器和微信浏览器 中复制无效,判断navigator.clipboard是存在的,但就是writeText时出错,显示拒绝,所以在chrome上运行的网页可以考虑使用该方法。

为什么在IOS和微信中会复制失败呢?

测试发现navigator.clipboard是存在的,但就是writeText时出错,显示拒绝
经查找,gpt给出的可能失败的原因有:

  • 安全策略对网页脚本的能力有限制,特别是在涉及到访问剪贴板这类敏感操作时。如果没有明确的用户交互,例如点击选中后复制,JavaScript可能无法成功执行复制命令。

在此情况下,我们可以尝试使用第三方库来解决无法复制的问题:

  1. 使用第三方库,例如 clipboard:
npm install clipboard --save

初始化时,new一个clipboard对象,同时通过类名绑定复制按钮,实现复制功能

useEffect(() => {
    var clipboard = new ClipboardJS(".copyBtn", {
      text: function (trigger) {
        return copyText; //copyText 需要复制的文本
      },
    })
      .on("success", (e) => {
        console.log("复制成功")
      })
      .on("error", (e) => {
        console.log("error", e);
        console.log("复制失败,请重试");
      });

    return () => {
      clipboard.destroy();
    };
  }, []);

经测试,在chrome和IOS、微信浏览器上都可以复制成功。

思考:为什么第三库可以绕过IOS和微信浏览器中的安全策略呢?

我们通过GitHub参考源码:查看源码

  1. 首先绑定 按钮 点击事件
listenClick(trigger) {
    this.listener = listen(trigger, 'click', (e) => this.onClick(e));
 }

初始化 new clipboard(“.btn”)后, listenClick函数负责监听click点击事件。会遍历所有的节点,给节点添加监听事件。

  1. 触发点击事件
/**
   * Defines a new `ClipboardAction` on each click event.
   * @param {Event} e
   */
  onClick(e) {
    const trigger = e.delegateTarget || e.currentTarget;
    const action = this.action(trigger) || 'copy';
    const text = ClipboardActionDefault({
      action,
      container: this.container,
      target: this.target(trigger),
      text: this.text(trigger),
    });

    // Fires an event based on the copy operation result.
    this.emit(text ? 'success' : 'error', {
      action,
      text,
      trigger,
      clearSelection() {
        if (trigger) {
          trigger.focus();
        }
        window.getSelection().removeAllRanges();
      },
    });
  }

当我们点击之后会触发执行this.onClick函数,会触发执行剪切板命令,并通过Emitter.emit进行来进行执行。

import ClipboardActionCut from './cut';
import ClipboardActionCopy from './copy';

/**
 * Inner function which performs selection from either `text` or `target`
 * properties and then executes copy or cut operations.
 * @param {Object} options
 */
const ClipboardActionDefault = (options = {}) => {
  //...省略

  // Define selection strategy based on `text` property.
  if (text) {
    return ClipboardActionCopy(text, { container });
  }

  // Defines which selection strategy based on `target` property.
  if (target) {
    return action === 'cut'
      ? ClipboardActionCut(target)
      : ClipboardActionCopy(target, { container });
  }
};

export default ClipboardActionDefault;

Clipboard除了指定点击按钮,还有可选项用于动态指定需要复制的目标,targettextcontainer

我们文章中直接使用的是text,意思就是接受一个 文本 作为复制项,所以直接给看上面代码中逻辑,会执行ClipboardActionCopy函数。

import select from 'select';
import command from '../common/command';
import createFakeElement from '../common/create-fake-element';

/**
 * Create fake copy action wrapper using a fake element.
 * @param {String} target
 * @param {Object} options
 * @return {String}
 */
const fakeCopyAction = (value, options) => {
  const fakeElement = createFakeElement(value);
  options.container.appendChild(fakeElement);
  const selectedText = select(fakeElement);
  command('copy');
  fakeElement.remove();

  return selectedText;
};

/**
 * Copy action wrapper.
 * @param {String|HTMLElement} target
 * @param {Object} options
 * @return {String}
 */
const ClipboardActionCopy = (
  target,
  options = { container: document.body }
) => {
  let selectedText = '';
  if (typeof target === 'string') {
    selectedText = fakeCopyAction(target, options);
  } else if (
    target instanceof HTMLInputElement &&
    !['text', 'search', 'url', 'tel', 'password'].includes(target?.type)
  ) {
    // If input type doesn't support `setSelectionRange`. Simulate it. https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/setSelectionRange
    selectedText = fakeCopyAction(target.value, options);
  } else {
    selectedText = select(target);
    command('copy');
  }
  return selectedText;
};

export default ClipboardActionCopy;

以上函数逻辑对三种类型做了判断:

  1. 字符串类型,说明目标是一个纯文本,就需要额外创建一个textarea标签,为什么不是input标签呢?是因为对于换行文本内容使用textarea会更友好。
  2. 第二种是节点类型,不为text', 'search', 'url', 'tel', 'password'这几种类型之一的,均通过创建textarea的方式进行拷贝。
  3. 第三种是为text', 'search', 'url', 'tel'类型其中之一的直接执行document.execCommand('copy')指令。

一般使用比较多会穿string类型,此时会执行fakeCopyAction函数。其中执行了createFakeElement(value)函数,就是创建一个textarea标签,并将需要复制的内容放进去,再执行const selectedText = select(fakeElement)操作,我们可以看下select函数:

function select(element) {
    var selectedText;

    if (element.nodeName === '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;
}

module.exports = select;

由上面代码可知:

如果元素是<input><textarea>标签:

  • 首先检查元素是否有readonly属性。如果有,则暂时移除该属性,以便能够更改和选中元素的内容。
  • 接着调用element.select()方法,这会使输入框或文本区域中的所有文本被选中。
  • 使用setSelectionRange()方法进一步确保整个文本被选中(从位置0到文本长度)。
  • 在获取完选择文本后,如果元素原来有readonly属性,则恢复该属性。

终于是找到了 核心逻辑了,通过js主动选中textarea中的文本,从而绕过了 IOS和微信浏览的保护机制了

后面就是执行command 复制文本的逻辑了:

/**
 * Executes a given operation type.
 * @param {String} type
 * @return {Boolean}
 */
export default function command(type) {
  try {
    return document.execCommand(type);
  } catch (err) {
    return false;
  }
}

clipboard的使用场景非常丰富,具体可以阅读官网

;