上一篇:一图流背景与明暗主题 · 系列总目录 · 下一篇:本站自定义组件合集

前言

当 Butterfly 的配置项无法实现某个效果时,最直接的做法似乎是修改主题模板。但 npm 更新可能覆盖 node_modules,Git 更新也容易与个人修改冲突。

更适合长期维护的方式,是把自定义资源放在 Hexo 的 source 目录,再通过 Butterfly 的 inject 配置加载。这样主题仍然负责基础结构,自定义代码只负责增强。

本文将实现一个小型“阅读提示”组件,并以此说明完整的组件开发流程。


一、Inject 解决什么问题

Butterfly 提供两个注入位置:

1
2
3
inject:
head:
bottom:
  • 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

当前项目为了保持已有地址没有立即移动所有文件,但独立主题阶段可以逐步采用这种结构。


十二、上线前检查

每个组件至少测试:

  1. 直接打开目标页面。
  2. 从主页通过 PJAX 进入目标页面。
  3. 连续来回切换页面。
  4. 切换亮暗主题。
  5. 调整到手机宽度。
  6. 关闭或阻止 JavaScript。
  7. 快速重复点击。
  8. 检查浏览器控制台。

下一篇将以本站为例,介绍已经完成的组件、各自解决的问题以及它们和 Hexo、Butterfly 的耦合程度。


参考资料与声明

本文结合本站组件开发和 PJAX 适配经验整理。部分结构与文字由 AI 协助完成。


上一篇:一图流背景与明暗主题 · 返回系列总目录 · 下一篇:本站自定义组件合集