在这段时间前端网页开发中,遇到点击按钮,实现复制文本到剪切板中的需求,记录一下实现过程和遇到的坑:
首先介绍一下常用的2种复制方法:
1. 使用 document.execCommand()
方法:
function copyToClipboard(text) {
const textArea = document.createElement('textArea')
textArea.value = val
//添加到dom节点中
textArea.style.width = 0
textArea.style.position = 'fixed'
textArea.style.left = '-999px'
textArea.style.top = '10px'
textArea.setAttribute('readonly', 'readonly')
document.body.appendChild(textArea)
textArea.select()
document.execCommand('copy')
// 移除元素
document.body.removeChild(textArea)
}
copyToClipboard("复制的内容")
该方法相对来说比较老,特别是 execCommand
已经弃用了,所以尽可能使用其他方法。
2. 使用 navigator.clipboard
方法:
function copyToClipboard(text) {
navigator.clipboard
.writeText(text)
.then(() => {
console.log("复制成功")
})
.catch((err) => {
console.log("复制失败,请重试");
});
}
copyToClipboard("复制的内容")
该方法在 ios浏览器和微信浏览器 中复制无效,判断navigator.clipboard是存在的,但就是writeText时出错,显示拒绝,所以在chrome上运行的网页可以考虑使用该方法。
为什么在IOS和微信中会复制失败呢?
测试发现navigator.clipboard是存在的,但就是writeText时出错,显示拒绝
经查找,gpt给出的可能失败的原因有:
- 安全策略对网页脚本的能力有限制,特别是在涉及到访问剪贴板这类敏感操作时。如果没有明确的用户交互,例如点击选中后复制,JavaScript可能无法成功执行复制命令。
在此情况下,我们可以尝试使用第三方库来解决无法复制的问题:
- 使用第三方库,例如
clipboard
:
npm install clipboard --save
初始化时,new一个clipboard对象,同时通过类名绑定复制按钮,实现复制功能
useEffect(() => {
var clipboard = new ClipboardJS(".copyBtn", {
text: function (trigger) {
return copyText; //copyText 需要复制的文本
},
})
.on("success", (e) => {
console.log("复制成功")
})
.on("error", (e) => {
console.log("error", e);
console.log("复制失败,请重试");
});
return () => {
clipboard.destroy();
};
}, []);
经测试,在chrome和IOS、微信浏览器上都可以复制成功。
思考:为什么第三库可以绕过IOS和微信浏览器中的安全策略呢?
我们通过GitHub参考源码:查看源码
- 首先绑定 按钮 点击事件
listenClick(trigger) {
this.listener = listen(trigger, 'click', (e) => this.onClick(e));
}
初始化 new clipboard(“.btn”)后, listenClick
函数负责监听click
点击事件。会遍历所有的节点,给节点添加监听事件。
- 触发点击事件
/**
* 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();
},
});
}
当我们点击之后会触发执行this.onClick
函数,会触发执行剪切板命令,并通过Emitter.emit
进行来进行执行。
import ClipboardActionCut from './cut';
import ClipboardActionCopy from './copy';
/**
* Inner function which performs selection from either `text` or `target`
* properties and then executes copy or cut operations.
* @param {Object} options
*/
const ClipboardActionDefault = (options = {}) => {
//...省略
// Define selection strategy based on `text` property.
if (text) {
return ClipboardActionCopy(text, { container });
}
// Defines which selection strategy based on `target` property.
if (target) {
return action === 'cut'
? ClipboardActionCut(target)
: ClipboardActionCopy(target, { container });
}
};
export default ClipboardActionDefault;
Clipboard除了指定点击按钮,还有可选项用于动态指定需要复制的目标,target
、text
,container
我们文章中直接使用的是text
,意思就是接受一个 文本
作为复制项,所以直接给看上面代码中逻辑,会执行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;
以上函数逻辑对三种类型做了判断:
- 字符串类型,说明目标是一个纯文本,就需要额外创建一个
textarea
标签,为什么不是input
标签呢?是因为对于换行文本内容使用textarea
会更友好。 - 第二种是节点类型,不为
text', 'search', 'url', 'tel', 'password'
这几种类型之一的,均通过创建textarea
的方式进行拷贝。 - 第三种是为
text', 'search', 'url', 'tel'
类型其中之一的直接执行document.execCommand('copy')
指令。
一般使用比较多会穿string类型,此时会执行fakeCopyAction
函数。其中执行了createFakeElement(value)
函数,就是创建一个textarea
标签,并将需要复制的内容放进去,再执行const selectedText = select(fakeElement)
操作,我们可以看下select
函数:
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 {
if (element.hasAttribute('contenteditable')) {
element.focus();
}
var selection = window.getSelection();
var range = document.createRange();
range.selectNodeContents(element);
selection.removeAllRanges();
selection.addRange(range);
selectedText = selection.toString();
}
return selectedText;
}
module.exports = select;
由上面代码可知:
如果元素是<input>
或<textarea>
标签:
- 首先检查元素是否有
readonly
属性。如果有,则暂时移除该属性,以便能够更改和选中元素的内容。 - 接着调用
element.select()
方法,这会使输入框或文本区域中的所有文本被选中。 - 使用
setSelectionRange()
方法进一步确保整个文本被选中(从位置0到文本长度)。 - 在获取完选择文本后,如果元素原来有
readonly
属性,则恢复该属性。
终于是找到了 核心逻辑了,通过js主动选中textarea
中的文本,从而绕过了 IOS和微信浏览的保护机制了
后面就是执行command
复制文本的逻辑了:
/**
* Executes a given operation type.
* @param {String} type
* @return {Boolean}
*/
export default function command(type) {
try {
return document.execCommand(type);
} catch (err) {
return false;
}
}