- name
- svelte
- description
- Svelte 5 + Astro 整合最佳實踐指南。當需要建立 Svelte 元件、使用 runes API、整合 Astro islands、或用 Testing Library 測試 Svelte 元件時使用。
# Svelte 5 + Astro Guide (v5.53+)
## Runes API
### $state
```svelte
<script>
let count = $state(0);
let items = $state([{ text: "hello" }]); // deep reactive proxy
</script>
<button onclick={() => count++}>{count}</button>
```
`$state.raw()` — 無深層 proxy,需整個重新賦值:
```js
let data = $state.raw({ big: "object" });
data = { big: "new" }; // 觸發更新
```
`$state.snapshot()` — 提取 plain object(給外部 lib 用)。
### $derived
```js
let doubled = $derived(count * 2);
let total = $derived.by(() => items.reduce((s, i) => s + i.value, 0));
```
### $effect
```js
$effect(() => { console.log(count); }); // 自動追蹤依賴
$effect.pre(() => { /* beforeUpdate */ });
```
### $props
```svelte
<script lang="ts">
let { name, age = 25, onchange }: {
name: string;
age?: number;
onchange?: (val: string) => void;
} = $props();
</script>
```
## 事件處理(Svelte 5)
不用 `on:` — 直接用 HTML 事件屬性:
```svelte
<button onclick={handler}>Click</button>
<button onclick={(e) => { e.preventDefault(); handler(e); }}>Click</button>
```
元件事件 = callback props:
```svelte
<!-- Parent -->
<Child onmessage={(msg) => console.log(msg)} />
<!-- Child -->
<script> let { onmessage } = $props(); </script>
<button onclick={() => onmessage?.("hello")}>Send</button>
```
## Snippets(取代 Slots)
```svelte
<!-- Parent -->
<Card>
{#snippet header()}<h2>Title</h2>{/snippet}
<p>Body (children)</p>
</Card>
<!-- Card.svelte -->
<script> let { header, children } = $props(); </script>
{@render header?.()}
{@render children()}
```
## Astro 整合
```js
// astro.config.mjs
import svelte from "@astrojs/svelte";
export default defineConfig({ integrations: [svelte()] });
```
```astro
<Counter client:load /> <!-- 立即 hydrate -->
<Chart client:visible /> <!-- 進入視窗才 hydrate -->
<Widget client:idle /> <!-- 瀏覽器 idle 時 hydrate -->
<App client:only="svelte" /> <!-- 純 client-side -->
```
不加 `client:*` = 靜態 HTML,零 JS。
### 傳資料給 Svelte
```astro
<Chart client:visible items={data} title="Sales" />
```
只接受可序列化型別。**不能傳 function**。
### 跨 island 共享狀態(Nanostores)
```bash
npm install nanostores @nanostores/svelte
```
```js
// stores/cart.ts
import { atom } from "nanostores";
export const isOpen = atom(false);
```
```svelte
<script>
import { isOpen } from "../stores/cart";
</script>
<button onclick={() => isOpen.set(true)}>Open ({$isOpen})</button>
```
## 測試(Vitest + Testing Library)
```bash
npm install -D @testing-library/svelte @testing-library/jest-dom @testing-library/user-event
```
### vitest.config.ts
```ts
import { svelte } from "@sveltejs/vite-plugin-svelte";
import { svelteTesting } from "@testing-library/svelte/vite";
import { defineConfig } from "vitest/config";
export default defineConfig({
plugins: [svelte(), svelteTesting()],
test: {
environment: "jsdom",
setupFiles: ["./vitest-setup.ts"],
},
});
```
`svelteTesting()` 自動做兩件事:browser resolve condition + afterEach cleanup。可個別關閉:
```ts
svelteTesting({ autoCleanup: false, resolveBrowser: false })
```
### vitest-setup.ts
```ts
import "@testing-library/jest-dom/vitest";
```
tsconfig.json 加上:
```json
{ "compilerOptions": { "types": ["@testing-library/jest-dom"] } }
```
---
### 基本模式:render + props + user interaction
```ts
import { render, screen } from "@testing-library/svelte";
import userEvent from "@testing-library/user-event";
import Counter from "./Counter.svelte";
test("increments on click", async () => {
const user = userEvent.setup();
render(Counter, { initial: 5 });
expect(screen.getByRole("button")).toHaveTextContent("5");
await user.click(screen.getByRole("button"));
expect(screen.getByRole("button")).toHaveTextContent("6");
});
```
props 可直接攤平傳入,也可用 `{ props: { ... } }` 形式:
```ts
render(MyComponent, { name: "Alice" });
// 等同
render(MyComponent, { props: { name: "Alice" } });
```
### 更新 props(rerender)
```ts
test("reacts to prop changes", async () => {
const { rerender } = render(Greeting, { name: "Alice" });
expect(screen.getByText("Hello Alice")).toBeInTheDocument();
await rerender({ name: "Bob" });
expect(screen.getByText("Hello Bob")).toBeInTheDocument();
});
```
`rerender()` 內部用 `Object.assign` 更新 `$state` 化的 props,再 `await tick()`。
### 測試 $state + $derived
元件內的 `$state` / `$derived` 不需特殊處理 — 透過 DOM 驗證即可:
```svelte
<!-- PriceCalc.svelte -->
<script lang="ts">
let { price, taxRate = 0.1 }: { price: number; taxRate?: number } = $props();
let quantity = $state(1);
let total = $derived(price * quantity * (1 + taxRate));
</script>
<input type="number" aria-label="quantity" bind:value={quantity} />
<p data-testid="total">{total}</p>
```
```ts
test("derived total updates when quantity changes", async () => {
const user = userEvent.setup();
render(PriceCalc, { price: 100 });
expect(screen.getByTestId("total")).toHaveTextContent("110"); // 100*1*1.1
const input = screen.getByLabelText("quantity");
await user.clear(input);
await user.type(input, "3");
expect(screen.getByTestId("total")).toHaveTextContent("330"); // 100*3*1.1
});
```
### 測試 $effect(async data loading)
$effect 中的非同步操作需搭配 `waitFor` 或 `findBy*`:
```svelte
<!-- UserProfile.svelte -->
<script lang="ts">
import { fetchUser } from "./api";
let { userId }: { userId: string } = $props();
let user = $state<{ name: string } | null>(null);
let loading = $state(true);
$effect(() => {
loading = true;
fetchUser(userId).then((u) => {
user = u;
loading = false;
});
});
</script>
{#if loading}<p>Loading...</p>{/if}
{#if user}<p>{user.name}</p>{/if}
```
```ts
import { render, screen, waitFor } from "@testing-library/svelte";
import { vi } from "vitest";
// Mock 模組
vi.mock("./api", () => ({
fetchUser: vi.fn(),
}));
import { fetchUser } from "./api";
import UserProfile from "./UserProfile.svelte";
test("loads and displays user", async () => {
vi.mocked(fetchUser).mockResolvedValue({ name: "Alice" });
render(UserProfile, { userId: "1" });
expect(screen.getByText("Loading...")).toBeInTheDocument();
// findBy* 自帶 waitFor,等待元素出現
expect(await screen.findByText("Alice")).toBeInTheDocument();
});
```
### Mock imports(vi.mock)
```ts
// Mock 整個模組
vi.mock("./api", () => ({
fetchUsers: vi.fn().mockResolvedValue([]),
}));
// Mock default export(如 Svelte component)
vi.mock("./HeavyChart.svelte", () => ({
default: { /* mock component */ },
}));
// Mock 部分匯出,保留其餘
vi.mock("./utils", async (importOriginal) => {
const actual = await importOriginal<typeof import("./utils")>();
return { ...actual, expensiveCalc: vi.fn().mockReturnValue(42) };
});
```
每次測試後記得清理:
```ts
afterEach(() => {
vi.restoreAllMocks();
});
```
### 測試 `{#each}` 迴圈 + 過濾列表(保留原始排名)
常見場景:列表有 filter 但顯示的 rank 需保持原始位置。
```svelte
<!-- Leaderboard.svelte -->
<script lang="ts">
let { users }: { users: { rank: number; name: string; score: number }[] } = $props();
let filter = $state("");
let filtered = $derived(
users.filter((u) => u.name.toLowerCase().includes(filter.toLowerCase()))
);
</script>
<input aria-label="filter" bind:value={filter} />
<table>
<tbody>
{#each filtered as user (user.rank)}
<tr><td>{user.rank}</td><td>{user.name}</td><td>{user.score}</td></tr>
{/each}
</tbody>
</table>
```
```ts
test("filtering preserves original rank", async () => {
const user = userEvent.setup();
const users = [
{ rank: 1, name: "Alice", score: 100 },
{ rank: 2, name: "Bob", score: 90 },
{ rank: 3, name: "Alicia", score: 80 },
];
Voir sur GitHub