惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

酷 壳 – CoolShell
酷 壳 – CoolShell
雷峰网
雷峰网
H
Hackread – Cybersecurity News, Data Breaches, AI and More
Spread Privacy
Spread Privacy
H
Hacker News: Front Page
PCI Perspectives
PCI Perspectives
Webroot Blog
Webroot Blog
罗磊的独立博客
H
Heimdal Security Blog
TaoSecurity Blog
TaoSecurity Blog
Exploit-DB.com RSS Feed
Exploit-DB.com RSS Feed
大猫的无限游戏
大猫的无限游戏
月光博客
月光博客
cs.CV updates on arXiv.org
cs.CV updates on arXiv.org
Google Online Security Blog
Google Online Security Blog
Last Week in AI
Last Week in AI
美团技术团队
Help Net Security
Help Net Security
The Hacker News
The Hacker News
C
Cisco Blogs
T
The Blog of Author Tim Ferriss
J
Java Code Geeks
The Register - Security
The Register - Security
IT之家
IT之家
WordPress大学
WordPress大学
Jina AI
Jina AI
Recent Commits to openclaw:main
Recent Commits to openclaw:main
H
Help Net Security
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
T
Threat Research - Cisco Blogs
P
Proofpoint News Feed
NISL@THU
NISL@THU
爱范儿
爱范儿
The GitHub Blog
The GitHub Blog
Scott Helme
Scott Helme
V
Vulnerabilities – Threatpost
B
Blog
T
Tenable Blog
博客园 - 三生石上(FineUI控件)
T
The Exploit Database - CXSecurity.com
S
Security Affairs
小众软件
小众软件
Hacker News: Ask HN
Hacker News: Ask HN
Security Latest
Security Latest
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
W
WeLiveSecurity
A
Arctic Wolf
L
LINUX DO - 热门话题
Google DeepMind News
Google DeepMind News
M
MIT News - Artificial intelligence

静水深流's blog

探索 SSE:服务器推送技术的魅力与应用 | 静水深流 图解DIFF算法介绍 | 静水深流 基于vuepress2搭建专属自己的博客,并集成各种常用功能 | 静水深流 听说你至今不晓得缓存淘汰算法?实现LRU、LFU和FIFO? | 静水深流 最长递增子序列及vue3.0中diff算法 | 静水深流 二进制之入门到应用实践 | 静水深流 常见算法学习 | 静水深流 CSS 形状的实现 | 静水深流 ajax取消接口请求 | 静水深流 前端常见的安全问题 | 静水深流 关于http服务端的学习&总结 | 静水深流 前端面试题总结 | 静水深流 javascript原生代码实现及代码总结 | 静水深流 LeetCode算法学习总结-简单 | 静水深流 LeetCode算法学习总结-困难 | 静水深流 LeetCode算法学习总结- 中等 | 静水深流 排序算法总结 | 静水深流 扫码登录的实现原理 | 静水深流 Javascript之常见类型判断汇总 | 静水深流 JavaScript各种继承方式和优缺点 | 静水深流 webpack开发、使用及优化总结 | 静水深流 从JavaScript中的拷贝开始思考 | 静水深流 vue原理、使用及面试方面的总结 | 静水深流 前端发展及选择 | 静水深流 css面试总结 | 静水深流 JavaScript 数组展开(扁平化)和underscore的 flatten | 静水深流 首页 | 静水深流 学习网站收藏 | 静水深流 文章列表 | 静水深流
如何使用javascript实现复制出的文案带链接? | 静水深流
shaolibao · 2023-07-25 · via 静水深流's blog

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

复制

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

监听并修改复制内容

以前在做个人博客open in new window时做过监听并修改用户复制的内容的功能,其实现原理就是通过监听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作者:邵XX\n原文:${window.location.href}\n著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。`
  clipboardData.setData('text/plain', copyTxt);
});

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

搜索发现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://slbyml.github.io/').then(_ => console.log("复制成功"));

虽然Clipboard API如此强大与方便;但它的兼容却让人堪:clipboard API兼容性open in new windowClipboardItem 兼容性open in new window;通过caniuse查询可以发现各方法的兼容性不尽相同,总体来说chrome浏览器还好,firefox简直不忍直视,完全不兼容;

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://slbyml.github.io/');
  _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://slbyml.github.io/" 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是一个轻量级的发布订阅模式库;主要作用就是向外部发送successerror来告诉我们剪切板操作成功还是失败

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,我们使用的targetcopy,所以进入的是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进行选中、其他的使用getSelectioncreateRange`进行选中

  • 复制元素

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

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

clipboard

参考

Navigator.clipboard MDNopen in new window

剪贴板操作 Clipboard API 教程open in new window

JS复制文字到剪切板的极简实现及扩展open in new window