上段时间做需求时,产品在中途突然添加了一个新功能:实现一键复制功能;也就是如下,点击复制链接后,会复制出一段拼接好的title文案,粘贴的文案是携带链接的,可以在浏览器打开指定页面。

当时第一反应是做不了,因为在浏览器里当选中 a标签里的内容时,浏览器会自动完成这件事,所以一直感觉这是浏览器自带的能力,javascript以前只接触过复制纯文字的,其它复杂功能大概率干不了,但是直接跟产品说做不了是很没说服力的,所以想先调研下,有理有据的把需求怼回去

监听并修改复制内容

这个需求实现很简单,很多个人博客就有这个功能,其实现原理就是通过监听copy事件来实现:

document.addEventListener("copy", e => {
  const clipboardData = e.clipboardData || window.clipboardData
  if(!clipboardData) return;
  let copyTxt = ""
  e.preventDefault(); // 取消默认的复制事件
  copyTxt = window.getSelection(0).toString() // 网页上选中的内容
  copyTxt = `${copyTxt}n作者:码农资源网n原文:${window.location.href}n著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。`
  clipboardData.setData('text/plain', copyTxt);
});

在用户复制的时候,我们会在复制内容后面自动添加版权和出处等内容;很明显,虽然可以做到自定义剪切板内容的功能,但是这是被动监听的行为,用在这个需求是不靠谱的;

navigator.clipboard API

搜索发现Clipboard 接口实现了Clipboard APIopen in new window;在用户授予相应权限的情况下可以异步实现剪切板的读写能力,在开发环境或者https协议下的页面才可以调用这个API;

它返回一个Promise对象,如果剪切板被拒绝访问,Promise也会被拒绝;其主要有一下四个方法:

read:返回的是一个ClipboardItemopen in new window对象的数组;我们可以通过他获取剪切板数据;比如图片、文本等;我们可以将以下代码复制到浏览器开发者工具内执行,然后进行网页内容复制即可看到剪切板内容

document.addEventListener("copy", _ => {
  try {
    navigator.clipboard.read().then(async clipboardItems => {
      console.log('ClipboardItem:', clipboardItems);
      for (const clipboardItem of clipboardItems) {
        for (const type of clipboardItem.types) {
          const blob = await clipboardItem.getType(type);
          blob.text().then(res=>{
            console.log(`%ctype:%c${type},%c复制内容:%c${res}`, 'color:blue', 'color:#333','color:blue', 'color:#333');
          })
        }
      }
    })
  } catch (error) {
    console.log('读取失败:',error);
  }
})

注意:读剪切板的操作需要用户明确同意(浏览器会弹授权对话框),这也是浏览器为了用户安全和隐私而设计的;如果用户不同意会报错;因此最好使用try…catch来包裹逻辑

readText:和上面类似,只不过它返回的是从剪切板中检索到的纯文本数据;

document.addEventListener("copy", _ => {
  navigator.clipboard.readText().then(res => {
  	console.log(`%c复制内容:%c${res}`, 'color:blue', 'color:#333');
  })
})

write:向系统剪贴板写入任意内容;它以ClipboardItem实例作为参数,ClipboardItem 是浏览器原生提供的构造函数,键名为MIME typeopen in new window类型、键值为数据的Boldopen in new window类型;操作完成返回promise的成功回调;然后就可以在任意聊天软件(飞书、微信)内进行粘贴发送了

async function testClipboardCopy() {
  try {
    const imgURL = 'https://slbblog.oss-cn-beijing.aliyuncs.com/head.png';
    const data = await fetch(imgURL);
    const blob = await data.blob();
    const item = new window.ClipboardItem({
      [blob.type]: Promise.resolve(blob)
    });
    await navigator.clipboard.write([item]);
    console.log('图片已被复制!现在可以向聊天软件内发送了');
  } catch (err) {
    console.error(err);
  }
}
testClipboardCopy()

writeText:同上,只不过写入的是文本数据

navigator.clipboard.writeText("向剪切板写入内容").then(res=>console.log("写入成功"))

我们发现write方法可以非常方便、灵活的往剪切板放入多种格式的内容,非常符合我们的需求;因此基于Clipboard来实现我们需求的代码很容易就能写出来了:

async function testClipboardCopy(title, url=window.location.href) {
  return new Promise((resolve, reject) => {
    try {
      const text = new Blob([title], { type: 'text/plain' });
      const href = new Blob([`<a href="${url}">${title}</a>`], {
        type: 'text/html'
      });
			// 定义要放入的内容
      const item = new window.ClipboardItem({
        'text/plain': Promise.resolve(text),
        'text/html': Promise.resolve(href)
      });
      navigator.clipboard.write([item]).then(() => {
        return resolve(true);
      });
    } catch (error) {
      return reject(new Error('复制失败'))
    }
	});
}
testClipboardCopy("测试复制", 'https://www.codesou.cn').then(_ => console.log("复制成功"));

execCommand方法

document.execCommand算是非常老牌操作剪贴板的方法了,它的返回值是Boolean如果是 false 则表示操作不被支持或未被启用。

此方法基本得到了各大浏览器厂商的支持;虽然在MDNopen in new window上已经给出了提示:不再推荐使用该特性,但因为其良好的兼容性,使其依然在被广泛使用;

虽然此方法兼容性如此的好,但是它依然有它的不足,这也是导致官方要放弃它,另寻新欢的原因:

需要先手动选中或者配合createRange等操作选区的方法来选中要复制的内容,灵活度不够
execCommand是一个同步方法,如果要复制的内容比较多的话,极易造成卡顿、暂时失去响应等问题
比较常见的使用就是通过textarea复制一段文案到剪切板:

button.addEventListener("click", function() {
  var textarea = document.createElement('textarea');
  document.body.appendChild(textarea);
  textarea.style.cssText = 'position: absolute;left:-9999px;opacity:0';
  textarea.value = '测试execCommand复制能力';
  // 选中要复制的文本
  textarea.select();
  var isCopy = document.execCommand('copy');
  document.body.removeChild(textarea)
  if(!isCopy) {alert('复制失败')}
})

在我们的需求中,需要的并不是复制纯文案,而是需要复制文本链接;幸运的是我们可以通过createRange创建选区来模拟用户选中网页内容并复制的操作:

btns[0].addEventListener('click',function() {
  const _a = document.createElement('a');
  _a.innerHTML = '测试execCommand复制能力';
  _a.setAttribute('href', 'https://www.codesou.cn/');
  _a.style.cssText = 'position: absolute;left:-9999px;opacity:0'
  document.body.appendChild(_a);
  // 创建选区
  const range = document.createRange();
  range.selectNode(_a);
  // 先清除网页已有的选区
  window?.getSelection()?.removeAllRanges();
  // 选中我们创建出来的内容
  window?.getSelection()?.addRange(range);
  var isCopy = document.execCommand('copy', true);
  window?.getSelection()?.removeAllRanges();
  document.body.removeChild(_a);
  if(!isCopy) {alert('复制失败')}
})

当然,如果不放心,javascript也为我们提供了检测当前浏览器是否支持指定的编辑指令的原生方法:

var isSupported = document.queryCommandSupported('copy')

clipboard.js原理

这是一个非常知名的操作剪切板的开源项目;项目整体的体积也不是很大,压缩后大概12kb;现在有非常多的开源项目比如boostrap都在使用这个开源项目来实现复制、剪切的能力。

我们先用clipboard.js写个小demo来实现我们本次的需求:

<!DOCTYPE html>
<html lang="en">
  <head><title>测试复制能力</title></head>
  <body>
    <button class="btn">clipboard.js</button>
    <a href="https://www.codesou.cn/" style="opacity: 0;position: absolute;left: -9999px">测试clipboard.js的能力</a>
    <script src="https://cdn.bootcdn.net/ajax/libs/clipboard.js/2.0.11/clipboard.js"></script>
    <script>
      var btns = document.querySelectorAll('.btn');
      var clipboard = new ClipboardJS(btns, {
        target() {
          return document.querySelector('a')
        }
      });
      clipboard.on('success', function (e) {console.info(e) });
      clipboard.on('error', function (e) {console.info(e)});
    </script>
  </body>
</html>

可以发现整个逻辑实现起来还是很简单的;其实clipboard.js的原码也是非常简单的,我们可以根据我们这个demo看下整个逻辑实现下来是如何实现复制的

class Clipboard extends Emitter {
  constructor(trigger, options) {
    super();
    this.resolveOptions(options);
    this.listenClick(trigger);
  }
  // 其它...
 }

整个构造函数继承自Emitter函数,Emitter是一个轻量级的发布订阅模式库;主要作用就是向外部发送success和error来告诉我们剪切板操作成功还是失败

new ClipboardJS()后主要做了两件事情,resolveOptions()和listenClick();

第一个函数是初始化我们传的参数,将字符串参数转为函数、设置默认值;逻辑比较简单,不是我们的重点

第二个函数就是为我们的按钮绑定click事件,并在我们触发对应事件的时候执行相应动作

listenClick(trigger) {
  this.listener = listen(trigger, 'click', (e) => this.onClick(e));
},
onClick(e) {
  const trigger = e.delegateTarget || e.currentTarget;
  const action = this.action(trigger) || 'copy'; // copy还是cut
  const text = ClipboardActionDefault({
    action,
    container: this.container, // 我们没传,所以是document.body
    target: this.target(trigger),
    text: this.text(trigger),
  });
  // 通知外部是否操作成功
  this.emit(text ? 'success' : 'error', {
    action, text, trigger,
    clearSelection() {
      if (trigger) { trigger.focus(); }
      window.getSelection().removeAllRanges();
    },
  });
}

调用ClipboardActionDefault函数,根据返回值触发emit通知外部操作结果;那么我们的重点其实就变成了ClipboardActionDefault函数

const ClipboardActionDefault = (options = {}) => {
  const { action = 'copy', container, target, text } = options;
	//... 省略部分错误判断的代码

  // 优先text参数使用
  if (text) {
    return ClipboardActionCopy(text, { container });
  }

	
  if (target) {
    return action === 'cut'
      ? ClipboardActionCut(target)
      : ClipboardActionCopy(target, { container });
  }
};

此函数有两个主要判断:

如果使用时传了text参数,则优先复制这个参数里的内容

如果传递了target 则判断使用粘贴还是复制功能

根据我们的demo,我们使用的target和copy,所以进入的是ClipboardActionCopy(target, { container })方法;因此我们继续往里面看

const ClipboardActionCopy = (
  target, 
  options = { container: document.body }
) => {
  let selectedText = '';
  if (typeof target === 'string') {
    // 只有参数传递了text才会进入此判断
    selectedText = fakeCopyAction(target, options);
  } else if (
    // 传递的target是一个input标签
    target instanceof HTMLInputElement &&
    !['text', 'search', 'url', 'tel', 'password'].includes(target?.type)
  ) {
    selectedText = fakeCopyAction(target.value, options);
  } else {
    selectedText = select(target);
    command('copy');
  }
  return selectedText;
};

我们重点放在最后一条判断上;最后一条主要做了两件事情:

选中元素

select是一个三方库,用来选中标签的内容,大致选中逻辑:SELECT标签使用focus进行选中、INPUT和TEXTAREA标签使用setSelectionRange进行选中、其他的使用getSelection和createRange`进行选中

复制元素

这一步就非常简单了,主要代码就一行document.execCommand(type)翻译出来就是document.execCommand(copy)

整个复制流程到这就结束,可以发现的是它的实现和我们自己写的基本是一致的;下面我们大致梳理一遍clipboard.js流程。