前端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 应用。标签
Astro
Astro 是一个现代的静态站点生成器(SSG),它允许你使用多种前端框架(如React、Vue、Svelte等)构建网站,并且能够输出干净、轻量级的HTML文件,不含有客户端的 JavaScript。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 对多框架场景带来了什么变化?