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

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