上一篇:一图流背景与明暗主题 · 系列总目录 · 下一篇:本站自定义组件合集
前言
当 Butterfly 的配置项无法实现某个效果时,最直接的做法似乎是修改主题模板。但 npm 更新可能覆盖 node_modules,Git 更新也容易与个人修改冲突。
更适合长期维护的方式,是把自定义资源放在 Hexo 的 source 目录,再通过 Butterfly 的 inject 配置加载。这样主题仍然负责基础结构,自定义代码只负责增强。
本文将实现一个小型“阅读提示”组件,并以此说明完整的组件开发流程。
一、Inject 解决什么问题
Butterfly 提供两个注入位置:
head 会在 </head> 前插入内容,适合 CSS、字体和 meta。
bottom 会在 </body> 前插入内容,适合 JavaScript。
例如:
1 2 3 4 5
| inject: head: - <link rel="stylesheet" href="/css/reading-tip.css?v=1"> bottom: - <script defer src="/js/reading-tip.js?v=1"></script>
|
文件放在:
1 2
| source/css/reading-tip.css source/js/reading-tip.js
|
Hexo 生成后会复制到:
1 2
| public/css/reading-tip.css public/js/reading-tip.js
|
因此网页路径写 /css/...,而不是 /source/css/...。
二、组件的基本组成
一个可维护组件通常包含:
1 2 3 4 5 6
| 结构:由模板、Markdown 或 JavaScript创建 样式:独立 CSS 文件 行为:独立 JavaScript 文件 配置:集中放在主题配置或 data 属性 生命周期:首次加载和 PJAX 跳转 降级:JavaScript失败时仍可阅读
|
尽量不要把几百行 HTML、CSS 和 JavaScript 全部写进 _config.butterfly.yml。配置文件只负责声明资源,源码放在独立文件中。
三、示例:文章阅读提示
目标是在文章右下角显示一个不打扰阅读的小提示,点击后回到文章开头。
创建 source/js/reading-tip.js:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
| (function () { var COMPONENT_ID = 'reading-tip';
function removeOldComponent() { var old = document.getElementById(COMPONENT_ID); if (old) old.remove(); }
function initReadingTip() { removeOldComponent();
var article = document.querySelector('#article-container'); if (!article) return;
var button = document.createElement('button'); button.id = COMPONENT_ID; button.type = 'button'; button.textContent = '回到开头'; button.setAttribute('aria-label', '返回文章开头');
button.addEventListener('click', function () { article.scrollIntoView({ behavior: 'smooth', block: 'start' }); });
document.body.appendChild(button); }
document.addEventListener('DOMContentLoaded', initReadingTip); document.addEventListener('pjax:complete', initReadingTip); })();
|
创建 source/css/reading-tip.css:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
| #reading-tip { position: fixed; right: 24px; bottom: 24px; z-index: 100; padding: 9px 14px; border: 1px solid rgba(59, 130, 246, 0.42); border-radius: 999px; background: rgba(255, 255, 255, 0.86); box-shadow: 0 8px 24px rgba(15, 23, 42, 0.16); color: #2563eb; cursor: pointer; backdrop-filter: blur(12px); }
[data-theme='dark'] #reading-tip { background: rgba(15, 23, 42, 0.82); color: #93c5fd; }
@media (max-width: 768px) { #reading-tip { right: 14px; bottom: 18px; } }
@media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } }
|
最后加入 inject,重新构建即可。
四、为什么普通初始化在 Butterfly 中会失效
如果开启了 PJAX,站内跳转不会完整刷新浏览器。下面的代码只会在首次打开网站时运行:
1
| document.addEventListener('DOMContentLoaded', init);
|
从主页进入文章后,新内容已经替换,但脚本没有重新初始化,于是就会出现:
- 组件只在主页存在
- 文章页按钮消失
- 目录或公式无法处理
- 同一组件重复出现
- 旧页面的事件仍然残留
最简单的兼容方式:
1 2
| document.addEventListener('DOMContentLoaded', init); document.addEventListener('pjax:complete', init);
|
但 init 必须允许被多次调用。
五、防止重复初始化
方法一:初始化前删除旧节点
1 2 3 4
| function init() { document.querySelector('#my-component')?.remove(); }
|
适合结构简单、状态不需要保留的组件。
方法二:使用 data 标记
1 2 3 4 5
| function enhance(element) { if (element.dataset.componentReady === 'true') return; element.dataset.componentReady = 'true'; }
|
适合增强 Hexo 已经生成的 DOM,例如分类磁贴或文章简介。
方法三:拆分 init 和 destroy
1 2 3 4 5 6 7 8
| function destroy() { window.removeEventListener('resize', handleResize); }
function init() { destroy(); window.addEventListener('resize', handleResize); }
|
适合播放器、Canvas 动画、全局快捷键等复杂组件。
如果只不断执行 addEventListener,每次 PJAX 跳转都会多绑定一次,最终一次点击会触发多次行为。
六、不要依赖固定的页面结构
脆弱的选择器:
1 2 3
| document.querySelector( 'body > div:nth-child(3) > main > div:nth-child(2)' );
|
主题稍微调整 DOM 就会失效。
优先选择语义稳定的 ID、类名或属性:
1 2 3
| document.querySelector('#article-container'); document.querySelector('.category-lists'); document.querySelector('[data-theme]');
|
如果组件结构由自己创建,应使用独立命名空间:
1 2 3
| my-player my-player__button my-player__playlist
|
避免使用 .button、.card、.title 这类容易和主题冲突的通用类名。
七、明暗主题适配
Butterfly 通过 data-theme 表示主题:
1 2 3 4 5 6 7 8 9
| .my-component { color: #1f2937; background: rgba(255, 255, 255, 0.88); }
[data-theme='dark'] .my-component { color: #e5e7eb; background: rgba(15, 23, 42, 0.86); }
|
如果 JavaScript 需要在切换主题时重绘 Canvas,可以监听属性变化:
1 2 3 4 5 6 7 8
| var observer = new MutationObserver(function () { redraw(); });
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
|
纯 CSS 能完成的切换不要交给 JavaScript。
八、资源版本与浏览器缓存
修改 CSS 后线上没有变化,经常只是浏览器或 CDN 仍在使用旧文件。
可以给路径加版本号:
1 2
| - <link rel="stylesheet" href="/css/reading-tip.css?v=2"> - <script defer src="/js/reading-tip.js?v=2"></script>
|
版本号不需要和 npm 包版本一致。每当产生需要立刻刷新的修改时递增即可。
还应检查:
- Cloudflare 缓存
- Service Worker 或 PWA 缓存
- 手机浏览器缓存
- HTML 是否已经引用新版本
九、性能与移动端降级
组件不应该因为能运行就无限增加动画。需要重点关注:
- 不在
scroll 中反复读取和写入大量布局属性。
- 高频事件使用
requestAnimationFrame 或节流。
- 图片使用懒加载。
- Canvas 在页面隐藏时暂停。
- 音频只预加载必要内容。
- 手机端降低粒子数量和模糊强度。
页面隐藏状态可以这样判断:
1 2 3 4 5 6 7
| document.addEventListener('visibilitychange', function () { if (document.hidden) { pauseAnimation(); } else { resumeAnimation(); } });
|
同时提供减少动态效果支持:
1 2 3
| var reduceMotion = window.matchMedia( '(prefers-reduced-motion: reduce)' ).matches;
|
十、组件的可访问性
自己制作按钮时,不要只创建可点击的 div:
1 2 3
| <button type="button" aria-label="打开音乐播放器"> <i class="fas fa-music" aria-hidden="true"></i> </button>
|
还应做到:
- 键盘可以聚焦和操作
- 图标按钮具有
aria-label
- 展开状态使用
aria-expanded
- 弹层打开后可以关闭
- 文字和背景对比度足够
- 动画不是理解内容的唯一方式
十一、推荐的组件目录
当组件越来越多时,可以按功能分组:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| source/ ├── css/ │ ├── components/ │ │ ├── music-player.css │ │ ├── category-tiles.css │ │ └── command-palette.css │ └── theme/ │ ├── background.css │ └── dark-mode.css └── js/ ├── components/ │ ├── music-player.js │ ├── category-tiles.js │ └── command-palette.js └── theme/ └── atmosphere.js
|
当前项目为了保持已有地址没有立即移动所有文件,但独立主题阶段可以逐步采用这种结构。
十二、上线前检查
每个组件至少测试:
- 直接打开目标页面。
- 从主页通过 PJAX 进入目标页面。
- 连续来回切换页面。
- 切换亮暗主题。
- 调整到手机宽度。
- 关闭或阻止 JavaScript。
- 快速重复点击。
- 检查浏览器控制台。
下一篇将以本站为例,介绍已经完成的组件、各自解决的问题以及它们和 Hexo、Butterfly 的耦合程度。
参考资料与声明
本文结合本站组件开发和 PJAX 适配经验整理。部分结构与文字由 AI 协助完成。
上一篇:一图流背景与明暗主题 · 返回系列总目录 · 下一篇:本站自定义组件合集