2571 字
13 分钟
Fuwari 博客主题深度架构解析:从源码到自定义开发
引言
Fuwari 是一个基于 Astro 构建的静态博客主题,以其精美的设计、流畅的动画效果和丰富的功能受到众多博主的喜爱。本文将从架构层面深入剖析 Fuwari 的实现原理,帮助你理解其设计思想,并掌握自定义开发的方法。
一、整体架构概览
1.1 技术栈组成
Fuwari 采用了现代化的前端技术栈:
| 层级 | 技术 | 用途 |
|---|---|---|
| 框架 | Astro 5.x | 静态站点生成、 Islands 架构 |
| 样式 | Tailwind CSS 3.x | 原子化 CSS 样式 |
| 组件 | Svelte 5.x | 交互式组件(主题切换等) |
| 动画 | Swup + CSS | 页面过渡动画 |
| 搜索 | Pagefind | 静态搜索索引 |
| 图标 | Iconify | 图标系统 |
| 代码高亮 | Expressive Code | 代码块渲染 |
1.2 项目结构
fuwari/├── src/│ ├── components/ # 可复用组件│ │ ├── control/ # 控制组件(分页、返回顶部)│ │ ├── misc/ # 杂项组件(Markdown、图片)│ │ └── widget/ # 侧边栏组件│ ├── content/ # 内容集合配置│ │ ├── config.ts # 文章 schema 定义│ │ └── posts/ # 博客文章目录│ ├── i18n/ # 国际化│ ├── layouts/ # 页面布局│ ├── pages/ # 路由页面│ ├── plugins/ # 自定义 remark/rehype 插件│ ├── styles/ # 全局样式│ ├── types/ # TypeScript 类型定义│ └── utils/ # 工具函数├── public/ # 静态资源└── scripts/ # 构建脚本二、核心架构设计
2.1 布局系统(Layout System)
Fuwari 采用了三层布局架构:
2.1.1 Layout.astro - 基础布局层
这是所有页面的根布局,负责:
- HTML 骨架和元数据
- 主题色和暗色模式初始化
- 全局样式变量注入
- PhotoSwipe 图片灯箱初始化
- Swup 页面过渡配置
<!-- 关键代码片段:主题初始化 --><script is:inline> // 从 localStorage 读取主题设置 const theme = localStorage.getItem('theme') || DEFAULT_THEME; switch (theme) { case LIGHT_MODE: document.documentElement.classList.remove('dark'); break; case DARK_MODE: document.documentElement.classList.add('dark'); break; case AUTO_MODE: // 根据系统偏好自动切换 }
// 读取主题色相 const hue = localStorage.getItem('hue') || configHue; document.documentElement.style.setProperty('--hue', hue);</script>2.1.2 MainGridLayout.astro - 主网格布局层
这是博客页面的主要布局,实现了响应式三栏设计:
┌─────────────────────────────────────────────┐│ Navbar │├─────────────────────────────────────────────┤│ ││ Banner │ ← 可选的顶部横幅│ │├──────────┬────────────────────────┬─────────┤│ │ │ ││ Sidebar │ Main Content │ TOC │ ← 文章目录(桌面端右侧)│ (左侧) │ (中间主内容) │ (右侧) ││ │ │ │├──────────┴────────────────────────┴─────────┤│ Footer │└─────────────────────────────────────────────┘关键实现细节:
<!-- 主网格使用 CSS Grid 实现 --><div id="main-grid" class="grid grid-cols-[17.5rem_auto] lg:grid-rows-[auto]"> <SideBar class="col-span-2 lg:col-span-1"></SideBar> <main id="swup-container" class="col-span-2 lg:col-span-1"> <slot></slot> <!-- 页面内容注入点 --> </main></div>响应式断点策略:
lg(1024px+): 三栏布局(侧边栏 + 主内容 + TOC)md(768px+): 两栏布局- 移动端: 单栏,侧边栏移至底部
2.2 内容管理系统
2.2.1 Astro Content Collections
Fuwari 使用 Astro 的内容集合 API 管理文章:
const postsCollection = defineCollection({ schema: z.object({ title: z.string(), published: z.date(), updated: z.date().optional(), draft: z.boolean().optional().default(false), description: z.string().optional().default(""), image: z.string().optional().default(""), tags: z.array(z.string()).optional().default([]), category: z.string().optional().nullable().default(""), lang: z.string().optional().default(""), // 内部使用的导航字段 prevTitle: z.string().default(""), prevSlug: z.string().default(""), nextTitle: z.string().default(""), nextSlug: z.string().default(""), }),});2.2.2 文章排序与导航
getSortedPosts() 函数实现了文章排序和前后篇关联:
export async function getSortedPosts() { const sorted = await getRawSortedPosts(); // 按发布日期降序
// 构建前后篇导航 for (let i = 1; i < sorted.length; i++) { sorted[i].data.nextSlug = sorted[i - 1].slug; sorted[i].data.nextTitle = sorted[i - 1].data.title; } for (let i = 0; i < sorted.length - 1; i++) { sorted[i].data.prevSlug = sorted[i + 1].slug; sorted[i].data.prevTitle = sorted[i + 1].data.title; } return sorted;}2.3 样式系统架构
2.3.1 CSS 变量体系
Fuwari 使用 Stylus 定义了一套完整的 CSS 变量系统:
:root --radius-large: 1rem --primary: oklch(0.70 0.14 var(--hue)) --page-bg: oklch(0.95 0.01 var(--hue)) --card-bg: white // ... 更多变量
// 暗色模式覆盖:root.dark --primary: oklch(0.75 0.14 var(--hue)) --page-bg: oklch(0.16 0.014 var(--hue)) --card-bg: oklch(0.23 0.015 var(--hue))设计亮点:
- 使用 OKLCH 色彩空间,支持色相自定义
--hue变量允许用户通过滑块调整主题色- 明暗模式通过 CSS 类切换,无需重新加载
2.3.2 Tailwind 集成
module.exports = { content: ["./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue,mjs}"], darkMode: "class", // 手动切换暗色模式 theme: { extend: { fontFamily: { sans: ["MiSans", "sans-serif", ...defaultTheme.fontFamily.sans], }, }, },}2.4 插件系统
Fuwari 通过 Remark/Rehype 插件扩展 Markdown 功能:
2.4.1 阅读时间统计
import { toString } from "mdast-util-to-string";import getReadingTime from "reading-time";
export function remarkReadingTime() { return (tree, { data }) => { const textOnPage = toString(tree); const readingTime = getReadingTime(textOnPage); // 注入到 frontmatter data.astro.frontmatter.minutes = Math.max(1, Math.round(readingTime.minutes)); data.astro.frontmatter.words = readingTime.words; };}2.4.2 文章摘要生成
export function remarkExcerpt() { return (tree, { data }) => { let excerpt = ""; // 遍历 AST,提取前 150 个字符 for (const node of tree.children) { if (node.type === "paragraph") { excerpt += extractText(node); if (excerpt.length > 150) break; } } data.astro.frontmatter.excerpt = excerpt.slice(0, 150) + "..."; };}2.4.3 自定义指令组件
支持在 Markdown 中使用自定义指令:
:::github{repo="facebook/react"}:::
:::tip这是一个提示框:::实现原理:
rehypePlugins: [ [rehypeComponents, { components: { github: GithubCardComponent, tip: (x, y) => AdmonitionComponent(x, y, "tip"), // ... } }]]三、核心功能实现详解
3.1 文章卡片组件(PostCard)
这是博客列表的核心组件,实现了响应式双模式布局:
桌面端布局:
┌─────────────────────────────────────────┬─────────┐│ │ ││ Title │ Cover ││ │ Image ││ Category • Date │ (右侧) ││ │ ││ Description excerpt... │ ││ │ ││ 1,234 words | 5 min │ ││ │ │└─────────────────────────────────────────┴─────────┘移动端布局:
┌─────────────────────────────────────────┐│ Cover Image (顶部) │├─────────────────────────────────────────┤│ Title > ││ Category • Date ││ Description... ││ 1,234 words | 5 min │└─────────────────────────────────────────┘关键代码实现:
<!-- 响应式布局切换 --><div class="flex flex-col-reverse md:flex-col ..."> <!-- 内容区域:桌面端左侧,移动端下方 --> <div class="w-full md:w-[calc(100%_-_var(--coverWidth)_-_12px)]"> <a href={url} class="group ..."> {title} <!-- 悬停动画效果 --> <Icon class="opacity-0 group-hover:opacity-100 ..." /> </a> <PostMetadata ... /> <div class="line-clamp-2 md:line-clamp-1">{description}</div> </div>
<!-- 封面区域:桌面端右侧绝对定位,移动端顶部 --> {hasCover && <a class="md:absolute md:right-3 ..."> <ImageWrapper src={image} ... /> </a>}</div>3.2 侧边栏组件系统
侧边栏由多个可复用的小部件组成:
3.2.1 个人资料卡片(Profile)
<div class="card-base p-3"> <!-- 头像区域 --> <a href="/about/" class="group block relative ..."> <div class="absolute ... group-hover:bg-black/30"> <Icon name="fa6-regular:address-card" class="opacity-0 group-hover:opacity-100" /> </div> <ImageWrapper src={config.avatar} ... /> </a>
<!-- 信息区域 --> <div class="text-center"> <div class="font-bold text-xl">{config.name}</div> <div class="h-1 w-5 bg-[var(--primary)] mx-auto rounded-full"> </div> <!-- 装饰性分隔线 --> <div class="text-neutral-400">{config.bio}</div>
<!-- 社交链接 --> <div class="flex flex-wrap gap-2 justify-center"> {config.links.map(item => <a href={item.url} class="btn-regular rounded-lg h-10 w-10"> <Icon name={item.icon} /> </a> )} </div> </div></div>3.2.2 分类和标签组件
<div class="flex flex-wrap gap-2"> {categories.map(category => <a href={category.url} class="btn-regular rounded-full px-3 py-1 text-sm"> {category.name} {category.count} </a> )}</div>3.3 文章详情页实现
文章页是功能最复杂的页面,包含:
3.3.1 动态路由生成
---export async function getStaticPaths() { const blogEntries = await getSortedPosts(); return blogEntries.map((entry) => ({ params: { slug: entry.slug }, props: { entry }, }));}
const { entry } = Astro.props;const { Content, headings } = await entry.render();---3.3.2 文章元数据展示
文章头部包含:
- 字数统计和阅读时间(通过 remark 插件计算)
- 发布/更新日期
- 分类和标签
- 封面图片
<div class="flex flex-row text-black/30 dark:text-white/30 gap-5 mb-3"> <div class="flex items-center"> <Icon name="material-symbols:notes-rounded" /> <div>{remarkPluginFrontmatter.words} words</div> </div> <div class="flex items-center"> <Icon name="material-symbols:schedule-outline-rounded" /> <div>{remarkPluginFrontmatter.minutes} min read</div> </div></div>3.3.3 Markdown 渲染
<Markdown class="mb-6 markdown-content onload-animation"> <Content /></Markdown>Markdown.astro 组件封装了:
- Tailwind Typography 样式 (
prose) - 代码块复制按钮
- 图片点击放大(PhotoSwipe)
- 锚点链接生成
3.4 搜索功能实现
Fuwari 使用 Pagefind 实现静态搜索:
3.4.1 构建时索引生成
{ "scripts": { "build": "astro build && pagefind --site dist" }}3.4.2 前端搜索组件
<script> let keyword = ""; let results = [];
async function search() { if (!window.pagefind) return; const response = await window.pagefind.search(keyword); results = await Promise.all( response.results.map(item => item.data()) ); }</script>
<div id="search-bar"> <input bind:value={keyword} on:input={search} placeholder="Search..." /></div>
<div id="search-results"> {#each results as item} <a href={item.url}> <div>{item.meta.title}</div> <div>{@html item.excerpt}</div> </a> {/each}</div>四、扩展开发指南
4.1 添加新页面
以添加”友链”页面为例:
步骤 1: 创建页面组件
---import Layout from "@/layouts/Layout.astro";---
<Layout title="友链 | Friends"> <div class="max-w-4xl mx-auto px-4 py-8"> <h1 class="text-4xl font-bold mb-8 text-center">✨ 友情链接</h1>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <!-- 友链卡片 --> <a href="https://example.com" class="card-base p-4 flex items-center gap-4 hover:border-[var(--primary)] transition-all"> <img src="avatar.jpg" class="w-12 h-12 rounded-full" /> <div> <h3 class="font-semibold">站点名称</h3> <p class="text-sm text-[var(--text-muted)]">站点描述</p> </div> </a> </div> </div></Layout>步骤 2: 添加到导航栏
export const navBarConfig: NavBarConfig = { links: [ LinkPreset.Home, LinkPreset.Archive, { name: "友链✨", url: "/friends/", }, LinkPreset.About, ],};4.2 自定义主题色
Fuwari 支持动态调整主题色相:
export const siteConfig: SiteConfig = { themeColor: { hue: 250, // 默认色相 (0-360) fixed: false, // 是否隐藏主题色选择器 },};实现原理:
- 所有颜色使用
oklch()函数定义 - 色相通过 CSS 变量
--hue控制 - 用户调整时更新 localStorage 和 CSS 变量
4.3 添加自定义组件
步骤 1: 创建组件
---interface Props { title: string; content: string;}const { title, content } = Astro.props;---
<div class="card-base p-6 my-4 border-l-4 border-[var(--primary)]"> <h3 class="text-xl font-bold mb-2">{title}</h3> <p class="text-[var(--text-muted)]">{content}</p></div>步骤 2: 在 Markdown 中使用
通过 rehype-components 插件注册:
import { MyComponent } from "./src/components/MyComponent.astro";
rehypePlugins: [ [rehypeComponents, { components: { mycomponent: MyComponent, } }]]Markdown 中使用:
:::mycomponent{title="提示" content="这是自定义组件"}:::4.4 修改文章 Schema
添加自定义字段:
const postsCollection = defineCollection({ schema: z.object({ // ... 原有字段 author: z.string().optional().default("默认作者"), difficulty: z.enum(["简单", "中等", "困难"]).optional(), series: z.string().optional(), // 系列文章 }),});在模板中使用:
<!-- 显示难度标签 -->{entry.data.difficulty && ( <span class="badge"> {entry.data.difficulty} </span>)}五、性能优化策略
5.1 Islands 架构
Astro 的 Islands 架构确保:
- 静态内容在构建时生成 HTML
- 交互组件(如搜索)按需 hydrate
- 减少客户端 JavaScript 体积
<!-- 静态渲染 --><PostCard entry={entry} />
<!-- 客户端交互组件 --><Search client:only="svelte" />5.2 图片优化
<!-- ImageWrapper 组件封装了 Sharp 优化 --><ImageWrapper src="large-image.jpg" alt="描述" class="w-full h-auto"/>5.3 字体加载策略
/* 使用 font-display: swap 防止 FOIT */@font-face { font-family: 'MiSans'; src: url('/fonts/MiSans-Normal.woff2') format('woff2'); font-weight: 400; font-display: swap; /* 关键:先显示后备字体 */}六、部署与构建
6.1 构建流程
# 1. 类型检查pnpm run type-check
# 2. 构建 Astro 站点astro build
# 3. 生成搜索索引pagefind --site dist
# 4. 输出目录: dist/6.2 部署配置
Vercel 配置:
{ "framework": "astro"}GitHub Actions:
name: Deployon: [push]jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v2 - run: pnpm install - run: pnpm build - uses: peaceiris/actions-gh-pages@v3 with: github_token: ${{ secrets.GITHUB_TOKEN }} publish_dir: ./dist七、总结
Fuwari 的架构设计体现了以下优秀实践:
- 组件化设计:可复用的卡片、按钮、布局组件
- 配置驱动:通过 config.ts 集中管理站点配置
- 类型安全:全面的 TypeScript 类型定义
- 性能优先:静态生成、图片优化、按需加载
- 可扩展性:插件系统支持自定义 Markdown 组件
- 国际化:完整的 i18n 支持
通过理解这些架构设计,你可以:
- 自信地修改和扩展主题功能
- 添加自定义页面和组件
- 优化性能和用户体验
- 迁移到其他 Astro 项目
参考资源:
Fuwari 博客主题深度架构解析:从源码到自定义开发
https://fuwari.vercel.app/posts/tech/fuwari-architecture-deep-dive/