使用时机
- 落地页和营销站点
- 企业官网
- 内容密集型站点(博客、文档)
- 任何对 SEO 和性能要求严苛的项目
- 大部分为静态、交互性极少的站点
- 需要快速首屏加载和良好 Core Web Vitals 的站点
不适用于全功能 Web 应用——交互式应用(仪表盘、CRUD 应用、实时应用)请使用 vue-frontend。
关键规则
- 默认零 JS——除非添加客户端指令,否则 Astro 不发送任何 JavaScript
- 始终使用 TypeScript——与所有 Vera Sesiom 项目一致,启用严格模式
- 对结构化内容使用内容集合——博客文章、文档、案例研究
- Islands 架构——仅在需要交互时使用 Vue/React 组件
- 默认静态输出 (SSG)——仅在需要动态内容时使用 SSR
- 使用 Tailwind CSS 进行样式设计——与 Vue 前端一致
- 尖叫架构——文件夹名称应反映业务领域,而非技术框架
决策标准:Astro vs Vue
| 何时使用 Astro | 何时使用 Vue |
|---|---|
| 落地页、营销站点 | Web 应用(仪表盘、管理后台) |
| 内容密集型(博客、文档) | 高交互性 UI |
| SEO 至关重要 | 需要实时功能 |
| 大部分是静态内容 | 复杂的状态管理 |
| 最小交互(表单、轮播图) | 完全 SPA 导航 |
| 零 JS 是一个特性 | Vue 生态系统的优势很重要 |
规则:如果站点内容是供用户阅读 → 使用 Astro。如果是供用户使用的应用 → 使用 Vue。
项目结构(尖叫架构)
apps/landing/ (or apps/website/)
├── src/
│ ├── pages/ # 基于文件的路由 (.astro 文件)
│ │ ├── index.astro # 首页
│ │ ├── about.astro # 关于我们
│ │ ├── pricing.astro # 定价页
│ │ ├── blog/
│ │ │ ├── index.astro # 博客列表
│ │ │ └── [slug].astro # 博客文章(动态)
│ │ └── contact.astro
│ ├── layouts/ # 页面布局
│ │ ├── BaseLayout.astro # 基础 HTML 结构
│ │ ├── BlogLayout.astro # 博客文章布局
│ │ └── LandingLayout.astro # 落地页布局
│ ├── components/ # Astro 组件(默认静态)
│ │ ├── sections/ # 页面区块
│ │ │ ├── Hero.astro
│ │ │ ├── Features.astro
│ │ │ ├── Pricing.astro
│ │ │ ├── Testimonials.astro
│ │ │ └── CTA.astro
│ │ ├── ui/ # 可复用的 UI 原子组件
│ │ │ ├── Button.astro
│ │ │ ├── Card.astro
│ │ │ ├── Badge.astro
│ │ │ └── Input.astro
│ │ └── interactive/ # Vue/React Islands
│ │ ├── ContactForm.vue # 带验证的表单
│ │ ├── Carousel.vue # 图片轮播
│ │ └── MobileMenu.vue # 移动端导航
│ ├── content/ # 内容集合
│ │ ├── blog/ # Markdown/MDX 博客文章
│ │ │ ├── post-1.md
│ │ │ └── post-2.mdx
│ │ └── config.ts # 集合模式 (Zod)
│ ├── styles/ # 全局样式
│ │ └── global.css # Tailwind 导入
│ ├── assets/ # 优化后的图片、字体
│ │ ├── images/
│ │ └── fonts/
│ └── utils/ # 辅助函数
│ ├── date-format.ts
│ └── seo.ts
├── public/ # 静态资源(不经过处理)
│ ├── favicon.ico
│ ├── robots.txt
│ └── fonts/
├── astro.config.mjs # Astro 配置
├── tailwind.config.mjs # Tailwind 配置
├── tsconfig.json
└── package.json
Astro 组件模式
每个 .astro 文件包含两部分:前置元数据(服务端)和模板(HTML)。
---
// src/components/ui/Button.astro
// 前置元数据:在服务端运行,不在浏览器端运行 JS
export interface Props {
variant?: 'primary' | 'secondary' | 'outline'
size?: 'sm' | 'md' | 'lg'
href?: string
}
const {
variant = 'primary',
size = 'md',
href,
...rest
} = Astro.props
const baseClasses = 'inline-flex items-center justify-center font-medium rounded-lg transition-colors'
const variantClasses = {
primary: 'bg-blue-600 text-white hover:bg-blue-700',
secondary: 'bg-gray-100 text-gray-900 hover:bg-gray-200',
outline: 'border-2 border-blue-600 text-blue-600 hover:bg-blue-50'
}
const sizeClasses = {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-base',
lg: 'px-6 py-3 text-lg'
}
const classes = `${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]}`
---
{href ? (
<a href={href} class={classes} {...rest}>
<slot />
</a>
) : (
<button class={classes} {...rest}>
<slot />
</button>
)}
关键点:
- 前置元数据在构建时(或 SSR 的请求时)运行
- 无需
export default——模板本身就是导出 - 使用
<slot />插入子内容 - 使用 TypeScript 接口对属性进行类型定义
布局模式
---
// src/layouts/BaseLayout.astro
import Nav from '../components/Nav.astro'
import Footer from '../components/Footer.astro'
export interface Props {
title: string
description?: string
image?: string
}
const { title, description = '', image = '/og-default.png' } = Astro.props
const canonicalURL = new URL(Astro.url.pathname, Astro.site)
---
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="canonical" href={canonicalURL} />
<title>{title}</title>
<meta name="description" content={description} />
<!-- OpenGraph -->
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:image" content={image} />
<meta property="og:url" href={canonicalURL} />
<meta property="og:type" content="website" />
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image" />
<slot name="head" />
</head>
<body class="bg-white text-gray-900 antialiased">
<Nav />
<main>
<slot />
</main>
<Footer />
</body>
</html>
使用方法:
---
// src/pages/index.astro
import BaseLayout from '../layouts/BaseLayout.astro'
import Hero from '../components/sections/Hero.astro'
---
<BaseLayout title="Vera Sesiom - Software Development" description="We build software that matters.">
<Hero />
</BaseLayout>
内容集合模式
使用 Zod 模式验证定义结构化内容。
// 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.coerce.date(),
updatedDate: z.coerce.date().optional(),
author: z.string().default('Vera Sesiom'),
image: z.object({
url: z.string(),
alt: z.string()
}).optional(),
tags: z.array(z.string()).default([]),
draft: z.boolean().default(false)
})
})
export const collections = {
blog: blogCollection
}
---
title: "Why We Chose Astro for Landing Pages"
description: "Performance and developer experience matter. Here's why Astro is our go-to for content sites."
pubDate: 2024-01-15
author: "Tech Lead"
image:
url: "/images/blog/astro-landing.jpg"
alt: "Astro logo on a modern website"
tags: ["astro", "performance", "architecture"]
---
Content goes here...
---
// src/pages/blog/index.astro
import { getCollection } from 'astro:content'
import BaseLayout from '../../layouts/BaseLayout.astro'
const posts = (await getCollection('blog'))
.filter(post => !post.data.draft)
.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf())
---
<BaseLayout title="Blog" description="Latest articles from Vera Sesiom">
<h1>Blog</h1>
<ul>
{posts.map(post => (
<li>
<a href={`/blog/${post.slug}`}>
<h2>{post.data.title}</h2>
<p>{post.data.description}</p>
<time>{post.data.pubDate.toLocaleDateString()}</time>
</a>
</li>
))}
</ul>
</BaseLayout>
---
// src/pages/blog/[slug].astro
import { getCollection } from 'astro:content'
import BaseLayout from '../../layouts/BaseLayout.astro'
export async function getStaticPaths() {
const posts = await getCollection('blog')
return posts.map(post => ({
params: { slug: post.slug },
props: { post }
}))
}
const { post } = Astro.props
const { Content } = await post.render()
---
<BaseLayout title={post.data.title} description={post.data.description}>
<article>
<header>
<h1>{post.data.title}</h1>
<time>{post.data.pubDate.toLocaleDateString()}</time>
<p>By {post.data.author}</p>
</header>
<Content />
</article>
</BaseLayout>
Islands 架构(Vue 组件)
交互式组件仅在需要时才发送 JS。使用客户端指令控制水合(hydration)。
<!-- src/components/interactive/ContactForm.vue -->
<script setup lang="ts">
import { ref, computed } from 'vue'
const form = ref({
name: '',
email: '',
message: ''
})
const errors = ref<Record<string, string>>({})
const isValid = computed(() => {
return form.value.name &&
form.value.email.includes('@') &&
form.value.message.length > 10
})
async function submitForm() {
if (!isValid.value) return
// Submit to API
const response = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form.value)
})
if (response.ok) {
// Handle success
form.value = { name: '', email: '', message: '' }
}
}
</script>
<template>
<form @submit.prevent="submitForm" class="space-y-4">
<div>
<label for="name">Name</label>
<input v-model="form.name" type="text" id="name" required />
</div>
<div>
<label for="email">Email</label>
<input v-model="form.email" type="email" id="email" required />
</div>
<div>
<label for="message">Message</label>
<textarea v-model="form.message" id="message" rows="4" required />
</div>
<button type="submit" :disabled="!isValid">
Send Message
</button>
</form>
</template>
在 Astro 中使用:
---
// src/pages/contact.astro
import BaseLayout from '../layouts/BaseLayout.astro'
import ContactForm from '../components/interactive/ContactForm.vue'
---
<BaseLayout title="Contact Us">
<h1>Get in Touch</h1>
<p>We'd love to hear from you.</p>
<!-- client:visible = 滚动到视口时水合(最佳实践) -->
<ContactForm client:visible />
</BaseLayout>
客户端指令决策树
| 指令 | 使用时机 | JS 发送时机 |
|---|---|---|
| 无指令 | 静态内容,无交互 | 零 JS |
client:visible | 折叠区以下的交互(表单、轮播图)— 默认 | 滚动到视口时 |
client:load | 折叠区以上关键的交互 | 立即 |
client:idle | 非关键的增强(分析工具、聊天) | 页面空闲后 |
client:media | 根据断点条件(移动端导航) | 媒体查询匹配时 |
client:only | SPA 类组件(跳过 SSR) | 立即,无 SSR |
最佳实践:
- 默认无指令——纯静态
- 需要交互时,默认使用
client:visible - 谨慎使用
client:load——仅用于关键的折叠区以上内容 - 使用
client:idle处理非阻塞增强功能
---
<!-- 好的例子 -->
import Hero from './Hero.astro' <!-- 静态,无 JS -->
import Carousel from './Carousel.vue'
import MobileMenu from './MobileMenu.vue'
import Chat from './Chat.vue'
import Stats from './Stats.astro' <!-- 静态计数器 = CSS -->
<Hero /> <!-- 静态,无水合 -->
<Carousel client:visible /> <!-- 可见时水合 -->
<MobileMenu client:media="(max-width: 768px)" /> <!-- 仅移动端 -->
<Chat client:idle /> <!-- 页面稳定后 -->
<Stats /> <!-- 纯 CSS 动画,无需 JS -->
图片优化
使用 astro:assets 实现自动优化。
---
// src/pages/index.astro
import { Image } from 'astro:assets'
import heroImage from '../assets/images/hero.png'
---
<Image
src={heroImage}
alt="Hero image"
width={800}
height={600}
loading="lazy"
format="webp"
/>
对于来自 CMS 或外部来源的图片:
---
import { Image } from 'astro:assets'
---
<Image
src="https://example.com/image.jpg"
alt="External image"
width={800}
height={600}
/>
规则:
- 始终指定
width和height以防止 CLS(累计布局偏移) - 对折叠区以下的图片使用
loading="lazy" - 优先使用
webp或avif格式 - 保持 Hero 图片经过优化并尺寸合适
SEO 模式
---
// src/components/SEO.astro
export interface Props {
title: string
description: string
image?: string
article?: boolean
publishedTime?: Date
author?: string
tags?: string[]
}
const {
title,
description,
image = '/og-default.png',
article = false,
publishedTime,
author,
tags = []
} = Astro.props
const canonicalURL = new URL(Astro.url.pathname, Astro.site)
const imageURL = new URL(image, Astro.site)
---
<!-- 主要元标签 -->
<title>{title}</title>
<meta name="title" content={title} />
<meta name="description" content={description} />
<link rel="canonical" href={canonicalURL} />
<!-- Open Graph -->
<meta property="og:type" content={article ? 'article' : 'website'} />
<meta property="og:url" content={canonicalURL} />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:image" content={imageURL} />
<!-- Twitter -->
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:url" content={canonicalURL} />
<meta property="twitter:title" content={title} />
<meta property="twitter:description" content={description} />
<meta property="twitter:image" content={imageURL} />
{article && publishedTime && (
<meta property="article:published_time" content={publishedTime.toISOString()} />
)}
{article && author && (
<meta property="article:author" content={author} />
)}
{tags.length > 0 && (
<>
{tags.map(tag => (
<meta property="article:tag" content={tag} />
))}
</>
)}
<!-- JSON-LD 结构化数据 -->
<script type="application/ld+json" set:html={JSON.stringify({
'@context': 'https://schema.org',
'@type': article ? 'Article' : 'WebPage',
'headline': title,
'description': description,
'image': imageURL,
'url': canonicalURL,
...(article && publishedTime && {
'datePublished': publishedTime.toISOString(),
'author': {
'@type': 'Person',
'name': author
}
})
})} />
使用方法:
---
// src/pages/blog/[slug].astro
import SEO from '../../components/SEO.astro'
<SEO
title={post.data.title}
description={post.data.description}
image={post.data.image?.url}
article
publishedTime={post.data.pubDate}
author={post.data.author}
tags={post.data.tags}
/>
路由模式
静态路由
---
// src/pages/about.astro
import BaseLayout from '../layouts/BaseLayout.astro'
---
<BaseLayout title="About Us" description="Learn about Vera Sesiom.">
<h1>About Us</h1>
<!-- Static content -->
</BaseLayout>
动态路由(SSG)
---
// src/pages/blog/[slug].astro
import { getCollection } from 'astro:content'
import BaseLayout from '../../layouts/BaseLayout.astro'
export async function getStaticPaths() {
const posts = await getCollection('blog')
return posts.map(post => ({
params: { slug: post.slug },
props: { post }
}))
}
const { post } = Astro.props
const { Content } = await post.render()
---
<BaseLayout title={post.data.title} description={post.data.description}>
<article>
<Content />
</article>
</BaseLayout>
重定向
---
// src/pages/old-page.astro
import { redirect } from 'astro/actions'
redirect('/new-page', 301)
---
表单处理
在 client:only 或 client:visible 表单中使用 Vue(参见上面的 ContactForm.vue 示例)。
样式指南
使用 Tailwind CSS 进行所有样式设计。
/* src/styles/global.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body {
@apply bg-white text-gray-900 antialiased;
}
h1 {
@apply text-4xl font-bold tracking-tight;
}
h2 {
@apply text-3xl font-semibold tracking-tight;
}
h3 {
@apply text-2xl font-semibold;
}
}
性能预算
每个页面的目标 Lighthouse 评分:
- 性能: 95+ (移动端 & 桌面端)
- 可访问性: 95+
- 最佳实践: 95+
- SEO: 100
- Core Web Vitals: 全部通过
总结:Astro 工作流程
- 规划:确定是 Astro 还是 Vue(内容 vs 应用)
- 搭建:使用模式和尖叫结构创建项目
- 构建:静态组件放在
components/,交互式组件放在components/interactive/ - 内容:在
src/content/中使用 Markdown/MDX 和 Zod 验证 - 优化:使用
astro:assets处理图片,添加 SEO,检查性能 - 部署:静态输出到 Netlify/Vercel/Cloudflare (无服务器端依赖)
记住:
- 默认零 JS
- TypeScript 严格模式
- 使用
client:visible处理交互(默认) - 尖叫架构:文件夹应反映领域