我所理解的 Hexo 最佳实践(7):用 Vue 重写主题并保留页面交互
- 用文件名管理文章,用稳定字段生成网址
- 让本地预览和公开链接共用一份资源
- 用系列目录组织连载正文
- 在首页分页前聚合连载
- 让列表排序与全站翻篇沿用同一顺序
- 把源码工作区嵌进 Hexo 文章
- 用 Vue 重写主题并保留页面交互(当前)
- 让 Hexo 支持 Markdown 扩展语法
博客用了很长一段时间的 Fluid。后来想调整正文与目录的位置、给背景加一点材质、让文章切换更自然,主题覆盖代码便越来越多。音乐播放器、Code Lab 和补充材料阅读器也都加进来了。继续改第三方主题,最难维护的部分已经变成这些功能和主题之间的接缝。
播放器尤其能暴露页面边界。普通静态页面跳转会销毁当前 Audio 对象;把文章放到新标签页,可以保住原页面播放,却又需要协调多个标签页的播放权。我曾经给它补过单一播放页所有权,但同标签页切换文章仍然会中断。
这次重写保留了 Markdown 和 Hexo 的静态生成流程。主题放在仓库里的 themes/sheng/,用 Vue 单文件组件维护模板、交互和样式;浏览器通过 Swup 切换文章,播放器留在阅读容器外。第一阶段以 Fluid 的现有布局为基线,先把功能迁齐,再逐步修改界面。
保留 Hexo,把模板换成 Vue
文章已经有稳定的日期、URL 和资源路径,正文里还用了代码工作区、提示块、折叠内容和补充材料标签。换主题时,我希望这些 Markdown 文件继续原样参与构建。真正需要替换的是页面模板,以及模板周围那些越来越难组合的交互。
Hexo 负责读取文章、执行标签与过滤器、生成首页和归档等路由。主题接收已经准备好的数据,再输出页面。它的 Renderer 扩展接口允许注册输入扩展名与输出类型,所以模板不必固定使用 EJS。
项目注册了一个 .vue 到 .html 的 renderer。为了让组件不直接依赖 Hexo 的内部对象,中间用 server/hexo-adapter.ts 把文章、分页、目录和主题配置整理成 ThemePage。Vue 模板只消费这份明确的数据。
实际渲染入口很短:
// sheng-blog/themes/sheng/server/render-page.ts:2-11
import 'virtual:uno.css';
import { createSSRApp } from 'vue';
import { renderToString } from 'vue/server-renderer';
import DocumentPage from '../layout/document.vue';
import type { ThemePage } from './types';
// 每次生成独立页面都创建新 app,文章数据不在构建进程中跨页面共享。
export async function renderPage(page: ThemePage) {
return '<!DOCTYPE html>\n' + (await renderToString(createSSRApp(DocumentPage, { page })));
}
这里使用 Vue 的服务端渲染 API,但调用发生在 Hexo 构建期。部署目录里仍然是一篇文章一个 HTML 文件,访问文章不需要启动 Node 服务。Vue 官方也将这种提前渲染、发布静态文件的方式归为 SSG。
静态模板没有在浏览器里重新挂载,也没有做整站 hydration。hydration 是浏览器用框架接管已有 HTML、补上状态与交互的过程;这里仅在需要交互的位置单独挂载组件。关掉 JavaScript,正文、目录链接和普通导航仍然存在。这样既能用组件、类型和 scoped 样式组织源码,也保留了静态站容易部署和缓存的特点。
主题代码集中放,独立功能继续独立
模板改用组件后,很容易顺手把所有前端代码都搬进主题。我最终按维护职责分了目录,下面省略了测试和直接发布的图片:
sheng-blog/
├─ themes/sheng/
│ ├─ layout/ # 完整文档、首页、文章、归档等静态模板
│ ├─ components/ # 页面组件与搜索面板
│ ├─ server/ # Hexo 数据适配与 HTML 渲染
│ ├─ client/ # 导航、页面增强、滚动条与播放器布局
│ ├─ styles/ # 文档与 Markdown 富文本样式
│ └─ vite.config.ts
├─ client/music-player/ # 播放器状态、播放权和 UI
├─ client/code-lab/ # 编辑器与可运行示例
├─ hexo-runtime/site/ # 编译到 scripts/ 的 renderer 与资源路由
└─ build-scripts/ # 主题与功能的构建、预览和验证入口
主题目前只服务这个博客,放在同一仓库里,修改组件、配置和文章可以一起验证。此时另起主题仓库、再维护 link 或发包,会多出一层同步成本。以后真要分发,先把播放器、站内标签和资源路径这些项目依赖整理成公开接口,再提取仓库也来得及。
配置仍然分两层:_config.yml 管站点和 Hexo,_config.sheng.yml 管主题覆盖,主题自己的 _config.yml 提供默认值。Fluid 的配置保留给对照预览,当前主题不会把所有 Fluid 配置直接当成自己的 API。
模板迁过去,CSS 也要跟着过去
初版先按 Fluid 的现有结构复刻,是为了把页面差异控制住:先确认文章、目录和现有标签正常,再拆组件、改 class 和样式。直接同时重做布局、组件结构和交互,出现问题时很难判断是哪一层引起的。
后续组件使用普通 HTML template、scoped SCSS 和 BEM。布局、标题、分页和目录各自拥有样式;Markdown 正文则仍然是 Hexo 渲染好的 HTML,通过 v-html 放入阅读区。它的 table、blockquote、pre 和历史标签不会凭空得到 SFC 的 scope 属性,需要由专门的富文本样式承接。正文来自仓库中的受信任内容,这也不意味着可以把任意外部 HTML 直接放进相同入口。
只在构建时使用的组件,也有 CSS 产物
这次很容易漏掉的一点是:静态页面组件不进入客户端 JS 模块图,它们的 scoped CSS 却必须发布。
如果只收集客户端入口的 CSS,播放器和搜索面板可能正常,文章布局组件的样式却缺了。JavaScript 开着时偶尔还能被其他样式掩盖,关掉脚本后更容易暴露。
主题因此构建两份入口:服务端入口用于输出 HTML,客户端入口用于导航和局部交互。服务端构建显式设置 emitAssets: true,构建脚本再从服务端 manifest 里提取 CSS:
// sheng-blog/build-scripts/build-theme.ts:12-19
// 静态布局不进入浏览器 JS 图,但其 scoped CSS 仍必须随 SSG HTML 发布。
const serverManifest = JSON.parse(
await readFile(path.join(repoRoot, '.theme-dist/server/.vite/manifest.json'), 'utf8'),
) as Record<string, { css?: string[] }>;
const staticCss = [...new Set(Object.values(serverManifest).flatMap((entry) => entry.css || []))];
for (const filename of staticCss) {
const target = path.join(repoRoot, '.theme-dist/client', filename);
await mkdir(path.dirname(target), { recursive: true });
await cp(path.join(repoRoot, '.theme-dist/server', filename), target);
}
这些样式与客户端 CSS 一起写进 HTML 的 <head>,服务端 bundle 留在构建进程中。验收时先关闭 JavaScript 看正文布局,再打开它检查客户端交互。
格式化与间距各自解决自己的问题
Vue 模板可以把结构标签按层级展开,Prettier 和 eslint-plugin-vue 能帮助维持可读性。但模板编译时的空白处理不是布局系统:把图标和日期文字之间的空格换成换行后,原先的视觉距离可能消失。
这类间距改成组件 CSS 中的 gap 或逻辑方向 margin,格式化就不会改变布局。英文单词之间的语义空格、单位和不换行要求仍然需要单独判断,不能把所有空白统一删除。
项目把社区 Vue 规则组合进本地 ESLint 配置,再叠加自己的组件和样式约束。自定义插件负责项目确实需要的规则,模板常见错误继续复用社区规则,避免重复维护一套不完整的 Vue 检查。
Swup 保留文档,只替换阅读容器
Vue 生成模板,并不会自动让音乐连续播放。真正改变同标签页导航方式的是 Swup:它请求下一页的 HTML,从中取出指定容器,替换当前文档里的对应部分。
初次访问时拿到的仍是完整静态页面。随后普通点击文章链接,可以留在同一个文档内完成切换;直接访问 URL、刷新和新标签页打开,则继续走完整 HTML。静态产物与局部导航可以同时成立。
当前 Swup 只替换 #page-content。导航栏、搜索挂载点和播放器位于它外面,文章标题、正文、目录和文章增强属于它里面。这个边界决定了谁跨页面保留,谁应当随文章离开而销毁。
文章增强需要可重复挂载,也需要释放
原来整页加载时,DOMContentLoaded 只执行一次没有问题,离开页面时浏览器也会替应用清掉许多资源。Swup 保留文档后,这两个假设都变了。官方的脚本重载说明也明确要求切页后重新初始化,并在替换前释放旧资源。
主题用 mountArticle(root) 装配当前阅读区,返回一个可以等待的清理函数。首次进入时调用一次,之后在 content:replace 前等待旧清理,在 page:view 后挂载新页面。清理会处理目录观察器、复制按钮事件、图片弹窗、目录滚动条、补充材料和 Code Lab。
把导航器中的版本校验、滚动和失败处理省略,接入形态可以简化成:
// 生命周期接入示意;完整导航器还包含历史、版本和错误处理。
import Swup from 'swup';
import { mountArticle } from '../article/mount-article';
let dispose = mountArticle(document.getElementById('page-content')!);
const swup = new Swup({ containers: ['#page-content'] });
swup.hooks.before('content:replace', async () => {
await dispose();
});
swup.hooks.on('page:view', () => {
dispose = mountArticle(document.getElementById('page-content')!);
});
初次挂载不能只放进 page:view 的回调里,否则直接访问文章时,增强功能可能一直等到下一次切页才出现。旧实例的清理要在替换前完成,才能使用还在文档中的节点退出全屏、归还内容和释放第三方运行时。
Code Lab 尤其不能只删掉外层 DOM。它内部有 React 根节点、编辑器、预览 iframe、消息监听和全屏状态。切页前要先退出全屏、卸载运行时,再让补充材料把移动过的内容归还。项目还用 pnpm 补丁补齐了 Sandpack 静态预览中转控制器的释放;补丁及升级检查保存在 patches/,不是手改依赖目录后就算结束。
动态导入也有时间差。用户可能点进文章后立刻离开,Code Lab 的模块此时才下载完成。当前实现通过文章作用域的 AbortController 判断是否已经离开:模块可以留在浏览器缓存里,但失效页面不再执行挂载。单纯让模块晚一点加载,不能代替这个检查。
补充材料的后退还要与页面后退分开。同页 hash 的变化由阅读器处理,关闭后回到这次点击的入口;跨文章历史才交给 Swup。否则按一次后退,可能同时触发关闭弹窗与跳走文章。
标题过渡、滚动恢复和失败需要一起设计
首页卡片和文章页头有相同的文章标题,可以用稳定 URL 派生 view-transition-name,让浏览器把两次快照里的标题关联起来。列表页只给本次参与切换的标题启用名称,避免其他卡片也参与动画。
返回首页时,滚动恢复必须发生在捕获目标快照之前。否则标题虽然找对了,快照里的列表却还停在页首,看起来会突然跳一下。当前主题在原生过渡路径里同步应用滚动缓存,减少动态效果时也单独恢复历史位置;不支持原生过渡的环境使用普通淡入淡出。
请求失败则保留当前文章和播放器,恢复与当前内容相符的 URL,提供重试和普通打开。页面缺少阅读容器,或新旧 HTML 的外壳版本不一致时,转为完整导航。完整导航会重建播放器,这是兼容更新所接受的边界,不能把局部导航的连续性承诺扩大到所有打开方式。
播放器和 Code Lab 可以使用不同框架
主题的静态模板用 Vue,不要求已经稳定工作的 Code Lab 也迁到 Vue。Code Lab 使用 React 和 Sandpack,播放器与搜索使用 Vue,二者各自管理自己的挂载根。
当前搜索和播放器采用 Vue 3.6.0-rc.14 的 Vapor 编译模式,静态模板仍使用服务端渲染。这个 RC 是项目的明确版本选择,迁移过程另见 Vapor 实践记录。主题的组件化并不依赖 Vapor,也不需要读者为了复用这个结构先采用 RC。
播放器在客户端入口中只初始化一次,mountShengMusicPlayer() 还有幂等保护。窗口中的公共 API 已存在时,直接返回原实例。它创建的挂载点在 body 下,普通 Swup 切页不会经过播放器的销毁流程。
同一标签页连续播放,与多标签页协调是两件事
同一标签页里,保留原 Audio 对象可以让正在播放的声音继续。右键新标签打开时,新文档仍然会创建自己的播放器,所以旧的 localStorage 短租约和 BroadcastChannel 协调机制继续保留。
新页面先展示已有播放页的信息,用户主动播放时再申请接管。原有方案减少同源页面重复发声,不提供跨浏览器、跨存储分区的协调,也不是强互斥锁。Swup 解决文档内导航,播放权解决不同文档之间的协作,它们保护的是不同边界。
播放器的位置也独立于播放状态。宽屏下放在目录或简介块下方,同宽对齐;窄屏隐藏侧栏后,通过导航栏入口打开音乐弹窗。弹窗移动的是同一个挂载点,关闭后再移回,不能为了换位置重建组件和音频对象。

布局与材质要服从阅读
首页和文章共用一套主列加侧栏的布局。1920px 视口下,主面板最大 1200px、侧栏 300px,间距 10px,合计 1510px 居中;这些是面板外框尺寸,正文可用宽度还要扣除内边距。992px 以下隐藏侧栏,让阅读区使用剩余空间。
首页仍然是一篇一行的文章列表,没有把代表图作为必填字段。技术文章里的图表首先服务解释,不一定适合做头图;主题不应为了动画效果,反过来要求每篇文章准备一张大图。
背景与玻璃材质保持文档层连续,正文、目录和导航在其上表达层级。透明度只作用于背景层,文字、代码和图表不跟着一起变淡。代码块内层 code 保持透明,避免与外层 pre 重复叠底;表格行与表头也有各自的半透明底色。
SVG 图的背景则在构建期生成展示版本,保留原图地址用于放大查看,不批量改写历史图源。无法识别的背景保持原样。这使正文可以适应材质,同时保留下载和查看原始图表的入口。
网站现在固定使用深色阅读外观,打印切回浅色。无脚本时仍有 CSS 材质;低透明度偏好使用实体阅读面。具体壁纸、模糊和颜色可以继续改,这些选择不应影响文章生成、导航和播放器的边界。

滚动条需要接到真正滚动的那一层
打开全屏抽屉时,原生滚动条消失会改变布局宽度。这不能只靠把面板做成玻璃解决。当前主题把页面主体、目录和补充材料正文接入 OverlayScrollbars,页面保留 window.scrollTo(),局部滚动区域保留原节点的 scrollTop。
页面实例跨 Swup 导航保留,目录与阅读器实例跟文章一起释放。首屏输出初始化标记,配合 scripting-enabled CSS,禁用 JavaScript 时保留原生滚动条。滚动条外观、背景滚动锁和全屏布局各有职责,仍要分别验证。
我倾向于在自有项目中默认接入成熟滚动条组件,减少每个面板都重新处理提示、尺寸和拖动的成本;已有组件满足需求时就复用。具体接入、主题变量和回退方式已经放在自定义滚动条的文章,页面级接入还需要额外检查窗口滚动、锚点和弹窗锁滚动。
验证主题,要跨过几次页面生命周期
一次打开首页,很难暴露这种改造的问题。真正需要检查的是:反复进出文章以后,播放器是否还在,目录是否还是当前文章,预览 iframe 是否留在旧页面,浏览器后退是否回到原位置。
这次写作时,项目的生命周期测试和适配器测试通过;浏览器回归覆盖了本地音频播放、搜索进入文章、补充材料后退关闭、连续导航和窄屏入口。连续 20 次切页后,文档、播放器和 Audio 保持同一实例,没有留下旧预览 iframe。滚动条检查保留浏览器原生滚动条,以便暴露真实宽度变化,再比较普通正文与固定导航的 DOMRect;当前测试覆盖 1920px 与 390px 视口、系统深浅偏好,站点实际外观仍然固定深色。验证条件与记录弹窗
可复测的入口集中在项目脚本里:
# 在仓库根目录执行;浏览器检查前先启动本地主题预览。
pnpm run server -- -p 4350
# 在另一个终端运行。
pnpm run test:theme
pnpm run test:theme:browser
pnpm run test:theme:scrollbars
这些检查使用本地预览和隔离浏览器。确定性音频用来验证对象身份和切页行为,不代表远端榜单或歌源始终可用;真实歌源有单独的 Fluid 对照检查。构建与资源验证也使用独立输出目录,避免预览清理正式发布目录。
构建性能同样需要分开讨论:增加 Vue 编译会增加构建工作,但不能由此推导浏览器会整站多跑一遍 Vue。静态模板没有进入客户端挂载流程,新增成本主要来自实际使用的局部交互和导航;是否比旧主题更快,仍要在同一内容、缓存和网络条件下测量,不能把组件化本身当成性能结论。
总结
这次重写把 Hexo 的内容生成流程留下来,用 Vue 组件承接模板和样式,再把需要状态的功能留在浏览器里。Swup 让普通文章切换保持同一文档,播放器得以继续播放;文章增强则通过明确的挂载与释放,适应不再整页刷新的导航方式。
真正影响后续维护的,是这些边界能否保持清楚:静态组件的 CSS 要独立发布,Code Lab 的资源要随文章释放,多标签播放权要继续协调,滚动条要保护原滚动契约。它们稳定以后,背景、布局和过渡才可以作为 UI 继续迭代,而不必每次修改都重新牵动整站。
主题迁移的验证条件与记录
验证基线是本地当前工作树,Node.js 26.12.0、Hexo 8.1.2、Vue 3.6.0-rc.14、Swup 4.10.0 与 OverlayScrollbars 2.17.0。音频连续性使用本地确定性 WAV,检查对象身份、播放进度与切页后的事件;它不覆盖远端歌源可用性。
滚动条检查使用保留原生滚动条的 Chrome,验证抽屉前后的正文与固定导航尺寸、背景锁定、局部动态内容滚动、导航后销毁和无脚本回退。测试引用的两篇文章已经拆分,固定地址与同材料的重复入口也随之调整,几何断言保持不变。备用全屏定位曾在一次完整运行中失败,独立复核和之后的完整复跑通过;原记录保留这项波动,未据此认定原因已查清。
两张机制图用项目字体导出并内嵌子集,检查了 XML、实际渲染和文字边界。桌面与手机图片来自同一份静态生成产物;手机截图展示手动添加音乐的面板,没有触发播放。
命令与浏览器观测
{
"date": "2026-11-02",
"environment": {
"node": "v26.12.0",
"chrome": "156.0.8078.74",
"hexo": "8.1.2",
"vue": "3.6.0-rc.14",
"swup": "4.10.0",
"overlayscrollbars": "2.17.0"
},
"commands": [
{
"command": "pnpm run test:theme",
"result": "通过"
},
{
"command": "pnpm run test:theme:browser",
"result": "通过,连续 20 次导航保持文档、播放器和 Audio;本地 WAV,非远端歌源检查"
},
{
"command": "pnpm run test:theme:scrollbars <静态预览 URL>",
"result": "完整复跑通过,使用更新后的文章 URL 与首个同材料入口"
},
{
"command": "pnpm run build:code-lab",
"result": "通过"
},
{
"command": "pnpm run test:kits",
"result": "通过,26 个源码包"
},
{
"command": "pnpm exec tsc -p build-scripts/tsconfig.json",
"result": "通过"
},
{
"command": "pnpm run build:theme",
"result": "通过"
},
{
"command": "pnpm exec tsx build-scripts/verify-theme-output.ts --generate",
"result": "通过,独立输出,不发布"
}
],
"knownObservation": {
"fullscreen": "备用全屏位置断言曾单次失败,独立复核及完整复跑通过;原因未隔离,不据此宣称稳定性问题已修复。",
"fixture": "原测试引用拆分前文章导致 404;更新 URL 与重复入口定位,没有放宽尺寸断言。"
},
"scrollbars": [
{
"width": 1920,
"colorScheme": "light",
"before": {
"width": 1920,
"viewport": 1920,
"scrollY": 2108,
"board": {
"x": 205,
"width": 1200
},
"navbar": {
"x": 0,
"width": 1920
},
"brand": {
"x": 405,
"width": 120.265625
}
},
"after": {
"width": 1920,
"viewport": 1920,
"scrollY": 2108,
"board": {
"x": 205,
"width": 1200
},
"navbar": {
"x": 0,
"width": 1920
},
"brand": {
"x": 405,
"width": 120.265625
}
},
"scroll": {
"top": 200,
"total": 2569,
"height": 703,
"width": 1098,
"box": 1098
}
},
{
"width": 1920,
"colorScheme": "dark",
"before": {
"width": 1920,
"viewport": 1920,
"scrollY": 2108,
"board": {
"x": 205,
"width": 1200
},
"navbar": {
"x": 0,
"width": 1920
},
"brand": {
"x": 405,
"width": 120.265625
}
},
"after": {
"width": 1920,
"viewport": 1920,
"scrollY": 2108,
"board": {
"x": 205,
"width": 1200
},
"navbar": {
"x": 0,
"width": 1920
},
"brand": {
"x": 405,
"width": 120.265625
}
},
"scroll": {
"top": 200,
"total": 2553,
"height": 703,
"width": 1098,
"box": 1098
}
},
{
"width": 390,
"colorScheme": "light",
"before": {
"width": 390,
"viewport": 390,
"scrollY": 1871,
"board": {
"x": 12,
"width": 366
},
"navbar": {
"x": 0,
"width": 390
},
"brand": {
"x": 16,
"width": 120.265625
}
},
"after": {
"width": 390,
"viewport": 390,
"scrollY": 1871,
"board": {
"x": 12,
"width": 366
},
"navbar": {
"x": 0,
"width": 390
},
"brand": {
"x": 16,
"width": 120.265625
}
},
"scroll": {
"top": 200,
"total": 4098,
"height": 827,
"width": 390,
"box": 390
}
},
{
"width": 390,
"colorScheme": "dark",
"before": {
"width": 390,
"viewport": 390,
"scrollY": 1871,
"board": {
"x": 12,
"width": 366
},
"navbar": {
"x": 0,
"width": 390
},
"brand": {
"x": 16,
"width": 120.265625
}
},
"after": {
"width": 390,
"viewport": 390,
"scrollY": 1871,
"board": {
"x": 12,
"width": 366
},
"navbar": {
"x": 0,
"width": 390
},
"brand": {
"x": 16,
"width": 120.265625
}
},
"scroll": {
"top": 200,
"total": 4098,
"height": 827,
"width": 390,
"box": 390
}
}
],
"screenshots": {
"browser": "156.0.8078.74",
"captures": [
{
"name": "theme-desktop",
"width": 1920,
"height": 1000,
"path": "/2026/11/02/hexo-vue-theme-static-pages-persistent-player/",
"scheme": "dark",
"overflow": false
},
{
"name": "theme-mobile-music",
"width": 390,
"height": 1000,
"path": "/2026/11/02/hexo-vue-theme-static-pages-persistent-player/",
"scheme": "dark",
"overflow": false
}
]
},
"scope": "本地静态产物与隔离 Chrome,不涵盖远端音频服务、其他浏览器或线上部署。",
"navigation": {
"report": [
"搜索 → 文章 → 补充材料 → Code Lab → Esc/后退",
"20 次切页:同一文档、播放器和 Audio;无 pause/emptied/loadstart;无残留 iframe",
"新标签显式播放接管;原页面点击后重新接管",
"固定深色、关于页",
"390px 手机导航与横向溢出检查"
],
"start": {
"identity": "61b4a34b-4db8-4c55-b0f2-b318b3f7a8f2",
"time": 0.310915,
"eventCount": 5
},
"after": {
"identity": "61b4a34b-4db8-4c55-b0f2-b318b3f7a8f2",
"samePlayer": true,
"audioCount": 1,
"time": 23.064861,
"events": [
"emptied",
"loadstart",
"emptied",
"play",
"loadstart"
],
"paused": false,
"readers": 0,
"lightboxes": 1,
"iframes": 0,
"iframeDetails": []
},
"errors": []
}
}