Loading...

Nuxt 搭建个人博客

2025-02-02 · 11min

由于工作的原因,对Vue3进行了一段时间的康复训练。距离上次接触Vue3已经过去了4年,那会Vue3刚刚出,许多功能兼容性以及Vite都还不太完善。在使用Vue3着手开发了几个项目后,我个人的开发体验是觉得Vue3要比React好太多了,具体对比后面另外开篇文章细说吧。也因为这段体验,过年在家把4个月前才用Next搭建好的博客改用Nuxt重写了一遍(这次最后一次重写博客)。

技术选型

项目目录结构

├─ 📁assets

│  ├─ 📁css

│  │  └─ 📄main.css ─────────────────  全局CSS配置

│  ├─ 📁icons ───────────────────── unocss转换图标目录

│   └─ ......

│  └─ 📁svgo ───────────────────── svgo转换图表组件目录

│   └─ 📄logo.svg

├─ 📁components ───────────────── 组件目录

│  ├─ 📁content  ────────────────── Nuxt Content Prose组件目录

│  │  ├─ 📄ProseA.vue

│  │  ├─ 📄ProseCode.vue

│  │  ├─ 📄ProseH2.vue

│  │  ├─ 📄ProseH3.vue

│  │  ├─ 📄ProseHr.vue

│  │  ├─ 📄ProseImg.vue

│  │  └─ 📄ProsePre.vue

├─ 📁content ───────────────── Nuxt Content Markdown读取目录

│  ├─ 📁blogs ──────────────── 文章目录

│  │  ├─ ......

│  └─ 📄index.md ───────────── 主页内容

├─ 📁layouts ──────────────── 布局目录

│  └─ 📄default.vue ─────────── 默认布局组件

├─ 📁pages ──────────────── 页面目录

│  ├─ 📁blogs 

│  │  ├─ 📄index.vue ───────── 博客列表页

│  │  └─ 📄[slug].vue ───────── 博客详情页

│  └─ 📄index.vue ─────────── 主页

├─ 📁public ─────────────── 静态资源目录

│  ├─ 📁fonts ────────────── 字体目录

│  │  ├─ ......

│  ├─ 📁images ──────────── 图片目录

│  │  └─ ......

│  ├─ 📄favicon.ico

│  └─ 📄robots.txt

├─ 📁server ───────────── nuxt 服务端目录

│  └─ 📄tsconfig.json

├─ 📄.gitignore

├─ 📄app.vue ─────────── 入口组件

├─ 📄content.config.ts ───── Nuxt Content 配置文件

├─ 📄nuxt.config.ts ─────── Nuxt 配置文件

├─ 📄package.json

├─ 📄pnpm-lock.yaml

├─ 📄README.md

├─ 📄tsconfig.json

└─ 📄uno.config.ts ────── Unocss 配置文件

创建项目

完全跟着官方文档走

pnpm dlx nuxi@latest init my-blog
cd my-blog
pnpm run dev

打开localhost:3000 就可以看到了运行后的项目

Unocss安装

与 Tailwind CSS 相同的 CSS 原子类,但 Unocss 配置可读性更高些,而且 Unocss 也是 Vue 团队成员开发的,所以这次采用 Unocss 进行开发。

安装依赖

pnpm add -save -D unocss @unocss/nuxt @unocss/preset-mini

注册模块

export default defineNuxtConfig({
  compatibilityDate: "2025-02-02",
  devtools: { enabled: true },
  modules: [
    "@unocss/nuxt",
    // ...
  ],
 // ...
});

配置文件

在根目录下创建配置文件 uno.config.ts

import { defineConfig, presetAttributify, presetUno } from "unocss";
import presetMini from "@unocss/preset-mini";
import presetIcons from "@unocss/preset-icons";
import { FileSystemIconLoader } from "@iconify/utils/lib/loader/node-loaders";

export function createConfig({ strict = true, dev = true } = {}) {
  return defineConfig({
    envMode: dev ? "dev" : "build",
    content: {
      pipeline: {
        include: [/\.(vue|svelte|[jt]sx|mdx?|astro|elm|php|phtml|html)($|\?)/],
      },
    },
    presets: [
      presetMini(),
      presetAttributify({ strict }),
      presetUno(),
    ],
  });
}

export default createConfig();

这样就可以在项目中使用 Unocss 了。

SVG 文件自动识别转换成 CSS

我选用 Unocss 最大的原因,是因为 Unocss 官方提供了 preset-icons 模块进行图标预设,将 SVG 转成纯 CSS 来使用。

官方是以 Iconify 作为图标数据源,需要额外按照@iconify-json/* 模式在 devDependencies 中安装相应的图标集才能进行使用。

我这里没有使用 Iconify 的图标集,而是通过 @iconify/utils 提供的 FileSystemIconLoader方法,提前加载指定目录下的 SVG 文件,提供给 preset-icons转换成CSS类。

安装依赖

pnpm add -save -D @unocss/preset-icons @iconify/utils

读取 SVG 文件配置

import { defineConfig, presetAttributify, presetUno } from "unocss";
import presetMini from "@unocss/preset-mini";
import presetIcons from "@unocss/preset-icons";
import { FileSystemIconLoader } from "@iconify/utils/lib/loader/node-loaders";

export function createConfig({ strict = true, dev = true } = {}) {
  return defineConfig({
    envMode: dev ? "dev" : "build",
    content: {
      pipeline: {
        include: [/\.(vue|svelte|[jt]sx|mdx?|astro|elm|php|phtml|html)($|\?)/],
      },
    },
    presets: [
      presetMini(),
      presetAttributify({ strict }),
      presetUno(),
      presetIcons({
        extraProperties: {
          display: "inline-block",
          "vertical-align": "middle",
          width: "1em",
          height: "1em",
        },
        collections: {
          // 从文件系统加载图标的助手
          // `./assets/icons` 下带有 `.svg` 扩展名的文件将按照文件名加载
          // 您还可以提供一个转换回调来更改每个图标(可选)
          icons: FileSystemIconLoader("./assets/icons/", (svg) => svg),
        },
        customizations: {
          customize(props) {
            props.height = "1em";
            props.width = "1em";
            return props;
          },
        },
      }),
    ],
  });
}

export default createConfig();

配置过后,会自动将根目录下的 assets/icons/*.svg 文件,按照文件名进行对于的 CSS 类转换。

需要注意的是 preset-icons 的纯CSS图标类名使用方式是:

  • <prefix><collection>-<icon>
  • <prefix><collection>:<icon>

因此我的 SVG 文件全部都才用破折号连接命名

使用示例

<template>
  <!-- 对应 sun.svg  -->
  <div class="i-icons-sun?mask "></div>
</template>

preset-icons 这种转成纯 CSS 图标的解决方案,我觉得非常适合任何一个前端开发项目。对于独立开发者来说,可以使用 Iconify 的图标集作为数据源使用;对于项目开说,又可以通过 @iconify/utils 读取本地的 SVG 文件进行相同的转换。只需要从Figma上将图标按照命名规范进行导出到指定目录中,开发者就可以直接通过 CSS 类的方式对图标进行使用了,无疑是减少了开发者在图标浪费过多的时间。

Markdown内容渲染

Nuxt官方提供了 Nuxt Content 模块,通过配置就可以对项目的 content 目录下的内容文件(yml、json和markdown 等)进行数据读取,比提供组件渲染内容。

安装

pnpm add @nuxt/content

在Nuxt配置文件中注册模块,并进行样式配置

export default defineNuxtConfig({
  // ... 
  modules: [
    // ...
    "@nuxt/content",
  ],
  // 读取外部css文件
  css: ["~/assets/css/main.css"],
  // Nuxt Content 配置
  content: {
    build: {
      markdown: {
        // 代码块高亮样式配置
        highlight: {
          theme: {
            // Default theme (same as single string)
            default: "slack-ochin",
            // Theme used if `html.dark`
            dark: "slack-dark",
          },
        },
      },
    },
  },
});

获取内容

在根目录下创建内容集合配置文件 content.config.ts,将 content 目录下的 markdown 文件读取创建集合,并命名为为 content。

import { defineContentConfig, defineCollection, z } from "@nuxt/content";

export default defineContentConfig({
  collections: {
    content: defineCollection({
      type: "page",
      source: "**/*.md",
    })
  },
});

根目录下创建 content/index.md

---
title: Nanacai
date: 2020-11-11
---

主页内容

根目录下创建 pages/index.vue, Nuxt会自动读取pages下的vue文件转换成路由页面。

<script setup lang="ts">
const { data } = await useAsyncData(() => queryCollection("content").path("/").first());

useSeoMeta({
  title: data.title,
  description: data.description,
});

</script>

<template>
  <ContentRenderer:value="data" />
</template>

Nuxt Content 提供 useAsyncData Hook请求内容集合数据,会以 Promise形式返回数据,将请求到的数据通过 ContentRenderer组件进行渲染。这里请求目标是 content/index.md。

博客列表

在 content 目录下在创建 blogs 目录,用于存放需要 Blog 内容,并在 content.config.ts 中注册集合:

import { defineContentConfig, defineCollection, z } from "@nuxt/content";

export default defineContentConfig({
  collections: {
    content: defineCollection({
      type: "page",
      source: "**/*.md",
      schema: z.object({
        date: z.string(),
      }),
    }),
    blogs: defineCollection({
      type: "page",
      source: "blogs/*.md",
      schema: z.object({
        date: z.string(),
      }),
    }),
  },
});

创建 pages/blogs/index.vue ,通过 queryCollection 即可获取博客列表的数据:

<template>
  <div class="flex flex-col w-full max-w-80ch md:max-w-80ch  gap-4xl page-appear">
    <div class="w-full pb-4 b-b-solid b-b b-b-gray-3 dark:b-b-gray-9 text-4xl font-500 c-gray-11 dark:c-gray-1 m-0">
      Blogs
    </div>
    <div v-if="Array.isArray(allPosts)" class="flex flex-col gap-2xl ">
      <NuxtLink
        v-for="post in allPosts"
        :key="`blog-post-${post.id}`"
        class="flex flex-col md:flex-row md:items-end gap-2 text-lg group cursor-pointer"
        :to="post.path"
      >
        <span
          class="text-18px font-bold c-gray-8 dark:c-gray-4 transition-all group-hover:c-gray-11 dark:group-hover:c-gray-0 md:lh-none"
        >
          {{ post.title }}
        </span>
      </NuxtLink>
    </div>
  </div>
</template>

<script setup lang="ts">
import type { ReadTimeResults } from "reading-time";

useSeoMeta({
  title: "Blogs - Nanacai",
});
const { data: allPosts } = await useAsyncData("all-post", () => queryCollection("blogs").order("date", "DESC").all());
</script>

进入 blogs 路由地址,博客列表如下:

博客列表页面

博客详情

在 pages/blogs 目录下,创建 [slug].vue

<script setup>
const slug = useRoute().params.slug;

const { data: post } = await useAsyncData(`blog-${slug}`, () => {
  return queryCollection("blogs").path(`/blogs/${slug}`).first();
});
</script>

<template>
  <!-- Render the blog post as Prose & Vue components -->
  <MakrdownRender :value="post" />
</template>

在博客列表点击博客后,会跳转到对应该组件,并渲染对应的博客内容:

博客内容

页面过渡

Nuxt 的官方文档中就有说明,只需要在 nuxt.config.ts 中配置下,就可以像 Vue Transition 一样进行页面过渡。

export default defineNuxtConfig({
  app: {
    pageTransition: { name: "page", mode: "out-in" },
  },
    // ...
});

在 App.vue 中添加过渡样式

<style>
.page-enter-active {
  animation: pageTransition ease-out 400ms;
}
.page-leave-active {
  animation: pageTransition ease-in 400ms reverse;
}

@keyframes pageTransition {
  from {
    clip-path: xywh(0 0 100% 0);
    opacity: 0;
    filter: blur(1rem);
  }
  to {
    clip-path: xywh(0 0 100% 100%);
    opacity: 1;
    filter: blur(0);
  }
}
</style>

自动化部署

自动化部署通过 Github Action 实现。

在仓库页面,进入 Action,点击 set up a workflow yourself 创建工作流。

image-20250302203814903

工作流是一个 Yml 文件,命名无所谓。

# 自动化部署名称
name: Publish And Deploy Demo

# 触发部署的条件: 当 master 分支, 有 push 的操作, 即可触发。
on:
  push:
    branches: # 此处不仅限于监听分支, 还监听 tag
      - master
    tags:
      - "v*"
    paths-ignore: # 下列文件的变更不触发部署,可以自行添加
      - README.md
      - LICENSE

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout Code
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 18

      - name: Install Dependencies
        run: npm install

      - name: Build Project
        run: npm run build

      - name: Package Artifacts
        run: |
          tar -zcvf release.tgz .output pm2.config.json
          ls -lh release.tgz

      - name: Copy release to server
        uses: appleboy/scp-action@master
        with:
          host: ${{ secrets.ALIYUN_HOST }}
          username: ${{ secrets.ALIYUN_USERNAME }}
          password: ${{ secrets.ALIYUN_PASSWORD}}
          port: ${{ secrets.ALIYUN_PORT || 22 }}
          source: 'release.tgz'
          target: '/www/blog/'

      - name: SSH Post Commands
        uses: appleboy/ssh-action@v1.2.1
        with:
          host: ${{ secrets.ALIYUN_HOST }}
          username: ${{ secrets.ALIYUN_USERNAME }}
          password: ${{ secrets.ALIYUN_PASSWORD}}
          script: |
            source /etc/profile
            cd /www/blog
            ls -l release.tgz  # 检查文件是否存在(关键调试步骤
            pm2 stop nuxt-blog || true
            tar zxvf release.tgz
            pm2 reload pm2.config.json
            rm -f release.tgz  # 清理临时文件

这样当推送 tag 时,就会触发 Github Action。依次执行安装依赖、打包、压缩打包产物,通过 scp 传输压缩包到指定目录, 连接服务器解压压缩包,并重启pm2,即可完成自动化部署。