跳到正文

我所理解的 Hexo 最佳实践(4):在首页分页前聚合连载

我所理解的 Hexo 最佳实践

  1. 用文件名管理文章,用稳定字段生成网址
  2. 让本地预览和公开链接共用一份资源
  3. 用系列目录组织连载正文
  4. 在首页分页前聚合连载(当前)
  5. 让列表排序与全站翻篇沿用同一顺序
  6. 把源码工作区嵌进 Hexo 文章
  7. 用 Vue 重写主题并保留页面交互
  8. 让 Hexo 支持 Markdown 扩展语法

《ESA Pages 体验之旅》最初有九篇分文参与首页排序,一组文章连在一起就能占去不少首页位置。本站让总览占一个首页位置,同时保留各篇文章的归档、搜索和直接访问入口。首页过滤发生在生成器分页之前。

先确定要从哪里隐藏

总览是一篇正常文章,里面放背景、各篇链接和简短说明。给子文章的 frontmatter 增加布尔值:

---
title: 连载第 1 篇
hidden: true
---

这里用 Hexo 常见的 title 示意;实际项目保留已有标题、网址和其他字段,只增加 hidden。总览不设置这个字段,或者显式设为 false。

这个方案的展示范围如下:

入口 总览 子文章
首页及首页分页 展示 不展示,也不占分页名额
文末全站上一篇/下一篇 可以成为跳转目标 跳过,系列内阅读使用分篇目录
归档 展示 展示
本站站内搜索 收录 收录
原文链接 正常访问 正常访问

hidden 的含义由读取它的插件决定,不是 Hexo 核心对所有入口统一生效的隐私开关。本站搜索生成器读取完整文章集合,因此保留子文章;换成其他搜索插件,也要检查它自己的过滤规则。

例如 hexo-hide-posts 默认会从多个文章列表移除隐藏文章,并添加 noindex。它适合更广的隐藏需求。本站按入口分别筛选,保留归档与搜索的发现能力;published: false 属于未发布状态,也不能用来替代首页聚合。

为什么归档和正文可以保留

Hexo 用不同生成器处理首页、归档和文章页。生成器接收 locals 中的站点数据,再返回需要生成的路由和模板数据。官方 Generator 文档分别给出了列表分页和独立文章生成的例子。

首页生成器可以取得一个经过筛选的集合,再做排序和分页。只要不修改全局文章集合,其他生成器仍能读取完整文章。

首页生成器在自己的输入中筛选 hidden,归档、搜索和正文仍使用完整集合

因此过滤应该放在分页之前。假设每页三篇,前三篇恰好都是隐藏的子文章:先分页、再在模板里隐藏,第一页就会空掉,分页总数也仍按未筛选的数据计算。先过滤再分页,才能让首页条数与实际内容一致。

用 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 的一行过滤。这份文件和下面的图记录当时如何把差异纳入依赖安装。

编辑副本经 patch-commit 生成项目补丁,其他设备通过安装恢复相同修改

首页聚合接入时的验收

打开原文件 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
}