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.htmlcategory/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"
}
小结
物是人非