从零搭建 Astro 个人博客
使用 Astro + TailwindCSS 构建现代化静态博客,实现内容集合、置顶排序、时间线导航等最佳实践。
从零搭建 Astro 个人博客
本文记录使用 Astro 构建个人博客的完整流程,涵盖项目初始化、内容管理、交互优化、组件抽象等核心环节,并总结实际开发中的最佳实践。
一、技术选型
为什么选择 Astro?
Astro 是专为内容驱动型网站设计的现代 Web 框架,核心优势包括:
- 零 JavaScript 输出 — 默认不发送 JS,按需 hydration,性能极致
- 静态生成优先 — 预渲染 HTML,SEO 友好
- 多框架兼容 — 可混用 React、Vue、Svelte 组件
- Markdown 原生支持 — Content Collections 提供类型安全的内容管理
技术栈
| 模块 | 技术选型 | 理由 |
|---|---|---|
| 框架 | Astro 4.x | 静态生成,零 JS 输出 |
| 样式 | TailwindCSS | 原子化 CSS,开发效率高 |
| 内容 | Markdown + Content Collections | 类型安全,写作体验佳 |
| 部署 | Cloudflare Pages | 全球 CDN,自动 HTTPS,免费 |
| 版本控制 | GitHub | 免费托管,CI/CD 集成 |
二、项目初始化
创建项目
npm create astro@latest my-blog
# 或
npx create-astro@latest my-blog
推荐选项:
- 使用空模板(Empty)
- 启用 TypeScript
- 添加 TailwindCSS 集成
安装 TailwindCSS
npm install -D @astrojs/tailwind tailwindcss
astro.config.mjs:
import { defineConfig } from 'astro/config';
import tailwind from '@astrojs/tailwind';
export default defineConfig({
integrations: [tailwind()],
});
三、内容集合(Content Collections)
1. 定义 Schema
src/content/config.ts:
import { defineCollection, z } from 'astro:content';
const blogCollection = defineCollection({
type: 'content',
schema: z.object({
title: z.string(),
description: z.string(),
pubDate: z.date(),
tags: z.array(z.string()).default([]),
pinned: z.boolean().default(false),
}),
});
export const collections = {
'blog': blogCollection,
};
Schema 设计要点:
title和description为必填字段pubDate使用z.date()自动解析 ISO 字符串tags默认为空数组,避免未定义错误pinned控制文章置顶,默认false
2. 创建文章
src/content/blog/your-post.md:
---
title: "文章标题"
description: "文章摘要,用于列表页展示"
pubDate: 2026-05-14
tags: ["技术", "笔记"]
pinned: true
---
# 正文
这里是文章内容...
Frontmatter 规范:
- 日期格式:
YYYY-MM-DD(ISO 8601) - 标签数组:使用双引号,避免解析错误
- 置顶标识:
pinned: true即可
四、核心功能实现
4.1 Tag 展示与筛选
问题描述
初始实现中,tag 标签挤在一起显示(如 “AI 自动化生产力”),缺乏间距和交互反馈。
设计目标
- 视觉:Stone 色系,适当间距,悬停微交互
- 交互:点击筛选,URL 同步,浏览器导航支持
- 无障碍:
aria-label,键盘可访问
实现方案
单篇文章页 (src/pages/blog/[slug].astro):
<div class="row gap-2 flex-wrap">
{post.data.tags.map((tag) => (
<a href={`/blog?tag=${encodeURIComponent(tag)}`}
class="pill text-xs text-stone-600 bg-stone-100 hover:bg-stone-200 hover:text-stone-900 transition-colors px-2.5 py-0.5"
aria-label={`查看 ${tag} 标签的文章`}>
#{tag}
</a>
))}
</div>
关键设计:
gap-2 flex-wrap:间距 + 换行- Stone 色系:
text-stone-600 bg-stone-100 - 微交互:
hover:bg-stone-200 transition-colors - 标签前缀:
#符号 - 可点击:
<a>标签跳转列表页
博客列表页 (src/pages/blog.astro):
<!-- Tag 筛选按钮 -->
<div class="tags">
<button class="tag" data-tag="all">
全部 <span class="ct">({posts.length})</span>
</button>
{allTags.map((tag) => (
<button class="tag" data-tag={tag}>
{tag} <span class="ct">({tagCounts[tag]})</span>
</button>
))}
</div>
<!-- 文章列表 -->
<ol class="posts" id="posts-list">
{posts.map((post) => (
<li class="post" data-tags={post.data.tags.join(',')}>
<!-- 文章内容 -->
</li>
))}
</ol>
客户端筛选逻辑 (<script is:inline>):
(function() {
const tagButtons = document.querySelectorAll('.tag');
const posts = document.querySelectorAll('#posts-list .post');
const urlParams = new URLSearchParams(window.location.search);
const selectedTag = urlParams.get('tag');
// 初始化状态
if (selectedTag) {
tagButtons.forEach(btn => {
btn.classList.toggle('on', btn.dataset.tag === selectedTag);
});
filterPosts(selectedTag);
} else {
tagButtons[0]?.classList.add('on');
}
// 点击筛选
tagButtons.forEach(btn => {
btn.addEventListener('click', () => {
const tag = btn.dataset.tag;
// 更新按钮状态
tagButtons.forEach(b => b.classList.remove('on'));
btn.classList.add('on');
// 更新 URL(不刷新)
const newUrl = tag === 'all'
? '/blog'
: '/blog?tag=' + encodeURIComponent(tag);
history.pushState({ tag: tag }, '', newUrl);
// 筛选文章
filterPosts(tag);
});
});
// 筛选函数
function filterPosts(tag) {
posts.forEach(post => {
const postTags = post.dataset.tags.split(',');
post.style.display = (tag === 'all' || postTags.includes(tag)) ? '' : 'none';
});
}
// 浏览器前进后退
window.addEventListener('popstate', function(e) {
var tag = (e.state && e.state.tag) || 'all';
tagButtons.forEach(function(btn) {
btn.classList.toggle('on', btn.dataset.tag === tag);
});
filterPosts(tag);
});
})();
踩坑记录
问题 1:Script 组件压缩错误
错误写法:
import Script from 'astro:script';
<Script>
const x = 1;
</Script>
正确写法:
<script is:inline>
const x = 1;
</script>
问题 2:模板字符串兼容
错误写法(压缩后出错):
const url = `/blog?tag=${encodeURIComponent(tag)}`;
正确写法:
const url = '/blog?tag=' + encodeURIComponent(tag);
问题 3:现代语法兼容
错误写法:
const tag = e.state?.tag || 'all';
tagButtons.forEach(btn => { ... });
正确写法:
var tag = (e.state && e.state.tag) || 'all';
tagButtons.forEach(function(btn) { ... });
最佳实践:
- 使用
<script is:inline>而非<Script>组件 - 避免模板字符串,使用字符串拼接
- 避免可选链
?.和箭头函数,使用传统语法 - 使用
var而非const/let增强兼容性
4.2 置顶文章功能
需求分析
支持文章置顶,置顶文章在列表和首页优先展示,并带有视觉标识。
实现方案
排序逻辑:
- 分离置顶和普通文章
- 各自按日期倒序排序
- 合并:置顶在前,普通在后
代码实现:
const pinnedPosts = allPosts.filter(post => post.data.pinned);
const normalPosts = allPosts.filter(post => !post.data.pinned);
const sortedPinned = pinnedPosts.sort((a, b) => {
const dateA = a.data.pubDate || new Date(0);
const dateB = b.data.pubDate || new Date(0);
return dateB.getTime() - dateA.getTime();
});
const sortedNormal = normalPosts.sort((a, b) => {
const dateA = a.data.pubDate || new Date(0);
const dateB = b.data.pubDate || new Date(0);
return dateB.getTime() - dateA.getTime();
});
const posts = [...sortedPinned, ...sortedNormal];
视觉标识:
<time class="date">
{post.data.pubDate?.toLocaleDateString('zh-CN')}
{post.data.pinned && (
<span class="pinned-badge" title="置顶">📌</span>
)}
</time>
CSS 样式:
.post.pinned .date {
color: var(--accent);
font-weight: 500;
}
.post.pinned .pinned-badge {
display: inline-block;
margin-left: 0.5rem;
font-size: 14px;
}
.post.pinned .date::after {
content: '• 置顶';
display: block;
color: var(--accent);
font-size: 10px;
margin-top: 2px;
}
效果:
- 日期高亮(主题色)
- 图钉图标 📌
- ”• 置顶” 文字说明
4.3 排序逻辑抽象化
问题
排序代码在首页、博客列表页重复,维护成本高。
解决方案
创建工具函数库,统一排序逻辑。
src/utils/posts.ts:
export interface BlogPost {
data: {
pubDate?: Date;
pinned?: boolean;
[key: string]: unknown;
};
slug: string;
[key: string]: unknown;
}
/**
* 按置顶和日期排序文章
*/
export function sortPostsByPinnedAndDate(posts: BlogPost[]): BlogPost[] {
const pinnedPosts = posts.filter(post => post.data.pinned);
const normalPosts = posts.filter(post => !post.data.pinned);
const sortedPinned = pinnedPosts.sort((a, b) => {
const dateA = a.data.pubDate || new Date(0);
const dateB = b.data.pubDate || new Date(0);
return dateB.getTime() - dateA.getTime();
});
const sortedNormal = normalPosts.sort((a, b) => {
const dateA = a.data.pubDate || new Date(0);
const dateB = b.data.pubDate || new Date(0);
return dateB.getTime() - dateA.getTime();
});
return [...sortedPinned, ...sortedNormal];
}
/**
* 按年月分组文章
*/
export function groupPostsByYearMonth(posts: BlogPost[]): Record<string, BlogPost[]> {
const groups: Record<string, BlogPost[]> = {};
posts.forEach(post => {
if (!post.data.pubDate) {
if (!groups['未发布']) groups['未发布'] = [];
groups['未发布'].push(post);
return;
}
const year = post.data.pubDate.getFullYear();
const month = String(post.data.pubDate.getMonth() + 1).padStart(2, '0');
const key = `${year}-${month}`;
if (!groups[key]) groups[key] = [];
groups[key].push(post);
});
return groups;
}
/**
* 获取年月显示文本
*/
export function getYearMonthLabel(key: string): string {
if (key === '未发布') return '未发布';
const [year, month] = key.split('-');
return `${year}年${parseInt(month)}月`;
}
复用示例:
src/pages/index.astro:
import { sortPostsByPinnedAndDate } from '../utils/posts';
const allPostsRaw = await getCollection('blog');
const allPosts = sortPostsByPinnedAndDate(allPostsRaw);
const latestPosts = allPosts.slice(0, 3);
优势:
- 代码复用,减少重复
- 统一排序逻辑,避免不一致
- 类型安全(TypeScript)
- 易于测试和维护
4.4 时间线组件
需求
在博客列表页右侧添加时间线,按年月分组展示文章,支持滚动跟随和折叠。
设计要点
- 布局:右侧固定宽度(280px),不挤占列表空间
- 定位:
position: sticky,滚动时跟随 - 分组:按
2026 年 5 月格式分组,显示文章数量 - 交互:可折叠,悬停效果,置顶标识
- 响应式:移动端隐藏
实现
src/components/Timeline.astro:
---
import { sortPostsByPinnedAndDate, groupPostsByYearMonth, getYearMonthLabel } from '../utils/posts';
interface Props {
posts: {
data: {
pubDate?: Date;
title: string;
pinned?: boolean;
};
slug: string;
}[];
}
const { posts } = Astro.props;
const sortedPosts = sortPostsByPinnedAndDate(posts);
const groupedPosts = groupPostsByYearMonth(sortedPosts);
const yearMonths = Object.keys(groupedPosts).sort((a, b) => {
if (a === '未发布' || b === '未发布') return a === '未发布' ? 1 : -1;
return b.localeCompare(a);
});
---
<aside class="timeline">
<div class="timeline-header">
<h3>时间线</h3>
<div class="timeline-toggle" title="收起/展开">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M4 6L8 10L12 6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</div>
</div>
<div class="timeline-content">
{yearMonths.map((yearMonth) => (
<div class="timeline-group">
<div class="timeline-year">
<span class="year-label">{getYearMonthLabel(yearMonth)}</span>
<span class="year-count">({groupedPosts[yearMonth].length})</span>
</div>
<ul class="timeline-list">
{groupedPosts[yearMonth].map((post) => (
<li class={`timeline-item ${post.data.pinned ? 'pinned' : ''}`}>
<div class="timeline-dot"></div>
<div class="timeline-content-item">
<time class="timeline-date">
{post.data.pubDate?.toLocaleDateString('zh-CN', { month: 'numeric', day: 'numeric' })}
</time>
<a href={`/blog/${post.slug}`} class="timeline-title">
{post.data.title}
{post.data.pinned && <span class="pinned-icon">📌</span>}
</a>
</div>
</li>
))}
</ul>
</div>
))}
</div>
</aside>
<style>
.timeline {
position: sticky;
top: 5rem;
max-height: calc(100vh - 6rem);
overflow-y: auto;
border-left: 1px solid var(--border);
padding-left: 1.5rem;
margin-left: 2rem;
}
.timeline-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 0;
border-bottom: 1px solid var(--border);
}
.timeline-content.collapsed {
max-height: 0;
opacity: 0;
overflow: hidden;
}
.timeline-item {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.5rem 0;
transition: transform 0.2s;
}
.timeline-item:hover {
transform: translateX(4px);
}
.timeline-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--text-muted);
margin-top: 0.5rem;
transition: all 0.2s;
}
.timeline-item:hover .timeline-dot {
background: var(--accent);
transform: scale(1.3);
}
@media (max-width: 768px) {
.timeline {
display: none;
}
}
</style>
<script is:inline>
(function() {
const toggle = document.querySelector('.timeline-toggle');
const content = document.querySelector('.timeline-content');
if (toggle && content) {
toggle.addEventListener('click', () => {
content.classList.toggle('collapsed');
const svg = toggle.querySelector('svg path');
if (svg) {
const isCollapsed = content.classList.contains('collapsed');
svg.setAttribute('d', isCollapsed ? 'M4 10L8 6L12 10' : 'M4 6L8 10L12 6');
}
});
}
})();
</script>
布局集成:
src/pages/blog.astro:
<section class="sec">
<div class="posts-container">
<ol class="posts" id="posts-list">
<!-- 文章列表 -->
</ol>
<Timeline posts={posts} />
</div>
</section>
src/styles/global.css:
.posts-container {
display: grid;
grid-template-columns: 1fr 280px;
gap: 2rem;
align-items: start;
}
交互效果:
- 悬停时文章右移 4px
- 圆点放大并高亮
- 置顶文章圆点始终高亮
- 点击标题收起/展开
五、部署与优化
5.1 Cloudflare Pages 部署
-
构建项目
npm run build -
配置 Cloudflare Pages
- 连接 GitHub 仓库
- 构建命令:
npm run build - 输出目录:
dist - 框架预设:Astro
-
自定义域名
- 在 Cloudflare Dashboard 添加自定义域名
- 自动配置 HTTPS
5.2 性能优化
构建优化:
- 使用 SSG(静态生成),避免 SSR
- 图片懒加载
- 字体子集化
运行时优化:
- 零 JavaScript 输出(按需 hydration)
- 客户端筛选使用原生 DOM 操作,无框架依赖
- 避免重排重绘,使用
transform动画
5.3 SEO 优化
- 语义化 HTML 标签(
<article>,<time>,<aside>) - Meta 标签完整(title, description, Open Graph)
- 结构化数据(JSON-LD)
- 站点地图(sitemap.xml)
- RSS 订阅
六、最佳实践总结
6.1 代码组织
- 工具函数抽象:排序、分组、格式化等通用逻辑放入
src/utils/ - 组件复用:时间线、Tag 等可复用 UI 放入
src/components/ - 类型安全:使用 TypeScript,定义清晰的接口
6.2 内容管理
- Frontmatter 规范:统一字段命名和格式
- 标签命名:使用有意义的关键词,避免过长
- 置顶策略:仅对重要文章使用
pinned: true
6.3 交互设计
- 无刷新筛选:使用
history.pushState更新 URL - 浏览器导航:监听
popstate事件支持前进后退 - 无障碍:添加
aria-label,键盘可访问
6.4 性能优先
- 避免框架依赖:客户端交互使用原生 JavaScript
- 减少重排:使用
transform而非top/left - 按需加载:图片、字体懒加载
七、后续扩展
已实现功能
- 评论系统(Giscus)
- 全文搜索(FlexSearch)
- RSS 订阅
- Tag 云图
- 相关文章推荐
- 时间线导航
- 暗黑模式(浅色/深色/跟随系统)
- 标签筛选
- 置顶文章
- 自定义面板(主题/字体/密度)
待实现功能
用户体验
- 键盘导航 -
j/k切换文章,/聚焦搜索,Esc关闭弹窗 ✅ 2026-05-21 - 图片懒加载 + 缩略图 -
loading="lazy"+ 响应式srcset✅ 2026-05-21 - 代码块复制按钮 - 一键复制代码,带 Toast 反馈 ✅ 2026-05-21
- 阅读进度条 - 页面顶部显示阅读进度 ✅ 2026-05-21
- 目录导航(TOC) - 左侧章节目录,滚动高亮 ✅ 2026-05-21
- 阅读时长估算 - 根据字数估算阅读时间 ✅ 2026-05-21
- 回到顶部按钮 - 滚动 300px 后显示 ✅ 2026-05-21
- 分享功能 - 微信/微博/Twitter/复制链接/二维码 ✅ 2026-05-21
性能优化
- 性能优化 - Lighthouse 评分目标(性能 95+,SEO 100) ✅ 2026-05-21
- 图片优化:使用
astro/assets,WebP 转换,响应式图片 - 预加载关键资源:
<link rel="preconnect">+<link rel="preload"> - 减少重排:使用
transform而非top/left
- 图片优化:使用
- 字体优化 -
font-display: swap防止 FOIT,子集化,本地托管 ✅ 2026-05-21 - 缓存策略 - Service Worker + HTTP 缓存头优化 ✅ 2026-05-21
SEO 与可访问性
- SEO 优化 - 结构化数据(JSON-LD),Open Graph 元标签
- BlogPosting 结构化数据
- Twitter Cards
- 站点地图(sitemap.xml)
- 无障碍增强 -
skip to main链接,焦点可见性,ARIA 标签
高级功能
- PWA 支持 - 离线访问,添加到主屏幕
- 多语言切换 - 中英文切换(i18n) ✅ 2026-05-21
- 网站统计 - Umami / Plausible(隐私友好)
- 邮件订阅 - Newsletter 新文章推送
更新时间: 2026-05-14
阅读时间: 12 分钟