用 Astro 搭建个人博客:从零到上线的完整实践
为什么选择 Astro
首先排除SPA模式,思考了以下几种:
- Next.js —— 太重,我只是写博客,不需要服务端渲染那套
- Nuxt —— Vue 技术栈,但我更想试试新东西
- VitePress —— 适合文档,博客功能不够灵活
- Astro —— 静态生成、按需加载、支持 Vue/React 组件
最后选了 Astro,原因很简单:
- 够轻 —— 默认零 JavaScript,页面加载快,SEO 友好
- 够灵活 —— 可以用 Vue 写组件
- 够新 —— 2024 年之后火起来的,新技术意味着有探索空间,值得试试
- 部署简单 —— 静态站点,扔哪都能跑
实际用下来,挺满意的。下面记录一下搭建过程。包括我踩过的每一个坑。希望你的博客之路能比我顺畅一些。
项目架构
最初只是普通的静态博客,后来加了”个人动态”功能,就需要后端了,考虑到项目并不复杂,没必要分多个仓,于是就搞了个 Monorepo。后续有其他想法也会加入其他动态功能。
我有两个选择:
- 纯前端方案 —— 用第三方服务(例如文章的评论使用的 Giscus,后续有空会自己实现)
- 前后端分离 —— 自己写个简单的 API 服务
我选了第二个。原因很简单:我想掌握全部控制权,随意改动。
于是我的博客变成了这样:
blog/
├── apps/
│ ├── web/ # 前端 — Astro 静态博客
│ └── server/ # 后端 — Hono API 服务
├── package.json
├── pnpm-workspace.yaml
└── pnpm-lock.yamlpnpm
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 配置几个设计原则:
- 按功能分组,而不是按类型 —— 比如
components/blog/里放所有博客相关的组件,而不是把所有组件平铺 - Markdown 和代码分离 ——
content/blog/只放 Markdown 文件,组件和逻辑放src/ - 路由即文件 —— 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),在构建时执行。而 window、document 这些浏览器 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 服务:简单的力量
个人动态需要登录、发布、删除,所以需要一个后端。
我的原则是:够用就行,不搞复杂。
技术选型
| 技术 | 用途 | 选择理由 |
|---|---|---|
| Hono | Web 框架 | 轻量、快速、TypeScript 友好 |
| better-sqlite3 | 数据库 | 单文件、零配置、性能够用 |
| jose | JWT 鉴权 | 现代、安全、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 appJWT 鉴权中间件:
原理是:用户登录后发一个 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-serverPM2 是个进程管理工具,可以保证服务挂了自动重启。
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 架构让前后端分离更清晰,部署也方便。虽然一开始配置麻烦了点,但方便后续维护,长期来看是值得的。
如果你也想搭博客
我的建议是:
- 先想清楚需求 —— 你需要一个什么样的博客?
- 选对工具 —— Astro 适合博客,Next.js 适合复杂应用
- 别过度设计 —— 够用就行,后续可以迭代,重要的是执行,不能空想
未来规划
博客不是一蹴而就的,我还有一些计划:
- 文章目录(TOC)组件和目录跳转
- 照片墙页面
- 评论系统集成(当前使用的 Giscus)
- 国际化支持(如果有外国朋友想看)
参考资源:
如果这篇文章对你有帮助,欢迎分享和交流!也欢迎来我的博客留言~
喜欢这篇文章嘛,觉得文章不错的话,奖励奖励我!


评论