使用 Noutious 和 Nuxt 重构我的博客

· 技术 · 约 1873 字

去年的 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 了。这一块比较见仁见智,就不贴出我自己的代码了(

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