这个 29.7 K 的剪贴板 JS 库有点东西!

开发 前端
本期阿宝哥将介绍一个被 157317 个项目引用的 JS 开源库 —— clipboard.js。相信挺多小伙伴在项目中,也用到了这个库。那么这个库背后的工作原理是什么?感兴趣的小伙伴,跟阿宝哥一起来揭开这背后的秘密吧。

[[357337]]

本期阿宝哥将介绍一个被 157317 个项目引用的 JS 开源库 —— clipboard.js。相信挺多小伙伴在项目中,也用到了这个库。那么这个库背后的工作原理是什么?感兴趣的小伙伴,跟阿宝哥一起来揭开这背后的秘密吧。

一、clipboard.js 简介

clipboard.js 是一个用于将文本复制到剪贴板的 JS 库。没有使用 Flash,没有使用任何框架,开启 gzipped 压缩后仅仅只有 3kb。


(图片来源:https://clipboardjs.com/#example-text)

那么为什么会有 clipboard.js 这个库呢?因为作者 zenorocha 认为:

  • 将文本复制到剪贴板应该不难。它不需要几十个步骤来配置,也不需要加载数百 KB 的文件。最最重要的是,它不应该依赖于 Flash 或其他任何框架。

该库依赖于 Selection 和 execCommand API,几乎所有的浏览器都支持 Selection API,然而 execCommand API 却存在一定的兼容性问题:


(图片来源:https://caniuse.com/?search=execCommand)


(图片来源:https://caniuse.com/?search=execCommand)

当然对于较老的浏览器,clipboard.js 也可以优雅地降级。好的,现在我们来看一下如何使用 clipboard.js。

二、clipboard.js 使用

在使用 clipboard.js 之前,你可以通过 NPM 或 CDN 的方式来安装它:

NPM

  1. npm install clipboard --save 

CDN

  1. <script src="https://cdn.jsdelivr.net/npm/clipboard@2.0.6/dist/clipboard.min.js"></script> 

clipboard.js 使用起来很简单,一般只要 3 个步骤:

1.定义一些标记

  1. <input id="foo" type="text" value="大家好,我是阿宝哥"
  2. <button class="btn" data-clipboard-action="copy" data-clipboard-target="#foo">复制</button> 

2.引入 clipboard.js

  1. <script src="https://cdn.jsdelivr.net/npm/clipboard@2.0.6/dist/clipboard.min.js"></script> 

3.实例化 clipboard

  1. <script> 
  2.   var clipboard = new ClipboardJS('.btn'); 
  3.  
  4.   clipboard.on('success'function(e) { 
  5.     console.log(e); 
  6.   }); 
  7.      
  8.   clipboard.on('error'function(e) { 
  9.     console.log(e); 
  10.   }); 
  11. </script> 

以上代码成功运行之后,当你点击 “复制” 按钮时,输入框中的文字会被选中,同时输入框中的文字将会被复制到剪贴板中,对应的效果如下图所示:

除了 input 元素之外,复制的目标还可以是 div 或 textarea 元素。在以上示例中,我们复制的目标是通过 data-* 属性 来指定。此外,我们也可以在实例化 clipboard 对象时,设置复制的目标:

  1. // https://github.com/zenorocha/clipboard.js/blob/master/demo/function-target.html 
  2. let clipboard = new ClipboardJS('.btn', { 
  3.   target: function() { 
  4.     return document.querySelector('div'); 
  5.   } 
  6. }); 

如果需要设置复制的文本,我们也可以在实例化 clipboard 对象时,设置复制的文本:

  1. // https://github.com/zenorocha/clipboard.js/blob/master/demo/function-text.html 
  2. let clipboard = new ClipboardJS('.btn', { 
  3.   text: function() { 
  4.     return 'to be or not to be'
  5.   } 
  6. }); 

关于 clipboard.js 的使用,阿宝哥就介绍到这里,感兴趣的小伙伴可以查看 Github 上 clipboard.js 的使用示例。

由于 clipboard.js 底层依赖于 Selection 和 execCommand API,所以在分析 clipboard.js 源码前,我们先来了解一下 Selection 和 execCommand API。

三、Selection 与 execCommand API

3.1 Selection API

Selection 对象表示用户选择的文本范围或插入符号的当前位置。它代表页面中的文本选区,可能横跨多个元素。文本选区由用户拖拽鼠标经过文字而产生。如果要获取用于检查或修改的 Selection 对象,可以调用 window.getSelection 方法。

Selection 对象所对应的是用户所选择的 ranges (区域),俗称 拖蓝。默认情况下,该函数只针对一个区域,我们可以这样使用这个函数:

  1. let selection = window.getSelection(); 
  2. let range = selection.getRangeAt(0); 

以上示例演示了如何获取选区中的第一个区域,其实除了获取选区中的区域之外,我们还可以通过 createRange API 创建一个新的区域,然后将该区域添加到选区中:

  1. <div>大家好,我是<strong>阿宝哥</strong>。欢迎关注<strong>全栈修仙之路</strong></div> 
  2. <script> 
  3.    let strongs = document.getElementsByTagName("strong"); 
  4.    let s = window.getSelection(); 
  5.  
  6.    if (s.rangeCount > 0) s.removeAllRanges(); // 从选区中移除所有区域 
  7.    for (let i = 0; i < strongs.length; i++) { 
  8.      let range = document.createRange(); // 创建range区域 
  9.      range.selectNode(strongs[i]); // 让range区域包含指定节点及其内容 
  10.      s.addRange(range); // 将创建的区域添加到选区中 
  11.    } 
  12. </script> 

 以上代码用于选中页面中所有的 strong 元素,但需要注意的是,目前只有使用 Gecko 渲染引擎的浏览器,比如 Firefox 浏览器实现了多个区域。

 

在某些场景下,你可能需要获取选中区域中的文本。针对这种场景,你可以通过调用 Selection 对象的 toString 方法来获取被选中区域中的纯文本。

3.2 execCommand API

document.execCommand API 允许运行命令来操作网页中的内容,常用的命令有 bold、italic、copy、cut、delete、insertHTML、insertImage、insertText 和 undo 等。下面我们来看一下该 API 的语法:

bool = document.execCommand(aCommandName, aShowDefaultUI, aValueArgument)

相关的参数说明如下:

  • aCommandName:字符串类型,用于表示命令的名称;
  • aShowDefaultUI:布尔类型,用于表示是否展示用户界面,一般为 false;
  • aValueArgument:额外参数,一些命令(比如 insertImage)需要额外的参数(提供插入图片的 URL),默认为 null。

调用 document.execCommand 方法后,该方法会返回一个布尔值。如果是 false 的话,表示操作不被支持或未被启用。对于 clipboard.js 这个库来说,它会通过 document.execCommand API 来执行 copy 和 cut命令,从而实现把内容复制到剪贴板。

那么现在问题来了,我们有没有办法判断当前浏览器是否支持 copy 和cut 命令呢?答案是有的,即使用浏览器提供的 API —— Document.queryCommandSupported,该方法允许我们确定当前的浏览器是否支持指定的编辑命令。

clipboard.js 这个库的作者,也考虑到了这种需求,所以提供了一个静态的 isSupported 方法,用于检测当前的浏览器是否支持指定的命令:

  1. // src/clipboard.js 
  2. static isSupported(action = ['copy''cut']) { 
  3.   const actions = (typeof action === 'string') ? [action] : action
  4.   let support = !!document.queryCommandSupported; 
  5.  
  6.   actions.forEach((action) => { 
  7.     support = support && !!document.queryCommandSupported(action); 
  8.   }); 
  9.  
  10.   return support; 

Document.queryCommandSupported 兼容性较好,大家可以放心使用,具体的兼容性如下图所示:


(图片来源:https://caniuse.com/?search=queryCommandSupported)

介绍完 Selection、execCommand 和 queryCommandSupported API,接下来我们开始分析 clipboard.js 的源码。

四、clipboard.js 源码解析

4.1 Clipboard 类

看源码的时候,阿宝哥习惯从最简单的用法入手,这样可以快速地了解内部的执行流程。下面我们来回顾一下前面的示例:

  1. <!-- 定义一些标记 --> 
  2. <input id="foo" type="text" value="大家好,我是阿宝哥"
  3. <button class="btn" data-clipboard-action="copy" data-clipboard-target="#foo">复制</button> 
  4.  
  5. <!-- 实例化 clipboard --> 
  6. <script> 
  7.   let clipboard = new ClipboardJS('.btn'); 
  8.  
  9.   clipboard.on('success'function(e) { 
  10.     console.log(e); 
  11.   }); 
  12.      
  13.   clipboard.on('error'function(e) { 
  14.     console.log(e); 
  15.   }); 
  16. </script> 

通过观察以上的代码,我们可以快速地找到切入点 —— new ClipboardJS('.btn')。在 clipboard.js 项目内的 webpack.config 配置文件中,我们可以找到 ClipboardJS 的定义:

  1. module.exports = { 
  2.   entry: './src/clipboard.js'
  3.   mode: 'production'
  4.   output: { 
  5.     filename: production ? 'clipboard.min.js' : 'clipboard.js'
  6.     path: path.resolve(__dirname, 'dist'), 
  7.     library: 'ClipboardJS'
  8.     globalObject: 'this'
  9.     libraryExport: 'default'
  10.     libraryTarget: 'umd' 
  11.   }, 
  12.   // 省略其他配置信息 

基于以上的配置信息,我们进一步找到了 ClipboardJS 指向的构造函数:

  1. import Emitter from 'tiny-emitter'
  2. import listen from 'good-listener'
  3.  
  4. class Clipboard extends Emitter { 
  5.   constructor(trigger, options) { 
  6.     super(); 
  7.     this.resolveOptions(options); 
  8.     this.listenClick(trigger); 
  9.   } 

在示例中,我们并没有设置 Clipboard 的配置信息,所以我们先不用关心 this.resolveOptions(options) 的处理逻辑。顾名思义 listenClick 方法是用来监听 click 事件,该方法的具体实现如下:

  1. listenClick(trigger) { 
  2.   this.listener = listen(trigger'click', (e) => this.onClick(e)); 

在 listenClick 方法内部,会通过一个第三方库 good-listener 来添加事件处理器。当目标触发 click 事件时,就会执行对应的事件处理器,该处理器内部会进一步调用 this.onClick 方法,该方法的实现如下:

  1. // src/clipboard.js 
  2. onClick(e) { 
  3.   const trigger = e.delegateTarget || e.currentTarget; 
  4.  
  5.   // 为每次点击事件,创建一个新的ClipboardAction对象 
  6.   if (this.clipboardAction) { 
  7.     this.clipboardAction = null
  8.   } 
  9.   this.clipboardAction = new ClipboardAction({ 
  10.     action    : this.action(trigger), 
  11.     target    : this.target(trigger), 
  12.     text      : this.text(trigger), 
  13.     container : this.container, 
  14.     trigger   : trigger
  15.     emitter   : this 
  16.   }); 

在 onClick 方法内部,会使用事件触发目标来创建 ClipboardAction对象。当你点击本示例 复制 按钮时,创建的 ClipboardAction 对象如下所示:


相信看完上图,大家对创建 ClipboardAction 对象时,所使用到的方法都有了解。那么 this.action、this.target 和 this.text 这几个方法是在哪里定义的呢?通过阅读源码,我们发现在 resolveOptions方法内部会初始化上述 3 个方法:

  1. // src/clipboard.js 
  2. resolveOptions(options = {}) { 
  3.   this.action = (typeof options.action === 'function')  
  4.     ? options.action :  this.defaultAction; 
  5.   this.target = (typeof options.target === 'function')  
  6.     ? options.target : this.defaultTarget; 
  7.   this.text = (typeof options.text === 'function'
  8.     ? options.text : this.defaultText; 
  9.   this.container = (typeof options.container === 'object')    
  10.     ? options.container : document.body; 

在 resolveOptions 方法内部,如果用户自定义了处理函数,则会优先使用用户自定义的函数,否则将使用 clipboard.js 中对应的默认处理函数。由于我们在调用 Clipboard 构造函数时,并未设置 options 参数,所以将使用默认的处理函数:

由上图可知在 defaultAction、defaultTarget 和 defaultText 方法内部都会调用 getAttributeValue 方法来获取事件触发对象上自定义属性,而对应的 getAttributeValue 方法也很简单,具体代码如下:

  1. // src/clipboard.js 
  2. function getAttributeValue(suffix, element) { 
  3.   const attribute = `data-clipboard-${suffix}`; 
  4.   if (!element.hasAttribute(attribute)) { 
  5.     return
  6.   } 
  7.   return element.getAttribute(attribute); 

介绍完 Clipboard 类,接下来我们来重点分析一下 ClipboardAction类,该类会包含具体的复制逻辑。

4.2 ClipboardAction 类

在 clipboard.js 项目中,ClipboardAction 类被定义在 src/clipboard-action.js 文件内:

  1. // src/clipboard-action.js 
  2. class ClipboardAction { 
  3.   constructor(options) { 
  4.     this.resolveOptions(options); 
  5.     this.initSelection(); 
  6.   } 

与 Clipboard 类的构造函数一样,ClipboardAction 类的构造函数会优先解析 options 配置对象,然后调用 initSelection 方法,来初始化选区。在 initSelection 方法中会根据 text 和 target 属性来选择不同的选择策略:

  1. initSelection() { 
  2.   if (this.text) { 
  3.     this.selectFake(); 
  4.   } else if (this.target) { 
  5.     this.selectTarget(); 
  6.   } 

对于前面的示例,我们是通过 data-* 属性 来指定复制的目标,即 data-clipboard-target="#foo",相应的代码如下:

  1. <input id="foo" type="text" value="大家好,我是阿宝哥"
  2. <button class="btn" data-clipboard-action="copy" data-clipboard-target="#foo">复制</button> 

所以接下来我们先来分析含有 target 属性的情形,如果含有 target属性,则会进入 else if 分支,然后调用 this.selectTarget 方法:

  1. // src/clipboard-action.js 
  2. selectTarget() { 
  3.   this.selectedText = select(this.target); 
  4.   this.copyText(); 

在 selectTarget 方法内部,会调用 select 函数获取已选中的文本,该函数是来自 clipboard.js 作者开发的另一个 npm 包,对应的代码如下:

  1. // https://github.com/zenorocha/select/blob/master/src/select.js 
  2. function select(element) { 
  3.   var selectedText; 
  4.  
  5.   if (element.nodeName === 'SELECT') { 
  6.     element.focus(); 
  7.     selectedText = element.value; 
  8.   } 
  9.   else if (element.nodeName === 'INPUT' || element.nodeName === 'TEXTAREA') { 
  10.     var isReadOnly = element.hasAttribute('readonly'); 
  11.  
  12.     if (!isReadOnly) { 
  13.       element.setAttribute('readonly'''); 
  14.     } 
  15.  
  16.     element.select(); 
  17.     element.setSelectionRange(0, element.value.length); 
  18.  
  19.     if (!isReadOnly) { 
  20.       element.removeAttribute('readonly'); 
  21.     }  
  22.       selectedText = element.value; 
  23.     } 
  24.   else { 
  25.     // 省略相关代码  
  26.   } 
  27.   return selectedText; 

因为在以上示例中,我们复制的目标是 input 元素,所以我们先来分析该分支的代码。在该分支中,使用了 HTMLInputElement 对象的 select 和 setSelectionRange 方法:

  • select:用于选中一个 <textarea> 元素或者一个带有 text 字段的 <input> 元素里的所有内容。
  • setSelectionRange:用于设定 <input> 或 <textarea> 元素中当前选中文本的起始和结束位置。

在获取选中的文本之后,selectTarget 方法会继续调用 copyText 方法来复制文本:

  1. copyText() { 
  2.   let succeeded; 
  3.   try { 
  4.     succeeded = document.execCommand(this.action); 
  5.   } catch (err) { 
  6.     succeeded = false
  7.   } 
  8.   this.handleResult(succeeded); 

前面阿宝哥已经简单介绍了 execCommand API,copyText 方法内部就是使用这个 API 来复制文本。在完成复制之后,copyText 方法会调用 this.handleResult 方法来派发复制的状态信息:

  1. handleResult(succeeded) { 
  2.   this.emitter.emit(succeeded ? 'success' : 'error', { 
  3.     action: this.action
  4.     text: this.selectedText, 
  5.     trigger: this.trigger
  6.     clearSelection: this.clearSelection.bind(this) 
  7.   }); 

看到这里有些小伙伴可能会问 this.emitter 对象是来自哪里的?其实 this.emitter 对象也就是 Clipboard 实例:

  1. // src/clipboard.js 
  2. class Clipboard extends Emitter { 
  3.   onClick(e) { 
  4.     const trigger = e.delegateTarget || e.currentTarget; 
  5.     // 省略部分代码 
  6.     this.clipboardAction = new ClipboardAction({ 
  7.       // 省略部分属性 
  8.       trigger   : trigger
  9.       emitter   : this // Clipboard 实例 
  10.     }); 
  11.   } 

而对于 handleResult 方法派发的事件,我们可以通过 clipboard 实例来监听对应的事件,具体的代码如下:

  1. let clipboard = new ClipboardJS('.btn'); 
  2.  
  3. clipboard.on('success'function(e) { 
  4.   console.log(e); 
  5. }); 
  6.      
  7. clipboard.on('error'function(e) { 
  8.   console.log(e); 
  9. }); 

在继续介绍另一个分支的处理逻辑之前,阿宝哥用一张图来总结一下上述示例的执行流程:

下面我们来介绍另一个分支,即含有 text 属性的情形,对应的使用示例如下:

  1. // https://github.com/zenorocha/clipboard.js/blob/master/demo/function-text.html 
  2. let clipboard = new ClipboardJS('.btn', { 
  3.   text: function() { 
  4.     return '大家好,我是阿宝哥'
  5.   } 
  6. }); 

当用户在创建 clipboard 对象时,设置了 text 属性,则会执行 if 分支的逻辑,即调用 this.selectFake 方法:

  1. // src/clipboard-action.js 
  2. class ClipboardAction { 
  3.   constructor(options) { 
  4.     this.resolveOptions(options); 
  5.     this.initSelection(); 
  6.   } 
  7.    
  8.   initSelection() { 
  9.     if (this.text) { 
  10.       this.selectFake(); 
  11.     } else if (this.target) { 
  12.       this.selectTarget(); 
  13.     } 
  14.   } 

在 selectFake 方法内部,它会先创建一个假的 textarea 元素并设置该元素的相关样式和定位信息,并使用 this.text 的值来设置 textarea 元素的内容,然后使用前面介绍的 select 函数来获取已选择的文本,最后通过 copyText 把文本拷贝到剪贴板:

  1. // src/clipboard-action.js 
  2. selectFake() { 
  3.   const isRTL = document.documentElement.getAttribute('dir') == 'rtl'
  4.  
  5.   this.removeFake(); // 移除事件监听并移除之前创建的fakeElem 
  6.  
  7.   this.fakeHandlerCallback = () => this.removeFake(); 
  8.   this.fakeHandler = this.container.addEventListener('click', this.fakeHandlerCallback) || true
  9.  
  10.   this.fakeElem = document.createElement('textarea'); 
  11.   // Prevent zooming on iOS 
  12.   this.fakeElem.style.fontSize = '12pt'
  13.   // Reset box model 
  14.   this.fakeElem.style.border = '0'
  15.   this.fakeElem.style.padding = '0'
  16.   this.fakeElem.style.margin = '0'
  17.   // Move element out of screen horizontally 
  18.   this.fakeElem.style.position = 'absolute'
  19.   this.fakeElem.style[ isRTL ? 'right' : 'left' ] = '-9999px'
  20.   // Move element to the same position vertically 
  21.   let yPosition = window.pageYOffset || document.documentElement.scrollTop; 
  22.   this.fakeElem.style.top = `${yPosition}px`; 
  23.  
  24.   this.fakeElem.setAttribute('readonly'''); 
  25.   this.fakeElem.value = this.text; 
  26.  
  27.   this.container.appendChild(this.fakeElem); 
  28.  
  29.   this.selectedText = select(this.fakeElem); 
  30.   this.copyText(); 

为了让大家能够更直观了解 selectFake 方法执行后的页面效果,阿宝哥截了一张实际的效果图:

其实 clipboard.js 除了支持拷贝 input 或 textarea 元素的内容之外,它还支持拷贝其它 HTML 元素的内容,比如 div 元素:

  1. <div>大家好,我是阿宝哥</div> 
  2. <button class="btn" data-clipboard-action="copy" data-clipboard-target="div">Copy</button> 

针对这种情形,在 clipboard.js 内部仍会利用前面介绍的 select 函数来选中目标元素并获取需拷贝的内容,具体的代码如下所示:

  1. function select(element) { 
  2.   var selectedText; 
  3.  
  4.   if (element.nodeName === 'SELECT') { 
  5.       element.focus(); 
  6.       selectedText = element.value; 
  7.   } 
  8.   else if (element.nodeName === 'INPUT' || element.nodeName === 'TEXTAREA') { 
  9.       // 省略相关代码  
  10.   } 
  11.   else { 
  12.      if (element.hasAttribute('contenteditable')) { 
  13.         element.focus(); 
  14.      } 
  15.  
  16.      var selection = window.getSelection(); // 创建选取 
  17.      var range = document.createRange(); // 新建区域 
  18.  
  19.      range.selectNodeContents(element); // 使新建的区域包含element节点的内容 
  20.      selection.removeAllRanges(); // 移除选取中的所有区域 
  21.      selection.addRange(range); // 往选区中添加新建的区域 
  22.      selectedText = selection.toString(); // 获取已选中的文本 
  23.     } 
  24.  
  25.     return selectedText; 

在获得要拷贝的文本之后,clipboard.js 会继续调用 copyText 方法把对应的文本拷贝到剪贴板。到这里 clipboard.js 的核心源码,我们差不多都分析完了,希望阅读本文后,大家不仅了解了 clipboard.js 背后的工作原理,同时也学会了如何利用事件派发器来实现消息通信 及 Selection 和 execCommand API 等相关的知识。

五、参考资源

  • MDN Selection
  • MDN execCommand
  • MDN queryCommandSupported
  • MDN selectNodeContents

 

责任编辑:姜华 来源: 全栈修仙之路
相关推荐

2009-12-18 14:10:29

Ruby访问剪贴板

2011-08-09 10:27:41

iOS剪贴板

2010-02-02 17:47:59

C++操作剪贴板

2016-05-11 15:01:31

Linux剪贴板管理器

2009-08-10 17:37:54

2021-12-02 10:11:44

鸿蒙HarmonyOS应用

2021-08-30 13:00:40

JS代码前端

2021-03-09 05:48:01

Windows10操作系统21H2

2023-12-21 08:02:31

React DnD拖拽库组件

2023-02-06 07:17:22

2022-03-31 22:53:47

Windows 11太阳谷2智能剪贴板

2018-03-23 10:15:28

Windows 10云剪贴板复制粘贴

2021-08-29 07:43:43

CopyQ操作系统微软

2021-11-30 05:37:51

App监听手机监管

2021-12-12 09:42:48

Windows 11桌面微软

2020-07-02 07:53:59

App操作系统应用

2024-04-09 08:27:01

Android高效管理数据

2009-10-21 10:15:29

VB.NET复制

2021-07-29 09:55:59

鸿蒙HarmonyOS应用

2021-12-05 09:28:18

Windows 11操作系统微软
点赞
收藏

51CTO技术栈公众号