日常业务中,会常常用到拷贝、剪切的需求,此外一些针对C端的平台复制内容下面会新增一段版权信息,那么这些都是如何实现的呢?
其实是用的window.execCommand方法,该方法允许运行命令来操作可编辑区域的元素,执行系统的copy命令或者cut命令,实现拷贝和剪切内容到系统剪切板中。window.execCommand可以执行copy和cut外,还有其他的命令可参考如下表格:
| 命令 | 描述 | 
|---|---|
| backColor | 修改文档的背景颜色。在 styleWithCss 模式下,则只影响容器元素的背景颜色。这需要一个<color> 类型的字符串值作为参数传入。注意,IE 浏览器用这个设置文字的背景颜色。 | 
| bold | 开启或关闭选中文字或插入点的粗体字效果。IE 浏览器使用 <strong>标签,而不是<b>标签。 | 
| ClearAuthenticationCache | 清除缓存中的所有身份验证凭据。 | 
| contentReadOnly | 通过传入一个布尔类型的参数来使能文档内容的可编辑性。(IE 浏览器不支持) | 
| copy | 拷贝当前选中内容到剪贴板。启用这个功能的条件因浏览器不同而不同,而且不同时期,其启用条件也不尽相同。使用之前请检查浏览器兼容表,以确定是否可用。 | 
| createLink | 将选中内容创建为一个锚链接。这个命令需要一个hrefURI 字符串作为参数值传入。URI 必须包含至少一个字符,例如一个空格。(浏览器会创建一个空链接) | 
| cut | 剪贴当前选中的文字并复制到剪贴板。启用这个功能的条件因浏览器不同而不同,而且不同时期,其启用条件也不尽相同。使用之前请检查浏览器兼容表,以确定是否可用。 | 
| decreaseFontSize | 给选中文字加上 <small> 标签,或在选中点插入该标签。(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 | 去除所选的锚链接的<a>标签 | 
| styleWithCSS | 用这个取代 useCSS 命令。参数如预期的那样工作,i.e. true modifies/generates 风格的标记属性,false 生成格式化元素。 | 
| AutoUrlDetect | 更改浏览器自动链接行为(仅 IE 浏览器支持) | 
document.execCommand可以做的事情很多,但是需要说明的是它在最新的web标准中已经被废弃。来看一下MDN的介绍:
已弃用: 不再推荐使用该特性。虽然一些浏览器仍然支持它,但也许已从相关的 web 标准中移除,也许正准备移除或出于兼容性而保留。请尽量不要使用该特性,并更新现有的代码;参见本页面底部的兼容性表格以指导你作出决定。请注意,该特性随时可能无法正常工作。
浏览器兼容性——取自MDN
WX20221026-172742.png
上面只展示了几个属性,可以看到,copy和cut是全部浏览器都支持的,此外还有paste,selectAll等等,在使用的时候最好做一下判断,其他的命令基本都已不支持,就不推荐再使用了。
介绍完document.execCommand,接下来我们准备”上菜“!
有请今天的主角登场 —— clipboardjs
基本使用
首先,我们来看一下clipboardjs的使用方式:
<!DOCTYPE html>
<html lang="en">
 <head>
   <meta charset="UTF-8" />
   <title>target-div</title>
   <meta name="viewport" content="width=device-width, initial-scale=1" />
 </head>
 <body>
   <!-- 1. Define some markup -->
   <div>hello</div>
   <button class="btn" data-clipboard-action="copy" data-clipboard-target="div">
     Copy
   </button>
   <!-- 2. Include library -->
   <script src="https://cdnjs.cloudflare.com/ajax/libs/clipboard.js/2.0.10/clipboard.min.js"></script>
   <!-- 3. Instantiate clipboard -->
   <script>
     const clipboard = new ClipboardJS('.btn');
     clipboard.on('success', function (e) {
       console.info('Action:', e.action);
       console.info('Text:', e.text);
       console.info('Trigger:', e.trigger);
     });
     clipboard.on('error', function (e) {
       console.info('Action:', e.action);
       console.info('Text:', e.text);
       console.info('Trigger:', e.trigger);
     });
   </script>
 </body>
</html>
   
点击button按钮之后,控制台打印如下内容:
image.png
- 
Action:执行命令 - 
Text:放入到剪切板的内容 - 
Trigger:触发的节点 
源码解析
可以边参考源码边看:点击查看源码
打开源码进入clipboardjs页面,可以看到定义了一个Clipboard类并继承于Emitter,Emitter是负责订阅命令执行之后剪切板操作成功和失败的关键,用到了Emitter的on和emit。
tiny-emitter是一个简单实现发布订阅的包(查看tiny-emitter源码),在函数的原型对象内定义了on、once、emit、off四个原型方法,具体的实现非常简单,代码量很少,这里不做过多介绍,感兴趣可以看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);
  }
  // 省略...
}
构造函数内有resolveOptions和listenClick方法分别是处理参数和监听点击事件。我们来一一分析。
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.action、this.target、this.text、this.container进行处理取值。
当不想在元素中写如data-clipboard-XXXX的自定义属性的时候,actions、target、text都允许通过配置项的方式声明一个函数,做自己的业务需求,最后返回一个值。所以在上面我们会看到,是通过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);
}   
下面来解释一下每个属性的含义:
- 
action是执行的方法,可以是copy复制,也可以是cut剪切。 - 
target是目标锚点,如data-clipboard-target="div"中的target就是div,此外也可以是className类名或者#id节点id,最终是通过document.querySelector(selector);来获取节点。 - 
text是选中的文本内容 - 
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后,创建一个监听事件,会监听所有点击行为,当监听到某一个节点点击,利用节点的Event的e.delegateTarget || e.currentTarget取得目标节点内容对象信息。根据trigger获取到action,this.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.text和this.target的处理,我们可以知道,它们有两种数据类型,一种是函数(typeof === function)类型,一种是通过data-clipboard-xxxx自定义属性获取的值。那么如果text没有声明function或者在节点中声明自定义属性值,那么它将不会通过if(text)的判断,直接跳过,往下执行。随后是target,由于前面做了边界异常处理,所以这里作为兜底执行,根据cut和copy分配给两个不同的函数处理,这里我们只介绍copy,因为cut和copy仅有执行命令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函数,针对三种类型做了判断,分别是
- 是字符串类型,说明目标是一个纯文本,就需要额外创建一个
textarea标签,为什么不是input标签呢?是因为对于换行文本内容使用textarea会更友好,如果使用input标签,那么需要拷贝的是textarea多行文本内容则会发生格式异常的问题,因为最终是需要将选中的文本进行input.value = value这样赋值的,多行变成单行,而反之使用textarea支持拷贝input和它自身类型节点。 - 第二种是节点类型,不为
text', 'search', 'url', 'tel', 'password'这几种类型之一的,均通过创建textarea的方式进行拷贝。 - 第三种是为
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,随手重写了盒子模型border、padding、 margin三个的样式,主要目的为了限制用户恶意设置全局盒子模型样式。然后将元素移出到屏幕以外,这里用到了isRTL来判断是移动至屏幕左边还是屏幕右边,我们看到上面判断了页面元素中是否存在dir属性,该属性是否为rtl,它有什么意义呢?
说到
rtl就需要说到另外一个ltr,它们是完全相反的关系,过去会在页面中写dir标签,但是现在已经不赞成使用了,感兴趣的可以自行查阅对应资料,这里简单的说说rtl和ltr的区别:
| 元素 | LTR | RTL | 
|---|---|---|
| 文本 | 句子从左向右阅读 | 句子从有向左阅读 | 
| 时间线 | 事件序列从左向右进行 | 事件序列从右向左进行 | 
| 图像 | 从左向右的运动 | 从右向左运动 | 
回到上面代码,设置了标签摆放的位置后,需要将元素设置为可编辑状态才行,所以取消了readonly,随后给textarea标签赋值,最终把处理的元素返回。
以上剪切板功能就实现了,那么我们需要订阅剪切板是成功还是失败改怎么办,tiny-emitter的发布订阅功能就派上用场啦!
我们来看onClick函数下的this.emit就是做的这个工作。把actions、text、trigger、clearSelection四个属性暴露出去,我们就可以实现订阅。前面三个前面有介绍过了,这里介绍一下clearSelection属性,它的职责是负责清理内容选中光标的,还记得前面讲fakeCopyAction函数中用到了select(fakeElement),这里的select也是作者自己写的插件,插件很简单才43行代码,实现原理是就是对选中的内容的元素设置focus聚集光标,感兴趣可以看源码。
至此,源码就解析完了。
简化版hook
了解了原理之后,我们能不能自己实现一个简单的剪切板功能呢?能在vue3、react中直接使用的hook
要实现剪切板最核心就只有两点:
- 创建
textarea标签,并把需要添加剪切板的内容赋值给该标签,随后将其聚焦选中,最后添加到剪贴板后,移除该节点。 - 执行
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()
}
使用方法
<script lang="ts" setup>
import { useClipboard } from "composable/index"
    
const handleCopy = (text: string): void => {
   useClipboard(text, () => {
        console.log("复制成功啦")
   })
}
</script>
LeetCode、CSDN网站复制内容长度比较长的时候,都会添加版权信息在底部是,这个是如何实现的?
其实也很简单,检查复制的内容长度是否超出阈值,超出则添加版权信息。
<script lang="ts" setup>
import { useClipboard } from "composable/index"
    
const handleCopy = (text: string): void => {
   let url = window.location.href;
   let newText = text
  // 如果超出规定长度,则添加版权信息
  if (text.length >= 50) {
    newText =`${text}
  
      原文出自 smallzip - url 转载请保留原文链接
      `
  }
   useClipboard(newText, () => {
        console.log("复制成功啦")
   })
}
</script>
兼容与降级
由于document.execCommand存在兼容问题,那么我该如何检查是否兼容呢?
- 通过执行命令查看返回值是为
true还是falseif (document.execCommand("copy")) { document.execCommand("copy") } else { window.alert("当前系统不支持复制操作~") } 
- 
和
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"])- 使用
Navigator.cliporad,这个是新的剪切板指令,兼容全部浏览器。推荐使用该方法,支持异步读写。下面贴一段来自MDN的介绍,更信息请自行查阅。 
剪贴板 Clipboard API 为
Navigator接口添加了只读属性clipboard,该属性返回一个可以读写剪切板内容的Clipboard对象。在 Web 应用中,剪切板 API 可用于实现剪切、复制、粘贴的功能。
只有在用户事先授予网站或应用对剪切板的访问许可之后,才能使用异步剪切板读写方法。许可操作必须通过取得权限 Permissions API 的"clipboard-read"或"clipboard-write"获得。 - 使用
 









网友评论