.astro 文件的三段式结构
基本结构
Astro 组件(.astro 文件)由三个部分组成,通过 --- 分隔符划分。这种结构受 Markdown frontmatter 的启发,把服务端逻辑与模板清晰分离:
---
// ① Frontmatter(组件脚本)— 在服务端执行,不进入浏览器
import { getCollection } from 'astro:content';
import Layout from '../layouts/Base.astro';
// 可直接 await 异步函数(顶层 await)
const posts = await getCollection('blog');
const title = 'My Blog';
// 定义 Props 接口
interface Props {
headline: string;
count?: number;
}
const { headline, count = 0 } = Astro.props;
---
<!-- ② HTML 模板 — JSX 风格,但输出纯 HTML -->
<Layout title={title}>
<h1>{headline}</h1>
<p>文章数量:{count}</p>
<ul>
{posts.map(post => (
<li><a href={`/blog/${post.slug}`}>{post.data.title}</a></li>
))}
</ul>
</Layout>
<!-- ③ 作用域 CSS(可选)— 自动隔离,只影响本组件 -->
<style>
h1 { color: var(--accent); }
</style>
如果你省略 ---,整个文件会被当作 HTML 模板,frontmatter 中的 JavaScript 代码会被原样渲染到 HTML 中,造成页面显示大段代码文本的诡异 bug。两个 --- 都必须写,即使 frontmatter 里什么都没有,也要保留空的 ---\n---。
Frontmatter(组件脚本)
核心特性详解
由于 frontmatter 在服务端运行,window、document、localStorage、navigator 等浏览器专有 API 在 frontmatter 中会报错 ReferenceError: window is not defined。需要访问这些 API 的代码必须放在 <script> 标签中,或者放在 client:* 指令激活的框架组件里。
Props 类型定义
用 interface Props 定义组件接口
在 frontmatter 中声明 interface Props(固定写法),Astro 会自动将其识别为该组件的 Props 类型,父组件调用时会有 TypeScript 类型检查和编辑器自动补全。
---
// src/components/Card.astro
interface Props {
title: string; // 必填
description?: string; // 可选(? 标记)
variant?: 'primary' | 'secondary' | 'outline'; // 枚举类型
href?: string;
class?: string; // 允许父组件传入额外的 class
}
// 解构时提供默认值
const {
title,
description,
variant = 'primary', // 默认值
href,
class: className, // 重命名(避免与 JS 关键字 class 冲突)
} = Astro.props;
---
<div class:list={['card', `card--${variant}`, className]}>
{href ? (
<a href={href}><h3>{title}</h3></a>
) : (
<h3>{title}</h3>
)}
{description && <p>{description}</p>}
<slot /> <!-- 子元素插入点 -->
</div>
---
// 父组件中使用 Card — 有类型检查
import Card from '../components/Card.astro';
---
<!-- 正确:title 是必填 -->
<Card title="标题" variant="secondary" />
<!-- TypeScript 错误:variant 只允许特定值 -->
<Card title="标题" variant="invalid" />
<!-- TypeScript 错误:title 必填 -->
<Card />
模板语法
表达式插值
---
const name = '世界';
const items = ['苹果', '香蕉', '橙子'];
const isLoggedIn = true;
const count = 5;
---
<!-- 基本插值:用 {} 包裹 JavaScript 表达式 -->
<h1>你好,{name}!</h1>
<p>共 {count} 篇文章</p>
<!-- 条件渲染:&& 短路写法 -->
{isLoggedIn && <p>欢迎回来!</p>}
<!-- 条件渲染:三元表达式 -->
{isLoggedIn ? <p>已登录</p> : <p>请登录</p>}
<!-- 列表渲染:用数组的 map 方法 -->
<ul>
{items.map(item => <li>{item}</li>)}
</ul>
<!-- 属性插值:属性值也可以用 {} -->
<img src={`/images/${name}.jpg`} alt={name} />
<a href={`/user/${name.toLowerCase()}`}>个人主页</a>
<!-- 布尔属性:值为 true 时输出属性名,false 时省略 -->
<input type="checkbox" checked={isLoggedIn} />
Astro 模板外观上很像 JSX,但有重要区别:
- 使用
class(不是className),因为 Astro 输出 HTML,不是 React 虚拟 DOM - 使用
for(不是htmlFor) - 不需要唯一的根元素(可以有多个顶层元素)
- 注释语法是 HTML 注释
<!-- -->,不能用{/* JSX注释 */}
set:html 指令
---
// 当你需要渲染原始 HTML 字符串时使用(注意 XSS 风险!)
const htmlContent = '<strong>加粗</strong> 和 <em>斜体</em>';
// 常见用途:Markdown 转换后的 HTML、富文本编辑器内容
---
<!-- 默认:HTML 字符会被转义,显示为纯文字 <strong>... -->
<div>{htmlContent}</div>
<!-- set:html:不转义,直接渲染为 HTML(字体会变粗)-->
<div set:html={htmlContent}></div>
<!-- 实际用途:渲染从 marked/remark 转换的 Markdown HTML -->
<article set:html={markdownToHtml(content)}></article>
set:html 会绕过 Astro 的自动转义,直接将字符串作为 HTML 插入。如果内容来自用户输入(评论、表单提交),必须先用 DOMPurify 等库进行 HTML 净化(sanitize),否则存在跨站脚本攻击(XSS)风险。只对你信任的内容(自己的 Markdown 文件、已净化的 CMS 内容)使用 set:html。
class:list 指令
---
const isActive = true;
const isDisabled = false;
const variant = 'primary';
const extraClasses = ['rounded', 'shadow'];
---
<!-- class:list 接受数组,支持多种格式,自动过滤 falsy 值 -->
<button class:list={[
'btn', <!-- 字符串:总是添加 -->
`btn-${variant}`, <!-- 模板字符串 -->
{ 'btn-active': isActive }, <!-- 对象:键为 class,值为条件 -->
{ 'btn-disabled': isDisabled }, <!-- false → 不添加 -->
extraClasses, <!-- 数组展开 -->
undefined, <!-- undefined/null/false 自动忽略 -->
]}>
点击我
</button>
<!-- 输出: class="btn btn-primary btn-active rounded shadow" -->
Slot(插槽)
默认插槽与具名插槽
插槽(Slot)是 Astro 组件的内容分发机制,允许父组件向子组件传递任意 HTML 内容。这是构建布局组件(Layout)的核心机制。
---
// src/layouts/BaseLayout.astro
interface Props {
title: string;
}
const { title } = Astro.props;
---
<html>
<head>
<title>{title}</title>
<!-- 具名插槽:父组件可以向 head 中注入内容 -->
<slot name="head" />
</head>
<body>
<header>
<!-- 具名插槽:允许替换导航栏 -->
<slot name="header">
<!-- 这是 fallback 内容:当父组件没有提供该插槽时显示 -->
<nav><a href="/">首页</a></nav>
</slot>
</header>
<main>
<!-- 默认插槽:接收父组件的主要内容 -->
<slot />
</main>
</body>
</html>
---
// 使用带具名插槽的 Layout
import BaseLayout from '../layouts/BaseLayout.astro';
---
<BaseLayout title="文章标题">
<!-- slot="head" 注入到 <head> 中 -->
<meta slot="head" name="description" content="文章描述" />
<!-- 没有 slot 属性 → 进入默认 <slot /> -->
<article>
<h1>文章内容</h1>
<p>正文...</p>
</article>
</BaseLayout>
作用域 CSS
Scoped Style 工作原理
Astro 组件中的 <style> 标签默认是作用域样式(Scoped Styles)。Astro 在编译时给每个组件生成唯一的哈希值,以 data-astro-cid-XXXXXXXX 属性的形式添加到模板中所有元素,并给 CSS 选择器追加对应的属性选择器。这是纯编译时处理,没有运行时性能损耗(不是 Shadow DOM)。
<!-- 源代码 -->
<style>
h2 { color: var(--accent); }
.card { border: 1px solid #ddd; }
</style>
<h2>标题</h2>
<div class="card">内容</div>
<!-- 编译输出 -->
<style>
h2[data-astro-cid-abc123] { color: var(--accent); }
.card[data-astro-cid-abc123] { border: 1px solid #ddd; }
</style>
<h2 data-astro-cid-abc123>标题</h2>
<div class="card" data-astro-cid-abc123>内容</div>
全局样式的三种方式
<!-- 方法1:style is:global — 整个 style 块不加作用域 -->
<style is:global>
:root { --color-primary: #FF5D01; }
body { font-family: 'Inter', sans-serif; }
</style>
<!-- 方法2::global() 伪选择器 — 单个规则不加作用域 -->
<style>
h2 { color: red; } <!-- 有作用域 -->
:global(.external) { margin: 0; } <!-- 无作用域,影响全局 -->
.card :global(p) { color: blue; } <!-- 混合:.card 有作用域,内部 p 无 -->
</style>
<!-- 方法3:在 Layout 中 import 全局 CSS 文件(推荐) -->
---
// src/layouts/BaseLayout.astro
// import 的 CSS 文件会作为全局样式应用到所有使用该 Layout 的页面
import '../styles/global.css';
import '../styles/typography.css';
---
<html>...</html>
script 标签
客户端 JavaScript
Astro 组件中的 <script> 标签用于添加客户端 JavaScript(在浏览器中运行)。Astro 会自动对其进行打包、去重(同一脚本在多个组件中只加载一次)和类型检查。
<!-- 默认:由 Astro 打包处理,支持 import -->
<script>
import confetti from 'canvas-confetti'; <!-- 可以 import npm 包 -->
document.getElementById('btn')?.addEventListener('click', () => {
confetti();
});
</script>
<!-- is:inline:不经过 Astro 处理,原样输出到 HTML(不能 import)-->
<script is:inline>
<!-- 必须在 DOM 渲染前运行的代码:如主题初始化防止闪白 -->
const theme = localStorage.getItem('theme') ?? 'light';
document.documentElement.dataset.theme = theme;
</script>
大多数情况用普通 <script>:Astro 会去重、打包、Tree-shake,性能更好。is:inline 仅用于两种场景:① 必须在页面 CSS/JS 加载前立即执行的代码(如防止主题闪白的初始化);② 引用了 frontmatter 变量的脚本(因为普通 script 中无法访问 frontmatter 变量)。
在 script 中使用 frontmatter 数据
---
const userId = 'u123';
const apiUrl = import.meta.env.PUBLIC_API_URL;
---
<!-- define:vars 将 frontmatter 变量传递到客户端 script -->
<script define:vars={{ userId, apiUrl }}>
<!-- userId 和 apiUrl 在这里可用 -->
console.log('用户 ID:', userId);
fetch(`${apiUrl}/user/${userId}`);
<!-- 注意:此脚本会变为 is:inline(因为使用了动态变量),无法 import -->
</script>
本章小结
- .astro 三段式结构:frontmatter(服务端 JS,---包裹)→ HTML 模板(JSX 风格,但用 class 不用 className)→ 作用域 CSS(可选)。三段职责清晰,frontmatter 里的代码绝不出现在浏览器中。
- interface Props 定义组件接口:在 frontmatter 中声明 interface Props,父组件调用时有 TypeScript 类型检查;通过解构 Astro.props 并设置默认值,比 React 的 defaultProps 更简洁。
- 模板与 React JSX 的差异:用 class(不是 className)、用 for(不是 htmlFor)、支持多个顶层元素、不支持 {/* JSX 注释 */}。是 HTML 超集而非 JS 超集。
- set:html 渲染原始 HTML:用于渲染可信的 HTML 字符串(如 Markdown 转换结果);对用户输入必须先 sanitize;默认情况下 Astro 自动转义 HTML 特殊字符,这是安全的默认行为。
- 作用域 CSS 原理:编译时给元素加 data-astro-cid-XXXXXXXX 属性,CSS 选择器自动附加属性过滤,与 Shadow DOM 无关,零运行时开销;用 is:global 或 :global() 突破作用域。
- script 与 is:inline:普通 script 支持 import,由 Astro 打包去重(推荐);is:inline 直接内联,用于需要最早执行的初始化代码;define:vars 将服务端数据传入客户端脚本(会隐式变为 inline)。