使用 Noutious 和 Nuxt 重构我的博客
去年的 4 月 13 号,我用 Nuxt 3 和 Nuxt Content v2 重构了我的博客、并稳定运行到了今年年末;今年年末,趁着终于有点动力、以及 Noutious 在 R2's thoughts 顺利工作,我用 Noutious 和 Nuxt 4 再次重构了我的博客。本文讲述一些比较个人认为新鲜的东西。
还要继续用 Nuxt 吗?
因为是我自造自用的轮子,Noutious 需要贴合我的博客使用需求。然而,和 Next.js (Pages Router) 和 SvelteKit 不同,Nuxt 需要在客户端通过 useFetch() 从服务端(Nitro)拉取数据下来,useAsyncData() 和 Nuxt Component Islands 不能使调用任何依赖 Node.js API 的函数、因为它们很可能会在客户端上运行一次。因此,在风平浪静的这几个月里,我一直在尝试修改 Noutious、让它的 API 能在 Vue 组件中直接使用,甚至想过要不要换个框架。
想了很久,反正我是全站预渲染,用 useFetch() 就用吧,框架换起来实在太麻烦。尝试了之后发现 useFetch() 效果令我惊喜、全站预渲染下 Nitro 也不会生成 /api 路径,再加上我导出 Noutious 的类型文件、可以在 useFetch() 上签名,让我终于能够安心留在 Nuxt 大本营里。
通过 API Routes 从 Noutious 获取数据
为了调用所有文章数据、单个文章数据、所有分类和所有标签的数据,我需要创建多个 API Routes,在里面一一初始化 Noutious 是不可能的。这里用到了 Nitro 的 Server Utils:
// 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
}
这里的用法是从 Shiki 文档里学来的 Singleton Pattern。
Nitro 会自动导入所有的 Server Utils,可以直接在 API Routes 里调用它:
// 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 },
},
})
请求所有文章数据(queryPosts())或是单个文章数据(queryPost())需要传入参数以便筛选。我这里创建了仅接受 POST 请求的 API Route,客户端组件在 useFetch() 里的 body 指定查询参数,API Route 里使用 readBody() 接收即可。
使用 zfeed 生成 RSS
在 构建 R2's thoughts 时,我使用了 KazariEX 老师的 zfeed。本次重构也是使用 Noutious 管理我的博客内容,因此写法上其实大差不差:
// 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)
})
不要忘了去 nuxt.config.ts 里的 nitro.prerender.routes 里添加 /atom.xml,即使你是全站预渲染(
哦,如何渲染 Markdown 成 VNode 呢
在最初的重构时,我用了 @crazydos/vue-markdown 将 Markdown 内容转换为 VNode。但是,问题很快就出现了:
- 单单依靠这个的话,我想处理 TOC 就有一点麻烦了;
- 这玩意貌似是运行时编译,导致我的 Client JS bundle 增大......
严肃阅读 使用 Next.js + Hexo 重构我的博客 (by Sukka) 后,我决定在 Nitro 侧预先将 Markdown 内容转换为 HTML AST,再在客户端拉取并渲染成 VNode。
这里我用了之前就馋了很久的 Unified Ecosystem 来完成 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'
// 避免完整打包,所以我用上了 Fine-Grained Bundle
const shikiHighlighter = createHighlighterCore({
engine: createJavaScriptRegexEngine(),
themes: [
import('@shikijs/themes/material-theme-ocean'),
],
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 - Shiki types are not fully compatible with rehype plugin interface
.use(rehypeShikiFromHighlighter, highlighter, {
theme: 'material-theme-ocean',
})
.use(rehypeSlug)
return pipeline
}
上文提到,Nitro 会自动导入所有的 Server Utils,可以直接在 API Routes 里直接使用 getPipeline():
// 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() 只渲染 MDAST...
const result = pipeline.parse(post.content)
return {
data: post,
// 要生成 HTML AST,需要再调用一次 pipeline.runSync(),并传入生成的 MDAST
astTree: pipeline.runSync(result),
}
})
客户端组件用 useFetch() 拉取 /api/post 的内容后,就可以拿着 HTML AST 去渲染 VNode 并处理 TOC 了。这一块比较见仁见智,就不贴出我自己的代码了(