缩略图

使用 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() 函数,供我们查询、获取文章数据并用在这些地方:

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

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

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

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

配合 useAsyncData() 一起使用

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

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

筛选出需要的数据

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

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

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、抄过来也用不了,但其原理大致相同,所以我们可以这样做:

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

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

获取上一篇 / 下一篇文章

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

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 的文章详情页便使用到了它们。

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

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

<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 遍历一下就可以了:

<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。它很方便的实现了我们上面所需要的所有需求,并且也不会出现原本方案的这个重大缺点。

配置

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

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 的值即可。下面是一个例子:

<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 里添加它即可。不过,我们需要多配置一个东西:

export default defineNuxtConfig({
    /* 你的其它配置 */
    site: {
        url: "https://blog.gxres.net"
    }
})

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

sitemap: {
    xsl: false
}

Schema.org

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

  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

pnpm add feed

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

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 配置路径预渲染即可:

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

尾声

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

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

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

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

这篇文章已发布 211 天
文章内所描述的内容可能已发生改变,请谨慎参考。
Loading Artalk...