使用 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。我是使用了 NuxtSEO 的 Robots
, Schema.org
和 Sitemap
三个组件。
Robots
由于我的网站没有什么内容是不需要或是不愿意被收录的,因此我只需要 pnpm add -D nuxt-simple-robots
并在 nuxt.config.ts
的 modules
组里添加它就大功告成了。
Sitemap
Sitemap 和 Robots 同理,pnpm add -D @nuxtjs/sitemap
并在 nuxt.config.ts
的 modules
里添加它即可。不过,我们需要多配置一个东西:
export default defineNuxtConfig({
/* 你的其它配置 */
site: {
url: "https://blog.gxres.net"
}
})
我个人不太喜欢它默认的 UI,所以通过下述配置禁用了:
sitemap: {
xsl: false
}
Schema.org
还是一样的 pnpm add -D nuxt-schema-org
,还是一样的 nuxt.config.ts
的 modules
添加它,然后直接上相关配置好了:
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-feed
和 nuxt-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 博客源码,再加上这篇教程,我的博客应该也算是某种意义上的「开源」了吧。