The Own Lab The Own Lab

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" />
DirectiveJS 載入時機對 LCP 影響適用元件
不載入靜態展示
client:visible進入 viewport頁面下方元件
client:idle主要工作完成後非關鍵功能
client:media符合 media query響應式元件
client:load頁面載入核心互動
client:only頁面載入(無 SSR)依賴 browser API

Warning

避免對所有元件都使用 client:load——這會把 Astro 的效能優勢還給傳統 SPA。優先使用 client:visibleclient:idle,只在必要時才用 client:load

client:only 注意事項###

client:only 完全跳過 server 渲染,使用時需要注意:

---
import MapView from '../components/MapView.jsx';
---

<!-- 必須指定框架名稱 -->
<MapView client:only="react" />

<!-- 錯誤:缺少框架提示 -->
<!-- <MapView client:only /> -->

適用場景:

  • 依賴 windowdocument 等 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 / .tsxReact
.svelteSvelte
.vueVue
.astroAstro(靜態,不需 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 會:

  1. 攔截連結點擊,改用 fetch 取得下一頁
  2. 用 View Transitions API 在新舊頁面之間做動畫
  3. 更新 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##

Single Choice

以下哪個 Client Directive 對頁面效能影響最小?

Single Choice

Astro 島嶼之間無法使用 React Context 共享狀態,官方推薦的替代方案是什麼?

Single Choice

加入 <ClientRouter /> 後,Astro 的頁面導航行為有什麼改變?

Single Choice

transition:name 的作用是什麼?

Single Choice

使用 client:only 時,以下哪個說法是正確的?

Summary##

  • Client Directive 選擇優先順序:不加 > client:visible > client:idle > client:load > client:only
  • 避免所有元件都用 client:load,優先用 client:visibleclient:idle 減少 JS 載入
  • Astro 透過副檔名識別框架(.jsx=React、.svelte=Svelte、.vue=Vue),同頁可混用
  • 跨島狀態共享用 Nano Stores——框架無關、極小 bundle、每個框架都有 adapter
  • <ClientRouter /> 啟用 View Transitions,讓 MPA 有 SPA 風格的頁面轉場
  • transition:name 匹配跨頁元素、transition:persist 保持元件狀態

留言 (0)

登入後即可留言