迁移 Astro 踩坑记录

迁移 Astro 踩坑记录

Why Astro?

这个想法来源于我某次试图给博客增加一个功能时,无奈发现代码太老,根本改不动(x)。

之前 Hexo 等框架大火的时候也没有试图迁移,主要是觉得迁移没办法对齐功能,如文章标签等功能可能会丢失。无意间发现朋友也用 Astro,遂阅读其文章,发现其灵活性很强,也许可以满足我的需求,于是决定试一下。

功能梳理

文章

文章自然是最基本的功能,Astro 理所应当也是支持的。在初始化出来的模板里,直接就有 blog 的示例。参照其修改,主要是对齐数据格式。例如,我原先的数据库结构为:

CREATE TABLE `#@__article` (
  `id` bigint(10) unsigned NOT NULL AUTO_INCREMENT,
  `title` varchar(255) DEFAULT NULL,
  `tags` varchar(255) DEFAULT NULL,
  `body` longtext,
  `publish` int(10) unsigned DEFAULT NULL,
  `modify` int(10) unsigned DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `publish` (`publish`),
  KEY `modify` (`modify`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;

那么,在 Astro 中,我需要定义这样的数据格式:

const archives = defineCollection({
	loader: glob({ base: './src/content/archive', pattern: '**/*.{md,mdx}' }),
	schema: () =>
		z.object({
			title: z.string(),
			tags: z.array(z.string()),
			publish: z.number().transform(v => new Date(v * 1000)),
			modify: z.number().transform(v => new Date(v * 1000)),
		}),
});

文章页面的模板中,就可以直接引用相关数据了:

type Props = CollectionEntry<"archives">;

const post = Astro.props;
const { title, modify, tags } = post.data;

// 部分模板示例
<h3>{title}</h3>
<span>{dayjs(modify).format("YYYY-MM-DD HH:mm")}</span>

标签/分类页面的生成

这些页面并不是直接来自于某一独立数据的页面,而是根据文章元信息(如标签)生成的页面。因此,Astro 也无法自动处理,需要自行编写一些逻辑。

例如,我的标签、分类页面有两种 URL:

  • tag/[tag-name]/[page].html:标签中文名对应的页面,例如 tag/前端/1.html
  • category/id/[id]/[page].html:分类 ID 对应的页面,例如 category/id/123/1.html

那么,我需要新建两个文件,分别是:

+ pages
  + tag
    + [tag-name]
      - [page].html
  + category
    + id
      + [id]
        - [page].html

两个页面的逻辑基本一样,拿标签页面为例,核心逻辑是通过 getStaticPaths 生成所有的标签页面路径:

export async function getStaticPaths() {
  const posts = await getCollection("archives");
  const allPaths: any = [];

  const tags: Record<string, string[]> = {};

  // 遍历所有文章,提取标签,并建立 标签-> 文章ID 的映射关系
  posts.forEach((post) =>
    post.data.tags.forEach((tag) => {
      if (!tags[tag]) {
        tags[tag] = [];
      }
      tags[tag].push(post.id);
    })
  );

  // 遍历所有标签,生成标签页面路径
  Object.keys(tags).forEach((tag) => {
    const totalPages = Math.ceil(tags[tag].length / PAGE_SIZE);
    for (let page = 1; page <= totalPages; page++) {
      allPaths.push({
        params: {
          name: tag,
          page: page.toString(),
        },
      });
    }
  });

  return allPaths;
}

getStaticPaths 应该返回一个数组,每个元素必须包含 params 属性,属性名称和文件路径中的占位符名称一致。可以包含 props 属性,该属性可以在渲染页面时直接通过 Astro.props 获取。

文章URL

原有的 URL 是通过 ID 拼接而来,如 /archives/123.html。这个 ID 是 MySQL 自增生成的。但我切换至 Astro 后,自然就不存在这个逻辑了。我既要让原有的文章 URL 不要变化(因为会导致评论丢失、链接失效),又要让新文章 URL 不那么“固化”(总不能我每次都手动生成一个 ID 吧)。

经过查询文档,Astro 提供了 slug 属性,可以指定 URL 的路径。因此,对于旧文章,我们可以把 ID 写入到 slug,新文章则正常写入自定义的 slug。例如:

---
slug: "354"
---

数据转换

接下来要把数据从 MySQL 中提取出来,并转换成 markdown,存放到 md 文件中。首先用 phpMyAdmin 将整个表导出为 JSON 格式,并删掉多余的头尾备用。

然后,用 JS 脚本将数据转换成 markdown 文件。其中,数据库中的文章内容是 html 格式,需要转换成 markdown 格式。我尝试了几个库,最后发现 html-to-md 的效果更好一点。

const html2md = require('html-to-md');
const fs = require('fs/promises');
const path = require('path');

async function main() {
  const posts = require('./posts.json');

  for (const post of posts) {
    const md = html2md(post.body);
    const meta = [
      `slug: "${post.id}"`,
      `title: ${post.title}`,
      `tags: ${JSON.stringify(post.tags.split(','))}`,
      `publish: ${post.publish}`,
      `modify: ${post.modify}`,
    ];
    await fs.writeFile(path.join(__dirname, `archive/${post.title}.md`), `---\n${meta.join('\n')}\n---\n${md}`);
  }
}

main();

视图过渡

Astro 最新的视图过渡是通过 ClientRouter 技术实现的,原理和 SPA 应用的本地路由相同。因此,除了在页面中引入 ClientRouter 组件外,还需要处理原有的 JS 初始化逻辑。例如,对于 LiveRe 评论系统,需要处理如下:

// 原有代码
(() => {
  const el = document.getElementById("lv-container");
  if (el) {
    loadScript("https://cdn-city.livere.com/js/embed.dist.js");
  }
})();

// 新代码
(() => {
  function initLiveRe() {
    const el = document.getElementById("lv-container");
    if (el) {
      // 需要考虑 js 已经完成加载的情况,直接调用 LivereTower 的 API 进行初始化
      if (window.LivereTower) {
        if (!el.querySelector("iframe")) {
          window.LivereTower.init();
        }
        return;
      }
      // 第一次加载 js
      loadScript("https://cdn-city.livere.com/js/embed.dist.js", () => {
        const e = document.getElementById("lv-container");
        // 页面改变,原来的元素已经移除了,所以不要再尝试初始化
        if (e !== el) {
          return;
        }
        if (!e.querySelector("iframe")) {
          window.LivereTower.init();
        }
      });
    }
  }

  document.addEventListener("astro:page-load", () => {
    initLiveRe();
  });
})();

另外,页面中嵌入的 JS 也只会运行一次,即使移除后重新进入页面(非刷新),也不会再次运行。因此,对于需要多次运行的 JS,需要监听 astro:page-load 事件自行处理。

其他杂杂的小优化

代码块高亮

Astro 默认使用 Shiki 渲染代码块。我也尝试了 Prism,但感觉效果也一般,那就给 Shiki 换个主题吧。在这里挑一个主题后,配置到 astro.config.mjs 中:

export default defineConfig({
  markdown: {
    shikiConfig: {
      theme: 'one-light',
    },
  }
});

打包文件名称

默认输出名称很长很丑,类似 _astro/index.astro_astro_type_script_index_0_lang.xxxxxx.js 这样一串,我考虑了一下决定只保留 hash 部分。

需要注意的是,我的博客中没有直接内联图片,所以 assetFileNames 可以直接这么写。如果有的话,需要写成函数,根据扩展名返回不同格式。

export default defineConfig({
  vite: {
    build: {
      rollupOptions: {
        output: {
          hashCharacters: 'base36',
          entryFileNames: 'assets/[hash:12].js',
          assetFileNames: 'assets/[hash:12][extname]'
        },
      },
    }
  },
});

EdgeOne 部署

在根目录下放置 edgeone.json 文件:

{
  "buildCommand": "npm run build",
  "installCommand": "pnpm install --frozen-lockfile",
  "outputDirectory": "dist",
  "nodeVersion": "22.11.0"
}

小结

物是人非