Chapter 03

.astro 组件语法详解

深入理解 frontmatter 代码块、模板表达式语法、指令、Props 类型定义与作用域样式

.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 中的所有代码只在构建时(SSG)或请求时(SSR)在服务端执行,绝对不会出现在浏览器的 JavaScript bundle 中。这意味着可以放心使用 Node.js API、环境变量、数据库驱动——这些代码对用户是完全不可见的。
顶层 await
frontmatter 原生支持顶层 await,无需将异步操作包裹在 async 函数中。这比 React 的 useEffect 更直观——数据在渲染时已经就绪,不需要加载态。
import 必须在 frontmatter
所有 import 语句只能在 frontmatter 代码块中,不能在 HTML 模板中动态导入(除非使用 <script> 标签中的 import())。这是 Astro 静态分析的基础。
Astro 全局对象
frontmatter 中可访问 Astro 全局对象:Astro.props(组件接收的 props)、Astro.params(路由参数)、Astro.url(当前 URL)、Astro.site(站点根 URL)、Astro.request(请求对象,SSR 模式)。
不能在 frontmatter 中使用浏览器 API

由于 frontmatter 在服务端运行,windowdocumentlocalStoragenavigator 等浏览器专有 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 模板与 React JSX 的区别

Astro 模板外观上很像 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 的 XSS 风险

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 vs is:inline 的选择

大多数情况用普通 <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>

本章小结

本章核心要点