JavaScript复制粘贴与剪贴板操作详解
来源网络
产品提了个需求,文档页要加一个「复制代码」按钮,顺手还想学掘金那样,用户复制超过一定长度的正文时自动在末尾追加版权信息。这两件事看起来都很小,真动手才发现剪贴板这块的 API 有点乱,clipboardData 在不同浏览器上挂的位置不一样,execCommand 又被标记成废弃了。
这篇把浏览器里操作剪贴板的几条路径捋一遍,从最基础的三个事件讲到能落地的几个场景,再说清楚老方案和新方案各自的适用范围。
在本篇文章中,我们将从浅入深,和大家一起学习以下知识:
copy、cut、paste三个事件的触发时机和绑定方式clipboardData对象的兼容处理和三个方法- 复制正文时自动追加版权信息的完整实现
- 防复制的几种做法,以及它们为什么拦不住人
- 用隐藏 input 加
execCommand实现点击复制 - 现代异步 Clipboard API 的用法和它的两道限制
- 什么时候该直接上 clipboard.js
# 一、剪贴板的三个事件
# 1.1 事件与触发条件
浏览器暴露了三个剪贴板相关的事件:
copy发生复制操作时触发cut发生剪切操作时触发paste发生粘贴操作时触发
每个事件都有一个 before 事件对应,beforecopy、beforecut、beforepaste。这几个 before 一般不怎么用,把注意力放在另外三个事件就可以了。
能触发它们的操作有两类,一类是鼠标右键菜单里的复制、粘贴、剪切,另一类是键盘组合键,比如 command+c、command+v。注意用 JS 主动调用 document.execCommand('copy') 同样会触发 copy 事件,这一点在后面实现点击复制的时候会用到。
# 1.2 怎么绑
以 copy 为例,两种写法都可以。
document.body.oncopy = e => {
// 监听全局复制 做点什么
};
// 还有这种写法:
document.addEventListener('copy', e => {
// 监听全局复制 做点什么
});
上面是在 document.body 上全局监听的。很多人不知道的是,我们还可以为某些 DOM 单独添加剪贴板事件。
// html结构
<div id="test1"></div>
<div id="test2"></div>
<script>
// 写法一样:
let test1 = document.querySelector('#test1');
test1.oncopy = e => {
// 监听test1发生的复制事件 做点什么
// test1发生的复制事件会触发回调,其他地方不会触发回调
}
</script>
其他事件也是一样的。
为什么绑在 document.body 上就能拦下页面里任何位置的复制?因为剪贴板事件和 click 一样是会冒泡的,从选区所在的元素一路往上传到 document。所以你既可以做全局拦截,也可以只在文章容器上绑一个,让评论区、代码块之类的地方不受影响。事件冒泡和事件委托的完整机制,可以看 JavaScript事件机制。
# 二、clipboardData 对象
clipboardData 对象用于访问以及修改剪贴板中的数据。
不同浏览器,它所属的对象不同。在 IE 中这个对象是 window 对象的属性,在 Chrome、Safari 和 Firefox 中,它是相应的 event 对象的属性。所以使用的时候需要做一下兼容。
document.body.oncopy = e => {
let clipboardData = e.clipboardData || window.clipboardData;
// 获取clipboardData对象 + do something
};
这个差别不只是挂载位置的问题。把它放在 event 上,等于强制你只能在真实的剪贴板事件回调里访问剪贴板,页面脚本没法在任意时刻偷偷读用户的剪贴板,这其实是一种安全措施。IE 那种挂在 window 上、随时能读写的设计,正是后来被否掉的原因。
对象有三个方法,getData()、setData()、clearData()。
# 2.1 getData 读数据
getData() 接受一个 text 参数,即要取得的数据的格式。
实际上在 Chrome 上测试,只有 paste 粘贴的时候才能用 getData() 访问到数据,用法如下。
// 要粘贴的数据:
document.body.onpaste = e => {
let clipboardData = e.clipboardData || window.clipboardData; // 兼容处理
console.log('要粘贴的数据', clipboardData.getData('text'));
};
这里补一句规范上的说法。copy 和 cut 事件里 clipboardData 处于「只写」状态,读不到东西是设计如此,不是浏览器 bug。要拿被复制的内容得走另一条路。
被复制和被剪切的数据,需要通过 window.getSelection(0).toString() 来访问。
document.body.oncopy = e => {
console.log('被复制的数据:', window.getSelection(0).toString());
};
顺带说个原文里的小问题,getSelection() 按规范是不接收参数的,写成 getSelection(0) 里的这个 0 会被直接忽略,不影响结果,但新写代码的时候直接写 window.getSelection().toString() 更规范。
# 2.2 setData 写数据
setData() 第一个参数也是 text,第二个参数是要放在剪贴板中的文本。
格式参数这块也演进过。规范里现在用的是标准 MIME 类型,比如 text/plain、text/html,'text' 是浏览器为了兼容老代码保留的别名。新写的代码建议直接写 text/plain,语义清楚,而且如果你要同时写入富文本,还能追加一份 text/html。
# 2.3 clearData
clearData() 清空剪贴板中指定格式的数据,实际业务里用得很少。
# 三、复制大段文本时追加版权信息
# 3.1 类知乎/掘金的实现
实现很简单。取消默认复制之后,主要是在被复制的内容后面添加信息,然后根据 clipboardData 的 setData() 方法将信息写入剪贴板。
// 掘金这里不是全局监听,应该只是监听文章的dom范围内。
document.body.oncopy = event => {
event.preventDefault(); // 取消默认的复制事件
let textFont,
copyFont = window.getSelection(0).toString(); // 被复制的文字 等下插入
// 防知乎掘金 复制一两个字则不添加版权信息 超过一定长度的文字 就添加版权信息
if (copyFont.length > 10) {
textFont =
copyFont +
'\n' +
'作者:OBKoro1\n' +
'链接:https://juejin.im/user/58714f0e325b123db4a2eb95372/posts\n' +
'来源:掘金\n' +
'著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。';
} else {
textFont = copyFont; // 没超过十个字 则采用被复制的内容。
}
if (event.clipboardData) {
return event.clipboardData.setData('text', textFont); // 将信息写入粘贴板
} else {
// 兼容IE
return window.clipboardData.setData('text', textFont);
}
};
然后 command+c、command+v,输出:
你复制的内容
作者:OBKoro1
链接:https://juejin.im/user/58714f0eb123db4a2eb95372/posts
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
# 3.2 这段代码的关键点
event.preventDefault() 这一行是整段的枢纽。剪贴板事件的默认行为就是「把当前选区的内容写进剪贴板」,你不取消它,后面 setData 写进去的东西会被默认行为覆盖掉。取消之后剪贴板就是一块空白画布,完全由你来填。
长度阈值那个 if 也不是随便加的。用户复制一个词去查释义,结果粘出来跟着四行版权声明,体验很差。设个门槛只对成段的正文追加,是这类功能里必备的一步。
还有一处原文没提但实际会踩的,就是监听范围。示例绑在 document.body 上,那用户从你页面的输入框里复制自己刚打的字,也会被追加版权信息。真上线的话建议绑在文章容器上,代码块所在的元素再单独 stopPropagation 一下。
# 四、防复制功能
# 4.1 几条常见做法
- 禁止复制和剪切
- 禁止右键,右键某些选项:全选、复制、粘贴等
- 禁用文字选择,能选择却不能复制,体验很差
user-select用 CSS 禁止选择文本
// 禁止右键菜单
document.body.oncontextmenu = e => {
console.log(e, '右键');
return false;
// e.preventDefault();
};
// 禁止文字选择。
document.body.onselectstart = e => {
console.log(e, '文字选择');
return false;
// e.preventDefault();
};
// 禁止复制
document.body.oncopy = e => {
console.log(e, 'copy');
return false;
// e.preventDefault();
}
// 禁止剪切
document.body.oncut = e => {
console.log(e, 'cut');
return false;
// e.preventDefault();
};
// 禁止粘贴
document.body.onpaste = e => {
console.log(e, 'paste');
return false;
// e.preventDefault();
};
/** css 禁止文本选择 这样不会触发js**/
body {
user-select: none;
-moz-user-select: none;
-webkit-user-select: none;
-ms-user-select: none;
}
使用 e.preventDefault() 也可以禁用,但原文建议使用 return false,这样就不用去访问 e 和 e 的方法了。有个前提要说清楚,return false 只在 DOM0 级的 onxxx = fn 这种写法下才等价于阻止默认行为,如果你用的是 addEventListener,return false 什么都不会发生,必须老老实实调 preventDefault()。