2020 年即将结束了,不知不觉 源码分析 专题已经写了 9 篇文章,往期的 8 篇文章介绍了 Axios、BetterScroll、koa-compose 和 FileSaver.js 等优秀的开源项目,该专题的每篇文章阿宝哥都花了挺多时间与精力。不过值得欣慰的是,专题中的多篇文章受到了社区小伙伴和公众号粉丝的认可与鼓励,这让阿宝哥有继续写该专题的动力,这里真心地感谢大家的支持。
对往期 8 篇文章感兴趣的小伙伴,可以阅读 如何更好地阅读源码?这八篇文章给你答案 这篇文章。
好的,我们马上回到正题。本期阿宝哥将介绍一个被 157317 个项目引用的 JS 开源库 —— clipboard.js。相信挺多小伙伴在项目中,也用到了这个库。那么这个库背后的工作原理是什么?感兴趣的小伙伴,跟阿宝哥一起来揭开这背后的秘密吧。
clipboard.js 是一个用于将文本复制到剪贴板的 JS 库。没有使用 Flash,没有使用任何框架,开启 gzipped 压缩后仅仅只有 3kb。
(图片来源:https://clipboardjs.com/#exam...)
那么为什么会有 clipboard.js 这个库呢?因为作者 zenorocha 认为:
将文本复制到剪贴板应该不难。它不需要几十个步骤来配置,也不需要加载数百 KB 的文件。最最重要的是,它不应该依赖于 Flash 或其他任何框架。
该库依赖于 Selection 和 execCommand API,几乎所有的浏览器都支持 Selection API,然而 execCommand API 却存在一定的兼容性问题:
(图片来源:https://caniuse.com/?search=e...)
(图片来源:https://caniuse.com/?search=e...)
当然对于较老的浏览器,clipboard.js 也可以优雅地降级。好的,现在我们来看一下如何使用 clipboard.js。
关注「全栈修仙之路」阅读阿宝哥原创的 3 本免费电子书(累计下载近2万)及 50 几篇 “重学TS” 教程。
在使用 clipboard.js 之前,你可以通过 NPM 或 CDN 的方式来安装它:
NPM
npm install clipboard --save
CDN
<script src="https://cdn.jsdelivr.net/npm/clipboard@2.0.6/dist/clipboard.min.js"></script>
clipboard.js 使用起来很简单,一般只要 3 个步骤:
1.定义一些标记
<input id="foo" type="text" value="大家好,我是阿宝哥"> <button class="btn" data-clipboard-action="copy" data-clipboard-target="#foo">复制</button>
2.引入 clipboard.js
<script src="https://cdn.jsdelivr.net/npm/clipboard@2.0.6/dist/clipboard.min.js"></script>
3.实例化 clipboard
<script> var clipboard = new ClipboardJS('.btn'); clipboard.on('success', function(e) { console.log(e); }); clipboard.on('error', function(e) { console.log(e); }); </script>
以上代码成功运行之后,当你点击 “复制” 按钮时,输入框中的文字会被选中,同时输入框中的文字将会被复制到剪贴板中,对应的效果如下图所示:
除了 input
元素之外,复制的目标还可以是 div
或 textarea
元素。在以上示例中,我们复制的目标是通过 data-* 属性 来指定。此外,我们也可以在实例化 clipboard 对象时,设置复制的目标:
// https://github.com/zenorocha/clipboard.js/blob/master/demo/function-target.html let clipboard = new ClipboardJS('.btn', { target: function() { return document.querySelector('div'); } });
如果需要设置复制的文本,我们也可以在实例化 clipboard 对象时,设置复制的文本:
// https://github.com/zenorocha/clipboard.js/blob/master/demo/function-text.html let clipboard = new ClipboardJS('.btn', { text: function() { return '大家好,我是阿宝哥'; } });
关于 clipboard.js 的使用,阿宝哥就介绍到这里,感兴趣的小伙伴可以查看 Github 上 clipboard.js 的使用示例。由于 clipboard.js 底层依赖于 Selection 和 execCommand API,所以在分析 clipboard.js 源码前,我们先来了解一下 Selection 和 execCommand API。
Selection
对象表示用户选择的文本范围或插入符号的当前位置。它代表页面中的文本选区,可能横跨多个元素。文本选区由用户拖拽鼠标经过文字而产生。如果要获取用于检查或修改的 Selection
对象,可以调用 window.getSelection
方法。
Selection 对象所对应的是用户所选择的 ranges
(区域),俗称 拖蓝。默认情况下,该函数只针对一个区域,我们可以这样使用这个函数:
let selection = window.getSelection(); let range = selection.getRangeAt(0);
以上示例演示了如何获取选区中的第一个区域,其实除了获取选区中的区域之外,我们还可以通过 createRange
API 创建一个新的区域,然后将该区域添加到选区中:
<div>大家好,我是<strong>阿宝哥</strong>。欢迎关注<strong>全栈修仙之路</strong></div> <script> let strongs = document.getElementsByTagName("strong"); let s = window.getSelection(); if (s.rangeCount > 0) s.removeAllRanges(); // 从选区中移除所有区域 for (let i = 0; i < strongs.length; i++) { let range = document.createRange(); // 创建range区域 range.selectNode(strongs[i]); // 让range区域包含指定节点及其内容 s.addRange(range); // 将创建的区域添加到选区中 } </script>
以上代码用于选中页面中所有的 strong
元素,但需要注意的是,目前只有使用 Gecko 渲染引擎的浏览器,比如 Firefox 浏览器实现了多个区域。
在某些场景下,你可能需要获取选中区域中的文本。针对这种场景,你可以通过调用 Selection 对象的 toString
方法来获取被选中区域中的纯文本。
document.execCommand
API 允许运行命令来操作网页中的内容,常用的命令有 bold、italic、copy、cut、delete、insertHTML、insertImage、insertText 和 undo 等。下面我们来看一下该 API 的语法:
bool = document.execCommand(aCommandName, aShowDefaultUI, aValueArgument)
相关的参数说明如下:
调用 document.execCommand
方法后,该方法会返回一个布尔值。如果是 false
的话,表示操作不被支持或未被启用。对于 clipboard.js 这个库来说,它会通过 document.execCommand
API 来执行 copy
和 cut
命令,从而实现把内容复制到剪贴板。
那么现在问题来了,我们有没有办法判断当前浏览器是否支持 copy
和 cut
命令呢?答案是有的,即使用浏览器提供的 API —— Document.queryCommandSupported,该方法允许我们确定当前的浏览器是否支持指定的编辑命令。
clipboard.js 这个库的作者,也考虑到了这种需求,所以提供了一个静态的 isSupported
方法,用于检测当前的浏览器是否支持指定的命令:
// src/clipboard.js static isSupported(action = ['copy', 'cut']) { const actions = (typeof action === 'string') ? [action] : action; let support = !!document.queryCommandSupported; actions.forEach((action) => { support = support && !!document.queryCommandSupported(action); }); return support; }
Document.queryCommandSupported 兼容性较好,大家可以放心使用,具体的兼容性如下图所示:
(图片来源:https://caniuse.com/?search=q...)
介绍完 Selection、execCommand 和 queryCommandSupported API,接下来我们开始分析 clipboard.js 的源码。
如果你想了解阅读源码的思路与技巧,可以阅读 使用这些思路与技巧,我读懂了多个优秀的开源项目 这篇文章。
看源码的时候,阿宝哥习惯从最简单的用法入手,这样可以快速地了解内部的执行流程。下面我们来回顾一下前面的示例:
<!-- 定义一些标记 --> <input id="foo" type="text" value="大家好,我是阿宝哥"> <button class="btn" data-clipboard-action="copy" data-clipboard-target="#foo">复制</button> <!-- 实例化 clipboard --> <script> let clipboard = new ClipboardJS('.btn'); clipboard.on('success', function(e) { console.log(e); }); clipboard.on('error', function(e) { console.log(e); }); </script>
通过观察以上的代码,我们可以快速地找到切入点 —— new ClipboardJS('.btn')
。在 clipboard.js 项目内的 webpack.config
配置文件中,我们可以找到 ClipboardJS
的定义:
module.exports = { entry: './src/clipboard.js', mode: 'production', output: { filename: production ? 'clipboard.min.js' : 'clipboard.js', path: path.resolve(__dirname, 'dist'), library: 'ClipboardJS', globalObject: 'this', libraryExport: 'default', libraryTarget: 'umd' }, // 省略其他配置信息 }
基于以上的配置信息,我们进一步找到了 ClipboardJS
指向的构造函数:
import Emitter from 'tiny-emitter'; import listen from 'good-listener'; class Clipboard extends Emitter { constructor(trigger, options) { super(); this.resolveOptions(options); this.listenClick(trigger); } }
在示例中,我们并没有设置 Clipboard 的配置信息,所以我们先不用关心 this.resolveOptions(options)
的处理逻辑。顾名思义 listenClick
方法是用来监听 click
事件,该方法的具体实现如下:
listenClick(trigger) { this.listener = listen(trigger, 'click', (e) => this.onClick(e)); }
在 listenClick
方法内部,会通过一个第三方库 good-listener 来添加事件处理器。当目标触发 click
事件时,就会执行对应的事件处理器,该处理器内部会进一步调用 this.onClick
方法,该方法的实现如下:
// src/clipboard.js onClick(e) { const trigger = e.delegateTarget || e.currentTarget; // 为每次点击事件,创建一个新的ClipboardAction对象 if (this.clipboardAction) { this.clipboardAction = null; } this.clipboardAction = new ClipboardAction({ action : this.action(trigger), target : this.target(trigger), text : this.text(trigger), container : this.container, trigger : trigger, emitter : this }); }
在 onClick
方法内部,会使用事件触发目标来创建 ClipboardAction
对象。当你点击本示例 复制 按钮时,创建的 ClipboardAction
对象如下所示:
相信看完上图,大家对创建 ClipboardAction
对象时,所使用到的方法都有了解。那么 this.action
、this.target
和 this.text
这几个方法是在哪里定义的呢?通过阅读源码,我们发现在 resolveOptions
方法内部会初始化上述 3 个方法:
// src/clipboard.js resolveOptions(options = {}) { this.action = (typeof options.action === 'function') ? options.action : this.defaultAction; this.target = (typeof options.target === 'function') ? options.target : this.defaultTarget; this.text = (typeof options.text === 'function') ? options.text : this.defaultText; this.container = (typeof options.container === 'object') ? options.container : document.body; }
在 resolveOptions
方法内部,如果用户自定义了处理函数,则会优先使用用户自定义的函数,否则将使用 clipboard.js
中对应的默认处理函数。由于我们在调用 Clipboard
构造函数时,并未设置 options
参数,所以将使用默认的处理函数:
由上图可知在 defaultAction
、defaultTarget
和 defaultText
方法内部都会调用 getAttributeValue
方法来获取事件触发对象上自定义属性,而对应的 getAttributeValue
方法也很简单,具体代码如下:
// src/clipboard.js function getAttributeValue(suffix, element) { const attribute = `data-clipboard-${suffix}`; if (!element.hasAttribute(attribute)) { return; } return element.getAttribute(attribute); }
介绍完 Clipboard
类,接下来我们来重点分析一下 ClipboardAction
类,该类会包含具体的复制逻辑。
在 clipboard.js 项目中,ClipboardAction
类被定义在 src/clipboard-action.js
文件内:
// src/clipboard-action.js class ClipboardAction { constructor(options) { this.resolveOptions(options); this.initSelection(); } }
与 Clipboard
类的构造函数一样,ClipboardAction
类的构造函数会优先解析 options
配置对象,然后调用 initSelection
方法,来初始化选区。在 initSelection
方法中会根据 text
和 target
属性来选择不同的选择策略:
initSelection() { if (this.text) { this.selectFake(); } else if (this.target) { this.selectTarget(); } }
对于前面的示例,我们是通过 data-* 属性 来指定复制的目标,即 data-clipboard-target="#foo"
,相应的代码如下:
<input id="foo" type="text" value="大家好,我是阿宝哥"> <button class="btn" data-clipboard-action="copy" data-clipboard-target="#foo">复制</button>
所以接下来我们先来分析含有 target
属性的情形,如果含有 target
属性,则会进入 else if
分支,然后调用 this.selectTarget
方法:
// src/clipboard-action.js selectTarget() { this.selectedText = select(this.target); this.copyText(); }
在 selectTarget
方法内部,会调用 select
函数获取已选中的文本,该函数是来自 clipboard.js 作者开发的另一个 npm 包,对应的代码如下:
// https://github.com/zenorocha/select/blob/master/src/select.js 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 { // 省略相关代码 } return selectedText; }
因为在以上示例中,我们复制的目标是 input
元素,所以我们先来分析该分支的代码。在该分支中,使用了 HTMLInputElement
对象的 select 和 setSelectionRange 方法:
<textarea>
元素或者一个带有 text 字段的 <input>
元素里的所有内容。<input>
或 <textarea>
元素中当前选中文本的起始和结束位置。在获取选中的文本之后,selectTarget
方法会继续调用 copyText
方法来复制文本:
copyText() { let succeeded; try { succeeded = document.execCommand(this.action); } catch (err) { succeeded = false; } this.handleResult(succeeded); }
前面阿宝哥已经简单介绍了 execCommand
API,copyText
方法内部就是使用这个 API 来复制文本。在完成复制之后,copyText 方法会调用 this.handleResult
方法来派发复制的状态信息:
handleResult(succeeded) { this.emitter.emit(succeeded ? 'success' : 'error', { action: this.action, text: this.selectedText, trigger: this.trigger, clearSelection: this.clearSelection.bind(this) }); }
看到这里有些小伙伴可能会问 this.emitter
对象是来自哪里的?其实 this.emitter
对象就是 Clipboard
实例:
// src/clipboard.js class Clipboard extends Emitter { onClick(e) { const trigger = e.delegateTarget || e.currentTarget; // 省略部分代码 this.clipboardAction = new ClipboardAction({ // 省略部分属性 trigger : trigger, emitter : this // Clipboard 实例 }); } }
而对于 handleResult
方法派发的事件,我们可以通过 clipboard
实例来监听对应的事件,具体的代码如下:
let clipboard = new ClipboardJS('.btn'); clipboard.on('success', function(e) { console.log(e); }); clipboard.on('error', function(e) { console.log(e); });
在继续介绍另一个分支的处理逻辑之前,阿宝哥用一张图来总结一下上述示例的执行流程:
下面我们来介绍另一个分支,即含有 text
属性的情形,对应的使用示例如下:
// https://github.com/zenorocha/clipboard.js/blob/master/demo/function-text.html let clipboard = new ClipboardJS('.btn', { text: function() { return '大家好,我是阿宝哥'; } });
当用户在创建 clipboard
对象时,设置了 text
属性,则会执行 if
分支的逻辑,即调用 this.selectFake
方法:
// src/clipboard-action.js class ClipboardAction { constructor(options) { this.resolveOptions(options); this.initSelection(); } initSelection() { if (this.text) { this.selectFake(); } else if (this.target) { this.selectTarget(); } } }
在 selectFake
方法内部,它会先创建一个假的 textarea
元素并设置该元素的相关样式和定位信息,并使用 this.text
的值来设置 textarea
元素的内容,然后使用前面介绍的 select
函数来获取已选择的文本,最后通过 copyText
把文本拷贝到剪贴板:
// src/clipboard-action.js selectFake() { const isRTL = document.documentElement.getAttribute('dir') == 'rtl'; this.removeFake(); // 移除事件监听并移除之前创建的fakeElem this.fakeHandlerCallback = () => this.removeFake(); this.fakeHandler = this.container.addEventListener('click', this.fakeHandlerCallback) || true; this.fakeElem = document.createElement('textarea'); // Prevent zooming on iOS this.fakeElem.style.fontSize = '12pt'; // Reset box model this.fakeElem.style.border = '0'; this.fakeElem.style.padding = '0'; this.fakeElem.style.margin = '0'; // Move element out of screen horizontally this.fakeElem.style.position = 'absolute'; this.fakeElem.style[ isRTL ? 'right' : 'left' ] = '-9999px'; // Move element to the same position vertically let yPosition = window.pageYOffset || document.documentElement.scrollTop; this.fakeElem.style.top = `${yPosition}px`; this.fakeElem.setAttribute('readonly', ''); this.fakeElem.value = this.text; this.container.appendChild(this.fakeElem); this.selectedText = select(this.fakeElem); this.copyText(); }
为了让大家能够更直观了解 selectFake
方法执行后的页面效果,阿宝哥截了一张实际的效果图:
其实 clipboard.js 除了支持拷贝 input
或 textarea
元素的内容之外,它还支持拷贝其它 HTML 元素的内容,比如 div
元素:
<div>大家好,我是阿宝哥</div> <button class="btn" data-clipboard-action="copy" data-clipboard-target="div">Copy</button>
针对这种情形,在 clipboard.js 内部仍会利用前面介绍的 select
函数来选中目标元素并获取需拷贝的内容,具体的代码如下所示:
function select(element) { var selectedText; if (element.nodeName === 'SELECT') { element.focus(); selectedText = element.value; } else if (element.nodeName === 'INPUT' || element.nodeName === 'TEXTAREA') { // 省略相关代码 } else { if (element.hasAttribute('contenteditable')) { element.focus(); } var selection = window.getSelection(); // 获取选取 var range = document.createRange(); // 新建区域 range.selectNodeContents(element); // 使新建的区域包含element节点的内容 selection.removeAllRanges(); // 移除选取中的所有区域 selection.addRange(range); // 往选区中添加新建的区域 selectedText = selection.toString(); // 获取已选中的文本 } return selectedText; }
在获得要拷贝的文本之后,clipboard.js 会继续调用 copyText
方法把对应的文本拷贝到剪贴板。到这里 clipboard.js 的核心源码,我们差不多都分析完了,希望阅读本文后,大家不仅了解了 clipboard.js 背后的工作原理,同时也学会了如何利用事件派发器来实现消息通信 及 Selection 和 execCommand API 等相关的知识。
关注「全栈修仙之路」阅读阿宝哥原创的 3 本免费电子书(累计下载近 2万)及 9 篇源码分析系列教程。