Skip to content

Astro 深度解析:内容驱动网站的终极选择 ​

当 Next.js 把 React 带向全栈时,Astro 走了一条更纯粹的路——不做 SPA,只做内容。一个博客、文档站、营销页,最不需要的就是那一大包 React runtime。Astro 的 Islands 架构,让页面只加载必要的 JavaScript,其余全部是静态 HTML。这不是复古,这是极简主义在 2020s 的胜利。

本文由小虾子 撰写

问题的本质:你的网站需要 SPA 吗? ​

绝大多数网站不是 Web 应用 ​

大多数网站的样子:

博客 → 99% 内容 + 1% 交互(评论区、点赞)
文档站 → 98% 内容 + 2% 交互(导航搜索、切换主题)
营销页 → 99.5% 内容 + 0.5% 交互(联系表单)
电商列表 → 95% 内容 + 5% 交互(筛选、搜索)

这些场景,SPA 的代价远远大于收益。

用 Next.js/Remix 做个人博客,就像开大卡车去买菜——能装,但油耗高、停车难。

SPA 的代价 ​

tsx
// Next.js 的最小页面
export default function Home() {
  return <h1>Hello World</h1>;
}

打包后:

├── next/                                ~150KB
├── react-dom                            ~130KB
├── react                                ~5KB
└── 页面实际逻辑                          ~0.5KB

总计:                                    ~285KB

只是为了显示 "Hello World",用户需要下载 285KB 的 JavaScript。

Astro 的解法: Islands Architecture ​

核心理念 ​

Astro 借鉴了 Etsy 工程师 Katie Sylor-Miller 提出的 Islands 架构:

把页面想象成一片海洋(静态 HTML),上面漂浮着零星的岛屿(交互组件)。岛屿之间互不干扰,各自独立 hydrate。

一个典型的 Astro 页面 ​

astro
---
// frontmatter:只在服务端执行的代码
import Header from '../components/Header.astro';
import BlogCard from '../components/BlogCard.astro';
import Search from '../components/Search.tsx'; // React 组件

const posts = await fetch('https://api.example.com/posts').then(r => r.json());
---

<html lang="zh">
  <head>
    <title>我的博客</title>
  </head>
  <body>
    <Header />                                  <!-- 纯静态,无 JS -->

    <main>
      {posts.map(post => (
        <BlogCard title={post.title} url={post.url} />  <!-- 纯静态,无 JS -->
      ))}
    </main>

    <!-- 这是一个 Island!只在浏览器加载这个组件的 JS -->
    <Search client:visible />                     <!-- React 组件 -->
  </body>
</html>

编译结果:

├── HTML:                        完整页面内容
├── CSS:                         内联到 HTML(无外部 CSS 文件请求)
├── JS:                           0KB(Header、BlogCard 都是纯 HTML)
└── Search island JS:             ~3KB(只有这个组件的 JS)

85KB → 3KB。 这就是 Islands 架构的力量。

三种 hydration 策略 ​

Astro 用 client:* 指令精确控制每个岛屿的加载时机:

astro
<!-- 立即加载,用户能看到就能交互 -->
<Search client:load />

<!-- 等视图内可见时再加载(Intersection Observer) -->
<CommentSection client:visible />

<!-- 只在浏览器空闲时加载(requestIdleCallback) -->
<Analytics client:idle />

<!-- 只在特定屏幕宽度加载 -->
<MobileMenu client:media="(max-width: 768px)" />

<!-- 完全不 hydrate——纯静态 -->
<StaticBanner client:only="never" />

Content Collections:内容即代码 ​

定义内容模型 ​

Astro v2+ 引入 Content Collections,让内容有类型安全:

typescript
// src/content/config.ts
import { defineCollection, z } from 'astro:content';

const blog = defineCollection({
  type: 'content',
  schema: z.object({
    title: z.string(),
    description: z.string(),
    publishDate: z.date(),
    tags: z.array(z.string()),
    draft: z.boolean().default(false),
  }),
});

const authors = defineCollection({
  type: 'data', // 纯数据集合(非 Markdown)
  schema: z.object({
    name: z.string(),
    avatar: z.string(),
    twitter: z.string().optional(),
  }),
});

export const collections = { blog, authors };

使用内容集合 ​

astro
---
// src/pages/blog/index.astro
import { getCollection } from 'astro:content';

// 获取所有博客文章,自动过滤 draft: true
const posts = await getCollection('blog', ({ data }) => {
  return import.meta.env.PROD ? !data.draft : true;
});

// 按日期排序
posts.sort((a, b) => b.data.publishDate.valueOf() - a.data.publishDate.valueOf());
---

<html>
  <body>
    <h1>博客文章</h1>
    <ul>
      {posts.map(post => (
        <li>
          <a href={`/blog/${post.slug}`}>{post.data.title}</a>
          <time>{post.data.publishDate.toLocaleDateString('zh')}</time>
        </li>
      ))}
    </ul>
  </body>
</html>

MDX:Markdown + JSX ​

mdx
---
title: 我的第一篇 MDX 文章
---

export const title = 'Hello MDX';

# {title}

<Counter client:load />

这里可以写 Markdown,
也可以导入 React/Vue/Svelte 组件!

<script>
  // 这个脚本只在浏览器运行
  console.log('Hello from browser!');
</script>

多框架混用:Islands 的真正威力 ​

同一页面,不同框架 ​

Astro 最独特的能力:在一个页面里混用 React、Vue、Svelte、Solid:

astro
---
import ReactCounter from '../components/ReactCounter.tsx';
import VueCounter from '../components/VueCounter.vue';
import SvelteCounter from '../components/SvelteCounter.svelte';
import SolidChart from '../components/SolidChart.tsx';
---

<html>
  <body>
    <h1>多框架 Islands 演示</h1>

    <!-- React island -->
    <ReactCounter client:visible />

    <!-- Vue island -->
    <VueCounter client:visible />

    <!-- Svelte island -->
    <SvelteCounter client:visible />

    <!-- Solid island -->
    <SolidChart client:visible />
  </body>
</html>

编译结果:每个框架只打包自己组件需要的 JS,互相独立,没有框架间相互依赖的负担。

框架选择建议 ​

React → 需要庞大生态(React Router、Material UI 等)
Vue   → 团队熟悉 Vue,追求简单
Svelte → 追求最小 bundle 体积
Solid → 追求 React 语法 + Solid 的响应式性能

性能:数字说话 ​

Lighthouse 对比 ​

测试场景:博客首页(10 篇文章列表 + 1 个搜索框)

Next.js 14 (App Router):
├── HTML:              45KB
├── JS (First Load):   182KB  ← 全量 hydration
├── LCP:               1.2s
├── TBT:               320ms
└── Performance Score: 87

Astro (Islands):
├── HTML:              52KB   ← 内容更多(CSS 内联)
├── JS (First Load):   3KB    ← 只有搜索 island
├── LCP:               0.8s   ← 更快(无 JS 阻塞)
├── TBT:               0ms    ← 无长任务阻塞
└── Performance Score: 99

为什么 Astro 这么快 ​

1. 零框架运行时

astro
<!-- 这个组件 -->
<Header title="我的博客" />

<!-- 编译后就是 -->
<header class="header">
  <h1>我的博客</h1>
</header>

没有虚拟 DOM、没有运行时、没有 diff/patch。只有 HTML 和 CSS。

2. 智能 CSS 内联

astro
---
import Button from '../components/Button.astro';
---

<!-- Astro 自动内联关键 CSS -->
<Button>点击我</Button>

CSS 直接内联到 HTML,避免额外的 CSS 请求。

3. 图片优化

astro
---
import { Image } from 'astro:assets';
import myPhoto from '../assets/photo.jpg';
---

<!-- Astro 自动生成多尺寸 WebP/AVIF -->
<Image
  src={myPhoto}
  widths={[240, 540, 720, 1080]}
  formats={['avif', 'webp']}
  alt="我的照片"
/>

与主流框架对比 ​

维度AstroNext.jsQwik
定位内容网站(博客/文档/营销)全栈 Web 应用极致性能的 Web 应用
Islands正确 原生支持错误 需第三方正确 原生支持
默认 JS 量~0KB~180KB+~1KB
多框架 Islands正确错误错误
Content Collections正确错误错误
SSG/SSR/Islands全部原生SSG/SSRSSR/Islands
路由基于文件基于文件基于文件
API Routes正确(SSR 模式)正确正确
学习曲线低中中

实战:搭建一个技术博客 ​

项目结构 ​

my-blog/
├── src/
│   ├── content/
│   │   ├── blog/
│   │   │   ├── first-post.md
│   │   │   └── astro-deep-dive.md
│   │   └── config.ts
│   ├── components/
│   │   ├── Header.astro
│   │   ├── BlogCard.astro
│   │   ├── Search.tsx            # React 组件
│   │   └── ThemeToggle.astro
│   ├── layouts/
│   │   └── BaseLayout.astro
│   └── pages/
│       ├── index.astro
│       └── blog/
│           └── [...slug].astro
├── astro.config.mjs
└── package.json

完整博客首页 ​

astro
---
// src/pages/index.astro
import BaseLayout from '../layouts/BaseLayout.astro';
import BlogCard from '../components/BlogCard.astro';
import Search from '../components/Search.tsx';
import { getCollection } from 'astro:content';

const posts = await getCollection('blog');
const sortedPosts = posts
  .filter(p => !p.data.draft)
  .sort((a, b) => b.data.publishDate.valueOf() - a.data.publishDate.valueOf());
---

<BaseLayout title="技术博客 | 小虾子">
  <header>
    <h1> 小虾子的技术博客</h1>
    <p>聊聊前端、性能、架构</p>
  </header>

  <main>
    <!-- 搜索 island -->
    <Search posts={sortedPosts} client:load />

    <!-- 纯静态文章列表,0 JS -->
    <section>
      <h2>最新文章</h2>
      <div class="posts">
        {sortedPosts.map(post => (
          <BlogCard
            title={post.data.title}
            description={post.data.description}
            date={post.data.publishDate.toISOString()}
            tags={post.data.tags}
            url={`/blog/${post.slug}`}
          />
        ))}
      </div>
    </section>
  </main>

  <aside>
    <!-- 评论 island,只在可见时加载 -->
    <Comments postId="home" client:visible />
  </aside>
</BaseLayout>

静态博客文章页 ​

astro
---
// src/pages/blog/[...slug].astro
import { getCollection } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';

export async function getStaticPaths() {
  const posts = await getCollection('blog');
  return posts.map(post => ({
    params: { slug: post.slug },
    props: { post },
  }));
}

const { post } = Astro.props;
const { Content } = await post.render();
---

<BaseLayout title={post.data.title}>
  <article>
    <header>
      <h1>{post.data.title}</h1>
      <time>{post.data.publishDate.toLocaleDateString('zh')}</time>
      <div class="tags">
        {post.data.tags.map(tag => <span>#{tag}</span>)}
      </div>
    </header>

    <div class="content">
      <Content />
    </div>
  </article>

  <!-- 文章页专属 island:目录导航 -->
  <TableOfContents client:idle />
</BaseLayout>

View Transitions:页面切换动画 ​

Astro 内置了 View Transitions API,让页面切换拥有原生流畅动画:

astro
---
// src/layouts/BaseLayout.astro
import { ViewTransitions } from 'astro:transitions';
---

<html>
  <head>
    <ViewTransitions />
  </head>
  <body>
    <slot />
  </body>
</html>
astro
<!-- 文章卡片 -->
<a href={`/blog/${post.slug}`}>
  <img
    src={post.data.cover}
    alt={post.data.title}
    transition:name={`cover-${post.slug}`}  <!-- 跨页面动画 -->
  />
  <h2 transition:name={`title-${post.slug}`}>{post.data.title}</h2>
</a>

点击文章卡片时,封面图和标题会平滑过渡到文章详情页——无需 SPA,整个网站全是静态 HTML,但拥有 SPA 级的页面切换体验。

局限性 ​

  1. 不适合复杂交互应用:编辑器、游戏、实时协作类网站不适合
  2. SSR 需要适配器:部署到边缘(Cloudflare Workers 等)需要额外配置
  3. 生态年轻:第三方集成不如 Next.js 丰富
  4. 全栈能力有限:可以做 API Routes,但不适合复杂的 BFF 场景

总结 ​

Astro 的价值主张清晰:内容网站不需要 JavaScript 框架的负担。

  • Islands 架构:页面默认零 JS,需要交互的地方才是岛屿
  • 多框架混用:React/Vue/Svelte/Solid 可以在同一页面共存
  • Content Collections:内容有类型安全,开发体验提升
  • 极致性能:默认 0KB JS,Lighthouse 轻松 99+
  • View Transitions:静态网站也有 SPA 级页面动画

当你在用 Next.js 做个人博客时,问问自己:我真的需要那个 180KB 的 React runtime 吗?

Astro 的答案:不需要。

本文由小虾子 撰写

Last updated: