BlogRefactor: 使用Nuxt重构博客 | episode 1

by CUNOE, April 4, 2024

最近在学习Nuxt,所以就想着把博客重构一下,学习一下Nuxt的使用。

目标是将博客系统的前端部分重构为Nuxt.js应用,包括文章列表、展示内容功能。

技术栈选型

这半年以来我与团队一齐开发了泡泡树洞项目,项目中我主要负责后端开发与CICD等运维工作,前端部分则由团队的前端工程师负责。在这个过程中,我对Nuxt.js以及其生态有一些了解。因此,我决定使用Nuxt.js重构我的博客,提升博客质量的同时也加深对Nuxt的理解。

可以说这是我第一次尝试使用Nuxt.js开发项目,我希望通过这次重构,更深入地了解Nuxt.js的特性与优势,同时也希望能够为其他对Nuxt感兴趣的开发者提供一些参考。

下面是我在重构博客过程中选用的技术栈:

- 依赖管理:pnpm
- 前端框架:Vue.js
- 服务端渲染与静态站点生成框架:Nuxt.js
- 内容管理:@nuxt/content
- CSS 框架:Tailwind CSS (含 Typography 插件)
- UI 组件库:DaisyUI
- 图标库:nuxt-icon

作为博客项目,其核心是内容管理与展示,因此我选择了@nuxt/content作为内容管理系统,以便更方便地管理博客文章、分类、标签等静态内容。同时,为了提升博客的视觉效果与用户体验,我选用了Tailwind CSS与DaisyUI,以及nuxt-icon提供的图标库。

技术栈安装与配置

安装Node.js与pnpm:

# https://github.com/nvm-sh/nvm
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
nvm use 20
# https://pnpm.io/
npm install -g pnpm

初始化Nuxt项目:

pnpm dlx nuxi@latest init nuxt-cunoe-blog
cd nuxt-cunoe-blog

在这里遇到了本次重构第一个问题

ERROR  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 的模板地址,然后直接下载了模板文件。

curl -o- https://raw.githubusercontent.com/nuxt/starter/main/templates/v3.json > v3.json
# v3.json 中有一个字段是 "tar" ,这个字段的值就是模板的地址
wget https://codeload.github.com/nuxt/starter/tar.gz/refs/heads/v3 -O nuxt-starter-v3.tar.gz
tar -xzf nuxt-starter-v3.tar.gz
mv nuxt-starter nuxt-cunoe-blog
cd nuxt-cunoe-blog
# 安装依赖,初始化项目
pnpm i
pnpm dev

这样就完成了项目的初始化,后续的安装与配置过程就比较顺利了,详情可以参考 Nuxt 以及各技术栈的官方文档。

内容迁移策略

我之前的Blog是基于Hexo搭建的,Hexo是一个静态博客框架,它使用Markdown文件来管理文章内容,这与Nuxt的@nuxt/content插件的使用方式有一定的相似性,因此我将文章从Hexo直接复制到Nuxt的content目录下,并稍作调整,即可完成内容迁移。

在我原来的Blog中,文章 Header 部分是这样的:

// 2018.md
---
title: 2018年度总结
date: 2018-12-31 09:00:00
urlname: 2018
categories:
- 笔记
tags:
- 总结
---

@nuxt/content会自动解析文件头,并转换为对象

在项目中可以使用@nuxt/content插件来获取文章数据,例如:

<!-- ./pages/blogs/[urlname].vue -->
<script setup>
const path = useRoute()
const { data: doc } = await useAsyncData('blog-data', () => queryContent(path).findOne())
</script>

即可获取到文章的数据,然后在页面中展示。

在页面中展示时,可以使用ContentRenderer组件来渲染文章内容,例如:

<!-- ./pages/blogs/[urlname].vue -->
<template>
  <div>
    <div class="container max-w-3xl mx-auto">
      <ContentDoc v-slot="{ doc }">
        <h2 class="my-4 text-4xl font-semibold">{{ doc.title }}</h2>
        <p class="my-4 text-gray-500">
          by CUNOE, {{ convertDate(doc.date) }}
        </p>
        <ContentRenderer class="prose" :value="doc"/>
      </ContentDoc>
    </div>
  </div>
</template>

为了实现prose样式,需要在tailwind.config.js中添加配置:

此处省略了一些配置,具体配置可以参考 Tailwindcss/typography 文档:https://github.com/tailwindlabs/tailwindcss-typography

pnpm i @tailwindcss/typography
// tailwind.config.js
export default {
    // ...
    plugins: [
        require('@tailwindcss/typography'),
    ]
}

部署与发布

我选择了Vercel作为博客的部署与发布平台,Vercel是一个提供Serverless部署服务的平台,它支持Nuxt.js项目的部署,并提供了CI/CD功能,可以方便地将项目部署到云端。

在Vercel中,只需要将项目与GitHub仓库关联,然后设置好CI/CD配置,即可实现自动部署。

具体的部署流程可以参考Vercel官方文档。

1. 在Vercel中创建一个新项目
2. 将Vercel安装到GitHub仓库并允许访问
3. 修改 settings -> domains 中的域名,绑定自己的域名即可

总结

通过这次重构,我对Nuxt.js有了更深入的了解。现在前端的开发方式已经发生了很大的变化,Nuxt.js提供了一种全新的前端开发方式,使得前端开发更加高效、简单,让前端开发变成像搭积木一样的体验。

在这次重构中,我学到了很多新的知识,也解决了一些问题,同时也发现了一些新的问题,这些问题将成为我下一步学习的方向。

前端的开发相比后端来说,更加灵活,更加有趣,但也更加复杂,需要不断学习,不断提升自己的技术水平。很多时候,前端开发不仅仅是技术问题,更多的是设计问题、用户体验问题,需要我们不断思考、不断尝试。

今天刚做完又看到了一些前端大佬的博客,好看的设计、炫酷的动画,让我感觉自己还有很长的路要走,也希望这个博客成为我前端学习的一个记录,也希期能够帮助到其他人。前端开发简单可以很简单,但是想要做到很好,还是需要花费很多的时间和精力,去学习、去实践、去尝试。

现在这个博客还有很多地方需要改进,比如SEO优化、性能优化、用户体验优化等等,这些都是我接下来要做的事情,不知道还有多少坑要踩,但是我会继续努力,不断学习,不断提升自己的技术水平。

以后每次更新博客,都会记录一下自己的学习经历,希望能够帮助到其他人,也希望能够帮助自己更好地学习前端开发。

前端开发永无止境,做好了一个功能,下一个功能就在等着你,属实是痛苦。

总的来说,这次重构是一次很好的学习经历,我希望通过这次重构,能够提升自己的技术水平,也希望能够为其他对Nuxt感兴趣的开发者提供一些参考。

项目布局与源码

下面是一些项目的基本结构,这里只列出了一些关键文件,具体文件以及目录结构参考 Nuxt 官方文档。

$ tree
.
├── README.md
├── app.vue
├── assets
│   └── css
│       └── main.css
├── components
│   └── main
│       ├── AppFooter.vue
│       └── AppNavbar.vue
├── content
│   ├── about
│   │   └── index.md
│   └── blogs
│       └── youtrack-with-testlink.md
├── layouts
│   ├── 404.vue
│   └── default.vue
├── nuxt.config.ts
├── package.json
├── pages
│   ├── [...slug].vue
│   ├── about.vue
│   ├── blogs
│   │   └── [blog].vue
│   └── index.vue
├── pnpm-lock.yaml
├── public
│   └── favicon.ico
├── tailwind.config.js
└── tsconfig.json

app.vue

<!-- ./app.vue -->
<template>
  <NuxtLoadingIndicator />
  <NuxtLayout>
    <NuxtPage />
  </NuxtLayout>
</template>

布局文件

<!-- ./layouts/default.vue -->
 <template>
  <div class="flex flex-col font-spacegrotesk h-screen">
    <header class="w-full bg-[#F1F2F4] dark:bg-slate-950 z-10">
      <AppNavbar />
    </header>
    <main class="flex-1 flex flex-col justify-start m-4">
      <div class="p-2"></div>
      <slot />
      <div class="p-2"></div>
    </main>
    <footer class="w-full">
      <AppFooter />
    </footer>
  </div>
</template>

<script setup lang="ts">
import AppNavbar from '~/components/main/AppNavbar.vue';
import AppFooter from '~/components/main/AppFooter.vue';
</script>

导航栏以及页脚组件

<!-- ./components/main/AppNavbar.vue -->
<template>
  <div>
    <div class="bg-base-300">
      <div class="container mx-auto p-2 flex justify-between items-center">
        <!-- 左侧链接部分 -->
        <div>
          <nuxt-link to="/" class="btn btn-ghost text-xl">Home</nuxt-link>
          <nuxt-link to="/about" class="btn btn-ghost text-xl">About</nuxt-link>
        </div>
        <!-- 右侧头像部分 -->
        <div>
          <img src="/favicon.ico" alt="Avatar" class="w-10 h-10 rounded-full">
        </div>
      </div>
    </div>
    <div class="bg-auto h-64 relative bg-center" style="background-image: url(https://s3.cunoe.com/files/background/bg-3.jpg)">
      <div class="absolute bottom-0 left-0 right-0 bg-opacity-50 text-white py-4 px-6">
        <div class="container mx-auto">
          <nuxt-link to="/" class="text-white text-3xl sm:text-3xl md:text-4xl lg:text-5xl xl:text-6xl">
            <strong>CUNOE&DIARY</strong>
          </nuxt-link>
        </div>
      </div>
    </div>
    <div class="bg-base-300">
      <div class="container mx-auto text-3xl p-2">
        <!-- 添加 flex 和 justify-end 类来实现靠右对齐 -->
        <div class="flex justify-end flex-1 space-x-4">
          <nuxt-link to="mailto:admin@cunoe.com" class=""><Icon name="ic:baseline-mail-outline" /></nuxt-link>
          <nuxt-link to="https://github.com/cunoe" target="_blank"><Icon name="uil:github" /></nuxt-link>
        </div>
      </div>
    </div>
  </div>
</template>
<!-- ./components/main/AppFooter.vue -->
<script>
import { ref, defineComponent } from 'vue';
const year = ref(new Date().getFullYear());
export default defineComponent({
  setup() {
    return {
      year
    };
  }
});
</script>
<template>
  <footer>
    <div class="flex flex-col justify-center items-center space-y-1 bg-base-300 h-40 mx-auto">
      <div class="container mx-auto">
        <div class="space-x-2 text-gray-600">
          <a href="https://icp.gov.moe/?keyword=20232394" rel="noreferrer" target="_blank">
            萌ICP备20232394号
          </a>
        </div>
        <div class="p-1"></div>
        <div>
          <span class="text-gray-400">Copyright © 2017 - {{year}} - By Cunoe</span>
        </div>
      </div>
    </div>
  </footer>
</template>

首页

<!-- ./pages/index.vue -->
<script setup>
const articles = await queryContent('blogs').where({ published: {$exists: false} }).sort({date:-1}).find()
const convertDate = (date) => {
  return new Date(date).toLocaleDateString('en-US', {
    year: 'numeric',
    month: 'long',
    day: 'numeric'
  })
}
onMounted(() => {
  document.title = 'CUNOE&DIARY'
})
</script>
<template>
  <div>
    <div class="container max-w-3xl mx-auto">
      <div class="grid grid-cols-1 gap-4">
        <div v-for="(article, index) in articles" :key="index">
          <nuxt-link :to="`/blogs/${article.urlname}`">
            <div class="p-4 rounded-badge">
              <h2 class="text-2xl font-bold mb-2">{{ article.title }}</h2>
              <p class="my-4 text-gray-500">
                by CUNOE, {{ convertDate(article.date) }}
              </p>
              <div class="prose" v-html="article.description" />
            </div>
          </nuxt-link>
          <!-- 分割线 -->
          <hr class="my-4 border-t border-gray-600 opacity-50">
        </div>
      </div>
    </div>
  </div>
</template>

文章详情页

<!-- ./pages/blogs/[urlname].vue -->
<script setup>
const { path } = useRoute()
const { data: doc } = await useAsyncData('blog-data', () => queryContent(path).findOne())
const convertDate = (date) => {
  return new Date(date).toLocaleDateString('en-US', {
    year: 'numeric',
    month: 'long',
    day: 'numeric'
  })
}
</script>
<template>
  <div>
    <div class="container max-w-3xl mx-auto">
      <ContentDoc v-slot="{ doc }">
        <h2 class="my-4 text-4xl font-semibold">{{ doc.title }}</h2>
        <p class="my-4 text-gray-500">
          by CUNOE, {{ convertDate(doc.date) }}
        </p>
        <ContentRenderer class="prose" :value="doc"/>
      </ContentDoc>
    </div>
  </div>
</template>