clipboard.js源码解析与实践

日常业务中,会常常用到拷贝、剪切的需求,此外一些针对C端的平台复制内容下面会新增一段版权信息,那么这些都是如何实现的呢?
其实是用的window.execCommand方法,该方法允许运行命令来操作可编辑区域的元素,执行系统的copy命令或者cut命令,实现拷贝和剪切内容到系统剪切板中。window.execCommand可以执行copycut外,还有其他的命令可参考如下表格:

命令 描述
backColor 修改文档的背景颜色。在 styleWithCss 模式下,则只影响容器元素的背景颜色。这需要一个 类型的字符串值作为参数传入。注意,IE 浏览器用这个设置文字的背景颜色。
bold 开启或关闭选中文字或插入点的粗体字效果。IE 浏览器使用 标签,而不是标签。
ClearAuthenticationCache 清除缓存中的所有身份验证凭据。
contentReadOnly 通过传入一个布尔类型的参数来使能文档内容的可编辑性。(IE 浏览器不支持)
copy 拷贝当前选中内容到剪贴板。启用这个功能的条件因浏览器不同而不同,而且不同时期,其启用条件也不尽相同。使用之前请检查浏览器兼容表,以确定是否可用。
createLink 将选中内容创建为一个锚链接。这个命令需要一个hrefURI 字符串作为参数值传入。URI 必须包含至少一个字符,例如一个空格。(浏览器会创建一个空链接)
cut 剪贴当前选中的文字并复制到剪贴板。启用这个功能的条件因浏览器不同而不同,而且不同时期,其启用条件也不尽相同。使用之前请检查浏览器兼容表,以确定是否可用。
decreaseFontSize 给选中文字加上 标签,或在选中点插入该标签。(IE 浏览器不支持)
defaultParagraphSeparator 更改在可编辑文本区域中创建新段落时使用的段落分隔符。有关更多详细信息,请参阅标记生成的差异。
delete 删除选中部分。
enableAbsolutePositionEditor 启用或禁用允许移动绝对定位元素的抓取器。Firefox 63 Beta/Dev Edition 默认禁用此功能 (bug 1449564)。
enableInlineTableEditing 启用或禁用表格行和列插入和删除控件。(IE 浏览器不支持)
enableObjectResizing 启用或禁用图像和其他对象的大小可调整大小手柄。(IE 浏览器不支持)
fontName 在插入点或者选中文字部分修改字体名称。需要提供一个字体名称字符串 (例如:"Arial") 作为参数。
fontSize 在插入点或者选中文字部分修改字体大小。需要提供一个 HTML 字体尺寸 (1-7) 作为参数。
foreColor 在插入点或者选中文字部分修改字体颜色。需要提供一个颜色值字符串作为参数。
formatBlock 添加一个 HTML 块式标签在包含当前选择的行,如果已经存在了,更换包含该行的块元素 (在 Firefox 中,BLOCKQUOTE 是一个例外 -它将包含任何包含块元素). 需要提供一个标签名称字符串作为参数。几乎所有的块样式标签都可以使用 (例如。"H1", "P", "DL", "BLOCKQUOTE"). (IE 浏览器仅仅支持标题标签 H1 - H6, ADDRESS,和 PRE,使用时还必须包含标签分隔符 < >, 例如 < H1 >
forwardDelete 删除光标所在位置的字符。和按下删除键一样。
heading 添加一个标题标签在光标处或者所选文字上。需要提供标签名称字符串作为参数(例如:"H1"、"H6")(IE 和 Safari 不支持)
hiliteColor 更改选择或插入点的背景颜色。需要一个颜色值字符串作为值参数传递。UseCSS 必须开启此功能。(IE 浏览器不支持)
increaseFontSize 在选择或插入点周围添加一个 BIG 标签。(IE 浏览器不支持)
indent 缩进选择或插入点所在的行,在 Firefox 中,如果选择多行,但是这些行存在不同级别的缩进,只有缩进最少的行被缩进。
insertBrOnReturn 控制当按下 Enter 键时,是插入 br 标签还是把当前块元素变成两个。(IE 浏览器不支持)
insertHorizontalRule 在插入点插入一个水平线(删除选中的部分)
insertHTML 在插入点插入一个 HTML 字符串(删除选中的部分)。需要一个个 HTML 字符串作为参数。(IE 浏览器不支持)
insertImage 在插入点插入一张图片(删除选中的部分)。需要一个 URL 字符串作为参数。这个 URL 图片地址至少包含一个字符。空白字符也可以(IE 会创建一个链接其值为 null)
insertOrderedList 在插入点或者选中文字上创建一个有序列表
insertUnorderedList 在插入点或者选中文字上创建一个无序列表。
insertParagraph 在选择或当前行周围插入一个段落。(IE 会在插入点插入一个段落并删除选中的部分.)
insertText 在光标插入位置插入文本内容或者覆盖所选的文本内容。
italic 在光标插入点开启或关闭斜体字。 (Internet Explorer 使用 EM 标签,而不是 I )
justifyCenter 对光标插入位置或者所选内容进行文字居中。
justifyFull 对光标插入位置或者所选内容进行文本对齐。
justifyLeft 对光标插入位置或者所选内容进行左对齐。
justifyRight 对光标插入位置或者所选内容进行右对齐。
outdent 对光标插入行或者所选行内容减少缩进量。
paste 在光标位置粘贴剪贴板的内容,如果有被选中的内容,会被替换。剪贴板功能必须在 user.js 配置文件中启用。参阅 [1].
redo 重做被撤销的操作。
removeFormat 对所选内容去除所有格式
selectAll 选中编辑区里的全部内容。
strikeThrough 在光标插入点开启或关闭删除线。
subscript 在光标插入点开启或关闭下角标。
superscript 在光标插入点开启或关闭上角标。
underline 在光标插入点开启或关闭下划线。
undo 撤销最近执行的命令。
unlink 去除所选的锚链接的标签
styleWithCSS 用这个取代 useCSS 命令。参数如预期的那样工作,i.e. true modifies/generates 风格的标记属性,false 生成格式化元素。
AutoUrlDetect 更改浏览器自动链接行为(仅 IE 浏览器支持)

document.execCommand可以做的事情很多,但是需要说明的是它在最新的web标准中已经被废弃。来看一下MDN的介绍:

已弃用: 不再推荐使用该特性。虽然一些浏览器仍然支持它,但也许已从相关的 web 标准中移除,也许正准备移除或出于兼容性而保留。请尽量不要使用该特性,并更新现有的代码;参见本页面底部的兼容性表格以指导你作出决定。请注意,该特性随时可能无法正常工作。

浏览器兼容性——取自MDN

WX20221026-172742.png

上面只展示了几个属性,可以看到,copycut是全部浏览器都支持的,此外还有pasteselectAll等等,在使用的时候最好做一下判断,其他的命令基本都已不支持,就不推荐再使用了。

介绍完document.execCommand,接下来我们准备”上菜“!

有请今天的主角登场 —— clipboardjs

基本使用

首先,我们来看一下clipboardjs的使用方式:




 
   
   target-div
   
 

 
   
   
hello

点击button按钮之后,控制台打印如下内容:

image.png
  • Action:执行命令
  • Text:放入到剪切板的内容
  • Trigger:触发的节点

源码解析

可以边参考源码边看:点击查看源码

打开源码进入clipboardjs页面,可以看到定义了一个Clipboard类并继承于EmitterEmitter是负责订阅命令执行之后剪切板操作成功和失败的关键,用到了Emitteronemit

tiny-emitter是一个简单实现发布订阅的包(查看tiny-emitter源码),在函数的原型对象内定义了ononceemitoff四个原型方法,具体的实现非常简单,代码量很少,这里不做过多介绍,感兴趣可以看tiny-emiter源码。

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);
  }

  // 省略...
}

构造函数内有resolveOptionslistenClick方法分别是处理参数和监听点击事件。我们来一一分析。

resolveOption

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函数对this.actionthis.targetthis.textthis.container进行处理取值。

当不想在元素中写如data-clipboard-XXXX的自定义属性的时候,actionstargettext都允许通过配置项的方式声明一个函数,做自己的业务需求,最后返回一个值。所以在上面我们会看到,是通过typeof判断是否为function类型,如果不是函数类型则取默认值,默认值则是通过自定义属性进行匹配,格式如:data-clipboard-XXXX

元素的data-clipboard-XXXX会有函数专门处理并读取对应的自定义属性值:

/**
* Helper function to retrieve attribute value.
* @param {String} suffix
* @param {Element} element
*/
function getAttributeValue(suffix, element) {
   const attribute = `data-clipboard-${suffix}`;

   if (!element.hasAttribute(attribute)) {
       return;
   }
   return element.getAttribute(attribute);
}   

下面来解释一下每个属性的含义:

  1. action是执行的方法,可以是copy复制,也可以是cut剪切。
  2. target是目标锚点,如data-clipboard-target="div"中的target就是div,此外也可以是className类名或者#id节点id,最终是通过document.querySelector(selector);来获取节点。
  3. text是选中的文本内容
  4. container是要将目标节点插入的父级根节点位置,可以根据实际业务需求配置,比如在vue中我们希望插入的是#app容器内,则可以传入document.getElementById('app'),否则默认是插入到document.body

以上就是resolveOptions所做的工作。下面来讲解一下this.listenClick函数。

listenClick

import listen from 'good-listener';
   
/**
* Adds a click event listener to the passed trigger.
* @param {String|HTMLElement|HTMLCollection|NodeList} trigger
*/
listenClick(trigger) {
   this.listener = listen(trigger, 'click', (e) => this.onClick(e));
}

listenClick函数负责监听click点击事件。会遍历所有的节点,给节点添加监听事件,这个是通过good-listener插件实现,插件是由作者本人写的,实现原理很简单,通过检查trigger是什么类型

  • 节点(node):直接给节点添加listen监听事件
    function listenNode(node, type, callback) {
        node.addEventListener(type, callback);
    
        return {
            destroy: function() {
                node.removeEventListener(type, callback);
            }
        }
    }
    
  • 节点列表(nodeList):会遍历列表,然后给每个节点添加监听事件
    function listenNodeList(nodeList, type, callback) {
        Array.prototype.forEach.call(nodeList, function(node) {
            node.addEventListener(type, callback);
        });
    
        return {
            destroy: function() {
                Array.prototype.forEach.call(nodeList, function(node) {
                    node.removeEventListener(type, callback);
                });
            }
        }
    }
    
  • 字符串 :传入的是字符串则直接监听document.body
    // 传入是字符串
    function listenSelector(selector, type, callback) {
        return delegate(document.body, selector, type, callback);
    }
    // 监听document.body
    function delegate(element, selector, type, callback, useCapture) {
        var listenerFn = listener.apply(this, arguments);
        element.addEventListener(type, listenerFn, useCapture);
        return {
            destroy: function() {
                element.removeEventListener(type, listenerFn, useCapture);
            }
        }
    }
    

查看源码

回到listenClick函数,当我们点击之后会触发执行this.onClick函数,会触发执行剪切板命令,最后返回选中的文本内容,利用Emitter.emit发布结果。我们可以通过clipboard.on('success')订阅其结果。这个就是继承于tiny-emitter的作用。

/**
* Defines a new `ClipboardAction` on each click event.
* @param {Event} e
*/
onClick(e) {
    const trigger = e.delegateTarget || e.currentTarget;
    const action = this.action(trigger) || 'copy';
    const text = ClipboardActionDefault({
      action,
      container: this.container,
      target: this.target(trigger),
      text: this.text(trigger),
    });

    // Fires an event based on the copy operation result.
    this.emit(text ? 'success' : 'error', {
      action,
      text,
      trigger,
      clearSelection() {
        if (trigger) {
          trigger.focus();
        }
        window.getSelection().removeAllRanges();
      },
    });
}

实例化ClipboardAction后,创建一个监听事件,会监听所有点击行为,当监听到某一个节点点击,利用节点的Evente.delegateTarget || e.currentTarget取得目标节点内容对象信息。根据trigger获取到actionthis.action可能是对象,也可能是字符串,当this.action不是对象的时候会异常,然后直接赋值copy

text是由ClipboardActionDefault函数得到,我们来看看它做了什么处理。

import ClipboardActionCut from './cut';
import ClipboardActionCopy from './copy';

const ClipboardActionDefault = (options = {}) => {
  const { action = 'copy', container, target, text } = options;
    
  // 省略action和target的边界处理代码... 
    
  // 定义基于“文本”属性的选择策略。
  if (text) {
    return ClipboardActionCopy(text, { container });
  }
  // 定义基于' target '属性的选择策略。
  if (target) {
    return action === 'cut'
      ? ClipboardActionCut(target)
      : ClipboardActionCopy(target, { container });
  }
};

根据resolveOptions函数对this.textthis.target的处理,我们可以知道,它们有两种数据类型,一种是函数(typeof === function)类型,一种是通过data-clipboard-xxxx自定义属性获取的值。那么如果text没有声明function或者在节点中声明自定义属性值,那么它将不会通过if(text)的判断,直接跳过,往下执行。随后是target,由于前面做了边界异常处理,所以这里作为兜底执行,根据cutcopy分配给两个不同的函数处理,这里我们只介绍copy,因为cutcopy仅有执行命令document.execCommand执行的差异。

我们来看看ClipboardActionCopy做的工作:

import select from 'select';
import command from '../common/command';
import createFakeElement from '../common/create-fake-element';

/**
 * Create fake copy action wrapper using a fake element.
 * @param {String} target
 * @param {Object} options
 * @return {String}
 */
const fakeCopyAction = (value, options) => {
  const fakeElement = createFakeElement(value);
  options.container.appendChild(fakeElement);
  const selectedText = select(fakeElement);
  command('copy');
  fakeElement.remove();

  return selectedText;
};

/**
 * Copy action wrapper.
 * @param {String|HTMLElement} target
 * @param {Object} options
 * @return {String}
 */
const ClipboardActionCopy = (
  target,
  options = { container: document.body }
) => {
  let selectedText = '';
  if (typeof target === 'string') {
    selectedText = fakeCopyAction(target, options);
  } else if (
    target instanceof HTMLInputElement &&
    !['text', 'search', 'url', 'tel', 'password'].includes(target?.type)
  ) {
    // If input type doesn't support `setSelectionRange`. Simulate it. https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/setSelectionRange
    selectedText = fakeCopyAction(target.value, options);
  } else {
    selectedText = select(target);
    command('copy');
  }
  return selectedText;
};

export default ClipboardActionCopy;

先来看ClipboardActionCopy函数,针对三种类型做了判断,分别是

  1. 是字符串类型,说明目标是一个纯文本,就需要额外创建一个textarea标签,为什么不是input标签呢?是因为对于换行文本内容使用textarea会更友好,如果使用input标签,那么需要拷贝的是textarea多行文本内容则会发生格式异常的问题,因为最终是需要将选中的文本进行input.value = value这样赋值的,多行变成单行,而反之使用textarea支持拷贝input和它自身类型节点。
  2. 第二种是节点类型,不为text', 'search', 'url', 'tel', 'password'这几种类型之一的,均通过创建textarea的方式进行拷贝。
  3. 第三种是为text', 'search', 'url', 'tel'类型其中之一的直接执行document.execCommand('copy')指令。

上面fakeCopyAction方法内有一个createFakeElement函数,它是负责创建textarae的,我们来看看作者是如何写的:

/**
* 创建一个带有值的textarea元素。
* @param {String} value
* @return {HTMLElement}
*/
export default function createFakeElement(value) {
 const isRTL = document.documentElement.getAttribute('dir') === 'rtl';
 const fakeElement = document.createElement('textarea');
 // 防止在IOS上缩放
 fakeElement.style.fontSize = '12pt';
 // 重写盒子模型
 fakeElement.style.border = '0';
 fakeElement.style.padding = '0';
 fakeElement.style.margin = '0';
 // 设置绝对定位,将元素移动出屏幕外面
 fakeElement.style.position = 'absolute';
 fakeElement.style[isRTL ? 'right' : 'left'] = '-9999px';
 // 垂直移动到屏幕相同位置
 let yPosition = window.pageYOffset || document.documentElement.scrollTop;
 fakeElement.style.top = `${yPosition}px`;

 fakeElement.setAttribute('readonly', '');
 fakeElement.value = value;

 return fakeElement;
}

源码很简单,就是创建textarea标签,为了防止在IOS中缩放,设置了字体大小为12pt,随手重写了盒子模型borderpaddingmargin三个的样式,主要目的为了限制用户恶意设置全局盒子模型样式。然后将元素移出到屏幕以外,这里用到了isRTL来判断是移动至屏幕左边还是屏幕右边,我们看到上面判断了页面元素中是否存在dir属性,该属性是否为rtl,它有什么意义呢?

说到rtl就需要说到另外一个ltr,它们是完全相反的关系,过去会在页面中写dir标签,但是现在已经不赞成使用了,感兴趣的可以自行查阅对应资料,这里简单的说说rtlltr的区别:

元素 LTR RTL
文本 句子从左向右阅读 句子从有向左阅读
时间线 事件序列从左向右进行 事件序列从右向左进行
图像 从左向右的运动 从右向左运动

回到上面代码,设置了标签摆放的位置后,需要将元素设置为可编辑状态才行,所以取消了readonly,随后给textarea标签赋值,最终把处理的元素返回。

以上剪切板功能就实现了,那么我们需要订阅剪切板是成功还是失败改怎么办,tiny-emitter的发布订阅功能就派上用场啦!

我们来看onClick函数下的this.emit就是做的这个工作。把actionstexttriggerclearSelection四个属性暴露出去,我们就可以实现订阅。前面三个前面有介绍过了,这里介绍一下clearSelection属性,它的职责是负责清理内容选中光标的,还记得前面讲fakeCopyAction函数中用到了select(fakeElement),这里的select也是作者自己写的插件,插件很简单才43行代码,实现原理是就是对选中的内容的元素设置focus聚集光标,感兴趣可以看源码。

至此,源码就解析完了。

简化版hook

了解了原理之后,我们能不能自己实现一个简单的剪切板功能呢?能在vue3、react中直接使用的hook

要实现剪切板最核心就只有两点:

  1. 创建textarea标签,并把需要添加剪切板的内容赋值给该标签,随后将其聚焦选中,最后添加到剪贴板后,移除该节点。
  2. 执行document.execCommand拷贝到剪切板。

了解了核心之后,我们就来写一个简单的版本。剪切板执行完毕之后,我们给一个回调让用户做其他业务逻辑。

代码如下:

 function useClipboard(value,cb) {
      const fakeElement = document.createElement('textarea');
      // 防止在IOS上缩放
      fakeElement.style.fontSize = '12pt';
      // 重写盒子模型
      fakeElement.style.border = '0';
      fakeElement.style.padding = '0';
      fakeElement.style.margin = '0';
      // 设置绝对定位,将标签移动出屏幕外面
      fakeElement.style.position = 'absolute';
      fakeElement.style.right = '-9999px';
      // 垂直移动到屏幕相同位置
      let yPosition = window.pageYOffset || document.documentElement.scrollTop;
      fakeElement.style.top = `${yPosition}px`;
       // 设置为可读
      fakeElement.setAttribute('readonly', '');
      // 赋值
      fakeElement.value = value;
      // 添加到根节点
      document.body.appendChild(fakeElement);
      // 选中内容
      fakeElement.select();
      // 检查命令是否支持
      if(document.execCommand('copy')){
        document.execCommand('copy')
        // 执行回调
        typeof cb === 'function' && cb()
      } else {
        alert("系统不支持,请手动复制!")
      }
      // 拷贝到剪切板之后,移除自身标签
      fakeElement.remove()
}

使用方法


LeetCode、CSDN网站复制内容长度比较长的时候,都会添加版权信息在底部是,这个是如何实现的?

其实也很简单,检查复制的内容长度是否超出阈值,超出则添加版权信息。


兼容与降级

由于document.execCommand存在兼容问题,那么我该如何检查是否兼容呢?

  1. 通过执行命令查看返回值是为true还是false
      if (document.execCommand("copy")) {
            document.execCommand("copy")
        } else {
            window.alert("当前系统不支持复制操作~")
        }
    
  1. document.execCommand相关的一个API可以检查当前运行的浏览器是否兼容 —— document.queryCommandSupported,它会检查执行的命令是否支持。

    if(document.queryCommandSupported && document.queryCommandSupported('copy')){
       document.execCommand("copy")
    } else {
       window.alert("当前系统不支持复制操作~")    
    }
    

    当然我可以封装成一个函数,来专门判断是否支持当前浏览器,更方便业务调用:

    export function 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;
    }
    
    // 使用方式
    isSupported("copy")
    isSupported(["copy", "cut", "selectAll"])
    
    1. 使用Navigator.cliporad,这个是新的剪切板指令,兼容全部浏览器。推荐使用该方法,支持异步读写。下面贴一段来自MDN的介绍,更信息请自行查阅。

    剪贴板 Clipboard API 为 Navigator 接口添加了只读属性 clipboard,该属性返回一个可以读写剪切板内容的 Clipboard 对象。在 Web 应用中,剪切板 API 可用于实现剪切、复制、粘贴的功能。
    只有在用户事先授予网站或应用对剪切板的访问许可之后,才能使用异步剪切板读写方法。许可操作必须通过取得权限 Permissions API 的 "clipboard-read""clipboard-write" 获得。

你可能感兴趣的:(clipboard.js源码解析与实践)