clipboard.js 是一個小型的複製到剪切板插件,只有3kb,非flashjavascript
公司項目有用到clipboard.js,因爲好奇心順手點開了源碼看看其到底是如何實現的,本覺得是九曲十八彎錯綜複雜,其實仍是挺容易看懂的,因此就分享下讀後感哈哈。html
本篇讀後感分爲五部分,分別爲前言、使用、解析、demo、總結,五部分互不相連可根據須要分開看。java
前言爲介紹、使用爲庫的使用、解析爲源碼的解析、demo是抽取源碼的核心實現的小demo,總結爲吹水,學以至用。node
建議跟着源碼結合本文閱讀,這樣更加容易理解!git
在閱讀源碼以前最好先了解其用法,有助於理解某些詭異的源碼爲什麼這樣寫。(下面是clipboard.js做者的demo)github
<button class="btn">Copy</button>
<div>hello</div>
<script> var clipboard = new ClipboardJS('.btn', { target: function() { return document.querySelector('div'); } }); clipboard.on('success', function(e) { console.log(e); }); clipboard.on('error', function(e) { console.log(e); }); </script>
複製代碼
從做者給出的demo能夠看到,點擊btn後複製了div爲hello的值,能夠當作三步:設計模式
即拆解核心:trigger(卡卡西) 對 target(忍術) 進行 copy(複製)數組
把trigger傳遞給ClipboardJS
函數,函數接受三種類型瀏覽器
<!-- 1.dom元素 -->
<div id="btn" data-clipboard-text="1"></div>
<script> var btn = document.getElementById('btn'); var clipboard = new ClipboardJS(btn); </script>
<!-- 2.nodeList -->
<button data-clipboard-text="1">Copy</button>
<button data-clipboard-text="2">Copy</button>
<button data-clipboard-text="3">Copy</button>
<script> var btns = document.querySelectorAll('button'); var clipboard = new ClipboardJS(btns); </script>
<!-- 3.選擇器 -->
<button class="btn" data-clipboard-text="1">Copy</button>
<button class="btn" data-clipboard-text="2">Copy</button>
<button class="btn" data-clipboard-text="3">Copy</button>
<script> var clipboard = new ClipboardJS('.btn'); </script>
複製代碼
target的目的是爲了獲取複製的值(text),因此target不必定是dom。獲取text有兩種方式數據結構
<!-- 1.trigger屬性賦值 data-clipboard-text -->
<button class="btn" data-clipboard-text="1">Copy</button>
<button class="btn" data-clipboard-text="2">Copy</button>
<button class="btn" data-clipboard-text="3">Copy</button>
<script> var clipboard = new ClipboardJS('.btn'); </script>
<!-- 2.target對象獲取值 text -->
<button class="btn">Copy</button>
<div>hello</div>
<script> var clipboard = new ClipboardJS('.btn', { target: function() { return document.querySelector('div'); } }); </script>
<!-- 2.target對象獲取值 value -->
<input id="foo" type="text" value="hello">
<button class="btn" data-clipboard-action="copy" data-clipboard-target="#foo">Copy</button>
<script> var clipboard = new ClipboardJS('.btn'); </script>
複製代碼
<!-- 1.複製:默認copy -->
<button class="btn">Copy</button>
<div>hello</div>
<script> var clipboard = new ClipboardJS('.btn', { target: function() { return document.querySelector('div'); } }); </script>
<!-- 2.剪切:cut -->
<textarea id="bar">hello</textarea>
<button class="btn" data-clipboard-action="cut" data-clipboard-target="#bar">Cut</button>
<script> var clipboard = new ClipboardJS('.btn'); </script>
複製代碼
源碼主要包含兩個核心文件clipboard.js和clipboard-action.js,但還需瞭解tiny-emitter.js。
tiny-emitter 是一個小型(小於1k)事件發射器(至關於node的events.EventEmitter)
你確定很奇怪爲何第一個解析的不是clipboard.js而是tiny-emitter.js,先看用法。
<div id="btn" data-clipboard-text="1">
<span>Copy</span>
</div>
<script> var btn = document.getElementById('btn'); var clipboard = new ClipboardJS(btn); // tiny-emitter.js的做用,處理當複製成功或者失敗後的回調函數 clipboard.on('success', function(data) { console.log(data); }); clipboard.on('error', function(data) { console.log(data); }); </script>
複製代碼
既然定義了事件,源碼在哪裏觸發事件觸發器的呢?從他的標識(success | error)天然而然的想到,是複製這個操做以後才觸發的。咱們先來簡單看看clipboard-action.js裏的emit
方法的代碼,不影響後續的閱讀
class ClipboardAction{
/** * 根據複製操做的結果觸發對應發射器 * @param {Boolean} succeeded 複製操做後的返回值,用於判斷複製是否成功 */
handleResult(succeeded) {
// 這裏this.emitter.emit至關於E.emit
this.emitter.emit(succeeded ? 'success' : 'error', {
action: this.action,
text: this.selectedText,
trigger: this.trigger,
clearSelection: this.clearSelection.bind(this)
});
}
}
複製代碼
clipboard.js中使用了tiny-emitter.js的on
和emit
方法。tiny-emitter.js聲明一個對象(this.e
),(success | error)定義標識,on
方法用來添加該標識事件,emit方法用來標識發射事件。舉例:你是一個古代的皇帝,在開朝之初就招了一批後宮佳麗(on
方法),某天你想檢查身體,就讓公公向後宮傳遞一個信號(emit
方法),就能雨露均沾了。
function E () {}
/** * @param {String} name 觸發事件的表識 * @param {function} callback 觸發的事件 * @param {object} ctx 函數調用上下文 */
E.prototype = {
on: function (name, callback, ctx) {
// this.e存儲全局事件
var e = this.e || (this.e = {});
// this.e的結構
// this.e = {
// success: [
// {fn: callback, ctx: ctx}
// ],
// error: [...]
// }
(e[name] || (e[name] = [])).push({
fn: callback,
ctx: ctx
});
return this;
},
emit: function (name) {
// 獲取標識後的參數,就是上面this.emitter.emit函數第二個參數對象{action, text, trigger, clearSelection}
// 最終從回調函數中獲取data。E.on(success, (data) => data)
var data = [].slice.call(arguments, 1);
// 獲取標識對應的函數
var evtArr = ((this.e || (this.e = {}))[name] || []).slice();
var i = 0;
var len = evtArr.length;
for (i; i < len; i++) {
// 循環觸發函數數組的函數,把data傳遞出去做爲on的回調函數的結果
evtArr[i].fn.apply(evtArr[i].ctx, data);
}
return this;
}
};
複製代碼
簡單理解就是tiny-emitter.js內部維護了一個對象(this.e
),this.e
對象用記錄一系列的屬性(例如:success、error),屬性是數組,當調用on
方法往對應屬性的數組添加觸發函數,調用emit
方法就觸發對應屬性的全部函數
clipboard.js主要由clipboard.js和clipboard-action.js組成。clipboard.js主要負責對接收傳遞進來的參數,並組裝成clipboard-action.js所須要的數據結構。clipboard-action.js就是複製的核心庫,負責複製的實現,咱們先來看看clipboard.js
import Emitter from 'tiny-emitter';
class Clipboard extends Emitter {
/** * @param {String|HTMLElement|HTMLCollection|NodeList} trigger * @param {Object} options */
constructor(trigger, options) {
super();
// 定義屬性
this.resolveOptions(options);
// 定義事件
this.listenClick(trigger);
}
}
複製代碼
從上面源碼能夠看到,Clipboard
繼承自Emitter
,Emitter
就是tiny-emitter.js的方法。而Clipboard
初始化時有兩個步驟
咱們先看resolveOptions
函數(注意區分trigger元素和target對象,trigger元素是用來綁定click事件的元素,target對象是複製的對象。也就是上面拆解核心:trigger(卡卡西) 對 target(忍術) 進行 copy(複製) )
import Emitter from 'tiny-emitter';
class Clipboard extends Emitter {
/** * @param {String|HTMLElement|HTMLCollection|NodeList} trigger * @param {Object} options */
constructor(trigger, options) {
super();
// 定義屬性
this.resolveOptions(options);
// 定義事件
this.listenClick(trigger);
}
/** * 定義函數的屬性,若是外部有傳函數,使用外部的函數,不然使用內部的默認函數 * @param {Object} options */
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;
}
/** * 定義行爲的回調函數 * @param {Element} trigger */
defaultAction(trigger) {
return getAttributeValue('action', trigger);
}
/** * 定義複製目標的回調函數 * @param {Element} trigger */
defaultTarget(trigger) {
const selector = getAttributeValue('target', trigger);
if (selector) {
return document.querySelector(selector);
}
}
/** * 定義複製內容的回調函數 * @param {Element} trigger */
defaultText(trigger) {
return getAttributeValue('text', trigger);
}
}
/** * 工具函數:獲取複製目標屬性的值 * @param {String} suffix * @param {Element} element */
function getAttributeValue(suffix, element) {
const attribute = `data-clipboard-${suffix}`;
if (!element.hasAttribute(attribute)) {
return;
}
return element.getAttribute(attribute);
}
複製代碼
極爲清晰,從resolveOptions
能夠看到格式化了4個所需的參數。
action
事件的行爲(複製copy、剪切cut)target
複製的目標text
複製的內容container
包含元素(對於使用者不須要太關心這個,爲實現複製功能暫時性的添加textarea
做爲輔助)格式化的套路是一致的,判斷是否傳遞了相應的參數,傳遞了就使用,沒有的話就從trigger元素中經過屬性獲取(data-clipboard-xxx)
當格式化所需參數後,接下來看listenClick,對trigger元素綁定點擊事件,實現複製功能
import Emitter from 'tiny-emitter';
import listen from 'good-listener';
class Clipboard extends Emitter {
/** * @param {String|HTMLElement|HTMLCollection|NodeList} trigger * @param {Object} options */
constructor(trigger, options) {
super();
// 定義屬性
this.resolveOptions(options);
// 定義事件
this.listenClick(trigger);
}
/** * 爲目標添加點擊事件 * @param {String|HTMLElement|HTMLCollection|NodeList} trigger */
listenClick(trigger) {
// 做者對綁定事件的封裝,能夠理解爲
// trigger.addEventListener('click', (e) => this.onClick(e))
this.listener = listen(trigger, 'click', (e) => this.onClick(e));
}
/** * 給目標添加clipboardAction屬性 * @param {Event} e */
onClick(e) {
// trigger元素
const trigger = e.delegateTarget || e.currentTarget;
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
});
}
}
複製代碼
當格式化所需參數後,就能夠調用clipboard-action.js,並把對應的參數傳遞下去,實現複製功能。猜測做者分兩個文件來實現是爲了以功能來區分模塊,清晰明瞭不至於代碼揉雜在一塊兒過於雜亂無章
class ClipboardAction {
/** * @param {Object} options */
constructor(options) {
// 定義屬性
this.resolveOptions(options);
// 定義事件
this.initSelection();
}
/** * 設置行爲action,能夠是copy(複製)和cut(剪切) * @param {String} action */
set action(action = 'copy') {
this._action = action;
// action的值設置爲除copy和cut以外都報錯
if (this._action !== 'copy' && this._action !== 'cut') {
throw new Error('Invalid "action" value, use either "copy" or "cut"');
}
}
/** * 獲取行爲action * @return {String} */
get action() {
return this._action;
}
/** * 使用將複製其內容的元素設置`target`屬性。 * @param {Element} target */
set target(target) {
if (target !== undefined) {
if (target && typeof target === 'object' && target.nodeType === 1) {
if (this.action === 'copy' && target.hasAttribute('disabled')) {
throw new Error('Invalid "target" attribute. Please use "readonly" instead of "disabled" attribute');
}
if (this.action === 'cut' && (target.hasAttribute('readonly') || target.hasAttribute('disabled'))) {
throw new Error('Invalid "target" attribute. You can\'t cut text from elements with "readonly" or "disabled" attributes');
}
this._target = target;
}
else {
throw new Error('Invalid "target" value, use a valid Element');
}
}
}
/** * 獲取target(目標) * @return {String|HTMLElement} */
get target() {
return this._target;
}
}
複製代碼
咱們先看constructor
構造函數,做者的老套路,分兩部執行。先定義屬性值,而後執行。除了構造函數外,還須要注意一下class
的get
和set
函數,由於它從新定義了某些變量或函數的執行方式。 但從上面看到,做者從新定義了action
和target
,把this._action
和this._target
做爲了載體,限制了取值範圍而已,小case。
咱們清楚了clipboard-action.js的初識設置後,就能夠開始看構造函數裏的resolveOptions函數。
class ClipboardAction {
/** * @param {Object} options */
constructor(options) {
// 定義屬性
this.resolveOptions(options);
// 定義事件
this.initSelection();
}
/** * 定義基礎屬性(從類Clipboard傳遞進來的) * @param {Object} options */
resolveOptions(options = {}) {
// 行爲copy / cut
this.action = options.action;
// 包含元素
this.container = options.container;
// 鉤子函數
this.emitter = options.emitter;
// 複製目標
this.target = options.target;
// 複製內容
this.text = options.text;
// 綁定元素
this.trigger = options.trigger;
// 選中的複製內容
this.selectedText = '';
}
}
複製代碼
把傳遞進來的值記錄在this
上方便存取,但這裏爲何會多一個this.selectedText
呢?
這裏要區分開text
和selectedText
。從文章開始使用上看庫的用法,this.text
是用戶傳遞進來須要複製的值,而當傳遞this.target
而沒有傳遞this.text
時,這時候用戶但願複製的值是這個目標元素的值。因此瞭解用法後這裏的this.selectedText
是最終須要複製的值,即this.text
的值或者this.target
的值
定義完屬性後就開始最爲核心高潮的代碼了!initSelection函數
class ClipboardAction {
/** * @param {Object} options */
constructor(options) {
// 定義屬性
this.resolveOptions(options);
// 定義事件
this.initSelection();
}
/** * 使用哪種策覺取決於提供的text和target */
initSelection() {
if (this.text) {
this.selectFake();
}
else if (this.target) {
this.selectTarget();
}
}
/** * 從傳遞的target屬性去選擇元素 */
selectTarget() {
// 選中
this.selectedText = select(this.target);
// 複製
this.copyText();
}
}
複製代碼
initSelection
函數的做用是什麼呢,翻譯意思是初始化選擇,從命名其實能夠透露出信息(賣個關子嘿嘿)。這裏有兩條路能夠走,this.text
和this.target
。咱們選擇先走this.target
的路selectTarget
(方便理解)。
回顧下咱們平時在瀏覽器中複製的操做是怎樣的:
ctrl + c
或者 右鍵複製selectTarget
函數就是實現這三個步驟。咱們能夠看到選中的操做交給了select
函數,下面看select
函數。
function select(element) {
var selectedText;
// target爲select時
if (element.nodeName === 'SELECT') {
// 選中
element.focus();
// 記錄值
selectedText = element.value;
}
// target爲input或者textarea時
else if (element.nodeName === 'INPUT' || element.nodeName === 'TEXTAREA') {
var isReadOnly = element.hasAttribute('readonly');
// 若是屬性爲只讀,不能選中
if (!isReadOnly) {
element.setAttribute('readonly', '');
}
// 選中target
element.select();
// 設置選中target的範圍
element.setSelectionRange(0, element.value.length);
if (!isReadOnly) {
element.removeAttribute('readonly');
}
// 記錄值
selectedText = element.value;
}
else {
if (element.hasAttribute('contenteditable')) {
element.focus();
}
// 建立getSelection,用來選中除input、testarea、select元素
var selection = window.getSelection();
// 建立createRange,用來設置getSelection的選中範圍
var range = document.createRange();
// 選中範圍設置爲target元素
range.selectNodeContents(element);
// 清空getSelection已選中的範圍
selection.removeAllRanges();
// 把target元素設置爲getSelection的選中範圍
selection.addRange(range);
// 記錄值
selectedText = selection.toString();
}
return selectedText;
}
複製代碼
做者這裏分三種狀況,其實原理爲兩步 (想深刻的話自行了解瀏覽器提供下面幾個方法)
element.select()
和window.getSelection()
)element.setSelectionRange(start, end)
和range.selectNodeContents(element)
)在咱們選中了須要複製的元素後,就能夠進行復制操做啦 -- copyText
函數
class ClipboardAction {
/** * @param {Object} options */
constructor(options) {
// 定義屬性
this.resolveOptions(options);
// 定義事件
this.initSelection();
}
/** * 定義基礎屬性(從類Clipboard傳遞進來的) * @param {Object} options */
resolveOptions(options = {}) {
// 行爲copy / cut
this.action = options.action;
// 包含元素
this.container = options.container;
// 鉤子函數
this.emitter = options.emitter;
// 複製目標
this.target = options.target;
// 複製內容
this.text = options.text;
// 綁定元素
this.trigger = options.trigger;
// 複製內容
this.selectedText = '';
}
/** * 使用哪種策覺取決於提供的text和target */
initSelection() {
if (this.text) {
this.selectFake();
}
else if (this.target) {
this.selectTarget();
}
}
/** * 從傳遞的target屬性去選擇元素 */
selectTarget() {
// 選中
this.selectedText = select(this.target);
// 複製
this.copyText();
}
/** * 對目標執行復制操做 */
copyText() {
let succeeded;
try {
succeeded = document.execCommand(this.action);
}
catch (err) {
succeeded = false;
}
this.handleResult(succeeded);
}
/** * 根據複製操做的結果觸發對應發射器 * @param {Boolean} succeeded */
handleResult(succeeded) {
this.emitter.emit(succeeded ? 'success' : 'error', {
action: this.action,
text: this.selectedText,
trigger: this.trigger,
clearSelection: this.clearSelection.bind(this)
});
}
}
複製代碼
整個庫最爲核心的方法就是document.execCommand
了,查看MDN文檔
當一個
HTML
文檔切換到設計模式 (designMode)時,document
暴露execCommand
方法,該方法容許運行命令來操縱可編輯區域的內容,大多數命令影響document
的selection
(粗體,斜體等)
document
的selection
(當this.target
不是input
、textarea
時實現咱們選中的內容)最後,handleResult
函數就是複製成功或者失敗後的鉤子函數,也即Clipboard
所繼承Emitter
,當實例化ClipboardAction
時就把Emitter
做爲this.emitter
傳遞進來,這是複製的整個過程了,哈哈是否是感受挺好讀的。
原理是同樣的,只要理解了this.target
這條分路,咱們回去initSelection
函數,看看this.text
這條路做者是怎麼實現的
class ClipboardAction {
/** * @param {Object} options */
constructor(options) {
// 定義屬性
this.resolveOptions(options);
// 定義事件
this.initSelection();
}
/** * 定義基礎屬性(從類Clipboard傳遞進來的) * @param {Object} options */
resolveOptions(options = {}) {
// 行爲copy / cut
this.action = options.action;
// 父元素
this.container = options.container;
// 鉤子函數
this.emitter = options.emitter;
// 複製目標
this.target = options.target;
// 複製內容
this.text = options.text;
// 綁定元素
this.trigger = options.trigger;
// 複製內容
this.selectedText = '';
}
/** * 使用哪種策覺取決於提供的text和target */
initSelection() {
if (this.text) {
this.selectFake();
}
else if (this.target) {
this.selectTarget();
}
}
/** * 建立一個假的textarea元素(fakeElem),設置它的值爲text屬性的值而且選擇它 */
selectFake() {
const isRTL = document.documentElement.getAttribute('dir') == 'rtl';
// 移除已經存在的上一次的fakeElem
this.removeFake();
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);
// 選中fakeElem
this.selectedText = select(this.fakeElem);
// 複製
this.copyText();
}
/** * 在用戶點擊其餘後再移除fakeElem。用戶依然可使用Ctrl+C去複製,由於fakeElem依然存在 */
removeFake() {
if (this.fakeHandler) {
this.container.removeEventListener('click', this.fakeHandlerCallback);
this.fakeHandler = null;
this.fakeHandlerCallback = null;
}
if (this.fakeElem) {
this.container.removeChild(this.fakeElem);
this.fakeElem = null;
}
}
/** * 對目標執行復制操做 */
copyText() {
let succeeded;
try {
succeeded = document.execCommand(this.action);
}
catch (err) {
succeeded = false;
}
this.handleResult(succeeded);
}
/** * 根據複製操做的結果觸發對應發射器 * @param {Boolean} succeeded */
handleResult(succeeded) {
this.emitter.emit(succeeded ? 'success' : 'error', {
action: this.action,
text: this.selectedText,
trigger: this.trigger,
clearSelection: this.clearSelection.bind(this)
});
}
}
複製代碼
回顧下複製的流程,當只給了文本而沒有元素時如何實現?咱們能夠本身模擬!做者構造了textarea
元素,而後選中它便可,套路跟this.target
同樣。
值得注意的是,做者巧妙的運用了事件冒泡機制。在selectFake
函數中做者把移除textarea
元素的事件綁定在this.container
上。當咱們點擊trigger
元素複製後,建立一個輔助的textarea
元素實現複製,複製完以後點擊事件冒泡到父級,父級綁定了移除textarea
元素的事件,就順勢移除了。
源碼看了不練,跟白看有什麼區別。接下來提煉最爲核心原理寫個demo,賊簡單(MDN的例子)
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
</head>
<body>
<p>點擊複製後在右邊textarea CTRL+V看一下</p>
<input type="text" id="inputText" value="測試文本"/>
<input type="button" id="btn" value="複製"/>
<textarea rows="4"></textarea>
<script type="text/javascript"> var btn = document.getElementById('btn'); btn.addEventListener('click', function(){ var inputText = document.getElementById('inputText'); inputText.focus() inputText.setSelectionRange(0, inputText.value.length); // or // inputText.select() document.execCommand('copy', true); }); </script>
</body>
</html>
複製代碼
這是第一篇文章,寫文章真的挺耗時間的比起本身看,但好處是反覆斟酌源碼,細看到一些粗略看看不到的東西。有不足的地方多多提意見,會接受但不必定會改哈哈。還有哪些小而美的庫推薦推薦,相互交流,相互學習,相互交易。