合者离之始
Record:001

把 Hexo 主题迁到 Astro:一次完整重构记录

从 Hexo + Pug + node-sass 迁到 Astro 7 + TypeScript 的完整过程:构建链、模板、内容模型、以及那些踩过的坑。

Posted2026-08-14Words2103Reading5 minUpdated2026-08-20

这个博客最早是一个 Hexo 主题,Pug 模板、node-sass 编译、jQuery 驱动。它很好看,但构建链已经跑不动了:node-sass@8 在 Node 20 以上直接装不上,hexo-renderer-pug 五年没有更新,评论区用的多说早在 2017 年就关停了。

于是我把它整个搬到了 Astro。

为什么是 Astro

选型时我比较了四个方案:

方案优点代价
留在 Hexo迁移成本最低构建链需要自己兜底,插件生态老化
Eleventy官方 Pug 插件,模板几乎原样迁移分页、分类、RSS 要自己拼
Astro生态最活跃,SCSS/TS 一等公民,内容集合有类型模板要改写
Hugo构建最快Go 模板,视觉还原成本高

最后选 Astro,核心原因是内容集合带类型:frontmatter 写错字段,构建时就会报错,而不是上线后才发现某篇文章没有日期。

内容模型

src/content.config.ts 里定义两个集合。用 glob loader 读取 src/content/posts/:

import { defineCollection } from 'astro:content';
import { z } from 'astro/zod';
import { glob } from 'astro/loaders';
 
const posts = defineCollection({
  loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/posts' }),
  schema: z.object({
    title: z.string(),
    date: z.coerce.date(),
    updated: z.coerce.date().optional(),
    desc: z.string().optional(),
    tags: z.array(z.string()).optional().default([]),
    draft: z.boolean().optional().default(false),
    toc: z.boolean().optional().default(false),
  }),
});

几个细节值得说:

  • z.coerce.date() 让 2026-08-14 这种字符串自动变成 Date,不必在 frontmatter 里写 ISO 时间。
  • draft: true 的文章在 getSortedPosts() 里被过滤,不会进列表、RSS 和站点地图。
  • 标签是数组,不做层级——分类和标签在结构上是重复的,我只要一个。

模板迁移

Pug 的 extends / block 对应 Astro 的布局组件和 <slot />。真正需要重写的是数据获取方式:

---
import { getSortedPosts } from '../lib/posts';
const posts = await getSortedPosts();
---
 
<ul>
  {posts.map((post) => (
    <li><a href={`/posts/${toSlug(post.id)}/`}>{post.data.title}</a></li>
  ))}
</ul>

有三处最容易出错:

  1. 分页路由必须带参数。Astro 7 的 paginate() 要求文件名叫 [...page].astro 或 [page].astro,写成 index.astro 会直接报错。
  2. slug 不可逆。post.id 是文件名,转成 URL 之后没法反推,所以详情页用 getStaticPaths 把完整对象放进 props。
  3. 客户端路由会重复绑定。脚本必须挂在 astro:page-load 上,并且用单例清理旧监听器。

构建链

原来 npm run build 是 node-sass,现在是 astro build,SCSS 直接由 Astro 编译。字体也从 Google Fonts 外链改成了自托管子集:

@font-face {
  font-family: "Manrope";
  font-weight: 400;
  font-display: swap;
  src: url("/fonts/manrope-latin-400-normal.woff2") format("woff2");
  unicode-range: U+0000-00FF, U+0131, U+0152-0153;
}

中文不加载 webfont,全部走系统字体栈。这一个决定把字体体积从几 MB 压到了 100KB 以内。

结果

  • 构建:一次 astro build 完成 33 个页面,约 0.5 秒
  • 首屏:1 个 CSS + 2 个小 chunk,没有框架运行时
  • 类型:astro check 零错误
  • 旧版本:完整保留在 git tag 里,随时可以 git checkout 回去对照

重构最花时间的不是写代码,而是搞清楚原来那行代码为什么那么写。Pug 模板里每一处奇怪的写法背后都有一个当年的决定,只有读懂它,才敢删掉它。