用 Astro 搭建个人博客:从零到上线的完整实践

为什么选择 Astro

首先排除SPA模式,思考了以下几种:

  • Next.js —— 太重,我只是写博客,不需要服务端渲染那套
  • Nuxt —— Vue 技术栈,但我更想试试新东西
  • VitePress —— 适合文档,博客功能不够灵活
  • Astro —— 静态生成、按需加载、支持 Vue/React 组件

最后选了 Astro,原因很简单:

  1. 够轻 —— 默认零 JavaScript,页面加载快,SEO 友好
  2. 够灵活 —— 可以用 Vue 写组件
  3. 够新 —— 2024 年之后火起来的,新技术意味着有探索空间,值得试试
  4. 部署简单 —— 静态站点,扔哪都能跑

实际用下来,挺满意的。下面记录一下搭建过程。包括我踩过的每一个坑。希望你的博客之路能比我顺畅一些。

项目架构

最初只是普通的静态博客,后来加了”个人动态”功能,就需要后端了,考虑到项目并不复杂,没必要分多个仓,于是就搞了个 Monorepo。后续有其他想法也会加入其他动态功能。

我有两个选择:

  1. 纯前端方案 —— 用第三方服务(例如文章的评论使用的 Giscus,后续有空会自己实现)
  2. 前后端分离 —— 自己写个简单的 API 服务

我选了第二个。原因很简单:我想掌握全部控制权,随意改动。

于是我的博客变成了这样:

blog/
├── apps/
│   ├── web/         # 前端 — Astro 静态博客
│   └── server/      # 后端 — Hono API 服务
├── package.json
├── pnpm-workspace.yaml
└── pnpm-lock.yaml

pnpm

pnpm 现代前端开发优选。它用硬链接的方式共享依赖,节省空间,速度快。对于 Monorepo 来说,pnpm 几乎是唯一选择。

技术栈

我的技术选型原则是:权衡利弊,刚刚好,不过度追求,不强行炫技

技术用途选择理由
Astro 5.x静态站点框架核心理由:零 JS 默认,快
Vue 3.x交互组件(个人动态页)日常使用,生态丰富
Swup页面无刷新过渡动画让网站有点 SPA 的感觉,提升体验
CSS Variables主题系统,使用 hsl()原生支持,不用额外库,暗色模式轻松搞

有尝试过 Tailwind CSS,但觉得太重了,有些地方需要去了解默认的各种样式,然后选择原生 CSS + Less 简单方便。

目录结构

根据开发过程逐渐需要新的结构并参考优秀项目目录,最后形成了自己的规范:

apps/web/
├── public/                  # 静态资源(图片、字体)— 直接复制不构建
├── src/
│   ├── components/          # 组件 — 按功能模块分组
│   │   ├── aside/           # 侧边栏(作者卡片、分类、标签)
│   │   ├── blog/            # 博客组件(文章卡片、详情)
│   │   ├── header/          # 页头(导航、搜索、主题切换)
│   │   └── moments/         # 个人动态(Vue 组件,因为交互多)
│   ├── content/             # 内容集合 — Astro 的核心特性
│   │   └── blog/            # 博客文章(Markdown 文件都放这里)
│   ├── layouts/             # 页面布局 — 统一 header/footer
│   ├── pages/               # 页面路由 — 文件即路由
│   ├── plugins/             # Remark/Rehype 插件 — Markdown 增强
│   └── styles/              # 全局样式 — CSS Variables 定义
└── astro.config.mjs         # Astro 配置

几个设计原则:

  1. 按功能分组,而不是按类型 —— 比如 components/blog/ 里放所有博客相关的组件,而不是把所有组件平铺
  2. Markdown 和代码分离 —— content/blog/ 只放 Markdown 文件,组件和逻辑放 src/
  3. 路由即文件 —— Astro 的 pages/ 目录结构直接对应 URL 路径,直观

核心功能实现

1. Markdown 内容管理

Astro 的 Content Collections 是我决定用它的重要原因之一。

我的文章 schema 定义:

const blog = defineCollection({
  type: 'content',
  schema: z.object({
    title: z.string(), // 标题必填
    pubDate: z.coerce.date(), // 发布日期,自动解析
    updatedDate: z.coerce.date().optional(), // 可选
    description: z.string().optional(),
    tags: z.array(z.string()).default([]),
    category: z.string().optional(),
    draft: z.boolean().default(false), // 草稿模式
  }),
})

每篇文章的 Frontmatter 长这样:

---
title: '文章标题'
pubDate: '2026-03-12'
description: '文章描述'
tags: ['Astro', '博客']
category: '前端'
draft: false
---

正文内容...

2. 阅读时间统计

我注意到很多博客都有”预计阅读时间”这个功能。

我的做法如下:核心依赖是两个插件

  • mdast-util-to-string —— 提取 Markdown 的纯文本内容
  • reading-time —— 计算阅读时间

插件代码:

// src/plugins/remark-reading-time.ts
import { toString } from 'mdast-util-to-string'
import readingTime from 'reading-time'

export function remarkReadingTime() {
  return function (tree, file) {
    const textContent = toString(tree) // 提取纯文本
    const stats = readingTime(textContent) // 计算阅读时间

    // 把结果写入 Frontmatter
    file.data.astro.frontmatter.readingTime = {
      words: stats.words,
      minutes: Math.ceil(stats.minutes),
    }
  }
}

最后将 remarkReadingTime 插件添加到 Astro 配置中。

3. Markdown 样式:从 Tailwind 到自定义

Markdown 的渲染样式,我走过一段弯路。

最开始,我直接用了 @tailwindcss/typography 插件,几行配置就能让 Markdown 有不错的默认样式。但用了一段时间后发现:

  • Tailwind 的类名太多,调试起来麻烦
  • 有些细节样式不符合我的设计
  • 为了自定义一个颜色,得覆盖一堆类

后来,我干脆自己写了一套 Markdown 样式,用 Less 组织,按模块拆分:

styles/markdown/
├── markdown.css      # 基础排版(标题、段落、列表、表格)
└── markdown-code.css # 代码块样式(行号、复制按钮、滚动条)

代码块,自定义复制按钮:

  • 用 CSS 画图标(SVG mask),不用额外图片
  • 悬停时显示,点击后变勾选动画
  • 复制成功有视觉反馈

提示

自定义样式虽然花时间,但完全可控,后期维护反而更轻松。

Markdown 插件方面,我加了这些:

  • remark-gfm —— GitHub Flavored Markdown,支持表格、删除线等
  • remark-github-admonitions-to-directives —— GitHub 风格的提示块(例如上方的提示 —— :::note、:::warning)
  • rehype-autolink-headings —— 标题自动生成锚点,点击可跳转
  • rehype-code-copy —— 代码块复制按钮(配合自定义样式)

4. Vue 组件集成:Astro 的 Island 架构

个人动态页(类似朋友圈)是我博客里动态数据的部分。有发布动态、图片上传、删除。

所以我用 Vue 写了这个页面。这也是 Astro “Island 架构”的典型应用——只有需要交互的部分才加载 JavaScript。

在 Astro 中使用 Vue 组件:

---
import MomentsPage from '../components/moments/MomentsPage.vue'
---

<MomentsPage client:load />

注意 client:load —— 这是 Astro 的指令,告诉它这个组件需要在客户端加载和渲染。

Astro 的几种客户端指令:

  • client:load —— 页面加载时立即加载(适合首屏交互)
  • client:idle —— 浏览器空闲时加载(适合非关键交互)
  • client:visible —— 滚动到可见区域时加载(适合长页面)
  • client:only —— 只在客户端渲染,构建时跳过(适合依赖浏览器 API 的组件)

坑:

一开始我忘了加客户端指令,部署后直接报错:“window is not defined”。

原因是 Astro 默认是服务端渲染(SSR),在构建时执行。而 windowdocument 这些浏览器 API 在服务端不存在。

加上 client:load 后,Astro 就知道这个组件要在客户端跑,构建时会跳过它。

5. 页面无刷新过渡:Swup 让体验更流畅

点击一个链接,页面一下切换,没有白屏,没有闪烁,像 SPA 一样流畅,这就是 Swup 做的事。

配置非常简单:

// astro.config.mjs
import swup from '@swup/astro'

export default defineConfig({
  integrations: [swup({ containers: ['#swup'] })],
})

然后在布局文件里给会变化的区域加个 id="swup"

<main id="swup" data-swup>
  <slot />
</main>

过渡动画用 CSS 写,我用的默认淡入淡出,也可以自定义更复杂的效果。

使用上可能说不清哪里不一样,但会觉得”这个网站很流畅”。

6. 主题切换:HSL 让调色变得简单

暗色模式现在是标配了。我用 CSS Variables 实现,而且用 HSL 定义颜色。

HSL(色相、饱和度、亮度)比 RGB 更直观。比如我想让主题色饱和度高一点,在 HSL 里只需要调饱和度(S),而在 RGB 里不够直观,需要多次尝试。

  • 想换主题色?只需要改 --hue 一个值,全站颜色自动更新
  • 调亮度?改 --lig 就行

亮暗模式切换逻辑:优先使用用户上次选择的主题,如果没有,就跟随系统偏好。

const theme =
  localStorage.getItem('theme') || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')

document.documentElement.classList.toggle('dark', theme === 'dark')

注意: 我把这段代码放在 <head> 里,并使用 <script is:inline></script>,避免页面加载时闪烁(先亮后暗)。

构建优化

博客性能是我非常在意的。我用了几种优化手段:

1. 代码压缩

astro-compress 一站式压缩 CSS、JS、HTML。配置里我开启了所有选项:

  • CSS 压缩 + 移除注释
  • HTML 移除空白 + 移除注释
  • JS 压缩 + 移除 console.log

最后再加上 EdgeOne Pages 对网站的优化,gzip/brotli,效果明显。

2. 图片优化

图片通常是网站最大的资源。Astro 的 Image 组件会自动把图片转换成 WebP 格式(现代浏览器支持),并且按需加载。

或者可以通过 getImage API 手动处理图片压缩为 WebP/Avif 格式,也支持配置质量。

---
import { Image } from 'astro:assets'
import cover from '../images/cover.jpg'
---

<Image src={cover} alt="封面" />

好处:

  • 自动裁剪不同尺寸,适配不同屏幕
  • 懒加载,滚动到视口才加载
  • WebP 格式比 JPEG 小 30% 左右

3. Vite 配置优化

Astro 底层用 Vite,所以可以配置 Vite 的构建选项:

vite: {
  build: {
    minify: 'esbuild',      // 快速压缩
    cssCodeSplit: true,     // CSS 分割,按需加载
    target: 'esnext',       // 只针对现代浏览器
  }
}

4. SEO 与分享卡片优化

博客写好了。微信、QQ、Twitter 上看到的链接预览卡片,都是靠 SEO 标签实现的。

<!-- src/pages/blog/[slug].astro -->
<head>
  <meta property="og:title" content={frontmatter.title} />
  <meta property="og:description" content={frontmatter.description} />
  <meta property="og:image" content={ogImage} />
  <meta property="og:url" content={Astro.url} />
  <meta property="og:type" content="article" />
  <meta property="article:published_time" content={frontmatter.pubDate} />
  <meta property="article:author" content={frontmatter.author} />
</head>

效果: 分享链接时,自动显示文章标题、描述和封面图。

后端 API 服务:简单的力量

个人动态需要登录、发布、删除,所以需要一个后端。

我的原则是:够用就行,不搞复杂

技术选型

技术用途选择理由
HonoWeb 框架轻量、快速、TypeScript 友好
better-sqlite3数据库单文件、零配置、性能够用
joseJWT 鉴权现代、安全、API 简单

Hono 是我第一次用,体验很好。它的设计理念是”小”,整个框架只有几十 KB,但功能完整。

核心代码结构

apps/server/
├── src/
│   ├── db/           # 数据库连接
│   ├── middleware/   # JWT 鉴权中间件
│   ├── routes/       # API 路由
│   └── index.ts      # 入口

入口文件简单:

import { Hono } from 'hono'
import { cors } from 'hono/cors'

const app = new Hono()
app.use('/api/*', cors()) // 允许跨域
app.route('/api/auth', authRoutes) // 登录接口
app.route('/api/moments', momentRoutes) // 动态接口

export default app

JWT 鉴权中间件:

原理是:用户登录后发一个 JWT token,之后每次请求都带上这个 token。服务端验证 token 有效性,有效才允许操作。

export async function authMiddleware(c, next) {
  const token = c.req.header('Authorization')?.replace('Bearer ', '')

  if (!token) return c.json({ error: 'Unauthorized' }, 401)

  try {
    const payload = await verify(token, JWT_SECRET)
    c.set('user', payload)
    await next()
  } catch {
    return c.json({ error: 'Invalid token' }, 401)
  }
}

这段代码保护了所有需要登录的接口。

部署流程:从本地到线上

部署策略:

  • 前端(web):静态部署到腾讯云 EdgeOne Pages,CDN 加速,足够快而且免费够用
  • 后端(server):跑在腾讯云轻量应用服务器,包年也便宜

这样设计的好处是:前端完全静态,几乎零成本;后端按需部署,资源占用低。

域名

我用的是腾讯云的域名,绑定了两个子域名:

  • blog.jingshihang.cn:博客主站
  • server.jingshihang.cn:API 服务

图片/文件

图片和文件都放到腾讯云的 COS 对象存储里,访问速度快,便宜够用。

开启图片防盗链,只允许指定域访问图片

# 用 PM2 守护进程
pm2 start dist/index.js --name blog-server

PM2 是个进程管理工具,可以保证服务挂了自动重启。

Nginx 反向代理:

我用 Nginx 做反向代理,把 server.jingshihang.cn 的请求转发到本地 3001 端口。

server {
    listen 443 ssl;
    server_name server.jingshihang.cn;

    location / {
        proxy_pass http://localhost:3001;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

踩坑记录:都是真金白银换来的

坑 1:Swup 页面切换后组件失效

用 Swup 做页面无刷新切换时,遇到一个问题:切换页面后,代码块复制按钮、评论系统、统计脚本都不工作了。

原因: Swup 只替换页面内容区域(<main id="swup">),但不会重新执行 <script> 标签。那些依赖 DOM 加载后初始化的脚本,在切换页面后就失效了。

解决: 监听 Swup 的 swup:content:replace 事件,在内容替换后重新初始化:

// 代码块复制按钮、评论系统、统计脚本、标题锚点滚动
document.addEventListener('swup:content:replace', initSomething)

关键点: 把需要初始化的逻辑封装成函数,首次加载时执行一次,每次 swup:content:replace 时再执行一次。

这个事件是 Swup 内容替换完成后触发的,是重新绑定事件、重新初始化组件的最佳时机。

坑 2:Shiki 代码高亮主题切换

Astro 默认用 Shiki 做代码高亮,支持双主题(亮色/暗色)。但自定义样式时踩了个坑:样式覆盖不了

问题: 我想让代码块的背景色和网站主题更搭,但无论怎么写 CSS,Shiki 的内联样式都优先生效。

原因: Shiki 会在 <pre><span> 标签上直接写内联样式(style="background-color: #xxx"),优先级比外部 CSS 高。

解决: 必须用 !important 强制覆盖,而且暗色模式要用 Shiki 提供的 CSS 变量:

/* 亮色模式 - Shiki 默认用 --shiki-light */
.markdown-body pre.astro-code,
.markdown-body pre.astro-code span {
  background-color: var(--shiki-light-bg) !important;
}

/* 暗色模式 - 必须用 --shiki-dark */
html.dark .markdown-body pre.astro-code,
html.dark .markdown-body pre.astro-code span {
  color: var(--shiki-dark) !important;
  background-color: var(--shiki-dark-bg-custom) !important;
}

关键点:

  • !important 是必须的,不然优先级不够
  • 暗色模式要用 var(--shiki-dark),这是 Shiki 生成的变量
  • 亮色模式默认用 var(--shiki-light),可以不写

这个坑让我明白了:Shiki 的双主题机制是靠 CSS 变量 + 类名切换实现的,要覆盖样式就得用同样的方式,而且必须加 !important

最后

博客搭好了,地址:就是当前看到的页面 https://blog.jingshihang.cn

整体用下来,Astro 给我的感觉是很友好的。

不做多余的事,不强迫用它的生态,不绑死在固定技术栈上。这种反而让它变得更灵活。

Monorepo 架构让前后端分离更清晰,部署也方便。虽然一开始配置麻烦了点,但方便后续维护,长期来看是值得的。

如果你也想搭博客

我的建议是:

  1. 先想清楚需求 —— 你需要一个什么样的博客?
  2. 选对工具 —— Astro 适合博客,Next.js 适合复杂应用
  3. 别过度设计 —— 够用就行,后续可以迭代,重要的是执行,不能空想

未来规划

博客不是一蹴而就的,我还有一些计划:

  • 文章目录(TOC)组件和目录跳转
  • 照片墙页面
  • 评论系统集成(当前使用的 Giscus)
  • 国际化支持(如果有外国朋友想看)

参考资源:


如果这篇文章对你有帮助,欢迎分享和交流!也欢迎来我的博客留言~

评论