日常业务中,会常常用到拷贝、剪切的需求,此外一些针对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
上面只展示了几个属性,可以看到,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
按钮之后,控制台打印如下内容:
-
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
还是false
if (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"
获得。 - 使用