一、前言

Eurkon 风格文章头部的吸引力来自封面主色、标题层级、分类标签和动态波浪,但直接迁移整套主题会与现有 Butterfly 定制发生大量冲突。本篇采用“增强层”方案:保留 Butterfly 原模板,在构建期只给文章页注入资源,再由浏览器重组现有 DOM。

这不是完整 Eurkon 主题迁移,也不修改 node_modules/hexo-theme-butterfly/。以后升级 Butterfly 时,站点级增强文件仍然独立存在。

二、交互演示:文章头部怎样逐步增强

Hexo 博客根目录
├─ scripts/post-header-effect.js # 判断文章并注入资源
├─ source/js/post-header-eurkon.js # DOM 增强、取色与 PJAX
└─ source/css/post-header-eurkon.css # 布局、主色和波浪

Eurkon 风格文章头部增强流程

三、第一步:确认不修改主题依赖

不要编辑:

node_modules/hexo-theme-butterfly/layout/includes/header/index.pug

依赖升级会覆盖这里的修改。我们只读取 Butterfly 已经生成的结构,例如:

<header id="page-header" class="post-bg">
<div id="post-info">
<h1 class="post-title">文章标题</h1>
<div id="post-meta">...</div>
</div>
</header>

四、第二步:只给文章页注入资源

新建 scripts/post-header-effect.js

'use strict'

const STYLE_URL = '/css/post-header-eurkon.css?v=20260808-1'
const SCRIPT_URL = '/js/post-header-eurkon.js?v=20260808-1'

hexo.extend.filter.register('after_render:html', (html, locals) => {
const page = locals?.page
const isPost = page?.layout === 'post' || page?.type === 'post'

if (!isPost || !page.cover) return html

const stylesheet = `<link id="post-header-eurkon-style" rel="stylesheet" href="${STYLE_URL}">`
const script = `<script src="${SCRIPT_URL}" defer data-pjax></script>`

return html
.replace('</head>', `${stylesheet}</head>`)
.replace('</body>', `${script}</body>`)
}, 35)

这样分类页、标签页和普通独立页不会加载文章头部资源。

五、第三步:找到并标记原始头部

新建 source/js/post-header-eurkon.js

(() => {
const initPostHeader = () => {
const header = document.querySelector('#page-header.post-bg')
const postInfo = header?.querySelector('#post-info')

if (!header || !postInfo || header.dataset.eurkonBound === 'true') return
header.dataset.eurkonBound = 'true'
header.classList.add('eurkon-post-header')

enhanceStructure(header, postInfo)
enhanceCover(header)
addWaves(header)
}
})()

data-eurkon-bound 防止 PJAX 多次进入文章时重复添加封面和波浪。

六、第四步:重组标题、分类、标签和元信息

不要重新生成另一套标题,而是移动原节点:

const enhanceStructure = (header, postInfo) => {
const title = postInfo.querySelector('.post-title')
const meta = postInfo.querySelector('#post-meta')
const tags = document.querySelector('.post-meta__tag-list')

const shell = document.createElement('div')
shell.className = 'eurkon-post-info'

if (tags) shell.append(tags)
if (title) shell.append(title)
if (meta) shell.append(meta)

postInfo.replaceChildren(shell)
}

真实站点中,分类和标签选择器要根据当前 Butterfly 输出确认。若找不到某个节点,跳过即可,不要让整个标题消失。

七、第五步:把封面放进可控制的 img

背景图不方便做取色、解码状态和错误回退。可以读取头部计算样式中的 URL,再建立图片:

const coverFromHeader = header => {
const background = getComputedStyle(header).backgroundImage
const matched = background.match(/url\(["']?(.*?)["']?\)/)
return matched?.[1] || ''
}

const enhanceCover = header => {
const url = coverFromHeader(header)
if (!url) return

const image = new Image()
image.className = 'eurkon-post-cover'
image.alt = ''
image.decoding = 'async'
image.src = url
header.prepend(image)
}

图片失败时不要隐藏原背景,保持 Butterfly 自己的兜底。

八、第六步:使用 Canvas 提取封面主色

图片加载完成后缩小到 32×32 采样:

const extractAccent = image => {
const canvas = document.createElement('canvas')
canvas.width = 32
canvas.height = 32
const context = canvas.getContext('2d', { willReadFrequently: true })

try {
context.drawImage(image, 0, 0, 32, 32)
const pixels = context.getImageData(0, 0, 32, 32).data
return dominantColor(pixels)
} catch {
return ''
}
}

跳过透明度过低、接近纯黑或纯白的像素,再对 RGB 分桶,选择出现次数最多的颜色。

应用到 CSS 变量:

image.addEventListener('load', () => {
const accent = extractAccent(image)
if (accent) header.style.setProperty('--post-accent', accent)
}, { once: true })

九、第七步:处理跨域取色失败

当封面来自图床且没有允许跨域读取像素时,Canvas 会被污染。可以尝试:

image.crossOrigin = 'anonymous'

但最终是否成功取决于图床响应头。失败时应回退:

.eurkon-post-header {
--post-accent: var(--theme-color, #6b66d8);
}

取色失败只影响配色,不应该影响文章标题和阅读。

十、 第八步:添加动态波浪

创建三个装饰层:

const addWaves = header => {
const waves = document.createElement('div')
waves.className = 'eurkon-waves'
waves.setAttribute('aria-hidden', 'true')
waves.innerHTML = '<i></i><i></i><i></i>'
header.append(waves)
}

CSS:

.eurkon-waves {
position: absolute;
z-index: 4;
right: 0;
bottom: -1px;
left: 0;
height: 48px;
overflow: hidden;
pointer-events: none;
}

.eurkon-waves i {
position: absolute;
right: -10%;
bottom: -22px;
left: -10%;
height: 54px;
border-radius: 48% 52% 0 0;
background: var(--card-bg);
animation: eurkon-wave 9s linear infinite alternate;
}

@media (prefers-reduced-motion: reduce) {
.eurkon-waves i { animation: none; }
}

十一、第九步:建立文章头部布局

.eurkon-post-header {
position: relative;
min-height: 460px;
overflow: hidden;
isolation: isolate;
}

.eurkon-post-cover {
position: absolute;
z-index: -2;
width: 100%;
height: 100%;
object-fit: cover;
}

.eurkon-post-header::after {
position: absolute;
z-index: -1;
inset: 0;
background: linear-gradient(
90deg,
color-mix(in srgb, var(--post-accent) 82%, #111),
transparent 76%
);
content: '';
}

.eurkon-post-info {
position: absolute;
right: min(8vw, 100px);
bottom: 88px;
left: min(8vw, 100px);
color: #fff;
}

十二、第十步:保留评论数与原主题能力

Butterfly 或 Twikoo 可能在页面加载后异步写入评论数。因为我们移动的是原有元信息节点,而不是复制文本,后续更新仍能进入正确位置。

不要把评论数量在构建期写死,也不要因为初始值为空就删除该节点。

十三、第十一步:处理 PJAX

if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initPostHeader, { once: true })
} else {
initPostHeader()
}

document.addEventListener('pjax:complete', initPostHeader)

如果从文章切到非文章页,还要确保上一个页面的计时器、观察器和临时状态被清理。

十四、第十二步:构建和验证

npm run clean
npm run build
npm run verify
hexo server

至少检查:

  1. 有封面的文章启用增强头部;
  2. 无封面文章仍能正常显示标题;
  3. 分类页、标签页和归档页未加载专用资源;
  4. 跨域封面取色失败时回退主题色;
  5. 图片 404 时原背景与标题仍存在;
  6. PJAX 往返不会增加重复波浪;
  7. 深色模式和手机端文字可读;
  8. 开启“减少动态效果”后动画停止。

十五、常见问题

1. 标题出现两份

你复制了节点而不是移动原节点。使用 append() 移动现有 .post-title#post-meta

2. Canvas 报安全错误

图床不允许跨域读取像素。保留取色 try/catch 和主题色回退,不要为了取色关闭网站安全策略。

3. PJAX 后波浪越来越多

初始化前检查 data-eurkon-bound,并保证每次只创建一组 .eurkon-waves

4. 标题被封面遮住

检查层级与 isolation: isolate,保证封面、遮罩、文字和波浪处于明确的 z-index 顺序。

十六、维护入口与结语

  • 构建期注入:scripts/post-header-effect.js
  • DOM、取色和 PJAX:source/js/post-header-eurkon.js
  • 布局和波浪:source/css/post-header-eurkon.css

这种实现保留了 Butterfly 的模板、元信息和评论能力,只把文章头部外观拆成可独立维护的增强层。主题升级时更容易检查和回退,也避免整套迁移带来的长期维护成本。