前端5月30日 19:50
Astro View Transitions 如何工作?怎么避免页面过渡踩坑?Astro View Transitions 的核心是:页面仍然按多页应用导航,但 Astro 在跳转时拦截链接、获取新页面、替换 DOM,并借助浏览器 View Transitions API 做过渡动画。它给静态站带来接近 SPA 的切页手感,但不等于把站点变成完整 SPA;SEO、静态输出和按页加载的优势仍保留。
## 追问
### View Transitions 和 SPA 路由有什么区别?
SPA 通常只替换客户端状态和组件树。Astro 更像增强版 MPA 导航,新页面仍是单独文档,只是在切换时接管加载和替换过程。
### 为什么脚本状态会丢?
导航后 DOM 会被替换,旧节点上的事件和第三方实例可能不存在。需要在 `astro:page-load` 里重新初始化必要脚本。
### transition:name 有什么边界?
同一次过渡里名称应当唯一。列表页要用 slug 或 id 拼接名称,不要所有卡片都叫 cover。
### 什么时候不该使用页面过渡?
表单提交、支付、登录回调、后台管理不适合花哨动画。内容站、作品集、相册更适合,但也要控制范围。
## 写段代码
```astro
<ViewTransitions />
<img src={post.cover} transition:name={`cover-${post.slug}`} />
```标签
Astro
Astro 是一个现代的静态站点生成器(SSG),它允许你使用多种前端框架(如React、Vue、Svelte等)构建网站,并且能够输出干净、轻量级的HTML文件,不含有客户端的 JavaScript。Astro 旨在为构建高性能网站提供最佳的开发体验和最优的加载性能。

前端5月30日 19:50
Astro 如何做好 SEO?Meta、结构化数据和站点地图怎么配?Astro 做 SEO 的优势不在插件多,而在默认输出更接近搜索引擎喜欢的页面:静态 HTML、少 JavaScript、首屏快、内容能直接被爬虫读到。落地时重点不是堆满 meta,而是保证每页有唯一标题、稳定 canonical、准确 description、可分享 OG、合适结构化数据,以及能被发现的 sitemap。
## 追问
### Astro 为什么比很多 SPA 更适合 SEO?
Astro 默认把页面渲染成 HTML,爬虫不需要等客户端 JavaScript 执行完才看到正文。但如果正文依赖客户端接口再拉取,仍可能被漏抓。
### Meta 标签是不是越多越好?
不是,title、description、canonical、OG 和必要的 Twitter Card 通常就够了。每页唯一、准确、和正文匹配更重要。
### sitemap 和 robots.txt 有哪些坑?
最常见的是 `site` 没配,导致 sitemap 生成错误域名;robots.txt 不提升排名,只控制抓取边界。
### SEO 组件放 layout 还是页面?
基础标签可以放 layout,但标题、描述、发布时间、文章类型必须由页面传入,避免大量页面共用同一 description。
## 写段代码
```astro
<title>{title}</title>
<meta name="description" content={description} />
<link rel="canonical" href={url} />
<script type="application/ld+json" set:html={JSON.stringify(schema)} />
```前端5月28日 06:34
Astro 组件的基本结构是什么?如何定义和使用 Props、插槽?Astro 是近年来增长最快的前端框架之一,其组件系统融合了服务端逻辑与客户端模板的独特设计,让开发者可以用最少的 JavaScript 构建高性能页面。本文将系统讲解 Astro 组件的三大核心结构——前置脚本、模板区域和样式作用域,以及 Props 传参与 Slots 插槽的完整用法。
## Astro 组件的三大结构
每个 `.astro` 文件都由三个可选部分组成:前置脚本(Frontmatter)、HTML 模板和 `<style>` 样式块。理解这三部分的执行时机和作用域,是掌握 Astro 组件的基础。
### 1. 前置脚本(Frontmatter)
用 `---` 分隔符包裹的顶部区域,是组件的"服务端大脑":
```astro
---
// 这里的代码在构建时(或 SSR 请求时)执行,不会发送到浏览器
const title = "我的博客文章";
const date = new Date().toLocaleDateString();
// 支持导入其他组件
import Card from './Card.astro';
// 支持异步操作,如数据获取
const posts = await fetch('/api/posts').then(r => r.json());
---
```
关键要点:
- 前置脚本中的代码**仅在服务端执行**,永远不会出现在客户端 bundle 中
- 可以使用完整的 JavaScript/TypeScript 语法,包括顶层 `await`
- 这里定义的变量可以在下方模板中直接使用
- 无法访问浏览器 API(如 `window`、`document`)
### 2. 模板区域
紧跟在前置脚本之后的 HTML 区域,支持类 JSX 语法:
```astro
<h1>{title}</h1>
<p>发布于 {date}</p>
<div class="posts">
{posts.map(post => (
<Card title={post.title} />
))}
</div>
```
模板支持的表达式:
| 语法 | 用途 | 示例 |
|------|------|------|
| `{variable}` | 变量插值 | `<h1>{title}</h1>` |
| `{condition && <Comp />}` | 条件渲染 | `{isAdmin && <AdminPanel />}` |
| `{a ? <A /> : <B />}` | 三元条件 | `{loggedIn ? <Dashboard /> : <Login />}` |
| `{items.map(...)}` | 列表渲染 | `{posts.map(p => <Card {...p} />)}` |
| `set:html={raw}` | 原始 HTML 注入 | `<div set:html={content} />` |
### 3. 样式作用域
```astro
<style>
/* 默认 scoped,不会影响其他组件 */
h1 {
color: #333;
}
/* 需要全局样式时使用 :global() */
:global(.markdown-body p) {
line-height: 1.8;
}
</style>
```
Astro 的样式默认是作用域隔离的——每个组件的样式会自动添加唯一属性选择器,杜绝样式泄漏。如果需要影响子组件或全局,使用 `:global()` 选择器。
## Props:组件间的数据传递
Props 是 Astro 组件接收外部数据的标准方式,通过 `Astro.props` 对象访问。
### 基本用法
```astro
---
// Card.astro
const { title, description } = Astro.props;
---
<div class="card">
<h2>{title}</h2>
<p>{description}</p>
</div>
```
使用组件时传入 Props:
```astro
---
import Card from './Card.astro';
---
<Card title="文章标题" description="文章描述" />
```
### TypeScript 类型约束
为 Props 添加类型定义,可以在构建时捕获错误:
```astro
---
interface Props {
title: string;
description?: string; // 可选属性
count?: number;
}
const { title, description = '暂无描述', count = 0 } = Astro.props satisfies Props;
---
<h1>{title}</h1>
<p>{description}</p>
<span>数量: {count}</span>
```
使用 `satisfies` 操作符既能获得类型检查,又能保留解构时的默认值推断。
### Props 传递的最佳实践
1. **保持 Props 简单**:Props 应该是序列化安全的原始数据(字符串、数字、布尔值、简单对象),避免传递函数或复杂类实例
2. **提供默认值**:通过解构默认值为可选 Props 设定合理的 fallback
3. **使用 `...rest` 透传**:当包装组件时,用 `const { class: className, ...rest } = Astro.props` 收集并透传属性
```astro
---
// 包装组件的最佳实践
interface Props {
class?: string;
variant?: 'primary' | 'secondary';
}
const { class: className = '', variant = 'primary', ...rest } = Astro.props satisfies Props;
---
<div class={`btn btn-${variant} ${className}`} {...rest}>
<slot />
</div>
```
## Slots:组件的内容分发
如果说 Props 传递的是"数据",那么 Slots 传递的就是"内容"。Slots 让组件成为可复用的布局容器。
### 默认插槽
```astro
---
// Layout.astro
const { title } = Astro.props;
---
<html>
<head><title>{title}</title></head>
<body>
<main>
<slot /> <!-- 所有子内容将渲染在这里 -->
</main>
</body>
</html>
```
使用时直接在组件标签内放入内容:
```astro
---
import Layout from './Layout.astro';
---
<Layout title="我的页面">
<h1>页面标题</h1>
<p>这些内容会出现在 <slot /> 的位置</p>
</Layout>
```
### 命名插槽
当组件需要多个内容入口时,使用命名插槽:
```astro
---
// PageLayout.astro
const { title } = Astro.props;
---
<div class="page">
<header>
<slot name="header" /> <!-- 命名插槽 -->
</header>
<main>
<slot /> <!-- 默认插槽 -->
</main>
<footer>
<slot name="footer" /> <!-- 命名插槽 -->
</footer>
</div>
```
使用命名插槽:
```astro
---
import PageLayout from './PageLayout.astro';
---
<PageLayout title="首页">
<nav slot="header">
<a href="/">首页</a>
<a href="/about">关于</a>
</nav>
<!-- 没有 slot 属性的内容进入默认插槽 -->
<h1>欢迎</h1>
<p>这是主要内容</p>
<p slot="footer">版权信息</p>
</PageLayout>
```
### 插槽的 Fallback 内容
插槽可以设置默认内容,当没有传入对应内容时自动显示:
```astro
---
// Card.astro
const { title } = Astro.props;
---
<div class="card">
<h2>{title}</h2>
<div class="body">
<slot>
<p>暂无内容</p> <!-- Fallback:未传入内容时显示 -->
</slot>
</div>
</div>
```
### 插槽传递(Slot Forwarding)
在嵌套布局中,子布局可以将插槽"透传"给父布局:
```astro
---
// BaseLayout.astro
---
<html>
<body>
<slot name="head" />
<slot />
</body>
</html>
```
```astro
---
// HomeLayout.astro
import BaseLayout from './BaseLayout.astro';
---
<BaseLayout>
<slot name="head" slot="head" />
<slot />
</BaseLayout>
```
这样最终页面使用 `<HomeLayout>` 时,内容会正确传递到 `<BaseLayout>` 的对应插槽位置。
### 框架组件中的 Slots
Astro 支持在 React、Vue、Svelte 等框架组件中使用插槽,但各框架的接收方式不同:
| 框架 | 默认插槽 | 命名插槽 |
|------|---------|---------|
| React / Preact / Solid | `children` prop | `slotName` 顶级 prop |
| Vue | `<slot />` | `<slot name="xxx" />` |
| Svelte | `<slot />` | `<slot name="xxx" />` |
注意:传给框架组件的命名插槽名会从 `kebab-case` 转为 `camelCase`(如 `slot="my-header"` 在 React 中变为 `myHeader` prop)。
## 常见陷阱与注意事项
1. **前置脚本不等于客户端脚本**:`---` 中的代码在服务端执行,需要交互逻辑时应使用 `<script>` 标签或 `client:*` 指令
2. **模板表达式是静态的**:`{variable}` 在构建时求值,不是响应式绑定
3. **Props 无法传递函数**:Astro 组件的 Props 是序列化传递的,函数和类实例无法通过 Props 传递
4. **样式隔离是默认行为**:不要假设子组件能继承父组件的 class 样式
5. **组件默认是静态的**:需要客户端交互时,必须使用 `client:load`、`client:visible` 等水合指令
```astro
---
// 静态组件 vs 交互组件
import StaticCard from './StaticCard.astro'; // 始终静态
import InteractiveCounter from './Counter.jsx'; // 需要水合指令
---
<StaticCard title="静态内容" />
<!-- client:load = 页面加载时立即水合 -->
<InteractiveCounter client:load />
<!-- client:visible = 进入视口时才水合,节省资源 -->
<InteractiveCounter client:visible />
```
## 总结
Astro 组件的设计哲学是**默认静态、按需交互**:
- **三大结构**:前置脚本处理服务端逻辑,模板渲染 HTML,样式自动隔离
- **Props**:通过 `Astro.props` 传递数据,配合 TypeScript 类型约束确保安全
- **Slots**:通过默认插槽和命名插槽实现内容分发,支持嵌套透传和跨框架使用
- **核心原则**:能静态就不动态,需要交互时使用 `client:*` 水合指令
掌握这三个核心概念,就能构建出结构清晰、性能优秀的 Astro 应用。前端5月28日 06:28
如何在 Astro 中创建和使用 API 路由?如何处理请求和响应?Astro 的 API 路由(Server Endpoints)允许你在项目中创建服务端接口,处理 HTTP 请求并返回响应。这是 Astro 构建全栈应用的核心能力之一,面试中常考请求处理方式、SSR/SSG 模式差异、类型安全等知识点。
## API 路由的基本原理
API 路由文件放在 `src/pages/` 目录下,文件路径即接口路径。与页面组件不同,API 路由文件使用 `.ts` 或 `.js` 扩展名,导出的是 HTTP 方法函数而非 Astro 组件。
Astro 使用 Web 标准的 `Request` 和 `Response` 对象,与 Cloudflare Workers、Deno 等运行时保持一致,这意味着你不需要学习 Express 那样的 `req`/`res` 专属 API,掌握 Fetch API 标准即可上手。
关键前提:API 路由需要服务端渲染(SSR)模式才能在请求时动态执行。如果你的项目是纯静态站点(SSG),API 路由只会在构建时执行一次。需要在 `astro.config.mjs` 中配置适配器:
```typescript
import { defineConfig } from 'astro/config';
import node from '@astrojs/node';
export default defineConfig({
output: 'server',
adapter: node({ mode: 'standalone' }),
});
```
## 创建第一个 API 路由
使用 `APIRoute` 类型可以获得完整的类型提示,这是推荐的做法:
```typescript
// src/pages/api/hello.ts
import type { APIRoute } from 'astro';
export const GET: APIRoute = async ({ request }) => {
return new Response(
JSON.stringify({ message: 'Hello, World!', timestamp: Date.now() }),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
}
);
};
```
访问 `/api/hello` 即可得到 JSON 响应。`APIRoute` 类型会自动推断 `params`、`request`、`cookies` 等参数的类型,避免手写类型声明。
## 支持哪些 HTTP 方法
每个 API 路由文件可以导出多个 HTTP 方法函数,Astro 根据请求方法自动路由到对应函数:
```typescript
// src/pages/api/users.ts
import type { APIRoute } from 'astro';
export const GET: APIRoute = async ({ request, url }) => {
const users = await fetchUsers();
return new Response(JSON.stringify(users), {
headers: { 'Content-Type': 'application/json' },
});
};
export const POST: APIRoute = async ({ request }) => {
const body = await request.json();
const newUser = await createUser(body);
return new Response(JSON.stringify(newUser), {
status: 201,
headers: { 'Content-Type': 'application/json' },
});
};
export const DELETE: APIRoute = async ({ request }) => {
const body = await request.json();
await deleteUser(body.id);
return new Response(null, { status: 204 });
};
```
支持的导出函数名包括 `GET`、`POST`、`PUT`、`PATCH`、`DELETE`、`OPTIONS` 和 `ALL`。`ALL` 函数会在请求方法没有对应导出函数时被调用,适合做兜底处理或方法校验。
## 动态路由参数
使用方括号语法定义动态路由参数,与页面路由的规则一致:
```typescript
// src/pages/api/users/[id].ts
import type { APIRoute } from 'astro';
export const GET: APIRoute = async ({ params }) => {
const { id } = params;
const user = await fetchUserById(id);
if (!user) {
return new Response(
JSON.stringify({ error: 'User not found' }),
{ status: 404, headers: { 'Content-Type': 'application/json' } }
);
}
return new Response(JSON.stringify(user), {
headers: { 'Content-Type': 'application/json' },
});
};
```
如果需要捕获多个路径段,使用剩余参数语法 `[...path].ts`,`params.path` 会得到完整的路径数组。静态模式下,动态路由必须导出 `getStaticPaths()` 来预生成路径。
## 请求处理:获取请求体、查询参数和请求头
API 路由函数接收一个上下文对象,从中可以提取请求的所有信息:
```typescript
// src/pages/api/search.ts
import type { APIRoute } from 'astro';
export const POST: APIRoute = async ({ request, url, cookies }) => {
try {
// 请求体:根据 Content-Type 选择解析方式
const body = await request.json(); // JSON 请求体
// const formData = await request.formData(); // 表单数据
// const text = await request.text(); // 纯文本
// 查询参数
const limit = parseInt(url.searchParams.get('limit') || '10');
const page = parseInt(url.searchParams.get('page') || '1');
// 请求头
const authHeader = request.headers.get('Authorization');
const contentType = request.headers.get('Content-Type');
// Cookie
const sessionToken = cookies.get('session')?.value;
const results = await search(body.query, { limit, page });
return new Response(JSON.stringify({ results, page, limit }), {
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'public, max-age=300',
},
});
} catch (error) {
return new Response(
JSON.stringify({ error: 'Invalid request body' }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
};
```
注意 `request.json()` 只能调用一次,因为 `Request.body` 是 ReadableStream,消费后不可重读。如果需要多次读取,先 `clone()` 再解析。
## 响应构建:状态码、头信息和重定向
Astro 返回的是标准 `Response` 对象,你可以完全控制状态码、头信息和响应体:
```typescript
// 成功响应
return new Response(JSON.stringify(data), {
status: 200,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-cache',
},
});
// 创建资源
return new Response(JSON.stringify(newItem), {
status: 201,
headers: { 'Location': `/api/items/${newItem.id}` },
});
// 重定向
return Response.redirect(new URL('/api/new-path', request.url), 301);
// 无内容
return new Response(null, { status: 204 });
```
面试中容易被问到:Astro 4+ 使用的是原生 `Response` 构造函数,不再返回 Astro 自定义的响应对象。如果你看到教程中使用 `({ body, status })` 的写法,那是 Astro 3 及更早版本的旧语法,已经废弃。
## 身份验证与授权
API 路由中实现鉴权通常从请求头或 Cookie 中提取凭证:
```typescript
// src/pages/api/admin/stats.ts
import type { APIRoute } from 'astro';
export const GET: APIRoute = async ({ request, cookies }) => {
// 方式一:Bearer Token
const token = request.headers.get('Authorization')?.replace('Bearer ', '');
if (!token) {
return new Response(
JSON.stringify({ error: 'Missing authorization token' }),
{ status: 401, headers: { 'Content-Type': 'application/json' } }
);
}
const user = await verifyToken(token);
if (!user) {
return new Response(
JSON.stringify({ error: 'Invalid or expired token' }),
{ status: 403, headers: { 'Content-Type': 'application/json' } }
);
}
// 方式二:Session Cookie(配合中间件更方便)
const sessionId = cookies.get('session_id')?.value;
const stats = await fetchAdminStats(user.id);
return new Response(JSON.stringify(stats), {
headers: { 'Content-Type': 'application/json' },
});
};
```
更推荐的做法是将鉴权逻辑提取到中间件(middleware)中,避免每个路由重复编写。中间件在 API 路由执行前运行,可以在 `locals` 上挂载用户信息:
```typescript
// src/middleware.ts
import { defineMiddleware } from 'astro:middleware';
export const onRequest = defineMiddleware(async (context, next) => {
const token = context.request.headers.get('Authorization')?.replace('Bearer ', '');
if (token) {
const user = await verifyToken(token);
if (user) {
context.locals.user = user;
}
}
return next();
});
```
在 API 路由中直接读取 `context.locals.user` 即可判断身份。
## 错误处理策略
推荐封装统一的错误处理工具,让 API 路由保持简洁:
```typescript
// src/lib/api-error.ts
export class ApiError extends Error {
constructor(
public statusCode: number,
message: string,
public code?: string
) {
super(message);
this.name = 'ApiError';
}
}
export function handleApiError(error: unknown): Response {
if (error instanceof ApiError) {
return new Response(
JSON.stringify({ error: error.message, code: error.code }),
{ status: error.statusCode, headers: { 'Content-Type': 'application/json' } }
);
}
console.error('Unexpected error:', error);
return new Response(
JSON.stringify({ error: 'Internal Server Error' }),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
);
}
```
在路由中使用:
```typescript
// src/pages/api/data.ts
import type { APIRoute } from 'astro';
import { ApiError, handleApiError } from '../../lib/api-error';
export const GET: APIRoute = async ({ params }) => {
try {
const data = await fetchData(params.id);
if (!data) throw new ApiError(404, 'Data not found', 'NOT_FOUND');
return new Response(JSON.stringify(data), {
headers: { 'Content-Type': 'application/json' },
});
} catch (error) {
return handleApiError(error);
}
};
```
## CORS 跨域配置
如果你的 API 需要被其他域名的前端调用,必须处理 CORS。可以通过 OPTIONS 预检和响应头来解决:
```typescript
// src/pages/api/public-data.ts
import type { APIRoute } from 'astro';
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Max-Age': '86400',
};
export const OPTIONS: APIRoute = async () => {
return new Response(null, { status: 204, headers: corsHeaders });
};
export const GET: APIRoute = async ({ request }) => {
const data = await fetchPublicData();
return new Response(JSON.stringify(data), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
};
```
更优雅的做法是在中间件中统一添加 CORS 头,避免每个路由重复定义。
## API 路由与 Astro Actions 的区别
Astro 4.9+ 引入了 Actions,这是处理服务端逻辑的新方式。面试中经常考察两者的适用场景:
**API 路由适合:**
- 对外提供 REST 接口,供第三方或前端 SPA 调用
- 需要处理多种 HTTP 方法的场景
- Webhook 回调接收
- 需要自定义响应格式(非 JSON)的场景
**Actions 适合:**
- 表单提交和数据变更
- 需要输入验证(Zod schema)和类型安全的场景
- 渐进增强需求——即使 JavaScript 禁用也能工作
- 组件内部的服务端调用
```typescript
// Actions 示例:带验证的表单处理
import { defineAction } from 'astro:actions';
import { z } from 'astro:schema';
export const server = {
createPost: defineAction({
input: z.object({
title: z.string().min(1).max(200),
content: z.string().min(1),
}),
handler: async (input) => {
const post = await db.post.create({ data: input });
return post;
},
}),
};
```
面试要点:Actions 底层仍基于 API 路由实现,但它封装了验证、序列化和错误处理,适合大多数表单交互场景。如果你不需要 REST 语义或对外暴露接口,优先用 Actions。
## 文件上传处理
处理文件上传需要从 `formData` 中提取文件对象,并进行类型和大小校验:
```typescript
// src/pages/api/upload.ts
import type { APIRoute } from 'astro';
export const POST: APIRoute = async ({ request }) => {
try {
const formData = await request.formData();
const file = formData.get('file') as File | null;
if (!file) {
return new Response(
JSON.stringify({ error: 'No file provided' }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp'];
if (!allowedTypes.includes(file.type)) {
return new Response(
JSON.stringify({ error: 'Unsupported file type' }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
const maxSize = 5 * 1024 * 1024; // 5MB
if (file.size > maxSize) {
return new Response(
JSON.stringify({ error: 'File exceeds 5MB limit' }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
const url = await uploadToStorage(file);
return new Response(JSON.stringify({ url }), {
status: 201,
headers: { 'Content-Type': 'application/json' },
});
} catch (error) {
return new Response(
JSON.stringify({ error: 'Upload failed' }),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
);
}
};
```
大文件上传建议使用流式处理(`request.body` 是 ReadableStream),避免将整个文件加载到内存。
## 数据库集成与分页查询
API 路由连接数据库时,分页是最常见的需求之一:
```typescript
// src/pages/api/posts.ts
import type { APIRoute } from 'astro';
import { db } from '../../lib/db';
export const GET: APIRoute = async ({ url }) => {
const page = Math.max(1, parseInt(url.searchParams.get('page') || '1'));
const limit = Math.min(50, Math.max(1, parseInt(url.searchParams.get('limit') || '10')));
const offset = (page - 1) * limit;
const [posts, total] = await Promise.all([
db.post.findMany({
take: limit,
skip: offset,
orderBy: { createdAt: 'desc' },
}),
db.post.count(),
]);
return new Response(
JSON.stringify({
posts,
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
}),
{ headers: { 'Content-Type': 'application/json' } }
);
};
```
注意对 `page` 和 `limit` 做了边界处理,防止负数或过大值导致的异常查询。
## SSG 模式下的 API 路由
静态站点生成模式下,API 路由在构建时执行,产出的 JSON 文件会被当作静态资源。这意味着动态路由需要通过 `getStaticPaths()` 声明所有可能的路径:
```typescript
// src/pages/api/tags/[tag].ts
import type { APIRoute } from 'astro';
export async function getStaticPaths() {
const tags = await fetchAllTags();
return tags.map(tag => ({ params: { tag } }));
}
export const GET: APIRoute = async ({ params }) => {
const posts = await fetchPostsByTag(params.tag);
return new Response(JSON.stringify(posts), {
headers: { 'Content-Type': 'application/json' },
});
};
```
如果需要运行时动态响应,必须将路由标记为按需渲染:
```typescript
export const prerender = false;
```
面试常问:SSG 模式的 API 路由本质上是构建时的数据预生成,适合数据不频繁变化的场景;SSR 模式才是真正的服务端接口,适合实时数据。搞混这两种模式是常见的错误。
## 实战中的常见问题
**请求体解析失败怎么办?** `request.json()` 在非法 JSON 时会抛异常,必须用 try/catch 包裹。同理 `request.formData()` 在非表单请求时也会报错。
**如何实现速率限制?** Astro 本身不提供速率限制,需要自行实现或使用中间件。简单的做法是基于 IP 和时间窗口做计数:
```typescript
// src/lib/rate-limit.ts
const requests = new Map<string, { count: number; resetAt: number }>();
export function rateLimit(ip: string, limit = 100, windowMs = 60000): boolean {
const now = Date.now();
const record = requests.get(ip);
if (!record || now > record.resetAt) {
requests.set(ip, { count: 1, resetAt: now + windowMs });
return true;
}
record.count++;
return record.count <= limit;
}
```
生产环境建议用 Redis 存储计数,避免内存泄漏和分布式场景下的不一致问题。
**如何做输入验证?** 除了 Actions 内置的 Zod 验证,API 路由中也可以直接用 Zod:
```typescript
import { z } from 'zod';
const CreatePostSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(1),
tags: z.array(z.string()).optional(),
});
export const POST: APIRoute = async ({ request }) => {
const body = await request.json();
const result = CreatePostSchema.safeParse(body);
if (!result.success) {
return new Response(
JSON.stringify({ error: 'Validation failed', details: result.error.flatten() }),
{ status: 422, headers: { 'Content-Type': 'application/json' } }
);
}
const post = await createPost(result.data);
return new Response(JSON.stringify(post), {
status: 201,
headers: { 'Content-Type': 'application/json' },
});
};
```
这样做的好处是类型从验证结果中推断,不需要手动声明 `body` 的类型。
掌握 Astro API 路由的关键在于理解它是基于 Web 标准的请求响应模型,与 Express 等框架的专有 API 不同。核心知识点包括:SSR/SSG 模式选择、`APIRoute` 类型标注、中间件集成鉴权、Actions 与 API 路由的适用场景区分,以及输入验证和错误处理的最佳实践。前端5月27日 21:13
Astro 的 Image 组件如何优化图片加载?Astro 的 Image 组件在构建时自动完成四件事:生成多尺寸响应式图片、转换现代格式(AVIF/WebP)、压缩质量、注入懒加载属性。浏览器根据 srcset 和 sizes 选择最合适的资源,避免加载冗余像素。
## 基本用法
```astro
---
import { Image } from 'astro:assets';
import hero from '../assets/hero.jpg';
---
<Image src={hero} alt="首页横幅" widths={[400, 800, 1200]} sizes="(max-width: 768px) 100vw, 50vw" formats={['avif', 'webp', 'jpeg']} />
```
widths 配合 sizes 让窄屏加载小图、宽屏加载大图。formats 按优先级尝试,AVIF 不可用时回退 WebP,再回退 JPEG。
## 关键属性速查
- **widths / sizes** — 响应式断点与显示尺寸,缺一不可
- **quality** — 压缩质量,推荐 75-85,肉眼无损但体积显著降低
- **loading** — 首屏图用 eager + priority,其余默认 lazy
- **format** — 输出格式,默认 WebP
## 远程图片处理
远程 URL 需在 astro.config.mjs 白名单域名,否则构建报错:
```javascript
image: {
remotePatterns: [{ protocol: 'https', hostname: 'cdn.example.com' }]
}
```
不确定尺寸时加 inferSize,Astro 会在构建时拉取图片获取宽高,避免 CLS 布局偏移。
## 三个常见坑
1. **public 目录图片不走优化** — 必须放 src/assets 并 import 引入
2. **宽高缺失导致 CLS** — 本地图片自动推断,远程图片需手动指定或用 inferSize
3. **忘记配 remotePatterns** — 远程图片直接报错,排查时先检查配置
## 追问
**Image 和 Picture 有什么区别?** Picture 生成 <picture> 元素,多格式同时输出让浏览器自行选择;Image 只输出单一最优格式。需要兼容老浏览器时用 Picture。
**构建时优化和 CDN 实时优化怎么选?** 构建时优化零运行时开销,适合静态站点;图片量大或频繁更新时,CDN 实时处理更灵活。两者可以结合使用。
**如何优化 LCP?** 首屏图片设 loading="eager" 并加 fetchpriority="high",同时用 widths 限制首屏图的最大尺寸,避免加载不必要的大图。前端5月27日 21:10
Astro 有哪些性能优化策略?## 核心策略:零 JS 默认 + 岛屿架构
Astro 默认只输出纯 HTML,不向客户端发送任何 JavaScript。交互组件通过 `client:*` 指令按需水合,这就是岛屿架构——页面像海洋(静态 HTML),交互区域像岛屿( hydrated JS)。
选择水合指令的原则:首屏交互用 `client:load`,非关键交互用 `client:idle`,滚动可见才用 `client:visible`,响应媒体查询用 `client:media`。多数场景 `client:visible` 就够了。
## 图片和字体优化
用 `<Image>` 组件自动生成 avif/webp 多格式、多尺寸响应式图片。首屏图设 `loading="eager" + priority`,其余全部 `loading="lazy"`。
字体用 `preload` 预加载 woff2 文件,避免 FOIT(无样式文字闪烁)阻塞渲染。
## 数据获取和构建优化
多个异步数据请求用 `Promise.all` 并行获取,不要串行 await。利用 Astro 的内容集合类型安全地查询数据。
构建配置中设 `inlineStylesheets: 'auto'` 让小样式内联、大样式外链。Vite 的 `manualChunks` 把 vendor 代码拆分,避免单文件过大。
## 缓存和部署
静态资源设 `Cache-Control: public, max-age=31536000, immutable`,API 响应按业务设短期缓存。部署选对适配器——Vercel 用 `@astrojs/vercel`,Cloudflare 用 `@astrojs/cloudflare`,Netlify 用 `@astrojs/netlify`,让平台做它最擅长的事。
## 实战效果
同样内容的博客,Next.js 加载约 2.8s,Astro 约 0.9s。Astro 站点的 Core Web Vitals "Good" 比例达 60%,而 WordPress/Gatsby 仅 38%。
---
**追问:Astro 的岛屿架构和 React Server Components 的服务端组件有什么本质区别?**
岛屿架构在 HTML 层面就隔离了交互边界,非交互区域零 JS;RSC 虽然也在服务端渲染,但交互组件仍需客户端 JS bundle 整体加载,粒度更粗。简单说:Astro 岛屿 = HTML 里嵌入 JS 岛,RSC = JS 里嵌入 HTML 流。前端5月27日 21:09
Astro 的岛屿架构(Islands Architecture)是如何工作的?client 指令有哪些类型?## 岛屿架构核心原理
Astro 默认输出纯静态 HTML,只有被 `client:*` 标记的组件才在客户端加载 JS 并水合——这些交互组件就是"岛屿",周围是静态 HTML"海洋"。
核心思路:**能静态就静态,需要交互才加载 JS。**
## 五种 client 指令
| 指令 | 水合时机 | 场景 |
|---|---|---|
| `client:load` | 页面加载后立即水合 | 导航栏、首屏轮播 |
| `client:idle` | 浏览器空闲时(`requestIdleCallback`) | 订阅表单 |
| `client:visible` | 进入视口时(`IntersectionObserver`) | 评论区 |
| `client:media` | 匹配媒体查询时 | 移动端菜单 |
| `client:only` | 跳过 SSR,纯客户端渲染 | 依赖浏览器 API 的组件 |
直接加在组件上即可:`<Nav client:load />`,不写 `client:*` 则只输出 HTML,零 JS。
## 与 SPA 的区别
SPA 全量下载 JS 再水合,岛屿架构只下载被标记组件的 JS,各岛屿独立水合互不阻塞。Astro 页面客户端 JS 体积通常只有同等 SPA 的 5%。
## 指令选择思路
首屏交互用 `client:load`,非关键用 `client:idle`,滚动可见用 `client:visible`,响应式用 `client:media`,必须依赖浏览器环境才用 `client:only`。**拿不准就不用——默认静态即最优解。**
## 追问
**Q: `client:only` 和 `client:load` 都在客户端渲染,区别是什么?**
`client:load` 先 SSR 输出 HTML 再水合;`client:only` 跳过 SSR,客户端从零渲染,首屏空白闪烁,仅用于无法在 Node 运行的组件。
**Q: 岛屿架构适合所有项目吗?**
不适合。适合内容驱动型网站(博客、文档、官网)。重交互应用(在线编辑器、IM)用 SPA 更合理,因为几乎所有组件都需要交互,岛屿架构优势无法发挥。前端5月27日 21:08
如何部署 Astro 应用到不同的平台(Vercel、Netlify、Node.js)?有哪些部署最佳实践?## 核心答案
Astro 部署分两条路线:**静态站点(SSG)直接丢到 Vercel/Netlify 就行,零配置**;SSR 应用则必须装对应平台的适配器(`@astrojs/vercel`、`@astrojs/netlify`、`@astrojs/node`),在 `astro.config.mjs` 里设 `output: 'server'` 并注册适配器。
选平台的关键判断:纯内容站选哪个都差不多,Vercel 对 SSR 支持更成熟(支持 ISR 缓存),Netlify 的 Edge Functions 延迟更低,自建服务器用 Node.js 适配器跑 `node ./dist/server/entry.mjs`。
## 静态部署:三行命令搞定
Vercel 和 Netlify 对 SSG 项目开箱即用——连仓库、自动构建、自动部署,不需要任何适配器。GitHub Pages 也没问题,在 `astro.config.mjs` 里配好 `site` 和 `base` 就行。
## SSR 部署的关键区别
三个平台的适配器装法一样(`npx astro add vercel/netlify/node`),但运行时差异很大:
- **Vercel**:支持 ISR(增量静态再生),设 `isr: true` 可对动态页面做缓存,适合内容偶尔更新的场景
- **Netlify**:Edge Functions 跑在 Deno runtime 上,冷启动极快,但有些 Node API 用不了
- **Node.js**:分 `standalone` 和 `middleware` 两种模式——前者直接跑,后者可以嵌入 Express/Fastify
## Docker 和 CI/CD
Docker 部署本质还是 Node.js 适配器:多阶段构建,builder 阶段装依赖+构建,runner 阶段只拷贝产物。CI/CD 就是标准的 checkout → install → build → deploy 流水线,各平台都有现成 Action。
## 追问:这些你大概率会被接着问
**Q:SSG 和 SSR 能混用吗?**
能。设 `output: 'hybrid'`,默认静态,需要动态的页面加 `export const prerender = false`。
**Q:环境变量怎么区分公开和私有?**
`PUBLIC_` 前缀的变量客户端可见,其他的只在服务端。别把密钥暴露到前端代码里。
**Q:部署后首屏慢怎么排查?**
先看是不是 SSR 模式下冷启动问题(Edge Functions 可缓解),再查有没有大量未优化图片,最后用 Lighthouse 跑一遍确认瓶颈在渲染还是网络。前端5月27日 21:07
什么是 Astro 的内容集合(Content Collections)?如何使用它来管理博客文章或文档?## Astro 内容集合是什么
Astro 内容集合(Content Collections)是 Astro 内置的结构化内容管理方案,把散落在项目里的 Markdown、MDX、JSON 等文件统一收进 `src/content/` 目录,用 Zod Schema 做 frontmatter 校验,构建时自动完成类型推断和验证——写错字段名直接报编译错误,不用等到线上才发现。
## 怎么用
两步走:定义 Schema,写内容文件。
在 `src/content/config.ts` 里声明集合:
```typescript
import { defineCollection, z } from 'astro:content';
const blog = defineCollection({
schema: z.object({
title: z.string(),
date: z.coerce.date(),
tags: z.array(z.string()),
}),
});
export const collections = { blog };
```
然后在 `src/content/blog/` 下创建 Markdown 文件,frontmatter 必须符合 schema 定义,否则构建失败。
页面里用 `getCollection` 批量查询,用 `getEntry` 按 slug 取单条:
```astro
---
import { getCollection, getEntry } from 'astro:content';
const posts = await getCollection('blog');
const post = await getEntry('blog', 'my-post');
const { Content } = await post.render();
---
<Content />
```
动态路由配合 `getStaticPaths` 就能自动生成所有文章页面。
## 追问:和直接在 pages 目录放 Markdown 有什么区别
没有内容集合时,每个 Markdown 文件就是一个路由,frontmatter 没有任何校验——`title` 拼成 `titel` 不会报错,日期格式不对也不会拦你。集合的核心价值就是**构建时校验 + 类型安全**,`getCollection` 返回的数据有完整的 TypeScript 类型,IDE 自动补全直接可用。
## 追问:一个项目能定义多个集合吗
可以。`config.ts` 里 export 多个集合就行,博客一个、文档一个、产品数据一个,各自独立的 schema,互不干扰。数据型内容(JSON/YAML)用 `type: 'data'`,文本型(Markdown/MDX)用 `type: 'content'`。
## 追问:内容集合有什么局限
内容集合是构建时处理的,不支持运行时动态添加内容。如果你的站点需要用户投稿或实时更新内容,得搭配 Headless CMS 或数据库,集合只负责静态内容的类型安全。前端5月27日 21:06
如何在 Astro 中实现国际化(i18n)?## 核心答案
Astro 从 4.0 开始内置了 i18n 路由支持,不需要第三方库就能实现多语言网站。在 `astro.config.mjs` 中配置:
```javascript
export default defineConfig({
i18n: {
locales: ['en', 'zh', 'ja'],
defaultLocale: 'en',
routing: {
prefixDefaultLocale: false,
},
},
});
```
这样默认语言走 `/about`,其他语言走 `/zh/about`、`/ja/about`。配合 `astro:i18n` 模块提供的 `getRelativeLocaleUrl()` 生成各语言链接,再用中间件做语言检测和重定向,一个完整的多语言站点就跑起来了。
## 路由策略怎么选?
**子目录路由**(`/zh/about`)是主流方案,配置简单、共享域名权重,适合大多数项目。子域名方案(`zh.example.com`)需要额外 DNS 和证书配置,只在团队和资源充足时考虑。
默认语言是否加前缀,取决于你的目标用户——如果主要受众是英语用户,`prefixDefaultLocale: false` 让 URL 更干净;如果各语言地位平等,统一加前缀更一致。
## 翻译文件怎么组织?
UI 文本用 JSON 文件按语言分目录存放:
```
src/i18n/
en/common.json
zh/common.json
ja/common.json
```
页面内容则用 Astro 的内容集合(Content Collections),按语言建集合或用 `slug` 后缀区分。读取时根据 `Astro.currentLocale` 过滤对应语言的内容。
## SEO 要注意什么?
三件事:**hreflang 标签**、**规范 URL**、**多语言站点地图**。
```astro
<link rel="alternate" hreflang="en" href="/en" />
<link rel="alternate" hreflang="zh" href="/zh" />
<link rel="alternate" hreflang="x-default" href="/" />
```
配合 `@astrojs/sitemap` 的 `i18n` 配置项,自动生成多语言 sitemap。漏掉 hreflang 是最常见的错误,搜索引擎会把不同语言的页面当作重复内容。
## 中间件怎么处理语言检测?
```typescript
// src/middleware.ts
import { defineMiddleware } from 'astro:middleware';
export const onRequest = defineMiddleware((context, next) => {
const locale = context.url.pathname.split('/')[1];
const supported = ['en', 'zh', 'ja'];
if (!supported.includes(locale)) {
const browserLang = context.request.headers
.get('accept-language')
?.split(',')[0].split('-')[0] || 'en';
const target = supported.includes(browserLang) ? browserLang : 'en';
return context.redirect(`/${target}${context.url.pathname}`);
}
context.locals.locale = locale;
return next();
});
```
根据 `Accept-Language` 头判断浏览器语言,首次访问自动跳转。
## 追问
- **astro-i18next 和原生 i18n 有什么区别?** 原生只管路由,不管翻译加载;astro-i18next 补了翻译函数和运行时,但增加了包体积。Astro 5 之后推荐原生路由 + 自建翻译工具函数。
- **SSR 模式下 i18n 有什么坑?** 静态模式下每个语言预生成页面没问题;SSR 模式要注意中间件里不能阻塞渲染,语言检测逻辑必须同步完成,且需要处理 cookie 记住用户偏好。
- **RTL 语言怎么处理?** 在根布局根据 `locale` 动态设置 `dir` 属性:`<html dir={isRTL ? 'rtl' : 'ltr'}>`,再用 CSS 逻辑属性(`margin-inline-start`)替代 `margin-left`。前端5月27日 21:05
Astro 的中间件是如何工作的?## 中间件是什么
Astro 中间件是一段在请求到达页面之前执行的代码。它可以拦截请求、修改响应、或者直接返回结果——本质上是一个运行在服务端的请求拦截器。
中间件定义在 `src/middleware.ts` 中,导出一个 `onRequest` 函数:
```typescript
import { defineMiddleware } from 'astro:middleware';
export const onRequest = defineMiddleware(async (context, next) => {
// 请求到达页面之前
const response = await next();
// 页面渲染之后,可修改响应
return response;
});
```
`context` 提供了 `request`、`url`、`cookies`、`locals` 等属性;`next()` 将请求传递给下一个处理环节并返回响应。
## 执行时机与渲染模式的关系
面试常考的一个点:中间件在不同渲染模式下的行为不同。对于按需渲染(SSR)的页面,中间件在每次请求时运行;对于预渲染(SSG)的页面,中间件在构建时运行。这意味着如果你的页面全部是静态的,中间件只在 build 阶段执行一次,运行时不会触发。
## 常见使用场景
**认证守卫**是最典型的场景。在中间件中检查 cookie 或 header 中的 token,未认证则重定向到登录页,通过后将用户信息存入 `context.locals`,后续页面直接从 `Astro.locals` 读取。
**重定向管理**适合在这里统一处理旧路径到新路径的映射,避免在页面组件里分散写 redirect 逻辑。
**国际化**也是常见用途:从 URL 路径或 `Accept-Language` 头检测语言偏好,不匹配时重定向到正确的语言版本,匹配则将 locale 写入 locals 供页面使用。
## 链式中间件
Astro 提供了 `sequence` 函数将多个中间件串联,按顺序依次执行:
```typescript
import { sequence } from 'astro:middleware';
import { auth, i18n, log } from './middleware';
export const onRequest = sequence(auth, i18n, log);
```
执行顺序就是参数顺序:auth 先于 i18n 先于 log。每个中间件都可以选择是否调用 `next()`。
## locals 的作用
`context.locals` 是中间件和页面之间的数据桥梁。中间件写入的数据在页面的 `Astro.locals` 中可以直接读取,这是 Astro 推荐的跨层传数据方式,避免了全局状态或重复请求。
## 追问:中间件里能直接返回 Response 吗
可以。不调用 `next()` 而直接 `return new Response(...)` 就能短路整个请求链,页面不会渲染。这在认证失败、限流、维护模式等场景下很有用。
另一个追问:中间件能修改响应体吗?能,但需要在 `next()` 返回之后 clone 一份 Response 再修改,因为 Response 对象是不可变的。
```typescript
export const onRequest = defineMiddleware(async (context, next) => {
const response = await next();
const body = await response.text();
const modified = body.replace('old', 'new');
return new Response(modified, {
status: response.status,
headers: response.headers,
});
});
```
Astro 中间件的核心思路和 Express 的 middleware 一脉相承:拦截-处理-传递。理解了请求生命周期中它的位置,以及 locals 的数据传递机制,面试中相关问题基本都能应对。前端5月27日 21:05
如何在 Astro 项目中集成和使用多个前端框架(React、Vue、Svelte)?## 核心答案
Astro 通过官方集成包支持在同一项目中混用 React、Vue、Svelte 等框架。安装集成后,各框架组件以独立文件存在,在 `.astro` 页面中统一导入使用:
```bash
npx astro add react vue svelte
```
```js
// astro.config.mjs
import { defineConfig } from 'astro/config';
import react from '@astrojs/react';
import vue from '@astrojs/vue';
import svelte from '@astrojs/svelte';
export default defineConfig({
integrations: [react(), vue(), svelte()],
});
```
页面中直接导入不同框架的组件即可共存,每个组件保持自身的文件扩展名和写法。
## 为什么能混用:Islands 架构
Astro 默认输出纯 HTML,不发送 JavaScript。交互组件通过 `client:*` 指令按需水合,各框架运行时仅加载到对应的"岛屿"上,彼此隔离、互不干扰:
- `client:load` — 立即水合
- `client:idle` — 浏览器空闲时水合
- `client:visible` — 进入视口时水合
- `client:only` — 跳过 SSR,纯客户端渲染
选择哪个指令直接影响页面性能:静态内容用 Astro 组件,交互部分才加 client 指令。
## 框架间如何共享数据
不同框架组件不能直接引用或共享状态。实际项目中有三种做法:
1. **Props 透传**:在 Astro 页面的 frontmatter 中获取数据,通过 props 传给各组件
2. **Storage 桥接**:用 localStorage 或 sessionStorage 做中转
3. **自定义事件**:通过 `window.dispatchEvent` / `addEventListener` 跨组件通信
Props 透传最直接,后两种适合无依赖关系的组件间联动。
## 混用多框架的代价
每引入一个框架就增加一份运行时体积。React 约 40KB gzip,Vue 约 33KB,Svelte 编译后体积最小。如果页面同时水合三个框架的组件,首屏 JS 会明显膨胀。实际项目中应控制框架数量,通常选 1-2 个交互框架配合 Astro 静态组件即可。
## 追问方向
- `client:only` 和 `client:load` 在 SSR 模式下有什么区别?
- 多个 JSX 框架(React + Preact + Solid)共存时如何避免解析冲突?
- Astro 5.0 的 Server Islands 对多框架场景带来了什么变化?前端5月27日 21:05
什么是 Astro 框架,它的核心特性和工作原理是什么?## 核心回答
Astro 是一个以内容驱动为核心的现代 Web 框架,最大特点是**默认零 JavaScript**——构建产物只有纯 HTML,除非你主动引入交互。
它的核心特性有三个:
1. **岛屿架构**:页面上每个交互组件是一个独立的"岛屿",其余部分是静态 HTML。只有标记了 `client:*` 指令的组件才会在浏览器中加载 JS 并水合。这意味着页面可以按需加载交互,而不是整个页面打包成一个 JS bundle。
2. **UI 无关性**:同一个项目里可以混用 React、Vue、Svelte 等框架的组件。Astro 不绑定任何 UI 库,你按需引入即可。
3. **服务器优先渲染**:组件代码在构建时(或 SSR 请求时)于服务端执行,输出 HTML。浏览器收到的是最小化的页面,不携带多余的运行时。
## 工作原理
Astro 组件用 `---` 分隔服务端脚本和客户端模板:
```astro
---
// 构建时执行,可异步获取数据
const posts = await fetch('/api/posts').then(r => r.json());
---
<h1>文章列表</h1>
{posts.map(post => <p>{post.title}</p>)}
```
`---` 上方是服务端代码,下方编译为 HTML。客户端拿到的只有渲染结果,没有框架运行时。
## 与 Next.js 的关键区别
Astro 构建**多页应用(MPA)**,页面间靠原生导航跳转;Next.js 本质是 SPA 架构,即使做了 SSG 也要加载 React 运行时做水合。Astro 只在你写了 `client:load` 的组件上才水合,其余部分零 JS。
## 面试追问
- **`client:only` 和 `client:visible` 有什么区别?**——前者跳过服务端渲染直接在客户端加载,后者在组件进入视口时才加载 JS。
- **Astro 适合哪些项目?**——博客、文档站、营销页等内容密集型站点。不适合强交互的仪表盘或 SPA 应用。前端5月27日 21:05
Astro 支持哪些渲染模式?SSG 和 SSR 有什么区别?## Astro 支持哪些渲染模式?
Astro 支持四种渲染模式:静态生成(SSG)、服务端渲染(SSR)、混合渲染和客户端渲染。其中 SSG 是默认模式,也是 Astro 推荐的首选方案。
## SSG 和 SSR 的核心区别是什么?
**SSG** 在构建时生成 HTML,部署后所有用户看到的内容相同,页面由 CDN 直接分发,TTFB 极低。**SSR** 在每次请求时动态生成 HTML,可以返回个性化内容,但需要服务器运行时,响应速度取决于服务端处理耗时。
一句话区分:SSG 用构建时间换访问速度,SSR 用服务端计算换内容实时性。
## 什么时候用 SSG?
内容稳定、更新频率低的页面——博客、文档、营销页、落地页。Astro 默认就是 SSG,不需要额外配置:
```javascript
// astro.config.mjs
import { defineConfig } from 'astro/config';
export default defineConfig({ output: 'static' });
```
动态路由也走 SSG,通过 `getStaticPaths` 在构建时预生成所有路径。
## 什么时候用 SSR?
需要实时数据或个性化内容的页面——用户仪表板、购物车、需要鉴权的后台页面。启用 SSR 需要安装适配器并切换 output:
```javascript
import { defineConfig } from 'astro/config';
import vercel from '@astrojs/vercel/server';
export default defineConfig({ output: 'server', adapter: vercel() });
```
页面逻辑在每次请求时执行,可以读取 Cookie、Session 等请求上下文。
## 混合渲染怎么用?
大型项目往往既有静态页面又有动态页面。Astro 的混合模式允许逐页指定渲染策略——基础 output 设为 `hybrid`,然后通过 `export const prerender` 控制单个页面:
```astro
---
export const prerender = true; // SSG
---
<!-- 这个页面在构建时生成 -->
```
```astro
---
export const prerender = false; // SSR
---
<!-- 这个页面在请求时生成 -->
```
这是 Astro 相比其他框架的显著优势:不需要在 SSG 和 SSR 之间做二选一。
## client:only 算什么渲染?
`client:only` 是纯客户端渲染,组件只在浏览器中执行,服务端不输出 HTML。适合不需要 SEO 且交互密集的组件,比如数据可视化图表:
```astro
<InteractiveChart client:only="react" />
```
注意:使用 `client:only` 的页面对搜索引擎不可见,务必只在确认不需要索引的场景下使用。
## 追问:Astro 的 Islands 架构和渲染模式有什么关系?
Islands 架构是 Astro 的底层设计哲学——页面主体是静态 HTML(零 JS),只有被 `client:*` 指令标记的交互组件才会加载并水合。这意味着即使页面启用了 SSR,大部分内容仍然不需要 JavaScript,只有 Islands 区域有运行时开销。理解这一点才能回答"为什么 Astro 的 SSR 比 Next.js 更快"——因为 Astro 默认不发送 JS,按需水合而非全量水合。服务端5月27日 15:55
Astro 中的状态管理是如何实现的?如何在 React、Vue、Svelte 组件中管理状态?Astro 的状态管理与传统 SPA 框架有本质区别。理解 Astro 的岛屿架构(Island Architecture)是掌握其状态管理的关键前提。
## 岛屿架构如何影响状态管理
Astro 默认发送零 JavaScript,页面以纯静态 HTML 输出。交互组件需要通过 `client:*` 指令才会被水合(hydrate)并获得客户端状态管理能力。这意味着:
- 没有 `client:*` 指令的组件只渲染静态 HTML,点击按钮不会触发状态更新
- 每个岛屿(island)是独立的,拥有各自的生命周期和状态作用域
- 岛屿之间默认无法共享状态,这是 Astro 状态管理的核心挑战
Astro 提供五种水合指令控制组件何时激活:
- `client:load`:页面加载后立即水合,适合首屏关键交互
- `client:idle`:浏览器空闲时水合,适合低优先级组件
- `client:visible`:组件进入视口时水合,适合首屏以下内容
- `client:media`:匹配媒体查询时水合,适合响应式场景
- `client:only`:跳过服务端渲染,仅在客户端渲染
## 各框架组件的局部状态管理
加上 `client:*` 指令后,各框架组件使用各自的内置状态机制,写法与在原生框架中完全一致。
### React 组件
React 组件使用 `useState` 和 `useEffect` 管理状态:
```jsx
// src/components/Counter.jsx
import { useState } from 'react';
export default function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>当前计数: {count}</p>
<button onClick={() => setCount(c => c + 1)}>+1</button>
<button onClick={() => setCount(c => c - 1)}>-1</button>
</div>
);
}
```
在 Astro 页面中使用时必须加上 `client:*` 指令:
```astro
---
import Counter from '../components/Counter.jsx';
---
<Counter client:load />
```
如果漏掉 `client:load`,组件只会渲染初始 HTML,按钮点击不会有任何响应。
### Vue 组件
Vue 组件使用 `ref` 和 `computed` 管理状态:
```vue
<!-- src/components/TodoList.vue -->
<script setup>
import { ref, computed } from 'vue';
const todos = ref([
{ id: 1, text: '学习 Astro', done: false },
{ id: 2, text: '构建应用', done: true },
]);
const remaining = computed(() =>
todos.value.filter(t => !t.done).length
);
function toggle(id) {
const todo = todos.value.find(t => t.id === id);
if (todo) todo.done = !todo.done;
}
</script>
<template>
<p>剩余 {{ remaining }} 项</p>
<ul>
<li v-for="todo in todos" :key="todo.id">
<input type="checkbox" :checked="todo.done" @change="toggle(todo.id)" />
<span :style="{ textDecoration: todo.done ? 'line-through' : 'none' }">
{{ todo.text }}
</span>
</li>
</ul>
</template>
```
在 Astro 页面中使用:
```astro
---
import TodoList from '../components/TodoList.vue';
---
<TodoList client:visible />
```
### Svelte 组件
Svelte 使用内置 store 管理状态,语法最简洁:
```svelte
<!-- src/components/Cart.svelte -->
<script>
import { writable } from 'svelte/store';
const items = writable([]);
const total = writable(0);
function add(product) {
items.update(list => [...list, product]);
total.update(t => t + product.price);
}
</script>
<h2>购物车</h2>
<p>合计: {$total} 元</p>
{#each $items as item}
<p>{item.name} - {item.price} 元</p>
{/each}
```
## 跨框架状态共享:Nanostores
各框架的局部状态(React 的 useState、Vue 的 ref、Svelte 的 writable)只能在同一个岛屿内部使用。当不同框架的岛屿需要共享状态时,Astro 官方推荐使用 Nanostores。
Nanostores 是一个不到 1KB 的框架无关状态管理库,为每个框架提供适配器,实现跨岛屿状态同步。
### 安装
```bash
npm install nanostores @nanostores/react @nanostores/vue
# Svelte 无需额外包,原生支持
```
### 创建共享 Store
```js
// src/store/cartStore.js
import { atom, map } from 'nanostores';
export const isCartOpen = atom(false);
export const cartItems = map({});
export function addCartItem(name, price) {
cartItems.set({ ...cartItems.get(), [name]: { name, price } });
isCartOpen.set(true);
}
```
### React 岛屿读取共享状态
```jsx
// src/components/CartButton.jsx
import { useStore } from '@nanostores/react';
import { isCartOpen, cartItems } from '../store/cartStore';
export default function CartButton() {
const $isOpen = useStore(isCartOpen);
const $items = useStore(cartItems);
const count = Object.keys($items).length;
return (
<button onClick={() => isCartOpen.set(!$isOpen)}>
购物车 ({count})
</button>
);
}
```
### Vue 岛屿读取共享状态
```vue
<!-- src/components/CartPanel.vue -->
<script setup>
import { useStore } from '@nanostores/vue';
import { isCartOpen, cartItems } from '../store/cartStore';
const $isOpen = useStore(isCartOpen);
const $items = useStore(cartItems);
</script>
<template>
<div v-if="$isOpen" class="cart-panel">
<h3>购物车</h3>
<ul>
<li v-for="(item, key) in $items" :key="key">
{{ item.name }} - {{ item.price }} 元
</li>
</ul>
</div>
</template>
```
### Svelte 岛屿读取共享状态
```svelte
<!-- src/components/CartCount.svelte -->
<script>
import { isCartOpen, cartItems } from '../store/cartStore';
</script>
<span>商品数: {Object.keys($cartItems).length}</span>
<button on:click={() => isCartOpen.set(!$isCartOpen)}>
{#if $isCartOpen}关闭{:else}打开{/if}
</button>
```
### 在 Astro 页面中组合
```astro
---
import CartButton from '../components/CartButton.jsx';
import CartPanel from '../components/CartPanel.vue';
import CartCount from '../components/CartCount.svelte';
---
<header>
<CartButton client:load />
<CartCount client:load />
</header>
<main>
<CartPanel client:load />
</main>
```
三个不同框架的岛屿通过 Nanostores 共享同一份购物车状态,修改一个岛屿中的数据会实时反映到其他岛屿。
## 全局状态管理方案选择
除了 Nanostores,Astro 项目还可以根据场景选择其他方案:
### Zustand(React 专属项目)
如果项目只使用 React,Zustand 是更熟悉的选择:
```ts
// src/store/useStore.ts
import { create } from 'zustand';
interface AppState {
user: { name: string } | null;
theme: 'light' | 'dark';
setUser: (user: { name: string }) => void;
setTheme: (theme: 'light' | 'dark') => void;
}
export const useStore = create<AppState>((set) => ({
user: null,
theme: 'light',
setUser: (user) => set({ user }),
setTheme: (theme) => set({ theme }),
}));
```
Zustand 的状态只在 React 岛屿内有效,无法直接被 Vue 或 Svelte 岛屿访问。
### React Query / TanStack Query(服务端数据缓存)
用于管理从 API 获取的服务端状态,自动处理缓存、重试和失效更新:
```jsx
// src/components/PostList.jsx
import { useQuery } from '@tanstack/react-query';
export default function PostList() {
const { data, isLoading, error } = useQuery({
queryKey: ['posts'],
queryFn: () => fetch('/api/posts').then(r => r.json()),
});
if (isLoading) return <p>加载中...</p>;
if (error) return <p>加载失败</p>;
return (
<ul>
{data.map(post => <li key={post.id}>{post.title}</li>)}
</ul>
);
}
```
## 服务端数据获取
Astro 组件在服务端执行,可以直接在 frontmatter 中获取数据,无需客户端请求:
```astro
---
// src/pages/dashboard.astro
const user = await getUser(Astro.request);
const posts = await getCollection('blog');
---
<h1>{user.name} 的仪表板</h1>
<ul>
{posts.map(post => <li>{post.data.title}</li>)}
</ul>
```
这种方式获取的数据作为静态 HTML 输出,不产生客户端 JavaScript 开销,是 Astro 性能优势的核心来源。
## URL 作为状态源
筛选、分页、排序等状态适合存储在 URL 参数中,好处是可分享、可前进后退:
```jsx
// src/components/ProductFilter.jsx
import { useSearchParams } from 'react-router-dom';
export default function ProductFilter() {
const [params, setParams] = useSearchParams();
const category = params.get('category') || 'all';
const page = parseInt(params.get('page') || '1');
function update(key, value) {
setParams(prev => {
const next = new URLSearchParams(prev);
next.set(key, value);
return next;
});
}
return (
<div>
<select value={category} onChange={e => update('category', e.target.value)}>
<option value="all">全部分类</option>
<option value="electronics">电子产品</option>
</select>
<button disabled={page === 1} onClick={() => update('page', page - 1)}>上一页</button>
<span>第 {page} 页</span>
<button onClick={() => update('page', page + 1)}>下一页</button>
</div>
);
}
```
## 状态管理方案选型指南
| 场景 | 推荐方案 | 说明 |
|------|---------|------|
| 单岛屿内部状态 | 框架内置(useState/ref/writable) | 最简单,无额外依赖 |
| 跨岛屿/跨框架共享 | Nanostores | 官方推荐,轻量通用 |
| 仅 React 项目全局状态 | Zustand | API 简洁,社区活跃 |
| API 数据缓存 | TanStack Query | 自动缓存和失效更新 |
| 筛选/分页/排序 | URL 参数 | 可分享,支持前进后退 |
| 静态内容展示 | Astro frontmatter 直接获取 | 零 JS 开销 |
选择状态管理方案时,核心判断依据是状态的共享范围:岛屿内部用框架内置方案,跨岛屿用 Nanostores,服务端数据优先在 Astro frontmatter 中直接获取。服务端5月27日 15:38
如何在 Astro 项目中使用 Vitest 和 Playwright 进行测试?Astro 项目中的测试涉及单元测试、组件测试和端到端测试三个层次,不同层次的测试需要搭配不同的工具和策略。实际开发中,Vitest 负责 .ts 工具函数和框架组件的单元测试,Playwright 负责页面级别的端到端测试,而 .astro 组件的测试则需要借助 Container API 或 vitest-browser-astro 方案。
## 测试框架选型
**Vitest** 是 Astro 官方推荐的单元测试框架,它基于 Vite 构建,与 Astro 的构建体系天然兼容,配置简单且执行速度快。如果你的项目已经使用 Vite,Vitest 几乎可以零配置接入。
**Playwright** 是端到端测试的首选,支持 Chromium、Firefox 和 WebKit 三大浏览器引擎,能够在真实浏览器环境中模拟用户操作,验证页面导航、表单提交、交互逻辑等完整流程。
**Jest** 虽然生态成熟,但需要额外的 transform 配置才能处理 Astro 项目中的 TypeScript 和 JSX,配置成本较高,一般不推荐在 Astro 项目中使用。
## 安装测试依赖
```bash
# 安装 Vitest
npm install -D vitest
# 安装 vitest-browser-astro(用于测试 .astro 组件)
npm install -D vitest-browser-astro @vitest/browser-playwright playwright
# 安装框架组件测试工具(按需选择)
npm install -D @testing-library/react # React 组件
npm install -D @testing-library/vue # Vue 组件
npm install -D @vue/test-utils # Vue 组件(替代方案)
# 安装 Playwright
npm install -D @playwright/test
npx playwright install
```
## 配置 Vitest
Astro 提供了 `getViteConfig()` 辅助函数,可以自动将项目的 Astro 配置应用到 Vitest,避免手动对齐 Vite 插件和路径别名。
```typescript
// vitest.config.ts
/// <reference types="vitest" />
import { getViteConfig } from 'astro/config';
export default getViteConfig({
test: {
// 服务端代码测试用 node 环境
environment: 'node',
globals: true,
setupFiles: ['./src/test/setup.ts'],
},
});
```
如果部分测试依赖 DOM 环境,可以在单个测试文件顶部通过注释指定:
```typescript
// @vitest-environment jsdom
import { describe, it, expect } from 'vitest';
```
`getViteConfig()` 还支持第二个参数,用于在测试中覆盖 Astro 配置:
```typescript
export default getViteConfig(
{ test: { environment: 'node' } },
{ site: 'https://example.com/', trailingSlash: 'always' }
);
```
## 测试 .astro 组件
.astro 组件是服务端渲染的,不能像 React 组件那样直接用 Testing Library 的 render 方法。Astro 提供了两种测试方案:
### 方案一:Container API
Container API 是 Astro 内置的实验性功能,可以在服务端渲染 .astro 组件并返回 HTML 字符串:
```typescript
// src/components/__tests__/Card.test.ts
import { describe, it, expect } from 'vitest';
import { Container } from 'astro:container';
import Card from '../Card.astro';
describe('Card 组件', () => {
it('渲染标题文本', async () => {
const container = await Container.create();
const result = await container.renderToString(Card, {
props: { title: 'Hello Astro' },
});
expect(result).toContain('Hello Astro');
});
});
```
### 方案二:vitest-browser-astro
vitest-browser-astro 将组件渲染到真实浏览器 DOM 中,支持更丰富的交互断言:
```typescript
// vitest.config.ts 中添加插件
import { getViteConfig } from 'astro/config';
import { astroRenderer } from 'vitest-browser-astro/plugin';
import { playwright } from '@vitest/browser-playwright';
export default getViteConfig({
plugins: [astroRenderer()],
test: {
browser: {
enabled: true,
instances: [{ browser: 'chromium' }],
provider: playwright(),
headless: true,
},
},
});
```
```typescript
// src/components/__tests__/Card.test.ts
import { render } from 'vitest-browser-astro';
import { expect, test } from 'vitest';
import Card from '../Card.astro';
test('渲染卡片标题', async () => {
const screen = await render(Card, {
props: { title: 'Hello Astro' },
});
await expect.element(screen.getByText('Hello Astro')).toBeVisible();
});
```
如果组件中嵌套了 React 或 Vue 框架组件,需要在配置中注册对应的 container renderer:
```typescript
import { getContainerRenderer } from '@astrojs/react';
const container = await Container.create({
renderers: [getContainerRenderer()],
});
```
注意:同一个配置中只能使用一种 JSX 框架 renderer,Vue 和 Svelte 等非 JSX 框架可以与 JSX 框架共存。
## 测试框架组件
### React 组件测试
```typescript
// src/components/__tests__/Counter.test.tsx
// @vitest-environment jsdom
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Counter from '../Counter';
describe('Counter', () => {
it('显示初始计数值', () => {
render(<Counter initialCount={0} />);
expect(screen.getByText('Count: 0')).toBeInTheDocument();
});
it('点击按钮后计数值加一', async () => {
const user = userEvent.setup();
render(<Counter initialCount={0} />);
await user.click(screen.getByRole('button', { name: 'Increment' }));
expect(screen.getByText('Count: 1')).toBeInTheDocument();
});
});
```
### Vue 组件测试
```typescript
// src/components/__tests__/TodoList.test.ts
// @vitest-environment jsdom
import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import TodoList from '../TodoList.vue';
describe('TodoList', () => {
it('渲染待办事项列表', () => {
const todos = [
{ id: 1, text: 'Learn Astro', completed: false },
{ id: 2, text: 'Build app', completed: true },
];
const wrapper = mount(TodoList, { props: { todos } });
expect(wrapper.findAll('.todo-item')).toHaveLength(2);
expect(wrapper.text()).toContain('Learn Astro');
});
it('勾选完成状态后触发事件', async () => {
const wrapper = mount(TodoList, {
props: { todos: [{ id: 1, text: 'Task', completed: false }] },
});
await wrapper.find('input[type="checkbox"]').setValue(true);
expect(wrapper.emitted('complete')).toBeTruthy();
});
});
```
## 测试 API 路由
Astro 的 API 路由导出 `GET`、`POST` 等函数,可以直接在测试中调用:
```typescript
// src/pages/api/__tests__/users.test.ts
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { GET, POST } from '../users';
describe('Users API', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('GET 返回用户列表', async () => {
const request = new Request('http://localhost/api/users');
const response = await GET({ request } as any);
const data = await response.json();
expect(response.status).toBe(200);
expect(Array.isArray(data.users)).toBe(true);
});
it('POST 创建新用户', async () => {
const request = new Request('http://localhost/api/users', {
method: 'POST',
body: JSON.stringify({ name: 'John', email: 'john@example.com' }),
});
const response = await POST({ request } as any);
const data = await response.json();
expect(response.status).toBe(201);
expect(data.name).toBe('John');
});
});
```
## 测试中间件
Astro 中间件的 `onRequest` 函数可以像普通函数一样测试,关键是构造正确的 context 对象:
```typescript
// src/middleware/__tests__/auth.test.ts
import { describe, it, expect, vi } from 'vitest';
import { onRequest } from '../auth';
describe('认证中间件', () => {
it('无 token 时重定向到登录页', async () => {
const request = new Request('http://localhost/dashboard');
const redirect = vi.fn();
await onRequest({ request, redirect } as any);
expect(redirect).toHaveBeenCalledWith('/login');
});
it('有效 token 时放行请求', async () => {
const request = new Request('http://localhost/dashboard', {
headers: { Authorization: 'Bearer valid-token' },
});
const next = vi.fn().mockResolvedValue(new Response());
await onRequest({ request, next } as any);
expect(next).toHaveBeenCalled();
});
});
```
## 端到端测试(Playwright)
Playwright 测试在真实浏览器中运行,需要先启动开发服务器。在 `playwright.config.ts` 中配置 `webServer`:
```typescript
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
webServer: {
command: 'npm run dev',
port: 4321,
reuseExistingServer: !process.env.CI,
},
testDir: './e2e',
});
```
编写端到端测试:
```typescript
// e2e/navigation.spec.ts
import { test, expect } from '@playwright/test';
test.describe('页面导航', () => {
test('首页加载正常', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveTitle(/My Astro App/);
await expect(page.locator('h1')).toContainText('Welcome');
});
test('导航到关于页', async ({ page }) => {
await page.goto('/');
await page.click('text=About');
await expect(page).toHaveURL(/\/about/);
});
test('表单提交成功', async ({ page }) => {
await page.goto('/contact');
await page.fill('input[name="name"]', 'John');
await page.fill('input[name="email"]', 'john@example.com');
await page.fill('textarea[name="message"]', 'Hello!');
await page.click('button[type="submit"]');
await expect(page.locator('.success-message')).toBeVisible();
});
});
```
## 测试覆盖率
在 vitest.config.ts 中配置覆盖率报告:
```typescript
// vitest.config.ts
import { getViteConfig } from 'astro/config';
export default getViteConfig({
test: {
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: [
'node_modules/',
'src/test/',
'**/*.d.ts',
'**/*.config.*',
],
},
},
});
```
## npm scripts 配置
在 package.json 中添加测试命令:
```json
{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"test:ui": "vitest --ui",
"test:coverage": "vitest run --coverage",
"test:e2e": "playwright test",
"test:e2e:headed": "playwright test --headed"
}
}
```
## 测试策略建议
**分层测试金字塔**:底层大量单元测试覆盖工具函数和独立组件,中间适量集成测试验证组件协作和 API 逻辑,顶层少量端到端测试保障核心业务流程。
**.astro 组件测试优先用 Container API**:简单场景用 renderToString 断言 HTML 内容即可,需要交互断言时再用 vitest-browser-astro。
**Mock 外部依赖**:用 vi.mock() 隔离第三方模块和 API 调用,确保测试稳定且不依赖外部服务。
**CI 中强制执行**:在 CI 流程中运行 vitest run 和 playwright test,并设置覆盖率阈值,低于阈值则构建失败。