使用 Noutious 和 Nuxt 重构我的博客

· 技术 · 约 2714 字

去年的 4 月 13 号,我用 Nuxt 3 和 Nuxt Content v2 重构了我的博客、并稳定运行到了今年年末;今年年末,趁着终于有点动力、以及 Noutious 在 R2's thoughts 顺利工作,我用 Noutious 和 Nuxt 4 再次重构了我的博客。咳咳,由于本文之前写的过于简单,因此我在年末重新写了一版、希望能将重构博客中的大部分细节带给屏幕前的你。

你好,选 Nuxt 还是选 SvelteKit

R2's thoughtsgxres.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。具体的代码就不放出来了,感兴趣读者可以自行研究一下。

喜欢的话,投喂亿下孩子吧(逃)
爱发电