Skip to content

Astro 组件与样式

Astro 组件基础#

🙋 Astro 组件是构建页面的基本单元。和 React/Vue 组件不同,Astro 组件默认在服务端渲染,输出纯 HTML,不会发送任何 JavaScript 到客户端。

组件结构:Frontmatter + Template#

每个 .astro 文件都是一个组件,结构分为两部分:

---
// 🔷 组件脚本 (Frontmatter)
// 在服务端执行,支持 JavaScript/TypeScript
import Header from './Header.astro'
import { formatDate } from '../utils/date'
// 接收 Props
interface Props {
title: string
date: Date
}
const { title, date } = Astro.props
// 定义变量
const formattedDate = formatDate(date)
const items = ['苹果', '香蕉', '橙子']
---
<!-- 🔷 组件模板 --><!-- 类似 JSX 的 HTML 模板 -->
<article>
<Header />
<h1>{title}</h1>
<time>{formattedDate}</time>
<ul>
{items.map((item) => <li>{item}</li>)}
</ul>
</article>
<style>
/* 🔷 组件样式(默认 scoped) */
article {
max-width: 800px;
margin: 0 auto;
}
</style>

Props 定义与类型声明#

使用 TypeScript 接口定义 Props 类型:

---
// 方式 1: 直接定义 Props 接口
interface Props {
title: string
subtitle?: string // 可选
count: number
tags: string[]
}
const { title, subtitle, count, tags } = Astro.props
---

也可以导入共享的类型:

---
// 方式 2: 导入类型
import type { PostSchema } from '~/content/schema'
interface Props {
post: PostSchema
}
const { post } = Astro.props
---

Astro.props 使用方式#

Astro.props 包含传递给组件的所有属性:

---
// 解构赋值(推荐)
const { title, author = '匿名' } = Astro.props
// 或直接访问
const props = Astro.props
console.log(props.title)
---
<h1>{title}</h1>
<p>作者: {author}</p>

🎯 默认值: 使用解构赋值时可以设置默认值,处理可选 Props。

传递 Props#

使用组件时通过属性传递数据:

---
import Card from './Card.astro'
const post = {
title: 'Hello Astro',
description: '入门教程',
tags: ['astro', 'tutorial'],
}
---
<!-- 传递单个属性 -->
<Card title="标题" count={42} />
<!-- 传递对象 -->
<Card post={post} />
<!-- 展开对象属性 -->
<Card {...post} />
<!-- 动态属性 -->
<Card title={post.title} tags={post.tags} />

插槽系统#

插槽(Slot)让组件可以接收并渲染子内容,类似 Vue 的 slot 或 React 的 children。

默认插槽 <slot />#

最简单的插槽用法:

src/components/Card.astro
---
interface Props {
title: string
}
const { title } = Astro.props
---
<div class="card">
<h2>{title}</h2>
<div class="content">
<slot />
<!-- 子内容渲染在这里 -->
</div>
</div>

使用时:

---
import Card from './Card.astro'
---
<Card title="我的卡片">
<p>这段内容会插入到 slot 位置</p>
<button>点击我</button>
</Card>

渲染结果:

<div class="card">
<h2>我的卡片</h2>
<div class="content">
<p>这段内容会插入到 slot 位置</p>
<button>点击我</button>
</div>
</div>

具名插槽 <slot name="xxx" />#

一个组件可以有多个插槽:

src/components/Layout.astro
<div class="layout">
<header>
<slot name="header" />
</header>
<main>
<slot />
<!-- 默认插槽 -->
</main>
<footer>
<slot name="footer" />
</footer>
</div>

使用具名插槽:

---
import Layout from './Layout.astro'
---
<Layout>
<nav slot="header">导航栏</nav>
<article>这是主要内容,放入默认插槽</article>
<p slot="footer">版权信息 © 2025</p>
</Layout>

🔶 注意: 使用 slot="name" 属性指定内容放入哪个插槽。

插槽回退内容#

当没有传入内容时显示默认值:

src/components/Button.astro
<button class="btn">
<slot>
默认按钮文字 <!-- 没有传入内容时显示 -->
</slot>
</button>
<!-- 显示 "默认按钮文字" -->
<Button />
<!-- 显示 "点击提交" -->
<Button>点击提交</Button>

检查插槽是否有内容#

使用 Astro.slots API:

---
const hasHeader = Astro.slots.has('header')
---
<div class="card">
{
hasHeader && (
<header>
<slot name="header" />
</header>
)
}
<main>
<slot />
</main>
</div>

组件组合#

布局组件 (Layout)#

布局组件定义页面的公共结构:

src/layouts/BaseLayout.astro
---
import '~/styles/global.css'
import Header from '~/components/Header.astro'
import Footer from '~/components/Footer.astro'
interface Props {
title: string
description?: string
}
const { title, description } = Astro.props
---
<!doctype html>
<html lang="zh-Hans">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width" />
<meta name="description" content={description} />
<title>{title}</title>
</head>
<body>
<Header />
<main>
<slot />
</main>
<Footer />
</body>
</html>

页面使用布局:

src/pages/about.astro
---
import BaseLayout from '~/layouts/BaseLayout.astro'
---
<BaseLayout title="关于我" description="了解更多关于我的信息">
<h1>关于我</h1>
<p>这是关于页面的内容...</p>
</BaseLayout>

可复用 UI 组件#

将重复的 UI 抽取为组件:

src/components/PostCard.astro
---
interface Props {
title: string
description: string
date: Date
href: string
}
const { title, description, date, href } = Astro.props
const formattedDate = date.toLocaleDateString('zh-CN')
---
<article class="post-card">
<a href={href}>
<h3>{title}</h3>
<p>{description}</p>
<time datetime={date.toISOString()}>{formattedDate}</time>
</a>
</article>
<style>
.post-card {
padding: 1.5rem;
border: 1px solid #eee;
border-radius: 8px;
transition: box-shadow 0.2s;
}
.post-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
h3 {
margin: 0 0 0.5rem;
color: #333;
}
p {
color: #666;
margin: 0 0 1rem;
}
time {
font-size: 0.875rem;
color: #999;
}
</style>

组件导入与路径别名#

使用路径别名简化导入:

---
// 使用别名(推荐)
import Header from '~/components/Header.astro'
import { SITE } from '~/config'
import { formatDate } from '~/utils/date'
// 而不是相对路径
// import Header from '../../components/Header.astro'
---

配置路径别名(tsconfig.json):

{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"~/*": ["./src/*"]
}
}
}

框架组件集成#

Astro 最强大的特性之一是可以混用不同框架的组件。

安装框架集成#

Terminal window
# React
pnpm astro add react
# Vue
pnpm astro add vue
# Svelte
pnpm astro add svelte
# Solid
pnpm astro add solid-js

React/Vue/Svelte 组件导入#

安装后即可直接导入框架组件:

---
// 在 Astro 组件中混用多个框架
import ReactCounter from '../components/ReactCounter.jsx'
import VueTimer from '../components/VueTimer.vue'
import SvelteToggle from '../components/SvelteToggle.svelte'
---
<div>
<h1>混合框架演示</h1>
<ReactCounter client:load />
<VueTimer client:idle />
<SvelteToggle client:visible />
</div>

client:* 指令详解#

🎯 框架组件默认只渲染 HTML,不会加载 JavaScript。要启用交互,需要添加 client:* 指令。

指令加载时机使用场景
client:load页面加载时立即需要立即交互的组件
client:idle页面空闲时非关键交互组件
client:visible组件进入视口时页面下方的组件
client:media匹配媒体查询时响应式组件
client:only只在客户端渲染依赖浏览器 API

client - 最高优先级

<!-- 页面加载时立即 hydrate -->
<SearchBar client:load />

client - 延迟加载

<!-- 页面空闲时加载,使用 requestIdleCallback -->
<NewsletterForm client:idle />

client - 懒加载

<!-- 滚动到可见区域时加载 -->
<CommentSection client:visible />
<InfiniteScroll client:visible />

client - 条件加载

<!-- 仅在移动端加载 -->
<MobileMenu client:media="(max-width: 768px)" />

client - 纯客户端渲染

<!-- 不在服务端渲染,只在客户端渲染 --><!-- 必须指定框架 -->
<BrowserOnlyChart client:only="react" />

🔶 注意: client:only 需要指定框架名称,因为服务端不会执行渲染。

混合框架组件#

一个 Astro 页面可以同时使用多个框架:

---
import Layout from '../layouts/Layout.astro'
import ReactHeader from '../components/ReactHeader.jsx'
import VueFooter from '../components/VueFooter.vue'
import SvelteButton from '../components/SvelteButton.svelte'
---
<Layout>
<ReactHeader client:load />
<main>
<h1>这是 Astro 内容</h1>
<SvelteButton client:visible> 点击我 </SvelteButton>
</main>
<VueFooter client:idle />
</Layout>

🤔 什么时候这样做有意义?

样式方案#

Scoped CSS(组件内样式)#

Astro 组件中的 <style> 默认是 scoped 的:

Button.astro
<button class="btn">点击</button>
<style>
/* 只影响当前组件的 .btn */
.btn {
padding: 0.5rem 1rem;
background: blue;
color: white;
border: none;
border-radius: 4px;
}
.btn:hover {
background: darkblue;
}
</style>

Astro 通过添加唯一的 data-astro-cid-xxx 属性实现样式隔离:

<!-- 渲染结果 -->
<button class="btn" data-astro-cid-abc123>点击</button>
<style>
.btn[data-astro-cid-abc123] {
/* styles */
}
</style>

全局样式#

方式 1: 使用 is:global 指令

<style is:global>
/* 这些样式是全局的 */
body {
margin: 0;
font-family: system-ui, sans-serif;
}
a {
color: blue;
}
</style>

方式 2: 在组件脚本中导入

---
// 导入全局样式文件
import '../styles/global.css'
---

方式 3: 使用 :global() 选择器

<style>
/* 只有 .wrapper 内的 p 是全局的 */
.wrapper :global(p) {
color: red;
}
</style>

CSS 变量与主题切换#

使用 CSS 变量实现主题切换:

src/styles/theme.css
:root {
/* 亮色主题 */
--color-bg: #ffffff;
--color-text: #333333;
--color-primary: #0066cc;
--color-secondary: #666666;
}
:root.dark {
/* 暗色主题 */
--color-bg: #1a1a1a;
--color-text: #e5e5e5;
--color-primary: #66b3ff;
--color-secondary: #999999;
}

组件中使用变量:

<style>
.card {
background: var(--color-bg);
color: var(--color-text);
border: 1px solid var(--color-secondary);
}
.card a {
color: var(--color-primary);
}
</style>

主题切换脚本:

<script>
function toggleTheme() {
document.documentElement.classList.toggle('dark')
const isDark = document.documentElement.classList.contains('dark')
localStorage.setItem('theme', isDark ? 'dark' : 'light')
}
// 初始化主题
const savedTheme = localStorage.getItem('theme')
if (savedTheme === 'dark') {
document.documentElement.classList.add('dark')
}
</script>

UnoCSS 在当前项目中的应用#

当前博客使用 UnoCSS 作为原子化 CSS 方案。UnoCSS 是一个即时原子化 CSS 引擎,性能优于 Tailwind CSS。

配置 UnoCSS:

uno.config.ts
import { defineConfig, presetUno, presetIcons } from 'unocss'
export default defineConfig({
presets: [
presetUno(), // 默认预设(类似 Tailwind)
presetIcons({
// 图标预设
scale: 1.2,
cdn: 'https://esm.sh/',
}),
],
shortcuts: {
btn: 'px-4 py-2 rounded bg-blue-500 text-white hover:bg-blue-600',
card: 'p-4 rounded-lg shadow-md bg-white dark:bg-gray-800',
},
})

在 Astro 中使用:

---
// astro.config.ts 中已配置 unocss 集成
---
<div class="flex items-center gap-4 p-6">
<button class="btn">点击</button>
<div class="card">
<h3 class="text-lg font-bold mb-2">标题</h3>
<p class="text-gray-600 dark:text-gray-400">内容</p>
</div>
</div>
<!-- 使用图标 -->
<span class="i-carbon-moon text-2xl"></span>
<span class="i-ri-sun-line text-2xl"></span>

UnoCSS vs Tailwind CSS:

特性UnoCSSTailwind CSS
构建速度极快(按需生成)
配置灵活性高(可自定义引擎)
图标支持内置 preset需额外配置
生态系统增长中成熟

实战:分析 AntfuStyle 组件#

让我们看看当前博客的组件是如何组织的。

Header/Footer 布局组件#

BaseLayout.astro - 基础布局:

src/layouts/BaseLayout.astro
---
import '@unocss/reset/tailwind.css'
import '~/styles/main.css'
import '~/styles/prose.css'
import Head from '~/components/base/Head.astro'
import NavBar from '~/components/nav/NavBar.astro'
import Footer from '~/components/base/Footer.astro'
import Background from '~/components/backgrounds/Background.astro'
interface Props {
title?: string
description?: string
bgType?: 'plum' | 'dot' | 'rose' | 'particle' | false
}
const { title, description, bgType } = Astro.props
---
<!doctype html>
<html lang="zh-Hans">
<head>
<Head {title} {description} />
</head>
<body
class="relative flex flex-col min-h-screen font-sans text-gray-700 dark:text-gray-200"
>
{bgType && <Background type={bgType} />}
<NavBar />
<main id="main" class="px-7 py-10">
<slot />
</main>
<Footer />
</body>
</html>

🎯 关键设计:

卡片/按钮等 UI 组件#

一个典型的卡片组件:

src/components/PostCard.astro
---
interface Props {
title: string
description?: string
date: Date
href: string
tags?: string[]
}
const { title, description, date, href, tags = [] } = Astro.props
---
<a
href={href}
class="group block p-4 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 transition-colors"
>
<h3
class="text-lg font-semibold text-gray-900 dark:text-gray-100 group-hover:text-blue-500 transition-colors"
>
{title}
</h3>
{
description && (
<p class="mt-2 text-gray-600 dark:text-gray-400 line-clamp-2">
{description}
</p>
)
}
<div class="mt-3 flex items-center justify-between text-sm">
<time class="text-gray-500 dark:text-gray-500">
{date.toLocaleDateString('zh-CN')}
</time>
{
tags.length > 0 && (
<div class="flex gap-2">
{tags.slice(0, 3).map((tag) => (
<span class="px-2 py-0.5 bg-gray-100 dark:bg-gray-800 rounded text-xs">
{tag}
</span>
))}
</div>
)
}
</div>
</a>

主题切换实现原理#

主题切换按钮组件:

src/components/ThemeToggle.astro
<button
id="theme-toggle"
class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
aria-label="切换主题"
>
<span class="i-ri-sun-line dark:hidden text-xl"></span>
<span class="i-ri-moon-line hidden dark:inline text-xl"></span>
</button>
<script>
const toggle = document.getElementById('theme-toggle')
// 检测系统主题偏好
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)')
// 初始化主题
function initTheme() {
const saved = localStorage.getItem('theme')
if (saved) {
document.documentElement.classList.toggle('dark', saved === 'dark')
} else if (prefersDark.matches) {
document.documentElement.classList.add('dark')
}
}
// 切换主题
function toggleTheme() {
const isDark = document.documentElement.classList.toggle('dark')
localStorage.setItem('theme', isDark ? 'dark' : 'light')
}
initTheme()
toggle?.addEventListener('click', toggleTheme)
// 监听系统主题变化
prefersDark.addEventListener('change', (e) => {
if (!localStorage.getItem('theme')) {
document.documentElement.classList.toggle('dark', e.matches)
}
})
</script>

动画背景组件 (Plum/Dot/Rose)#

当前博客支持多种动画背景。以 Plum(梅花)背景为例:

src/components/backgrounds/Background.astro
---
interface Props {
type: 'plum' | 'dot' | 'rose' | 'particle'
}
const { type } = Astro.props
---
{type === 'plum' && <PlumBackground />}
{type === 'dot' && <DotBackground />}
{type === 'rose' && <RoseBackground />}
{type === 'particle' && <ParticleBackground />}

Plum 背景使用 Canvas 绘制生长动画:

src/components/backgrounds/PlumBackground.astro
<canvas id="plum-canvas" class="fixed inset-0 pointer-events-none -z-10 op-50"
></canvas>
<script>
const canvas = document.getElementById('plum-canvas') as HTMLCanvasElement
const ctx = canvas.getContext('2d')!
// 设置画布尺寸
function resize() {
canvas.width = window.innerWidth
canvas.height = window.innerHeight
}
resize()
window.addEventListener('resize', resize)
// 绘制梅花枝干生长动画...
// (具体实现略,使用 requestAnimationFrame 绘制贝塞尔曲线)
</script>

组件最佳实践#

Props 类型完整定义#

---
// ✅ 完整的类型定义
interface Props {
/** 文章标题 */
title: string
/** 文章描述(可选) */
description?: string
/** 发布日期 */
date: Date
/** 是否显示作者 */
showAuthor?: boolean
}
const {
title,
description,
date,
showAuthor = true, // 默认值
} = Astro.props
---

合理使用 Slots#

---
// 复杂布局使用多个具名插槽
---
<article class="post">
<header>
<slot name="title" />
<slot name="meta" />
</header>
<div class="content">
<slot />
<!-- 默认插槽放主要内容 -->
</div>
{
Astro.slots.has('footer') && (
<footer>
<slot name="footer" />
</footer>
)
}
</article>

样式组织#

src/styles/
├── global.css # 全局基础样式
├── prose.css # 文章内容样式
├── markdown.css # Markdown 样式
└── page.css # 页面级样式

下一步#

🎯 现在你已经掌握了 Astro 组件系统:

下一篇文章,我们将深入学习 Astro 5 的内容集合,包括:

参考资料#