ProjAnvil

frontend-svelte

现代前端开发综合技能,涵盖 Svelte、SvelteKit、shadcn-svelte 和 Bun 生态系统。使用此技能构建现代 Web 应用、创建 UI 组件、实现状态管理、处理表单,或使用基于 TypeScript 的前端架构时使用。适用于需要响应式框架、服务端渲染或类型安全组件开发的项目。

ProjAnvil 3 1 Updated 4mo ago
GitHub

Install

npx skillscat add projanvil/mindforge/frontend-svelte

Install via the SkillsCat registry.

SKILL.md

前端开发技能

现代前端开发的综合技能,包含 Svelte、SvelteKit、shadcn-svelte 和 Bun 生态系统。

技术栈

核心框架:Svelte + SvelteKit

Svelte 基础

  • 响应式框架:编译时框架,将组件转换为高效的命令式代码
  • 真正的响应式:自动依赖跟踪,无需虚拟 DOM
  • 小包体积:最小的运行时开销
  • 内置动画:过渡和动画指令
  • 作用域样式:组件样式默认作用域

SvelteKit 特性

  • 全栈框架:服务端渲染(SSR)、静态站点生成(SSG)和客户端渲染(CSR)
  • 基于文件的路由:路由由文件系统结构定义
  • API 路由:在前端代码旁构建 API 端点
  • 表单操作:服务端表单处理,渐进式增强
  • 钩子:拦截和修改请求/响应
  • 适配器:部署到任何平台(Node、Vercel、Netlify、Cloudflare 等)

UI 组件库:shadcn-svelte

概览

  • shadcn/ui 的 Svelte 版本
  • 基于 Radix Svelte(Melt UI)构建的可访问、可定制组件
  • 复制粘贴组件方式(不是 npm 包)
  • 使用 Tailwind CSS 构建
  • TypeScript 支持

关键组件

  • 表单:Input、Textarea、Select、Checkbox、Radio、Switch
  • 反馈:Alert、Toast(Sonner)、Dialog、Alert Dialog
  • 导航:Button、Dropdown Menu、Tabs、Command Menu
  • 布局:Card、Separator、Accordion、Collapsible
  • 数据展示:Table、Badge、Avatar、Skeleton
  • 覆盖层:Popover、Tooltip、Sheet、Drawer

安装和使用

# 初始化 shadcn-svelte
bunx shadcn-svelte@latest init

# 按需添加组件
bunx shadcn-svelte@latest add button
bunx shadcn-svelte@latest add card
bunx shadcn-svelte@latest add form

包管理器:Bun

为什么选择 Bun?

  • 性能:比 npm 快 25 倍,比 pnpm 快 4 倍
  • 一体化:运行时、打包器、测试运行器、包管理器
  • 直接替换:兼容 npm/Node.js 生态系统
  • 内置测试:兼容 Vitest 的测试运行器
  • 原生 TypeScript:原生 TypeScript 支持,无需编译

npm 兼容性

  • 读取 package.json 和 package-lock.json/bun.lockb
  • 与大多数 npm 包兼容
  • 可以通过 bun run 运行 npm 脚本
  • 特定包需要时可回退到 npm

项目架构

推荐的目录结构

project-root/
├── src/
│   ├── lib/
│   │   ├── components/
│   │   │   ├── ui/              # shadcn-svelte 组件
│   │   │   │   ├── button/
│   │   │   │   ├── card/
│   │   │   │   └── ...
│   │   │   ├── layout/          # 布局组件
│   │   │   │   ├── Header.svelte
│   │   │   │   ├── Footer.svelte
│   │   │   │   └── Sidebar.svelte
│   │   │   └── features/        # 功能特定组件
│   │   │       ├── auth/
│   │   │       ├── dashboard/
│   │   │       └── ...
│   │   ├── stores/              # Svelte stores
│   │   │   ├── user.ts
│   │   │   ├── theme.ts
│   │   │   └── notifications.ts
│   │   ├── utils/               # 工具函数
│   │   │   ├── api.ts
│   │   │   ├── validation.ts
│   │   │   └── formatting.ts
│   │   ├── types/               # TypeScript 类型
│   │   │   ├── api.ts
│   │   │   └── models.ts
│   │   ├── server/              # 服务端工具
│   │   │   ├── db.ts
│   │   │   └── auth.ts
│   │   └── config/              # 配置
│   │       ├── constants.ts
│   │       └── env.ts
│   ├── routes/                  # SvelteKit 路由
│   │   ├── +page.svelte         # 首页
│   │   ├── +layout.svelte       # 根布局
│   │   ├── +error.svelte        # 错误页面
│   │   ├── api/                 # API 路由
│   │   │   └── users/
│   │   │       └── +server.ts
│   │   ├── (auth)/              # 路由组
│   │   │   ├── login/
│   │   │   └── register/
│   │   └── dashboard/
│   │       ├── +page.svelte
│   │       └── +page.server.ts
│   ├── app.html                 # HTML 模板
│   ├── app.css                  # 全局样式
│   └── hooks.server.ts          # 服务器钩子
├── static/                      # 静态资源
│   ├── images/
│   └── fonts/
├── tests/                       # 测试
│   ├── unit/
│   └── integration/
├── bun.lockb                    # Bun 锁文件
├── package.json
├── svelte.config.js
├── vite.config.ts
├── tailwind.config.js
└── tsconfig.json

核心模式和最佳实践

1. 组件开发

组件结构最佳实践

<script lang="ts">
  // 1. 导入(外部,然后内部)
  import { onMount, createEventDispatcher } from 'svelte';
  import { fade } from 'svelte/transition';
  import type { User } from '$lib/types';
  import { Button } from '$lib/components/ui/button';

  // 2. 类型定义(如果不在单独文件中)
  interface $$Props {
    user: User;
    variant?: 'default' | 'compact';
  }

  // 3. 带默认值的 Props
  export let user: User;
  export let variant: $$Props['variant'] = 'default';

  // 4. 事件调度器
  const dispatch = createEventDispatcher<{
    edit: { userId: string };
    delete: { userId: string };
  }>();

  // 5. 本地状态
  let isEditing = false;
  let formData = { ...user };

  // 6. 响应式声明
  $: fullName = `${user.firstName} ${user.lastName}`;
  $: hasChanges = JSON.stringify(formData) !== JSON.stringify(user);

  // 7. 函数
  function handleEdit() {
    isEditing = true;
  }

  function handleSave() {
    dispatch('edit', { userId: user.id });
    isEditing = false;
  }

  // 8. 生命周期钩子
  onMount(() => {
    console.log('组件已挂载');

    return () => {
      console.log('组件将卸载');
    };
  });
</script>

<!-- 具有清晰层次结构的模板 -->
<div class="user-card" class:compact={variant === 'compact'}>
  {#if isEditing}
    <form on:submit|preventDefault={handleSave}>
      <!-- 编辑模式 -->
    </form>
  {:else}
    <!-- 查看模式 -->
    <div class="user-info" transition:fade>
      <h3>{fullName}</h3>
      <p>{user.email}</p>
    </div>
    <Button on:click={handleEdit}>编辑</Button>
  {/if}
</div>

<!-- 作用域样式 -->
<style lang="postcss">
  .user-card {
    @apply rounded-lg border p-4;

    &.compact {
      @apply p-2;
    }
  }

  .user-info {
    @apply space-y-2;
  }
</style>

TypeScript Props 模式

// 对于复杂的 props,使用 interface
interface $$Props {
  items: Item[];
  selectedId?: string;
  onSelect?: (item: Item) => void;
  class?: string;
}

export let items: $$Props['items'];
export let selectedId: $$Props['selectedId'] = undefined;
export let onSelect: $$Props['onSelect'] = undefined;

// 用于 class prop 转发
let className: $$Props['class'] = '';
export { className as class };

2. 状态管理

Svelte Stores

可写 Store

// stores/user.ts
import { writable } from 'svelte/store';
import type { User } from '$lib/types';

function createUserStore() {
  const { subscribe, set, update } = writable<User | null>(null);

  return {
    subscribe,
    set,
    login: (user: User) => set(user),
    logout: () => set(null),
    updateProfile: (updates: Partial<User>) =>
      update(user => user ? { ...user, ...updates } : null)
  };
}

export const user = createUserStore();

派生 Store

// stores/user.ts (续)
import { derived } from 'svelte/store';

export const isAuthenticated = derived(
  user,
  $user => $user !== null
);

export const userPermissions = derived(
  user,
  $user => $user?.roles.flatMap(role => role.permissions) ?? []
);

可读 Store(用于外部数据)

import { readable } from 'svelte/store';

export const time = readable(new Date(), (set) => {
  const interval = setInterval(() => {
    set(new Date());
  }, 1000);

  return () => clearInterval(interval);
});

带持久化的自定义 Store

import { writable } from 'svelte/store';
import { browser } from '$app/environment';

export function persistedStore<T>(key: string, initialValue: T) {
  const stored = browser ? localStorage.getItem(key) : null;
  const initial = stored ? JSON.parse(stored) : initialValue;

  const store = writable<T>(initial);

  if (browser) {
    store.subscribe(value => {
      localStorage.setItem(key, JSON.stringify(value));
    });
  }

  return store;
}

// 使用
export const theme = persistedStore<'light' | 'dark'>('theme', 'light');

Context API(组件树状态)

<!-- Parent.svelte -->
<script lang="ts">
  import { setContext } from 'svelte';
  import type { Writable } from 'svelte/store';
  import { writable } from 'svelte/store';

  const formData = writable({ name: '', email: '' });
  setContext('form', formData);
</script>

<!-- Child.svelte -->
<script lang="ts">
  import { getContext } from 'svelte';
  import type { Writable } from 'svelte/store';

  const formData = getContext<Writable<FormData>>('form');
</script>

<input bind:value={$formData.name} />

3. SvelteKit 路由和数据加载

页面结构

// routes/blog/[slug]/+page.ts
import type { PageLoad } from './$types';

export const load: PageLoad = async ({ params, fetch, parent }) => {
  // 访问父布局数据
  const parentData = await parent();

  // 获取数据
  const response = await fetch(`/api/posts/${params.slug}`);
  const post = await response.json();

  return {
    post,
    meta: {
      title: post.title,
      description: post.excerpt
    }
  };
};
<!-- routes/blog/[slug]/+page.svelte -->
<script lang="ts">
  import type { PageData } from './$types';

  export let data: PageData;
</script>

<svelte:head>
  <title>{data.meta.title}</title>
  <meta name="description" content={data.meta.description} />
</svelte:head>

<article>
  <h1>{data.post.title}</h1>
  <div>{@html data.post.content}</div>
</article>

服务端数据加载

// routes/dashboard/+page.server.ts
import type { PageServerLoad, Actions } from './$types';
import { error, fail, redirect } from '@sveltejs/kit';

export const load: PageServerLoad = async ({ locals, depends }) => {
  // depends() 为失效创建依赖
  depends('app:dashboard');

  if (!locals.user) {
    throw redirect(307, '/login');
  }

  try {
    const stats = await fetchUserStats(locals.user.id);
    return { stats };
  } catch (err) {
    throw error(500, '加载仪表板数据失败');
  }
};

export const actions: Actions = {
  updateProfile: async ({ request, locals }) => {
    const formData = await request.formData();
    const name = formData.get('name');

    if (!name) {
      return fail(400, { name, missing: true });
    }

    await updateUser(locals.user.id, { name });
    return { success: true };
  }
};

渐进式增强的表单操作

<script lang="ts">
  import { enhance } from '$app/forms';
  import type { ActionData } from './$types';

  export let form: ActionData;

  let loading = false;
</script>

<form
  method="POST"
  action="?/updateProfile"
  use:enhance={() => {
    loading = true;

    return async ({ result, update }) => {
      await update();
      loading = false;

      if (result.type === 'success') {
        // 处理成功
      }
    };
  }}
>
  <input
    name="name"
    value={form?.name ?? ''}
    aria-invalid={form?.missing}
  />

  {#if form?.missing}
    <p class="error">姓名是必填项</p>
  {/if}

  <button disabled={loading}>
    {loading ? '保存中...' : '保存'}
  </button>
</form>

4. API 开发

REST API 端点

// routes/api/users/+server.ts
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';

export const GET: RequestHandler = async ({ url, locals }) => {
  const page = Number(url.searchParams.get('page')) || 1;
  const limit = 20;

  try {
    const users = await db.users.findMany({
      skip: (page - 1) * limit,
      take: limit
    });

    return json({
      users,
      pagination: {
        page,
        limit,
        total: await db.users.count()
      }
    });
  } catch (err) {
    throw error(500, '获取用户失败');
  }
};

export const POST: RequestHandler = async ({ request, locals }) => {
  if (!locals.user?.isAdmin) {
    throw error(403, '未授权');
  }

  const body = await request.json();

  // 验证
  if (!body.email || !body.name) {
    throw error(400, '缺少必填字段');
  }

  const user = await db.users.create({ data: body });

  return json(user, { status: 201 });
};

类型安全的 API 客户端

// lib/utils/api.ts
import { error } from '@sveltejs/kit';

export async function apiClient<T>(
  url: string,
  options?: RequestInit
): Promise<T> {
  const response = await fetch(url, {
    ...options,
    headers: {
      'Content-Type': 'application/json',
      ...options?.headers
    }
  });

  if (!response.ok) {
    throw error(response.status, await response.text());
  }

  return response.json();
}

// 使用
import type { User } from '$lib/types';

const users = await apiClient<User[]>('/api/users');

5. 表单处理和验证

使用 shadcn-svelte 表单

<script lang="ts">
  import { z } from 'zod';
  import { superForm } from 'sveltekit-superforms/client';
  import { zodClient } from 'sveltekit-superforms/adapters';
  import { Button } from '$lib/components/ui/button';
  import { Input } from '$lib/components/ui/input';
  import { Label } from '$lib/components/ui/label';
  import type { PageData } from './$types';

  export let data: PageData;

  const schema = z.object({
    email: z.string().email('无效的邮箱地址'),
    password: z.string().min(8, '密码至少需要 8 个字符'),
    confirmPassword: z.string()
  }).refine(data => data.password === data.confirmPassword, {
    message: "密码不匹配",
    path: ['confirmPassword']
  });

  const { form, errors, enhance, delayed } = superForm(data.form, {
    validators: zodClient(schema),
    resetForm: false,
    onUpdated: ({ form }) => {
      if (form.valid) {
        // 处理成功
        toast.success('注册成功!');
      }
    }
  });
</script>

<form method="POST" use:enhance>
  <div class="space-y-4">
    <div>
      <Label for="email">邮箱</Label>
      <Input
        id="email"
        type="email"
        name="email"
        bind:value={$form.email}
        aria-invalid={!!$errors.email}
      />
      {#if $errors.email}
        <p class="text-sm text-destructive">{$errors.email}</p>
      {/if}
    </div>

    <div>
      <Label for="password">密码</Label>
      <Input
        id="password"
        type="password"
        name="password"
        bind:value={$form.password}
        aria-invalid={!!$errors.password}
      />
      {#if $errors.password}
        <p class="text-sm text-destructive">{$errors.password}</p>
      {/if}
    </div>

    <div>
      <Label for="confirmPassword">确认密码</Label>
      <Input
        id="confirmPassword"
        type="password"
        name="confirmPassword"
        bind:value={$form.confirmPassword}
        aria-invalid={!!$errors.confirmPassword}
      />
      {#if $errors.confirmPassword}
        <p class="text-sm text-destructive">{$errors.confirmPassword}</p>
      {/if}
    </div>

    <Button type="submit" disabled={$delayed}>
      {$delayed ? '注册中...' : '注册'}
    </Button>
  </div>
</form>

6. 使用 Tailwind CSS 样式化

配置

// tailwind.config.js
export default {
  content: ['./src/**/*.{html,js,svelte,ts}'],
  theme: {
    extend: {
      colors: {
        border: 'hsl(var(--border))',
        input: 'hsl(var(--input))',
        ring: 'hsl(var(--ring))',
        background: 'hsl(var(--background))',
        foreground: 'hsl(var(--foreground))',
        primary: {
          DEFAULT: 'hsl(var(--primary))',
          foreground: 'hsl(var(--primary-foreground))'
        },
        // ... 更多颜色
      },
      borderRadius: {
        lg: 'var(--radius)',
        md: 'calc(var(--radius) - 2px)',
        sm: 'calc(var(--radius) - 4px)'
      }
    }
  },
  plugins: []
};

组件样式模式

<script lang="ts">
  import { cn } from '$lib/utils';

  let className: string = '';
  export { className as class };

  export let variant: 'default' | 'outline' = 'default';
  export let size: 'sm' | 'md' | 'lg' = 'md';
</script>

<button
  class={cn(
    'inline-flex items-center justify-center rounded-md font-medium transition-colors',
    'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2',
    'disabled:pointer-events-none disabled:opacity-50',
    {
      'bg-primary text-primary-foreground hover:bg-primary/90': variant === 'default',
      'border border-input hover:bg-accent': variant === 'outline',
    },
    {
      'h-8 px-3 text-sm': size === 'sm',
      'h-10 px-4': size === 'md',
      'h-12 px-6 text-lg': size === 'lg',
    },
    className
  )}
  {...$$restProps}
>
  <slot />
</button>

7. 测试

使用 Vitest 进行单元测试

// Button.test.ts
import { render, screen, fireEvent } from '@testing-library/svelte';
import { expect, test, describe, vi } from 'vitest';
import Button from './Button.svelte';

describe('Button', () => {
  test('渲染文本', () => {
    render(Button, { props: { children: '点击我' } });
    expect(screen.getByRole('button')).toHaveTextContent('点击我');
  });

  test('点击时调用 onClick', async () => {
    const onClick = vi.fn();
    render(Button, { props: { onclick: onClick } });

    await fireEvent.click(screen.getByRole('button'));
    expect(onClick).toHaveBeenCalledOnce();
  });

  test('当 disabled prop 为 true 时被禁用', () => {
    render(Button, { props: { disabled: true } });
    expect(screen.getByRole('button')).toBeDisabled();
  });
});

集成测试

// login.test.ts
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
import { expect, test, vi } from 'vitest';
import LoginPage from './+page.svelte';

test('登录流程', async () => {
  const mockFetch = vi.fn(() =>
    Promise.resolve({
      ok: true,
      json: () => Promise.resolve({ token: 'abc123' })
    })
  );

  global.fetch = mockFetch;

  render(LoginPage);

  await fireEvent.input(screen.getByLabelText('邮箱'), {
    target: { value: 'user@example.com' }
  });

  await fireEvent.input(screen.getByLabelText('密码'), {
    target: { value: 'password123' }
  });

  await fireEvent.click(screen.getByRole('button', { name: '登录' }));

  await waitFor(() => {
    expect(mockFetch).toHaveBeenCalledWith('/api/auth/login', expect.any(Object));
  });
});

8. 性能优化

代码分割

<script lang="ts">
  import { onMount } from 'svelte';

  let HeavyComponent;

  onMount(async () => {
    const module = await import('./HeavyComponent.svelte');
    HeavyComponent = module.default;
  });
</script>

{#if HeavyComponent}
  <svelte:component this={HeavyComponent} />
{:else}
  <div>加载中...</div>
{/if}

图片优化

<script lang="ts">
  import { browser } from '$app/environment';

  export let src: string;
  export let alt: string;

  let loaded = false;
  let visible = false;

  function handleIntersection(entries: IntersectionObserverEntry[]) {
    if (entries[0].isIntersecting) {
      visible = true;
    }
  }

  function setupObserver(node: HTMLElement) {
    if (!browser) return;

    const observer = new IntersectionObserver(handleIntersection);
    observer.observe(node);

    return {
      destroy() {
        observer.disconnect();
      }
    };
  }
</script>

<div use:setupObserver class="aspect-video bg-muted">
  {#if visible}
    <img
      {src}
      {alt}
      loading="lazy"
      class:opacity-0={!loaded}
      class:opacity-100={loaded}
      class="transition-opacity duration-300"
      on:load={() => loaded = true}
    />
  {/if}
</div>

9. 无障碍访问最佳实践

<script lang="ts">
  import { createDialog } from '@melt-ui/svelte';

  const {
    elements: { trigger, overlay, content, title, description, close },
    states: { open }
  } = createDialog();
</script>

<button use:trigger>打开对话框</button>

{#if $open}
  <div use:overlay class="fixed inset-0 bg-black/50" />
  <div
    use:content
    role="dialog"
    aria-labelledby="dialog-title"
    aria-describedby="dialog-description"
    class="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2"
  >
    <h2 use:title id="dialog-title">对话框标题</h2>
    <p use:description id="dialog-description">对话框描述</p>

    <button use:close aria-label="关闭对话框">
      <X class="h-4 w-4" />
    </button>
  </div>
{/if}

10. 部署

Bun 构建和部署

# 生产构建
bun run build

# 预览生产构建
bun run preview

# 使用适配器部署(示例:Node)
# svelte.config.js 应该有:adapter: adapter-node()
node build/index.js

环境变量

// lib/config/env.ts
import { PUBLIC_API_URL } from '$env/static/public';
import { PRIVATE_API_KEY } from '$env/static/private';

export const config = {
  apiUrl: PUBLIC_API_URL,
  apiKey: PRIVATE_API_KEY // 仅服务器端可用
};

常见模式和解决方案

1. 使用 shadcn-svelte 的深色模式

// stores/theme.ts
import { persistedStore } from '$lib/utils/stores';

export const theme = persistedStore<'light' | 'dark'>('theme', 'light');

export function toggleTheme() {
  theme.update(t => t === 'light' ? 'dark' : 'light');
}

// 应用主题
if (browser) {
  theme.subscribe(value => {
    document.documentElement.classList.toggle('dark', value === 'dark');
  });
}

2. Toast 通知

// lib/components/ui/sonner.ts
import { toast as sonnerToast } from 'svelte-sonner';

export const toast = {
  success: (message: string, options?) =>
    sonnerToast.success(message, options),
  error: (message: string, options?) =>
    sonnerToast.error(message, options),
  info: (message: string, options?) =>
    sonnerToast.info(message, options),
};

3. 认证流程

// hooks.server.ts
import type { Handle } from '@sveltejs/kit';
import { sequence } from '@sveltejs/kit/hooks';

const authHandler: Handle = async ({ event, resolve }) => {
  const token = event.cookies.get('auth_token');

  if (token) {
    try {
      const user = await verifyToken(token);
      event.locals.user = user;
    } catch {
      event.cookies.delete('auth_token');
    }
  }

  return resolve(event);
};

export const handle = sequence(authHandler);

4. 使用 SSE 的实时更新

// routes/api/events/+server.ts
import type { RequestHandler } from './$types';

export const GET: RequestHandler = async () => {
  const stream = new ReadableStream({
    start(controller) {
      const interval = setInterval(() => {
        const data = `data: ${JSON.stringify({ time: Date.now() })}\n\n`;
        controller.enqueue(new TextEncoder().encode(data));
      }, 1000);

      return () => clearInterval(interval);
    }
  });

  return new Response(stream, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      'Connection': 'keep-alive'
    }
  });
};

故障排查

常见问题

  1. Bun 兼容性问题:某些包可能不适用于 Bun。使用 npm/pnpm 作为回退
  2. 水合不匹配:确保 SSR 和客户端渲染相同的内容
  3. 内存泄漏:始终在 onDestroy 中清理或从 onMount 返回清理函数
  4. shadcn-svelte 的类型错误:确保已安装 @melt-ui/svelte 类型

调试工具

<script lang="ts">
  import { dev } from '$app/environment';

  // 仅在开发环境
  $: if (dev) {
    console.log('组件状态:', { /* ... */ });
  }
</script>

资源