昨天闲着没事翻自己博客,发现一个老问题又跳出来了——文章里的代码块没有复制按钮。每次想复制一段代码,得手动选中,有时候鼠标一抖还选多了,烦得很。我博客用的是Hexo,主题是NexT,版本号8.19.2,这破主题其实自带了代码复制功能,但不知道从哪个版本开始,那个按钮样式就崩了,点一下根本没反应,控制台报错说clipboard API没初始化。干脆自己动手写一个吧,顺便把整个流程记录下来,免得下次又忘。
先说说我遇到的具体场景。我那博客里贴了不少技术笔记,比如Python脚本、Shell命令、还有YAML配置文件。读者想复制的时候,要么自己框选,要么手动敲一遍——后者基本等于劝退。我翻了下NexT的主题配置文件,里面有个`codeblock.copy_button`的开关,打开之后确实能看到一个复制按钮,但点了之后控制台报错:`Uncaught TypeError: Cannot read properties of undefined (reading ‘writeText’)`。一看就是clipboard对象没拿到,估计是主题引用的某个JS文件加载顺序出了问题,或者跟其他插件冲突了。
我不想动主题本身的源码,因为一升级主题就全覆盖了,维护起来太累。索性在博客根目录的`source/js`文件夹里新建一个文件,叫`custom.js`,然后通过Hexo的配置引入到所有页面。这样主题升级了,我自己的脚本还在,互不干扰。
具体步骤是这样的:先检查Hexo的`_config.yml`,找到`theme_config`下面的`vendors`字段,确认NexT加载了哪些JS库。NexT默认用了`clipboard.js`这个库,版本是2.0.11。但我发现它只在文章页加载,首页和分类页都跳过,这就导致有些页面复制按钮点了没反应。更坑的是,它把clipboard初始化写在了`post.js`里,而`post.js`只在单篇文章页面执行。如果从首页直接点复制按钮,根本找不到clipboard实例。
于是我自己写了个通用的方案。不依赖任何第三方库,直接用浏览器原生的`navigator.clipboard.writeText` API。这个API在Chrome 66、Firefox 63、Safari 13.1之后都支持了,兼容性足够。不过它要求在HTTPS环境下才能用,我博客刚好已经上了HTTPS,没问题。如果有人在HTTP环境下访问,我再加个降级方案——用`document.execCommand(‘copy’)`做备胎。
代码长这样:
“`javascript
// custom.js
(function() {
‘use strict’;
function addCopyButtons() {
var codeBlocks = document.querySelectorAll(‘figure.highlight, pre’);
codeBlocks.forEach(function(block) {
// 避免重复添加按钮
if (block.querySelector(‘.copy-btn’)) return;
var btn = document.createElement(‘button’);
btn.className = ‘copy-btn’;
btn.textContent = ‘复制’;
btn.style.cssText = ‘position:absolute;top:8px;right:8px;z-index:10;padding:4px 12px;border:1px solid #ccc;border-radius:4px;background:#f5f5f5;cursor:pointer;font-size:13px;’;
// 找到代码内容
var codeEl = block.querySelector(‘code’) || block;
var codeText = codeEl.textContent || ”;
btn.addEventListener(‘click’, function(e) {
e.stopPropagation();
copyToClipboard(codeText).then(function() {
btn.textContent = ‘已复制’;
setTimeout(function() { btn.textContent = ‘复制’; }, 2000);
}).catch(function(err) {
console.error(‘复制失败:’, err);
btn.textContent = ‘失败’;
});
});
// 给代码块容器加相对定位,不然按钮会飞出去
if (getComputedStyle(block).position === ‘static’) {
block.style.position = ‘relative’;
}
block.appendChild(btn);
});
}
function copyToClipboard(text) {
if (navigator.clipboard && navigator.clipboard.writeText) {
return navigator.clipboard.writeText(text);
}
// 降级方案
return new Promise(function(resolve, reject) {
try {
var textarea = document.createElement(‘textarea’);
textarea.value = text;
textarea.style.position = ‘fixed’;
textarea.style.left = ‘-9999px’;
textarea.style.top = ‘-9999px’;
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
var success = document.execCommand(‘copy’);
document.body.removeChild(textarea);
if (success) resolve();
else reject(new Error(‘copy command failed’));
} catch (e) {
reject(e);
}
});
}
// 页面加载完后执行,并且监听可能的动态加载(比如Pjax)
document.addEventListener(‘DOMContentLoaded’, addCopyButtons);
// 如果用了NexT的Pjax,还得监听这个事件
document.addEventListener(‘pjax:end’, addCopyButtons);
})();
“`
写完后把文件放到`themes/next/source/js/custom.js`,然后在NexT的主题配置里加上一行:
“`yaml
custom_file_path:
# 其他配置…
bodyEnd: source/js/custom.js
“`
这样每个页面底部都会加载这个脚本。注意`bodyEnd`这个路径是相对于主题根目录的,别搞错了。如果你跟我一样把`custom.js`放在`source/js/`下,那路径就是`source/js/custom.js`。
部署之后发现一个问题:按钮样式跟主题不搭。NexT的代码块本身有深色背景,按钮白底黑字显得很突兀。我又改了下样式,让按钮半透明,鼠标悬停时显示完整。还加了个小图标,用Unicode字符`📋`,但后来觉得太花哨,换成了纯文字。最终样式调成了这样:
“`css
.copy-btn {
position: absolute;
top: 8px;
right: 8px;
z-index: 10;
padding: 2px 10px;
border: none;
border-radius: 3px;
background: rgba(255,255,255,0.15);
color: #ccc;
cursor: pointer;
font-size: 12px;
line-height: 1.6;
transition: background 0.2s, color 0.2s;
}
.copy-btn:hover {
background: rgba(255,255,255,0.3);
color: #fff;
}
.copy-btn:active {
background: rgba(255,255,255,0.5);
}
“`
把这段CSS加到主题的样式文件里。NexT的样式在`themes/next/source/css/main.styl`,或者你也可以在`custom_file_path`里单独加载一个CSS文件。我图省事,直接写到`custom.js`前面,用`document.createElement(‘style’)`动态注入。但这不是好习惯,建议还是单独放一个CSS文件。
折腾完复制按钮,我又顺手修了另一个小问题:文章里的外链默认在新窗口打开。NexT也有这个配置,但只对Markdown里的链接生效,对HTML标签里的``无效。我写博客有时候会直接贴HTML代码,比如嵌入iframe或者自定义链接,这些就漏掉了。所以在`custom.js`里再加一段:
“`javascript
function openExternalLinks() {
var links = document.querySelectorAll(‘a[href^=”http”]’);
links.forEach(function(link) {
// 排除站内链接
if (link.hostname !== window.location.hostname) {
link.setAttribute(‘target’, ‘_blank’);
link.setAttribute(‘rel’, ‘noopener noreferrer’);
}
});
}
document.addEventListener(‘DOMContentLoaded’, openExternalLinks);
document.addEventListener(‘pjax:end’, openExternalLinks);
“`
注意加`rel=”noopener noreferrer”`,防止新页面通过`window.opener`篡改原页面,这是一个安全漏洞,以前踩过坑。
测试的时候发现一个小细节:如果文章里已经有`target=”_blank”`的链接,再重复设置一次不会出问题,但`rel`属性会被覆盖。所以最好先检查一下,如果已经存在就不动了。不过我懒得写判断,直接覆盖,反正`noopener noreferrer`加两次也不会有副作用。
全部改完后,执行`hexo clean && hexo generate && hexo deploy`,刷新页面测试。复制按钮正常工作了,点一下显示”已复制”,两秒后恢复。外链也都新窗口打开,控制台没报错。完美。
接下来记录一下踩的坑。第一个坑是`navigator.clipboard.writeText`在非HTTPS环境下会直接报错,Promise reject掉。如果你博客还没上HTTPS,建议先用`document.execCommand(‘copy’)`,或者用clipboard.js库做兼容。第二个坑是代码块的DOM结构,NexT不同版本生成的HTML标签不一样。我用的8.19.2版本,代码块外面包了一层`