Skip to main content

svelte

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

الانتقال إلى التثبيت

معلومات المصدر

المستودع
recca0120/codeatlas
آخر نشاط في المصدر
٢٨ مارس ٢٠٢٦ في ٠٥:٣٤
لغة SKILL.md المكتشفة
الصينية
النجوم
١
التفرعات
٠

خيارات التثبيت

يُحدَّد Prompt الذي يراجع المصدر أولًا بشكل افتراضي. يمكنك التبديل إلى أمر مباشر أو تنزيل نسخة محلية.

مراجعة ملفات المصدر

اقرأ SKILL.md وأي ملفات مرافقة يعرضها SkillsMP قبل أن تقرر التثبيت.

عرض SKILL.md

SKILL.md
تعليمات المصدر · معاينة للقراءة فقط
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 }, ];
عرض على GitHub
ملف SKILL.md هذا كبير جدا، لذلك يعرض SkillsMP القسم الاول فقط هنا. عرض على GitHub