最近在学习Nuxt,所以就想着把博客重构一下,学习一下Nuxt的使用。
目标是将博客系统的前端部分重构为Nuxt.js应用,包括文章列表、展示内容功能。
技术栈选型
这半年以来我与团队一齐开发了泡泡树洞项目,项目中我主要负责后端开发与CICD等运维工作,前端部分则由团队的前端工程师负责。在这个过程中,我对Nuxt.js以及其生态有一些了解。因此,我决定使用Nuxt.js重构我的博客,提升博客质量的同时也加深对Nuxt的理解。
可以说这是我第一次尝试使用Nuxt.js开发项目,我希望通过这次重构,更深入地了解Nuxt.js的特性与优势,同时也希望能够为其他对Nuxt感兴趣的开发者提供一些参考。
下面是我在重构博客过程中选用的技术栈:
1- 依赖管理:pnpm
2- 前端框架:Vue.js
3- 服务端渲染与静态站点生成框架:Nuxt.js
4- 内容管理:@nuxt/content
5- CSS 框架:Tailwind CSS (含 Typography 插件)
6- UI 组件库:DaisyUI
7- 图标库:nuxt-icon
作为博客项目,其核心是内容管理与展示,因此我选择了@nuxt/content作为内容管理系统,以便更方便地管理博客文章、分类、标签等静态内容。同时,为了提升博客的视觉效果与用户体验,我选用了Tailwind CSS与DaisyUI,以及nuxt-icon提供的图标库。
技术栈安装与配置
安装Node.js与pnpm:
1# https://github.com/nvm-sh/nvm
2curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
3nvm use 20
4# https://pnpm.io/
5npm install -g pnpm
初始化Nuxt项目:
1pnpm dlx nuxi@latest init nuxt-cunoe-blog
2cd nuxt-cunoe-blog
在这里遇到了本次重构第一个问题
1ERROR Error: Failed to download template from registry: Failed to download https://raw.githubusercontent.com/nuxt/starter/templates/templates/v3.json: TypeError: fetch failed
我意识到是因为我在国内,无法访问GitHub的原因,如果在 WSL 中我会直接使用 proxychains 来解决这个问题,但是我现在是在 Mac 环境下,由于 Proxychains 无法在 Mac 上使用,故我选择直接找到了 Nuxt 的模板地址,然后直接下载了模板文件。
1curl -o- https://raw.githubusercontent.com/nuxt/starter/main/templates/v3.json > v3.json
2# v3.json 中有一个字段是 "tar" ,这个字段的值就是模板的地址
3wget https://codeload.github.com/nuxt/starter/tar.gz/refs/heads/v3 -O nuxt-starter-v3.tar.gz
4tar -xzf nuxt-starter-v3.tar.gz
5mv nuxt-starter nuxt-cunoe-blog
6cd nuxt-cunoe-blog
7# 安装依赖,初始化项目
8pnpm i
9pnpm dev
这样就完成了项目的初始化,后续的安装与配置过程就比较顺利了,详情可以参考 Nuxt 以及各技术栈的官方文档。
内容迁移策略
我之前的Blog是基于Hexo搭建的,Hexo是一个静态博客框架,它使用Markdown文件来管理文章内容,这与Nuxt的@nuxt/content插件的使用方式有一定的相似性,因此我将文章从Hexo直接复制到Nuxt的content目录下,并稍作调整,即可完成内容迁移。
在我原来的Blog中,文章 Header 部分是这样的:
1// 2018.md
2---
3title: 2018年度总结
4date: 2018-12-31 09:00:00
5urlname: 2018
6categories:
7- 笔记
8tags:
9- 总结
10---
@nuxt/content会自动解析文件头,并转换为对象
在项目中可以使用@nuxt/content插件来获取文章数据,例如:
1<!-- ./pages/blogs/[urlname].vue -->
2<script setup>
3const path = useRoute()
4const { data: doc } = await useAsyncData('blog-data', () => queryContent(path).findOne())
5</script>
即可获取到文章的数据,然后在页面中展示。
在页面中展示时,可以使用ContentRenderer组件来渲染文章内容,例如:
1<!-- ./pages/blogs/[urlname].vue -->
2<template>
3 <div>
4 <div class="container max-w-3xl mx-auto">
5 <ContentDoc v-slot="{ doc }">
6 <h2 class="my-4 text-4xl font-semibold">{{ doc.title }}</h2>
7 <p class="my-4 text-gray-500">
8 by CUNOE, {{ convertDate(doc.date) }}
9 </p>
10 <ContentRenderer class="prose" :value="doc"/>
11 </ContentDoc>
12 </div>
13 </div>
14</template>
为了实现prose样式,需要在tailwind.config.js中添加配置:
此处省略了一些配置,具体配置可以参考 Tailwindcss/typography 文档:https://github.com/tailwindlabs/tailwindcss-typography
1pnpm i @tailwindcss/typography
1// tailwind.config.jsexport default {
2 // ... plugins: [
3 require('@tailwindcss/typography'), ]
4}
部署与发布
我选择了Vercel作为博客的部署与发布平台,Vercel是一个提供Serverless部署服务的平台,它支持Nuxt.js项目的部署,并提供了CI/CD功能,可以方便地将项目部署到云端。
在Vercel中,只需要将项目与GitHub仓库关联,然后设置好CI/CD配置,即可实现自动部署。
具体的部署流程可以参考Vercel官方文档。
11. 在Vercel中创建一个新项目
22. 将Vercel安装到GitHub仓库并允许访问
33. 修改 settings -> domains 中的域名,绑定自己的域名即可
总结
通过这次重构,我对Nuxt.js有了更深入的了解。现在前端的开发方式已经发生了很大的变化,Nuxt.js提供了一种全新的前端开发方式,使得前端开发更加高效、简单,让前端开发变成像搭积木一样的体验。
在这次重构中,我学到了很多新的知识,也解决了一些问题,同时也发现了一些新的问题,这些问题将成为我下一步学习的方向。
前端的开发相比后端来说,更加灵活,更加有趣,但也更加复杂,需要不断学习,不断提升自己的技术水平。很多时候,前端开发不仅仅是技术问题,更多的是设计问题、用户体验问题,需要我们不断思考、不断尝试。
今天刚做完又看到了一些前端大佬的博客,好看的设计、炫酷的动画,让我感觉自己还有很长的路要走,也希望这个博客成为我前端学习的一个记录,也希期能够帮助到其他人。前端开发简单可以很简单,但是想要做到很好,还是需要花费很多的时间和精力,去学习、去实践、去尝试。
现在这个博客还有很多地方需要改进,比如SEO优化、性能优化、用户体验优化等等,这些都是我接下来要做的事情,不知道还有多少坑要踩,但是我会继续努力,不断学习,不断提升自己的技术水平。
以后每次更新博客,都会记录一下自己的学习经历,希望能够帮助到其他人,也希望能够帮助自己更好地学习前端开发。
总的来说,这次重构是一次很好的学习经历,我希望通过这次重构,能够提升自己的技术水平,也希望能够为其他对Nuxt感兴趣的开发者提供一些参考。
项目布局与源码
下面是一些项目的基本结构,这里只列出了一些关键文件,具体文件以及目录结构参考 Nuxt 官方文档。
1$ tree
2.
3├── README.md
4├── app.vue
5├── assets
6│ └── css
7│ └── main.css
8├── components
9│ └── main
10│ ├── AppFooter.vue
11│ └── AppNavbar.vue
12├── content
13│ ├── about
14│ │ └── index.md
15│ └── blogs
16│ └── youtrack-with-testlink.md
17├── layouts
18│ ├── 404.vue
19│ └── default.vue
20├── nuxt.config.ts
21├── package.json
22├── pages
23│ ├── [...slug].vue
24│ ├── about.vue
25│ ├── blogs
26│ │ └── [blog].vue
27│ └── index.vue
28├── pnpm-lock.yaml
29├── public
30│ └── favicon.ico
31├── tailwind.config.js
32└── tsconfig.json
app.vue
1<!-- ./app.vue -->
2<template>
3 <NuxtLoadingIndicator />
4 <NuxtLayout>
5 <NuxtPage />
6 </NuxtLayout>
7</template>
布局文件
1<!-- ./layouts/default.vue -->
2 <template>
3 <div class="flex flex-col font-spacegrotesk h-screen">
4 <header class="w-full bg-[#F1F2F4] dark:bg-slate-950 z-10">
5 <AppNavbar />
6 </header>
7 <main class="flex-1 flex flex-col justify-start m-4">
8 <div class="p-2"></div>
9 <slot />
10 <div class="p-2"></div>
11 </main>
12 <footer class="w-full">
13 <AppFooter />
14 </footer>
15 </div>
16</template>
17
18<script setup lang="ts">
19import AppNavbar from '~/components/main/AppNavbar.vue';
20import AppFooter from '~/components/main/AppFooter.vue';
21</script>
导航栏以及页脚组件
1<!-- ./components/main/AppNavbar.vue -->
2<template>
3 <div>
4 <div class="bg-base-300">
5 <div class="container mx-auto p-2 flex justify-between items-center">
6 <!-- 左侧链接部分 -->
7 <div>
8 <nuxt-link to="/" class="btn btn-ghost text-xl">Home</nuxt-link>
9 <nuxt-link to="/about" class="btn btn-ghost text-xl">About</nuxt-link>
10 </div>
11 <!-- 右侧头像部分 -->
12 <div>
13 <img src="/favicon.ico" alt="Avatar" class="w-10 h-10 rounded-full">
14 </div>
15 </div>
16 </div>
17 <div class="bg-auto h-64 relative bg-center" style="background-image: url(https://s3.cunoe.com/files/background/bg-3.jpg)">
18 <div class="absolute bottom-0 left-0 right-0 bg-opacity-50 text-white py-4 px-6">
19 <div class="container mx-auto">
20 <nuxt-link to="/" class="text-white text-3xl sm:text-3xl md:text-4xl lg:text-5xl xl:text-6xl">
21 <strong>CUNOE&DIARY</strong>
22 </nuxt-link>
23 </div>
24 </div>
25 </div>
26 <div class="bg-base-300">
27 <div class="container mx-auto text-3xl p-2">
28 <!-- 添加 flex 和 justify-end 类来实现靠右对齐 -->
29 <div class="flex justify-end flex-1 space-x-4">
30 <nuxt-link to="mailto:[email protected]" class=""><Icon name="ic:baseline-mail-outline" /></nuxt-link>
31 <nuxt-link to="https://github.com/cunoe" target="_blank"><Icon name="uil:github" /></nuxt-link>
32 </div>
33 </div>
34 </div>
35 </div>
36</template>
1<!-- ./components/main/AppFooter.vue -->
2<script>
3import { ref, defineComponent } from 'vue';
4const year = ref(new Date().getFullYear());
5export default defineComponent({
6 setup() {
7 return {
8 year
9 };
10 }
11});
12</script>
13<template>
14 <footer>
15 <div class="flex flex-col justify-center items-center space-y-1 bg-base-300 h-40 mx-auto">
16 <div class="container mx-auto">
17 <div class="space-x-2 text-gray-600">
18 <a href="https://icp.gov.moe/?keyword=20232394" rel="noreferrer" target="_blank">
19 萌ICP备20232394号
20 </a>
21 </div>
22 <div class="p-1"></div>
23 <div>
24 <span class="text-gray-400">Copyright © 2017 - {{year}} - By Cunoe</span>
25 </div>
26 </div>
27 </div>
28 </footer>
29</template>
首页
1<!-- ./pages/index.vue -->
2<script setup>
3const articles = await queryContent('blogs').where({ published: {$exists: false} }).sort({date:-1}).find()
4const convertDate = (date) => {
5 return new Date(date).toLocaleDateString('en-US', {
6 year: 'numeric',
7 month: 'long',
8 day: 'numeric'
9 })
10}
11onMounted(() => {
12 document.title = 'CUNOE&DIARY'
13})
14</script>
15<template>
16 <div>
17 <div class="container max-w-3xl mx-auto">
18 <div class="grid grid-cols-1 gap-4">
19 <div v-for="(article, index) in articles" :key="index">
20 <nuxt-link :to="`/blogs/${article.urlname}`">
21 <div class="p-4 rounded-badge">
22 <h2 class="text-2xl font-bold mb-2">{{ article.title }}</h2>
23 <p class="my-4 text-gray-500">
24 by CUNOE, {{ convertDate(article.date) }}
25 </p>
26 <div class="prose" v-html="article.description" />
27 </div>
28 </nuxt-link>
29 <!-- 分割线 -->
30 <hr class="my-4 border-t border-gray-600 opacity-50">
31 </div>
32 </div>
33 </div>
34 </div>
35</template>
文章详情页
1<!-- ./pages/blogs/[urlname].vue -->
2<script setup>
3const { path } = useRoute()
4const { data: doc } = await useAsyncData('blog-data', () => queryContent(path).findOne())
5const convertDate = (date) => {
6 return new Date(date).toLocaleDateString('en-US', {
7 year: 'numeric',
8 month: 'long',
9 day: 'numeric'
10 })
11}
12</script>
13<template>
14 <div>
15 <div class="container max-w-3xl mx-auto">
16 <ContentDoc v-slot="{ doc }">
17 <h2 class="my-4 text-4xl font-semibold">{{ doc.title }}</h2>
18 <p class="my-4 text-gray-500">
19 by CUNOE, {{ convertDate(doc.date) }}
20 </p>
21 <ContentRenderer class="prose" :value="doc"/>
22 </ContentDoc>
23 </div>
24 </div>
25</template>
页面切换添加过渡
为了让页面切换更加流畅美观,我们需要对页面切换时的加载进行优化,添加加载指示器以及页面切换时的动画。
参考:https://nuxt.com/docs/getting-started/transitions
1<!-- app.vue -->
2<template>
3 <!-- 加载指示器 -->
4 <NuxtLoadingIndicator />
5 <NuxtLayout>
6 <NuxtPage />
7 </NuxtLayout>
8</template>
9<!-- 过渡 -->
10<style>
11 .page-enter-active,
12 .page-leave-active {
13 transition: all 0.4s;
14 }
15 .page-enter-from,
16 .page-leave-to {
17 opacity: 0;
18 filter: blur(1rem);
19 }
20
21 .layout-enter-active,
22 .layout-leave-active {
23 transition: all 0.4s;
24 }
25 .layout-enter-from,
26 .layout-leave-to {
27 opacity: 0;
28 filter: blur(1rem);
29 }
30
31 html.dark{
32 color-scheme: dark;
33 }
34</style>
Emoji移动
为了让博客更加生动,我在博客首页添加了一个Emoji移动效果。实现方式是通过@formkit/auto-animate以及nuxt-swiper实现的。
1pnpm add @formkit/auto-animate
2pnpm add nuxt-swiper
1// nuxt.config.tsexport default defineNuxtConfig({
2 // ... modules: ['@formkit/auto-animate/nuxt', 'nuxt-swiper'],})
1<!-- pages/index.vue -->
2<script>
3import { ref } from 'vue'
4const items = ref(["😏","😐","😑","😒","😕"])
5function moveItem(toRemove) {
6 items.value = items.value.filter((item) => item !== toRemove)
7 if (Math.random() < 0.5) {
8 items.value.unshift(toRemove);
9 } else {
10 items.value.push(toRemove);
11 }
12}
13// ...
14</script>
15<template>
16 <swiper class="max-w-screen-md">
17 <ul v-auto-animate class="container flex flex-row text-2xl justify-center space-x-0.5">
18 <li
19 v-for="item in items"
20 :key="item"
21 @click="moveItem(item)"
22 >
23 {{ item }}
24 </li>
25 </ul>
26 </swiper>
27 <!-- ... -->
28</template>
网页SEO
添加meta
参考:https://nuxt.com/docs/getting-started/seo-meta
1// nuxt.config.tsexport default defineNuxtConfig({
2 // ... app: {
3 head: {
4 charset: 'utf-8', viewport: 'width=device-width,initial-scale=1', title: 'CUNOE&DIARY', meta: [{name: 'description', content: 'CUNOE&DIARY'}], }, },})
添加sitemap
1pnpm add @nuxtjs/sitemap
1// nuxt.config.tsexport default defineNuxtConfig({
2 // ... modules: ['@nuxtjs/sitemap'],})