关于前端:这个-297-K-的剪贴板-JS-库有点东西

4次阅读

共计 12163 个字符,预计需要花费 31 分钟才能阅读完成。

2020 年行将完结了,人不知; 鬼不觉 源码剖析 专题曾经写了 9 篇文章,往期的 8 篇文章介绍了 Axios、BetterScroll、koa-compose 和 FileSaver.js 等优良的开源我的项目,该专题的每篇文章阿宝哥都花了挺多工夫与精力。不过值得快慰的是,专题中的多篇文章受到了社区小伙伴和公众号粉丝的认可与激励,这让阿宝哥有持续写该专题的能源,这里真心地感激大家的反对。

对往期 8 篇文章感兴趣的小伙伴,能够浏览 如何更好地浏览源码?这八篇文章给你答案 这篇文章。

好的,咱们马上回到正题。本期阿宝哥将介绍一个被 157317 个我的项目援用的 JS 开源库 —— clipboard.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 应用

在应用 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 元素之外,复制的指标还能够是 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 '大家好,我是阿宝哥';}
});

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

在某些场景下,你可能须要获取选中区域中的文本。针对这种场景,你能够通过调用 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 兼容性较好,大家能够放心使用,具体的兼容性如下图所示:

(图片起源:https://caniuse.com/?search=q…)

介绍完 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 对象如下所示:

置信看完上图,大家对创立 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 参数,所以将应用默认的处理函数:

由上图可知在 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);
});

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

上面咱们来介绍另一个分支,即含有 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 除了反对拷贝 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 等相干的常识。

关注「全栈修仙之路」浏览阿宝哥原创的 3 本收费电子书(累计下载近 2 万)及 9 篇源码剖析系列教程。

五、参考资源

  • MDN Selection
  • MDN execCommand
  • MDN queryCommandSupported
  • MDN selectNodeContents
正文完
 0