我所理解的 Hexo 最佳实践(4):在首页分页前聚合连载
《ESA Pages 体验之旅》最初有九篇分文参与首页排序,一组文章连在一起就能占去不少首页位置。本站让总览占一个首页位置,同时保留各篇文章的归档、搜索和直接访问入口。首页过滤发生在生成器分页之前。
先确定要从哪里隐藏
总览是一篇正常文章,里面放背景、各篇链接和简短说明。给子文章的 frontmatter 增加布尔值:
---
title: 连载第 1 篇
hidden: true
---
这里用 Hexo 常见的 title 示意;实际项目保留已有标题、网址和其他字段,只增加 hidden。总览不设置这个字段,或者显式设为 false。
这个方案的展示范围如下:
| 入口 | 总览 | 子文章 |
|---|---|---|
| 首页及首页分页 | 展示 | 不展示,也不占分页名额 |
| 文末全站上一篇/下一篇 | 可以成为跳转目标 | 跳过,系列内阅读使用分篇目录 |
| 归档 | 展示 | 展示 |
| 本站站内搜索 | 收录 | 收录 |
| 原文链接 | 正常访问 | 正常访问 |
hidden 的含义由读取它的插件决定,不是 Hexo 核心对所有入口统一生效的隐私开关。本站搜索生成器读取完整文章集合,因此保留子文章;换成其他搜索插件,也要检查它自己的过滤规则。
例如 hexo-hide-posts 默认会从多个文章列表移除隐藏文章,并添加 noindex。它适合更广的隐藏需求。本站按入口分别筛选,保留归档与搜索的发现能力;published: false 属于未发布状态,也不能用来替代首页聚合。
为什么归档和正文可以保留
Hexo 用不同生成器处理首页、归档和文章页。生成器接收 locals 中的站点数据,再返回需要生成的路由和模板数据。官方 Generator 文档分别给出了列表分页和独立文章生成的例子。
首页生成器可以取得一个经过筛选的集合,再做排序和分页。只要不修改全局文章集合,其他生成器仍能读取完整文章。
因此过滤应该放在分页之前。假设每页三篇,前三篇恰好都是隐藏的子文章:先分页、再在模板里隐藏,第一页就会空掉,分页总数也仍按未筛选的数据计算。先过滤再分页,才能让首页条数与实际内容一致。
用 CSS 隐藏卡片也有同样的问题。它只改变浏览器显示,生成器计算的分页和页面内容仍然存在。
用插件包装首页生成器
本站安装的 hexo-generator-index@4.0.0 直接拿全部文章排序。上游已经合并首页过滤,4.0.0 发布得更早,所以博客自己提供这一步。hexo-hide-from-index 在 Hexo 初始化完成后接入已经注册的 index 生成器。 查看早期的补丁记录弹窗 4.0.0 发布源码 · 上游 PR #125
// plugins/hexo-hide-from-index/src/index.ts 中登记包装器的部分
import type Hexo from 'hexo';
import type { SiteLocals } from 'hexo/dist/types';
declare const hexo: Hexo;
type IndexLocals = SiteLocals & { index_posts?: SiteLocals['posts'] };
hexo.extend.filter.register('after_init', function (this: Hexo) {
const { generator } = this.extend;
const generateIndex = generator.get('index');
if (!generateIndex) {
throw new Error('hexo-hide-from-index 需要先安装 hexo-generator-index');
}
generator.register('index', function (this: Hexo, locals: IndexLocals) {
const isVisible: Parameters<SiteLocals['posts']['filter']>[0] = (post) => !post.hidden;
const indexLocals = { ...locals, posts: locals.posts.filter(isVisible) };
if (typeof locals.index_posts?.filter === 'function') {
indexLocals.index_posts = locals.index_posts.filter(isVisible);
}
return generateIndex.call(this, indexLocals);
});
});
after_init 执行时,依赖和主题的生成器已经注册。包装器保存当时的 index 函数,每次生成都传入新的 indexLocals。call(this, …) 保留 Hexo 实例作为调用上下文,排序、置顶、分页和模板继续由原生成器处理。归档、搜索和正文收到的集合不受这次筛选影响。 Hexo Filter API · Hexo Generator API
Sheng 使用 hexo-generator-index,它读取 locals.posts。Fluid 自己注册了同名的首页生成器,读取的是 locals.index_posts。后者已经按 Fluid 的 hide 和 archive 字段筛过一次,插件在那份结果上再筛 hidden,保留主题已有的规则。换主题时,可以先查首页生成器实际读取哪份集合。 Fluid 1.9.9 的首页生成器
插件的源码和包清单放在 plugins/hexo-hide-from-index/。博客通过 pnpm 工作区安装,Hexo 按 插件文档里的 hexo- 前缀发现这个依赖,再从包清单指定的 dist/index.cjs 加载。这个入口由 TypeScript 源码编译生成,主题仍按原样渲染首页卡片。
如果当前主题的首页生成器已经支持 hidden,可以移除工作区插件,再用同一组文章核对首页分页、归档与搜索。切换到自行实现首页的主题时,也要核对它对这个字段的处理。各篇 frontmatter 里的 hidden: true 仍用来表达首页展示范围。
验证首页名额、归档和搜索
本地生成后要检查全部首页分页。总览应该只出现一次,子篇不占位置。归档和搜索仍应提供各篇入口,点进去要能打开正文和资源。搜索验证要看真实索引条目的标题与 URL,单在 XML 文本里搜到子篇链接无法区分它是条目,还是出现在总览正文里。
接入补丁时,九篇子文从首页退出,首页两页合计 124 个条目,归档和站内搜索仍各有 133 个条目。桌面和手机上都能从首页总览打开九篇正文。这些数字来自 2026 年 10 月 30 日的站点快照。 查看首页聚合的验收记录弹窗
总结
首页聚合由生成器自己的输入决定。把子篇从这份输入筛掉,原生成器就会按实际展示的文章计算分页。归档、搜索和正文保留完整的入口,读者仍可以按系列目录继续阅读。
首页过滤的早期补丁
打开原文件 hexo-generator-index@4.0.0.patch
2026 年 10 月 30 日,博客用 pnpm 保存了针对 4.0.0 的一行过滤。这份文件和下面的图记录当时如何把差异纳入依赖安装。
首页聚合接入时的验收
打开原文件 home-overview-validation.json
记录覆盖当时的两页首页、完整归档、站内搜索、九篇正文、资源样本,以及桌面和手机上的总览入口。计数对应 2026 年 10 月 30 日的站点快照。
入口与计数记录
{
"checkedAt": "2026-10-30T04:29:37.240Z",
"patch": "patches/hexo-generator-index@4.0.0.patch",
"patchChangedLines": {
"removed": 1,
"added": 1
},
"overview": "/2026/10/30/esa-pages-journey/",
"hidden": [
{
"title": "ESA Pages 体验之旅(1):为什么选择 ESA Pages",
"url": "/2026/10/29/esa-pages-plans-and-certificates/"
},
{
"title": "ESA Pages 体验之旅(2):部署静态页面和 API",
"url": "/2026/10/29/esa-pages-fullstack-deployment/"
},
{
"title": "ESA Pages 体验之旅(4):小项目的存储选型",
"url": "/2026/10/29/esa-pages-storage-selection/"
},
{
"title": "ESA Pages 体验之旅(3):ESA CLI 的构建配置",
"url": "/2026/10/29/esa-pages-build-platform/"
},
{
"title": "ESA Pages 体验之旅(5):修复 TableStore SDK 的变量声明错误",
"url": "/2026/10/29/esa-pages-sdk-source/"
},
{
"title": "ESA Pages 体验之旅(6):检查 ESA 的运行环境",
"url": "/2026/10/27/esa-runtime-dev-sdk-compatibility/"
},
{
"title": "ESA Pages 体验之旅(8):四次 fetch 限制下的接口设计",
"url": "/2026/10/29/esa-pages-subrequest-budget/"
},
{
"title": "ESA Pages 体验之旅(7):适配 Node.js 依赖",
"url": "/2026/10/29/esa-pages-tablestore-adaptation/"
},
{
"title": "ESA Pages 体验之旅(9):缓存、防刷与日志",
"url": "/2026/10/29/esa-pages-cache-and-operations/"
}
],
"views": [
{
"viewport": 1440,
"homePages": 2,
"homeEntries": 124,
"overviewEntries": 1,
"hiddenHomeEntries": 0,
"archivePages": 14,
"archiveEntries": 133,
"searchEntries": 133,
"childRoutes": 9,
"overviewLinks": 9
},
{
"viewport": 390,
"homePages": 2,
"homeEntries": 124,
"overviewEntries": 1,
"hiddenHomeEntries": 0,
"archivePages": 14,
"archiveEntries": 133,
"searchEntries": 133,
"childRoutes": 9,
"overviewLinks": 9
}
],
"resourceSamples": [
"files/esa-pages-fullstack-deployment/data/verification.json",
"files/nodejs-build-and-distribution/data/verification.json",
"files/esa-pages-subrequest-budget/kits/identity-migration/FILES.json",
"files/better-auth-edge-storage-evaluation/kits/identity-migration/FILES.json"
],
"globalPostCount": 133,
"errors": [],
"deployed": false
}