Astro Islands 與互動
Client Directives、多框架共存、跨島狀態共享與 View Transitions 實務
Overview##
在架構概覽中,我們知道 Astro 的 Island Architecture 讓頁面大部分是靜態 HTML,只有標記的元件才會 hydrate。
這篇文章深入探討實務問題:
- 怎麼選擇正確的 Client Directive?
- 不同框架的元件怎麼共存?
- 島嶼之間怎麼共享狀態?
- 頁面切換怎麼做動畫?
Client Directives##
選擇策略###
Client Directive 決定元件的 JS 何時載入。選錯 directive 會造成不必要的效能損耗或互動延遲:
graph TD
A[元件需要互動嗎?] -->|No| B[不加 directive - 純靜態]
A -->|Yes| C[首次可見時就需要嗎?]
C -->|Yes| D[頁面載入就需要嗎?]
C -->|No| E["client:visible"]
D -->|Yes| F["client:load"]
D -->|No| G[非關鍵互動?]
G -->|Yes| H["client:idle"]
G -->|No| I["需要特定裝置?"]
I -->|Yes| J["client:media"]
I -->|No| F
效能影響###
每個 directive 的 JS 載入時機不同,直接影響頁面效能:
---
import SearchBar from '../components/SearchBar.jsx';
import Newsletter from '../components/Newsletter.jsx';
import Comments from '../components/Comments.svelte';
import MobileMenu from '../components/MobileMenu.jsx';
import Chart from '../components/Chart.vue';
---
<!-- 最高優先:阻塞頁面互動 -->
<SearchBar client:load />
<!-- 中優先:瀏覽器閒置時載入(requestIdleCallback) -->
<Newsletter client:idle />
<!-- 低優先:滾動到才載入(IntersectionObserver) -->
<Comments client:visible />
<!-- 條件式:只在行動裝置載入 -->
<MobileMenu client:media="(max-width: 768px)" />
<!-- 純 client-side:不做 SSR -->
<Chart client:only="vue" />
| Directive | JS 載入時機 | 對 LCP 影響 | 適用元件 |
|---|---|---|---|
| 無 | 不載入 | 零 | 靜態展示 |
client:visible | 進入 viewport | 低 | 頁面下方元件 |
client:idle | 主要工作完成後 | 低 | 非關鍵功能 |
client:media | 符合 media query | 低 | 響應式元件 |
client:load | 頁面載入 | 中 | 核心互動 |
client:only | 頁面載入(無 SSR) | 高 | 依賴 browser API |
Warning
避免對所有元件都使用 client:load——這會把 Astro 的效能優勢還給傳統 SPA。優先使用 client:visible 和 client:idle,只在必要時才用 client:load。
client:only 注意事項###
client:only 完全跳過 server 渲染,使用時需要注意:
---
import MapView from '../components/MapView.jsx';
---
<!-- 必須指定框架名稱 -->
<MapView client:only="react" />
<!-- 錯誤:缺少框架提示 -->
<!-- <MapView client:only /> -->
適用場景:
- 依賴
window、document等 browser API 的元件 - 使用 Canvas、WebGL 的元件
- 第三方套件不支援 SSR(如某些地圖、圖表庫)
Caution
client:only 元件在 SSR 階段是空的——搜尋引擎爬蟲看不到內容。對 SEO 重要的內容不要用這個 directive。
Multi-Framework##
多框架共存###
Astro 允許在同一個頁面使用不同框架的元件。每個島嶼獨立運作,互不干擾:
---
// 同一頁面使用 React、Svelte、Vue
import ReactNav from '../components/ReactNav.jsx';
import SvelteForm from '../components/SvelteForm.svelte';
import VueChart from '../components/VueChart.vue';
---
<ReactNav client:load />
<main>
<article>
<!-- 靜態 HTML 內容 -->
<h1>混合框架示範</h1>
<p>這段是純 HTML,不需要任何 JS。</p>
</article>
<SvelteForm client:visible />
<VueChart client:idle />
</main>
框架設定###
在 astro.config.ts 中加入對應的 integration:
// astro.config.ts
import { defineConfig } from 'astro/config';
import react from '@astrojs/react';
import svelte from '@astrojs/svelte';
import vue from '@astrojs/vue';
export default defineConfig({
integrations: [react(), svelte(), vue()],
});
Astro 透過副檔名判斷框架:
| 副檔名 | 框架 |
|---|---|
.jsx / .tsx | React |
.svelte | Svelte |
.vue | Vue |
.astro | Astro(靜態,不需 directive) |
巢狀元件###
框架元件可以巢狀使用,但有限制:
---
import ReactWrapper from '../components/ReactWrapper.jsx';
import SvelteButton from '../components/SvelteButton.svelte';
---
<!-- 可以:Astro 包裹不同框架 -->
<ReactWrapper client:load>
<p>Astro 的靜態內容作為 children</p>
</ReactWrapper>
<!-- 可以:同框架巢狀 -->
<ReactWrapper client:load>
<!-- React children 在 React 內部渲染 -->
</ReactWrapper>
<!-- 限制:跨框架巢狀需要用 slot -->
<ReactWrapper client:load>
<SvelteButton client:load slot="actions" />
</ReactWrapper>
Note
跨框架巢狀時,子元件會在自己的島嶼中獨立 hydrate。它們之間不共享 React Context 或 Vue Provide/Inject——需要用其他方式共享狀態。
Sharing State##
島嶼間的狀態共享問題###
Island Architecture 的每個島嶼是獨立的——React 的 Context、Vue 的 Provide/Inject 都無法跨島傳遞。
┌──────────────────────────────────┐
│ Page │
│ ┌────────┐ ┌────────────┐ │
│ │ React │ │ Svelte │ │
│ │ Button │ ✗ │ Cart │ │
│ │ │──→ │ │ │
│ └────────┘ └────────────┘ │
│ Context 無法跨島傳遞 │
└──────────────────────────────────┘
Nano Stores###
Astro 官方推薦使用 Nano Stores 來跨島共享狀態。它是一個框架無關的狀態管理庫,每個框架都有對應的 adapter:
# 安裝
pnpm add nanostores
pnpm add @nanostores/react # React adapter
pnpm add @nanostores/vue # Vue adapter
# Svelte 原生支援 nanostores,不需要額外 adapter
定義共享 store:
// src/stores/cart.ts
import { atom, map } from 'nanostores';
// atom:簡單值
export const isCartOpen = atom(false);
// map:物件結構
export interface CartItem {
id: string;
name: string;
quantity: number;
}
export const cartItems = map<Record<string, CartItem>>({});
// helper function
export function addToCart(item: CartItem) {
const existing = cartItems.get()[item.id];
if (existing) {
cartItems.setKey(item.id, { ...existing, quantity: existing.quantity + 1 });
} else {
cartItems.setKey(item.id, item);
}
}
在 React 元件中使用:
// src/components/CartButton.tsx
import { useStore } from '@nanostores/react';
import { isCartOpen } from '../stores/cart';
export default function CartButton() {
const $isOpen = useStore(isCartOpen);
return (
<button onClick={() => isCartOpen.set(!$isOpen)}>{$isOpen ? 'Close' : 'Open'} Cart</button>
);
}
在 Svelte 元件中使用同一個 store:
<!-- src/components/CartCount.svelte -->
<script>
import { cartItems } from '../stores/cart';
// Svelte 原生支援 $ 語法訂閱 nanostores
$: count = Object.values($cartItems).reduce((sum, item) => sum + item.quantity, 0);
</script>
<span>Items: {count}</span>
在 Astro 頁面中組合:
---
import CartButton from '../components/CartButton.tsx';
import CartCount from '../components/CartCount.svelte';
import CartFlyout from '../components/CartFlyout.tsx';
---
<nav>
<!-- React 島嶼 -->
<CartButton client:load />
<!-- Svelte 島嶼,共享同一個 store -->
<CartCount client:load />
</nav>
<CartFlyout client:load />
graph TB
A[Nano Store] -->|subscribe| B[React Island]
A -->|subscribe| C[Svelte Island]
A -->|subscribe| D[Vue Island]
B -->|update| A
C -->|update| A
D -->|update| A
Tip
Nano Stores 的 bundle size 極小(約 300 bytes),不會增加明顯的 JS 開銷。它的設計就是為了這種跨框架、跨島嶼的場景。
View Transitions##
啟用頁面轉場###
Astro 是 MPA——每次導航是完整的頁面請求。View Transitions API 讓 MPA 也能有 SPA 風格的平滑轉場:
---
// src/layouts/BaseLayout.astro
import { ClientRouter } from 'astro:transitions';
---
<html lang="zh-TW">
<head>
<meta charset="utf-8" />
<ClientRouter />
</head>
<body>
<slot />
</body>
</html>
加入 <ClientRouter /> 後,Astro 會:
- 攔截連結點擊,改用
fetch取得下一頁 - 用 View Transitions API 在新舊頁面之間做動畫
- 更新 URL 和瀏覽器歷史紀錄
動畫控制###
透過 transition:animate 指定動畫類型:
---
import { fade, slide } from 'astro:transitions';
---
<!-- 淡入淡出(預設) -->
<header transition:animate="fade">
<nav>...</nav>
</header>
<!-- 滑動 -->
<main transition:animate={slide({ duration: '0.3s' })}>
<slot />
</main>
<!-- 不動畫 -->
<footer transition:animate="none">
<p>Footer content</p>
</footer>
| 動畫 | 效果 | 適用 |
|---|---|---|
fade | 淡入淡出 | 預設,適合大多數場景 |
slide | 左右滑動 | 內容區域 |
none | 不做動畫 | 不需要轉場的元素 |
| 自訂 | CSS animation | 需要特殊效果時 |
元素匹配###
用 transition:name 讓新舊頁面的元素產生關聯,實現跨頁面的元素動畫:
---
// 文章列表頁
---
{posts.map((post) => (
<a href={`/blog/${post.id}`}>
<img
src={post.data.cover}
transition:name={`cover-${post.id}`}
/>
<h2 transition:name={`title-${post.id}`}>
{post.data.title}
</h2>
</a>
))}
---
// 文章詳情頁
const { post } = Astro.props;
---
<img
src={post.data.cover}
transition:name={`cover-${post.id}`}
/>
<h1 transition:name={`title-${post.id}`}>
{post.data.title}
</h1>
列表頁的圖片和標題會「飛」到詳情頁的對應位置——這就是 View Transitions 的核心體驗。
狀態持久化###
用 transition:persist 讓元件在頁面切換時保持狀態:
<!-- 影片跨頁播放不中斷 -->
<video controls autoplay transition:persist>
<source src="/bg-music.mp4" type="video/mp4" />
</video>
<!-- 互動元件保持狀態 -->
<SearchBar client:load transition:persist />
Note
transition:persist 會保留整個 DOM 元素(包括事件監聽器和元件狀態)。適用於音樂播放器、聊天視窗等需要跨頁面保持的元件。
Quiz##
Summary##
- Client Directive 選擇優先順序:不加 >
client:visible>client:idle>client:load>client:only - 避免所有元件都用
client:load,優先用client:visible和client:idle減少 JS 載入 - Astro 透過副檔名識別框架(
.jsx=React、.svelte=Svelte、.vue=Vue),同頁可混用 - 跨島狀態共享用 Nano Stores——框架無關、極小 bundle、每個框架都有 adapter
<ClientRouter />啟用 View Transitions,讓 MPA 有 SPA 風格的頁面轉場transition:name匹配跨頁元素、transition:persist保持元件狀態
留言 (0)
登入後即可留言