Skip to main content

svelte

Svelte 5 + Astro 整合最佳實踐指南。當需要建立 Svelte 元件、使用 runes API、整合 Astro islands、或用 Testing Library 測試 Svelte 元件時使用。

Aller à l'installation

Informations de source

Dépôt
recca0120/codeatlas
Dernière activité de la source
28 mars 2026 à 05:34
Langue détectée de SKILL.md
chinois
Étoiles
1
Forks
0

Options d'installation

Le prompt qui vérifie d'abord la source est sélectionné par défaut. Vous pouvez passer à une commande directe ou télécharger une copie locale.

Vérifiez les fichiers source

Lisez SKILL.md et les fichiers associés affichés par SkillsMP avant de décider de l'installer.

Affichage de SKILL.md

SKILL.md
Instructions source · Aperçu en lecture seule
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
Ce SKILL.md est tres volumineux, SkillsMP affiche donc ici seulement la premiere section. Voir sur GitHub