20

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

 3 years ago
source link: https://mp.weixin.qq.com/s?__biz=MzI2MjcxNTQ0Nw%3D%3D&%3Bmid=2247488758&%3Bidx=1&%3Bsn=c536f9f375773eae11f23f32ae660e69
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

2020 年即将结束了,不知不觉 源码分析 专题已经写了 9 篇文章,往期的 8 篇文章介绍了 Axios、BetterScroll、koa-compose 和 FileSaver.js 等优秀的开源项目,该专题的每篇文章阿宝哥都花了挺多时间与精力。

不过值得欣慰的是,专题中的多篇文章受到了掘金小伙伴和公众号粉丝的认可与鼓励,这让阿宝哥有继续写该专题的动力,这里真心地感谢大家的支持。

对往期 8 篇文章感兴趣的小伙伴,可以阅读 如何更好地阅读源码?这八篇文章给你答案 这篇文章。

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

一、clipboard.js 简介

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

ABrQVzQ.jpg!mobile

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

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

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

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

FNRJnq.jpg!mobile

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

qQfq6vR.jpg!mobile

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

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

二、clipboard.js 使用

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

NPM

npm install clipboard --save

CDN

<script src="https://cdn.jsdelivr.net/npm/[email protected]/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/[email protected]/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>

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

BJVBVr6.jpg!mobile

除了 input 元素之外,复制的目标还可以是 divtextarea 元素。在以上示例中,我们复制的目标是通过 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 'to be or not to be';
}
});

关于 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 (区域),俗称 拖蓝 。默认情况下,该函数只针对一个区域,我们可以这样使用这个函数:

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 浏览器实现了多个区域。

IZbQvmy.jpg!mobile

在某些场景下,你可能需要获取选中区域中的文本。针对这种场景,你可以通过调用 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 来执行 copycut 命令,从而实现把内容复制到剪贴板。

那么现在问题来了,我们有没有办法判断当前浏览器是否支持 copycut 命令呢?答案是有的,即使用浏览器提供的 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 兼容性较好,大家可以放心使用,具体的兼容性如下图所示:

FVzE3uB.jpg!mobile

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

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

如果你想了解阅读源码的思路与技巧,可以阅读 使用这些思路与技巧,我读懂了多个优秀的开源项目 这篇文章。

四、clipboard.js 源码解析

4.1 Clipboard 类

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

<!-- 定义一些标记 -->
<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 对象如下所示:

YvqimqN.jpg!mobile

相信看完上图,大家对创建 ClipboardAction 对象时,所使用到的方法都有了解。那么 this.actionthis.targetthis.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 参数,所以将使用默认的处理函数:

yeYJRb.jpg!mobile

由上图可知在 defaultActiondefaultTargetdefaultText 方法内部都会调用 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 类,该类会包含具体的复制逻辑。

4.2 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 方法中会根据 texttarget 属性来选择不同的选择策略:

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 方法:

  • select:用于选中一个 <textarea> 元素或者一个带有 text 字段的 <input> 元素里的所有内容。
  • setSelectionRange:用于设定 <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);
});

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

YrMnAbz.jpg!mobile

下面我们来介绍另一个分支,即含有 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 方法执行后的页面效果,阿宝哥截了一张实际的效果图:

quQ3imv.jpg!mobile

其实 clipboard.js 除了支持拷贝 inputtextarea 元素的内容之外,它还支持拷贝其它 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 等相关的知识。

五、参考资源

  • MDN Selection

  • MDN execCommand

  • MDN queryCommandSupported

  • MDN selectNodeContents

聚焦全栈,专注分享 TypeScript、Web API、前端架构等技术干货。

Rvqy6zU.jpg!mobile


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK