使用 Nuxt 重构我的博客

使用 Nuxt 重构我的博客

2024年4月13日


在 Valaxy 博客优化到瓶颈、新的需求 Valaxy 也无法满足的情况下,是时候走回原点、用最开始所用的 Nuxt 重构博客了,顺便借助 Nuxt 的强大性及其丰富生态完善博客的功能。

其实,我在框架的选择上磨了很久...

这会刚从 Valaxy 走出,按理来讲可选的框架其实很多。这里顺手抓了几个没选到的、说说为什么不选他们:

Next.js

想必 Next.js 各位肯定并不陌生,这里就不过多介绍。

Next.js 算是我从手搓 HTML 到前端框架的大门,但它属于 React 框架,而 React 我至今为止还是学不明白(跟个人的 JavaScript / TypeScript 知识有关吧),最后放弃。

VitePress

VitePress 是一个目前基于 Vue.js 的静态站点生成器,它也为博客提供了一些支持。

不过 VitePress 强大性远不如 Nuxt,并且在博客重构到 Valaxy 之前我就是用 VitePress 驱动我的博客,想必新的需求 VitePress 也不能满足。

Astro

Astro 是一个现在很流行的、以内容为中心的站点生成器,它的碎片化也是比较有意思的。

但阅读官方文档后,我个人觉得要适应 Astro 比较费时间,最终也放弃了.jpg

内容管理:Nuxt Content

终于进入到正题了,那么先来讲一下博客最重要的一个部分:内容管理。

Nuxt 并不以内容为中心,所以在初始化的 Nuxt 项目里并没有内容管理这一部分。不过,Nuxt 官方出了一个针对内容管理这方面的组件:Nuxt Content,我们就用它来管理我们的内容吧。

Nuxt Content 的安装其实非常简单,这里就不过多赘述,接下来就讲一下如何从 Nuxt Content 获取内容并使用。

通过 queryContent() 获取文章数据

在 Restent's Notebook 里,首页文章列表、近期文章、归档页、分类页和标签页都需要获取文章数据。Nuxt Content 给予了一个 queryContent() 函数,供我们查询、获取文章数据并用在这些地方:

typescript
const posts = await queryContent("/").find();

Nuxt Content 的根目录是 content,任何函数或组件都会基于这个目录进行。假设你的文章放在了 content/posts,那么你应当写成 queryContent('posts')

同时,如果你需要按从新到旧排序文章的话,你只要在 queryContent() 后、.find() 前添加一个 .sort({ date: -1 }) 即可:

typescript
const posts = await queryContent("/").sort({ date: -1 }).find();

配合 useAsyncData() 一起使用

为防止重复获取数据,queryContent() 会配合 useAsyncData() 一起使用:

typescript
const { data } = await useAsyncData("posts", () => queryContent("/").find());

筛选出需要的数据

我打个赌:queryContent() 获取到的数据,你不可能每个页面都用的完。并且,如果配合上面提到的 useAsyncData() 一起使用的话,因为 useAsyncData() 获取到的数据会叠到 Nuxt payload 里,从而让你的 payload 大小直线起飞。

因此,我们就需要限制 queryContent() 输出特定的内容。这样做也并不复杂,和上面的 .sort() 一样的方式加个 .only() 即可:

typescript
const { data } = await useAsyncData("posts", () =>
  queryContent("/").only(["_path", "title"]).find()
);

这里用 .only() 返回了所有文章的标题和路径。除了路径 key 默认是 _path、文章详情默认是 body 外,其它你可以根据文章的 Front Matter 项来获取。

获取特定分类 / 标签的文章

当初制作 分类 / 标签 功能的时候,我通过搜索引擎找到了一个 2020 年的 GitHub Issue。这个 Issue 大概就是请求 Nuxt Content 增加「以分类或标签筛选文章」的特性,而 Nuxt Content 已经有了这个特性:用 .where() 过滤即可。

在 Issue 里,Benjamin Canac 给出了一个简单的示例。虽然它貌似仅适用于 Nuxt Content 1、抄过来也用不了,但其原理大致相同,所以我们可以这样做:

typescript
const filteredPosts = await queryContent("/")
  .where({ tags: { $containsAny: ["Android", "Bulma"] } })
  .find();

这样在输出的时候,Nuxt Content 就会筛选 Front Matter 中含有「Android」或「Bulma」标签的文章并输出。不过,我的分类 / 标签页只需要按一个值进行筛选,所以用 $contains 也是可以的。

获取上一篇 / 下一篇文章

(应该是)大部分博客的每一篇文章的末尾,都会提供上一篇和下一篇文章的跳转链接(按钮)。你可以将 queryContent() 后面的 .find() 换成 .findSurround() 以获取对应数据并生成跳转链接。

typescript
const { path } = useRoute();
const [prev, next] = await queryContent()
  .only(["_path", "title"])
  .sort({ date: -1 })
  .findSurround(path);

因为 .findSurround() 必须要提供一个 path,故这里通过 Vue Router 的 useRoute() 获取当前路径(path)喂给 findSurround()。我顺便用 .sort() 从新到旧排序后再获取数据。

通过 <ContentDoc /><ContentRenderer /> 渲染文章详情页

Nuxt Content 也提供了两个好用的组件:<ContentDoc /><ContentRenderer />,Restent's Notebook 的文章详情页便使用到了它们。

PostDetails.vue · vue
<template>
  <ContentDoc v-slot="{ doc }">
    <article>
      <h1>{{ doc.title }}</h1>
      <ContentRenderer :value="doc" />
    </article>
  </ContentDoc>
</template>

如果你的 Markdown 中没有任何内容(除了 Front Matter),ContentRenderer 就会出现一行默认提示。而如果你觉得它很丑的话,也是有办法可以改掉它的:增加一个 <template #not-found /> 组件:

PostDetails.vue · vue
<template>
  <ContentDoc>
    <template v-slot="{ doc }">
      <article>
        <h1>{{ doc.title }}</h1>
        <ContentRenderer :value="doc" />
      </article>
    </template>
    <template #not-found>
      <h1>Document not found</h1>
    </template>
  </ContentDoc>
</template>

获取文章目录

博客初成型时,文章目录并没有添加回来,而我一直在为此寻找方法。直到我在 GitHub 上找到了一个相关的 Issue(链接不明),我才知道可以通过 body.toc.links 获取。按上面的示例的话,便是 doc.body.toc.links

doc.body.toc.links 会返回一个组,我们只要用 v-for 遍历一下就可以了:

TOC.vue · vue
<template>
  <template v-for="link in props.links" :key="link.id">
    <li>
      <NuxtLink :to="`#${link.id}`">{{ link.text }}</NuxtLink>
      <ul v-if="link.children">
        <template v-for="child in link.children" :key="child.id">
          <li>
            <NuxtLink :to="`#${child.id}`">{{ child.text }}</NuxtLink>
          </li>
        </template>
      </ul>
    </li>
  </template>
</template>

doc.body.toc.links 返回的组里,第一层是 H2,而第二层是 H3,H3 便是在 H2 下层包成了 children,所以就需要这么写(我在胡言乱语什么.jpg)。

深浅色模式:@nuxtjs/color-mode

二月底,我得到了 Bulma 1.0 的内测、体验到了 Bulma 全新的深色模式。Bulma 1.0 深色模式根据 HTML DOM 上是否存在 class="theme-dark"data-theme="dark" 而启用或禁用,当时我的做法便是利用 onMounted() 写了一套切换逻辑,并调用 localStorage 存储访客选择的模式。

不过这个方案缺点很大:因为 onMounted() 要在网页加载完成之后才会工作,所以访客访问我的博客时,如果此时他主动保持深色模式、或是他的设备处在深色模式,那么他会先看到浅色模式的博客界面,随后页面加载完成、闪成了深色模式的博客界面。

不过,Nuxt 社区出了一个针对这方面的模块:@nuxtjs/color-mode。它很方便的实现了我们上面所需要的所有需求,并且也不会出现原本方案的这个重大缺点。

配置

模块的安装我也不过多赘述,这里就贴一个我自己的配置:

nuxt.config.ts · typescript
export default defineNuxtConfig({
  /* 你的其它配置 */
  colorMode: {
    preference: "system",
    fallback: "light",
    classPrefix: "theme-",
    classSuffix: "",
    storageKey: "nuxt-color-mode",
  },
  /* 你的其它配置 */
});

注意,classSuffix 一定要留一个空值,不然它会自动给你加一个 -mode 的 Suffix。

这里我设定了模式偏好为「跟随系统」,如果浏览器实在检测不到偏好值,就会设定 fallback 里设定的浅色模式。

在组件中使用

@nuxtjs/color-mode 的介绍页的 usage 是一个 select,但你只要更改 $colorMode.preference 的值即可。下面是一个例子:

modeSelector.vue · vue
<script setup lang="ts">
const colorMode = useColorMode();
</script>

<template>
  <button @click="colorMode.preference = 'light'">浅色模式</button> //
  其它模式同理
</template>

Nuxt SEO 的优化(?)

常见的博客都会具备 Sitemap 和 robots.txt 以优化站点的 SEO。我是使用了 NuxtSEORobots, Schema.orgSitemap 三个组件。

Robots

由于我的网站没有什么内容是不需要或是不愿意被收录的,因此我只需要 pnpm add -D nuxt-simple-robots 并在 nuxt.config.tsmodules 组里添加它就大功告成了。

Sitemap

Sitemap 和 Robots 同理,pnpm add -D @nuxtjs/sitemap 并在 nuxt.config.tsmodules 里添加它即可。不过,我们需要多配置一个东西:

nuxt.config.ts · typescript
export default defineNuxtConfig({
  /* 你的其它配置 */
  site: {
    url: "https://blog.gxres.net",
  },
});

我个人不太喜欢它默认的 UI,所以通过下述配置禁用了:

typescript
sitemap: {
  xsl: false;
}

Schema.org

还是一样的 pnpm add -D nuxt-schema-org,还是一样的 nuxt.config.tsmodules 添加它,然后直接上相关配置好了:

typescript
  schemaOrg: {
    identity: {
      type: "Person",
      name: "Restent's Notebook",
      url: "https://blog.gxres.net",
      logo: "https://library.gxres.net/images/icons/favicon.webp",
    },
  },

如果你不知道 Identity 写什么,那就直接写 Person。

生成 Atom RSS

虽然 Nuxt 目前有 nuxt-module-feednuxt-feedme 两个模组可用于生成 RSS,但是它们的效果我并不是很满意。所以,我参考了 BlogiNote 的 RSS 配置方法, 通过 feed 为我的博客添加 Atom RSS。

首先来安装一下 feed

shell
pnpm add feed

接下来,在 server 目录下创建 routes/atom.xml.ts,写入以下内容:

atom.xml.ts · typescript
import { Feed } from "feed";
import { defineEventHandler, appendHeader } from "h3";
import { serverQueryContent } from "#content/server";

export default defineEventHandler(async (event) => {
  const appConfig = useAppConfig();
  const feed = new Feed({
    id: `${appConfig.url}/`,
    title: appConfig.title,
    description: appConfig.description,
    link: `${appConfig.url}/`,
    generator: "Nuxt 3 + Nuxt Content 2 + Feed",
    feedLinks: {
      atom: `${appConfig.url}/atom.xml`,
    },
    image: appConfig.favicon,
    copyright: `版权信息`,
    author: {
      name: appConfig.author.name,
      link: appConfig.url,
    },
  });
  const docs = await serverQueryContent(event)
    .sort({ date: -1 })
    .limit(20)
    .find();
  for (const post of docs) {
    let postDate = new Date();

    if (post.updated) {
      postDate = new Date(post.updated);
    } else if (post.created) {
      postDate = new Date(post.created);
    }
    feed.addItem({
      id: `${appConfig.url}${post._path}`,
      title: post.title as string,
      link: `${appConfig.url}${post._path}`,
      description: post.description,
      content: `你想写入的内容,支持 HTML`,
      date: postDate,
      author: [
        {
          name: appConfig.author.name,
          email: appConfig.author.email,
          link: appConfig.url,
        },
      ],
      image: post.banner,
    });
  }
  appendHeader(event, "Content-Type", "application/atom+xml");
  return feed.atom1();
});

这里是根据我的博客的情况来填写 Feed 的信息,并使用了 serverQueryContent 从 Nuxt Content 获取文章信息并添加到 RSS 组。最后回到 nuxt.config.ts 配置路径预渲染即可:

typescript
  nitro: {
    prerender: {
      routes: ["/atom.xml"],
    },
  },

尾声

兜兜转转写到末尾,大概率把整个重构的要点都写在这篇文章里了。而纵观这篇文章,其实没有多少技术力,而是依靠 Nuxt 的强大性和其生态来达成的。

又想到在某个群里,我 diss 一个人的教程都写不好,而另一个人出来反驳我「学习不是喂饭」。而我认为的学习是什么呢?应当是参照官方文档和其他人给出的经验,并在自己的项目上实践;而喂饭又是什么呢?应该是从头到尾抄配置、跑不起来去骂那些提供经验的人甚至是官方文档的编写者的情况吧。

我不确定我的这篇文章能够给其它人带来多大的学习价值,但我希望这篇文章至少能够给你提供一点点辅助、让你在实践的路上能够走得舒坦(?)。

我的博客并不开源,后面也没有开源的想法。但我开源了 先前的 Valaxy 博客源码,再加上这篇教程,我的博客应该也算是某种意义上的「开源」了吧。

喜欢这篇文章?可否考虑一下打赏我呢?:P

爱发电