使用 Noutious 和 Nuxt 重构我的博客
去年的 4 月 13 号,我用 Nuxt 3 和 Nuxt Content v2 重构了我的博客、并稳定运行到了今年年末;今年年末,趁着终于有点动力、以及 Noutious 在 R2's thoughts 顺利工作,我用 Noutious 和 Nuxt 4 再次重构了我的博客。咳咳,由于本文之前写的过于简单,因此我在年末重新写了一版、希望能将重构博客中的大部分细节带给屏幕前的你。
你好,选 Nuxt 还是选 SvelteKit
R2's thoughts 是 gxres.net 域名下唯一一个使用 SvelteKit 驱动的网站项目。在 使用 Noutious 和 SvelteKit 构建一个船新的博客 中,我大致介绍了从 Noutious 的数据是如何从服务端传递至 Svelte 组件的。Nuxt 虽然没有一模一样的数据传递方法,但它自带了一个后端引擎「Nitro」。我可以在 Nitro 创建一系列 API Route、返回 Noutious 处理好的数据,并在组件中使用 useFetch() 请求这些 API Route、将数据拉下来处理。在全站预渲染的情况下,Nitro 也不会去渲染这些 API Route。
虽然 Nuxt 有实验性的组件岛屿(Component Island)和基于前者的服务端组件(Server Component),但截至本文初版写成、本版再次写成,组件岛屿仍然有几率在客户端上运行(原则上来讲仅在服务端运行)。因此,组件岛屿在未来稳定可用前,以 API Route 形式在 Nuxt 中使用 Noutious 仍然是最佳解。
Noutious 的初始化和使用
作为写给自己用的东西,我给 Noutious 做了许多懒人化功能。实际使用接触到的查询函数,只需要直接调用或是传入一点查询参数,就可以返回你想要的数据,不用再在函数后面跟一系列的查询操作。不过,想要使用 Noutious 仍需要先初始化一个实例。
实际的数据查询需要在 API Route 里实现,而在每一个 API Route 里初始化 Noutious 实例是不可能的,因此我们需要写一个 Util 供各个 API Route 使用:
// server/utils/noutious.ts
import { createNoutious } from 'noutious';
const noutious = createNoutious({
baseDir: './',
draft: process.env.NODE_ENV == 'development',
persist: process.env.NODE_ENV == 'production',
});
export async function useNoutious() {
return await noutious;
}
createNoutious() 会返回一个 Promise,useNoutious() 所 await 并返回的都是同一个 Promise,避免了 Noutious 实例的重复创建。
Nitro 会将 server/utils/ 下的所有 Utils 自动注册,因此你可以直接在 API Route 中使用 useNoutious():
// server/api/posts.post.ts
import type { PostsFilterOptions } from 'noutious/types';
export default defineEventHandler(async (event) => {
const body: PostsFilterOptions = await readBody(event);
const noutious = await useNoutious();
return await noutious.queryPosts(body);
});
// app/components/Theme/Post/List.vue
import type { PostSlim } from 'noutious/types';
const { data: postsData } = await useFetch<Record<string, PostSlim>>('/api/posts', {
method: 'POST',
body: { sort: { date: -1 } },
});
实际博客重构中,我需要用 noutious.queryPosts() 查询所有文章数据,以及所有归为某个分类或含有某个标签的文章数据。为此专门创建相应的 API Route 没有必要性,用 query params 在维护上又有点脑梗,那不如就用 POST 请求吧!在 useFetch() 的 body 中写好查询参数,API Route 读取 body 内容并传入给 noutious.queryPosts() 即可。
开发过程中,你只需要导入 noutious/types 暴露出的类型、给 useFetch() 传入泛型参数,便可轻松补全返回数据的实际类型。如果 Noutious 哪一天做大做强了,我或许会在文档上好好标注返回的数据的类型(
在 useFetch() 筛选你需要的数据
为了实现最大程度的自定义,Noutious 的 queryPosts() 从一开始的「主动帮你筛选需要的数据」转变为「返回所有文章数据,由用户自行发挥」,其中返回的数据就包含文章的主体内容和文件源内容。而构建文章列表时,我仅仅需要文章的标题、日期、分类和封面图。若不进行筛选,这些没有用到的数据会跟着一起堆在 Payload 里、拖慢加载速度。因此我们要在请求 API Route 时进行一点转换:
const { data: postsData } = await useFetch<Record<string, Post>>('/api/posts', {
method: 'POST',
body: { sort: { date: -1 } },
transform: (posts) => {
return Object.fromEntries(
Object.entries(posts).map(([id, post]) => [
id,
{
title: post.title,
date: post.date,
categories: post.categories,
excerpt: post.excerpt,
frontmatter: post.frontmatter,
} as Post,
])
);
},
});
Noutious 的文章数据类型是一个
Record<string, Post>,因此不能简单粗暴的使用useFetch()的pick功能。
使用 zfeed 生成 RSS
构建 R2's thoughts 时,我使用了 KazariEX 老师的 zfeed。本次重构也使用了 Noutious 和 zfeed,因此写法上其实大差不差:
// server/routes/atom.xml.ts
import { defineEventHandler, appendHeader } from 'h3';
import type { Post } from 'noutious/types';
import { createFeed, generateAtom1 } from 'zfeed';
export default defineEventHandler(async (event) => {
const { title, hostname, description, favicon, since, author } = useAppConfig();
const noutious = await useNoutious();
const postsRaw: Record<string, Post> = await noutious.queryPosts({
sort: { date: -1 },
limit: 5,
});
// createFeed() 需要传入一个接收类型为 string[] 的 items,因此这里需要转换一下
const posts = Object.entries(postsRaw).map(([slug, post]) => ({
id: `${hostname}/post/${slug}`,
title: post.title,
date: post.date,
link: `${hostname}/post/${slug}`,
description: post.excerpt,
publishedAt: new Date(post.date),
updatedAt: new Date(post.date),
content: `<p>${post.excerpt}</p><p>请访问 <a href="${hostname}/post/${slug}">${`${hostname}/post/${slug}`}</a> 以阅读全文。</p>`,
author: [{ name: author.name, email: author.email, link: hostname }],
}));
const feed = createFeed({
title: title,
id: `${hostname}/`,
description: description,
link: `${hostname}/`,
feed: { atom: `${hostname}/atom.xml` },
language: 'zh-CN',
generator: 'Noutious + Nuxt + zfeed',
image: favicon,
copyright: `Copyright © ${since} - ${new Date().getFullYear()} ${
title
}. All rights reserved.`,
author: { name: author.name, email: author.email, link: hostname },
items: posts,
});
// 这是很久以前加的,当时加了这个解决了乱码的问题,所以没有删......
appendHeader(event, 'Content-Type', 'application/atom+xml');
return generateAtom1(feed);
});
Markdown 的处理和渲染
最初重构博客的时候,我为了省时省力、使用了 @crazydos/vue-markdown 渲染文章内容。但是,它是一个 Runtime Markdown to VNode 方案、会增加不必要的 Client JS Bundle 大小;并且,就算忽略前一条的因素,它也仅仅只提供了 Markdown to VNode 的功能,我要是实现 TOC 仍然需要手动实现。
因此,我转向了重构前就在考虑、但最后没能实现的 Unified.js Ecosystem。阅读 Sukka 的 使用 Next.js + Hexo 重构我的博客 后,我在 Nitro 侧初始化 Unified、处理查询到的 Markdown 内容,并返回最终的 HTML AST:
// server/utils/pipeline.ts
import { unified } from 'unified';
import remarkParse from 'remark-parse';
import remarkGfm from 'remark-gfm';
import remarkRehype from 'remark-rehype';
import rehypeSlug from 'rehype-slug';
import rehypeShikiFromHighlighter from '@shikijs/rehype/core';
import { createHighlighterCore } from 'shiki/core';
import { createJavaScriptRegexEngine } from 'shiki/engine/javascript';
// Shiki 的默认导入是 Full Bundle、体积吓人是真吓人,因此手动创建一个
const shikiHighlighter = createHighlighterCore({
engine: createJavaScriptRegexEngine(),
themes: [import('@shikijs/themes/one-light'), import('@shikijs/themes/one-dark-pro')],
langs: [
import('@shikijs/langs/javascript'),
import('@shikijs/langs/typescript'),
import('@shikijs/langs/json'),
import('@shikijs/langs/html'),
import('@shikijs/langs/css'),
import('@shikijs/langs/vue'),
import('@shikijs/langs/markdown'),
import('@shikijs/langs/yaml'),
import('@shikijs/langs/svelte'),
import('@shikijs/langs/toml'),
import('@shikijs/langs/shell'),
],
});
// 导出一个 getPipeline() 供使用
export async function getPipeline() {
const highlighter = await shikiHighlighter;
const pipeline = unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkRehype)
// @ts-expect-error - 文档提供的写法居然也会报 TS Error,只能这么忽略一下先...
.use(rehypeShikiFromHighlighter, highlighter, {
themes: { light: 'one-light', dark: 'one-dark-pro' },
})
.use(rehypeSlug);
return pipeline;
}
随后在查询单个文章的 API Route 里调用 getPipeline(),并将 HTML AST 跟随文章数据一起返回。
// server/routes/post.post.ts
export default defineEventHandler(async (event) => {
const pipeline = await getPipeline();
const body: { slug: string; options?: { sort?: { date?: 1 | -1 } } } = await readBody(event);
const noutious = await useNoutious();
const post = await noutious.queryPost(body.slug, body.options);
// unified().parse() 只渲染 Markdown AST...
const result = pipeline.parse(post.content);
return {
data: post,
// 要生成 HTML AST,需要再调用一次 pipeline.runSync(),并传入生成的 Markdown AST
astTree: pipeline.runSync(result),
};
});
有了 HTML AST,我用 Vue.js 的 h() 函数实现了 Markdown to VNode 的渲染组件,同时实现了一个仅在组件挂载时才启动高亮的 TOC。具体的代码就不放出来了,感兴趣读者可以自行研究一下。