Svelte
コンパイル時最適化によるWebフレームワーク。ビルド時にバニラJavaScriptにコンパイルし、実行時の仮想DOMが不要なため高性能。
GitHub概要
スター83,714
ウォッチ852
フォーク4,591
作成日:2016年11月20日
言語:JavaScript
ライセンス:MIT License
トピックス
compilertemplateui
スター履歴
データ取得日時: 2025/8/13 01:43
フレームワーク
Svelte
概要
Svelteは、コンパイル時に最適化された高性能なJavaScriptコードを生成する革新的なWebコンポーネントフレームワークです。Virtual DOMやランタイムオーバーヘッドがありません。
詳細
Svelte(スベルト)は、2016年にRich Harrisによって開発されたWebアプリケーションフレームワークで、従来のReactやVue.jsとは根本的に異なるアプローチを採用しています。Svelteはコンパイラフレームワークであり、ビルド時にコンポーネントを最適化されたバニラJavaScriptに変換し、Virtual DOMやランタイムフレームワークを必要としません。特徴として、簡潔で直感的な構文、リアクティブな状態管理、コンポーネントスコープCSS、アニメーションとトランジションの内蔵サポート、小さなバンドルサイズ、高いパフォーマンス、TypeScriptサポート、アクセシビリティの組み込みサポートなどがあります。Svelte 5ではルーン(Runes)システムが導入され、$state、$derived、$effect、$propsなどの新しいリアクティビティシステムが導入されました。特にパフォーマンスを重視するWebアプリケーション、インタラクティブダッシュボード、データ可視化、モバイルアプリケーション開発で採用され、The New York Times、Apple、スポティファイ、イケアなどの大手企業でも使用されています。
メリット・デメリット
メリット
- コンパイル時最適化: Virtual DOM不要で高速動作
- 小さなバンドルサイズ: ランタイムオーバーヘッドがほぼゼロ
- 簡潔な構文: 学習しやすい直感的な書き方
- リアクティブな状態管理: 複雑な状態管理ライブラリ不要
- コンポーネントスコープCSS: CSSのスコープが自動管理
- 組み込みアニメーション: 美しいトランジションを簡単に実現
- TypeScriptサポート: 優秀な型推論と開発体験
- アクセシビリティ: a11yチェックと警告機能内蔵
デメリット
- エコシステムの小ささ: React/Vue.jsと比較してライブラリが少ない
- コンパイルステップ: ビルドプロセスが必須
- デバッグの難しさ: コンパイル後コードのデバッグが困難
- コミュニティサイズ: 他のメジャーフレームワークと比較して小さい
- サーバーサイドレンダリング: 基本的にクライアントサイドフレームワーク
- 学習リソース: 日本語の情報源が限定的
- 既存プロジェクト移行: 他フレームワークからの移行コスト
主要リンク
書き方の例
Hello World
<!-- App.svelte -->
<script>
// JavaScriptロジック
let name = 'Svelte';
let count = 0;
// リアクティブステートメント
$: doubled = count * 2;
$: if (count >= 10) {
alert('カウントが10に達しました!');
}
// イベントハンドラー
function increment() {
count += 1;
}
function decrement() {
count -= 1;
}
function reset() {
count = 0;
}
</script>
<!-- HTMLテンプレート -->
<main>
<h1>Hello {name}!</h1>
<div class="counter">
<p>カウント: {count}</p>
<p>倖数: {doubled}</p>
<div class="buttons">
<button on:click={decrement}>-</button>
<button on:click={reset}>リセット</button>
<button on:click={increment}>+</button>
</div>
</div>
<!-- 条件付きレンダリング -->
{#if count > 5}
<p class="warning">カウントが高いです!</p>
{:else if count < 0}
<p class="error">カウントがマイナスです!</p>
{/if}
</main>
<!-- コンポーネントスコープCSS -->
<style>
main {
max-width: 500px;
margin: 0 auto;
padding: 2rem;
text-align: center;
font-family: Arial, sans-serif;
}
h1 {
color: #ff3e00;
font-size: 2.5rem;
margin-bottom: 2rem;
}
.counter {
background: #f9f9f9;
border-radius: 8px;
padding: 1.5rem;
margin: 1rem 0;
}
.counter p {
font-size: 1.2rem;
margin: 0.5rem 0;
}
.buttons {
display: flex;
gap: 1rem;
justify-content: center;
margin-top: 1rem;
}
button {
background: #ff3e00;
color: white;
border: none;
padding: 0.8rem 1.5rem;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
transition: background-color 0.2s;
}
button:hover {
background: #e03400;
}
.warning {
color: #ff9500;
font-weight: bold;
}
.error {
color: #ff0000;
font-weight: bold;
}
</style>
コンポーネントとProps
<!-- UserCard.svelte -->
<script>
// Propsの定義
export let user;
export let showEmail = true;
export let size = 'medium';
// イベントディスパッチャー
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
// ユーザーアクション
function editUser() {
dispatch('edit', {
user: user
});
}
function deleteUser() {
dispatch('delete', {
userId: user.id
});
}
// 計算された値
$: cardClass = `user-card ${size}`;
$: avatarUrl = user.avatar || `https://ui-avatars.com/api/?name=${encodeURIComponent(user.name)}&background=ff3e00&color=fff`;
</script>
<div class={cardClass}>
<div class="avatar">
<img src={avatarUrl} alt="{user.name}のアバター" />
</div>
<div class="user-info">
<h3>{user.name}</h3>
{#if showEmail}
<p class="email">{user.email}</p>
{/if}
<span class="role" class:admin={user.role === 'admin'}>
{user.role}
</span>
</div>
<div class="actions">
<button class="btn btn-primary" on:click={editUser}>
編集
</button>
<button class="btn btn-danger" on:click={deleteUser}>
削除
</button>
</div>
</div>
<style>
.user-card {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
border: 1px solid #e0e0e0;
border-radius: 8px;
background: white;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
transition: transform 0.2s, box-shadow 0.2s;
}
.user-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
.user-card.small {
padding: 0.75rem;
gap: 0.75rem;
}
.user-card.large {
padding: 1.5rem;
gap: 1.5rem;
}
.avatar img {
width: 50px;
height: 50px;
border-radius: 50%;
object-fit: cover;
}
.small .avatar img {
width: 40px;
height: 40px;
}
.large .avatar img {
width: 60px;
height: 60px;
}
.user-info {
flex: 1;
text-align: left;
}
.user-info h3 {
margin: 0 0 0.5rem 0;
color: #333;
}
.email {
margin: 0 0 0.5rem 0;
color: #666;
font-size: 0.9rem;
}
.role {
display: inline-block;
padding: 0.25rem 0.5rem;
background: #f0f0f0;
border-radius: 4px;
font-size: 0.8rem;
color: #555;
}
.role.admin {
background: #ff3e00;
color: white;
}
.actions {
display: flex;
gap: 0.5rem;
}
.btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
transition: background-color 0.2s;
}
.btn-primary {
background: #007bff;
color: white;
}
.btn-primary:hover {
background: #0056b3;
}
.btn-danger {
background: #dc3545;
color: white;
}
.btn-danger:hover {
background: #a71e2a;
}
</style>
<!-- UserList.svelte -->
<script>
import UserCard from './UserCard.svelte';
// ユーザーリスト
let users = [
{
id: 1,
name: '田中太郎',
email: '[email protected]',
role: 'admin',
avatar: 'https://i.pravatar.cc/150?img=1'
},
{
id: 2,
name: '佐藤花子',
email: '[email protected]',
role: 'user',
avatar: 'https://i.pravatar.cc/150?img=2'
},
{
id: 3,
name: '鈴木一郎',
email: '[email protected]',
role: 'user',
avatar: 'https://i.pravatar.cc/150?img=3'
}
];
// イベントハンドラー
function handleEditUser(event) {
console.log('ユーザー編集:', event.detail.user);
// 編集ロジックを実装
}
function handleDeleteUser(event) {
const userId = event.detail.userId;
if (confirm('このユーザーを削除しますか?')) {
users = users.filter(user => user.id !== userId);
}
}
</script>
<div class="user-list">
<h2>ユーザー一覧</h2>
{#if users.length > 0}
<div class="users-grid">
{#each users as user (user.id)}
<UserCard
{user}
on:edit={handleEditUser}
on:delete={handleDeleteUser}
/>
{/each}
</div>
{:else}
<p class="no-users">ユーザーが見つかりません。</p>
{/if}
</div>
<style>
.user-list {
max-width: 800px;
margin: 0 auto;
padding: 2rem;
}
h2 {
color: #333;
margin-bottom: 2rem;
text-align: center;
}
.users-grid {
display: grid;
gap: 1rem;
}
.no-users {
text-align: center;
color: #666;
font-style: italic;
}
</style>
ストアと状態管理
// stores/counter.js
import { writable, derived } from 'svelte/store';
// 基本的なストア
export const count = writable(0);
// 計算されたストア
export const doubled = derived(count, $count => $count * 2);
export const quadrupled = derived(doubled, $doubled => $doubled * 2);
// カスタムストア
function createCounter() {
const { subscribe, set, update } = writable(0);
return {
subscribe,
increment: () => update(n => n + 1),
decrement: () => update(n => n - 1),
reset: () => set(0),
set: (value) => set(value)
};
}
export const counter = createCounter();
// 非同期ストア
export const users = writable([]);
export async function loadUsers() {
try {
const response = await fetch('/api/users');
const userData = await response.json();
users.set(userData);
} catch (error) {
console.error('ユーザー読み込みエラー:', error);
}
}
export async function addUser(newUser) {
try {
const response = await fetch('/api/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(newUser)
});
if (response.ok) {
const user = await response.json();
users.update(userList => [...userList, user]);
return user;
}
} catch (error) {
console.error('ユーザー追加エラー:', error);
}
}
<!-- ストア使用例 -->
<script>
import { count, doubled, counter, users } from './stores/counter.js';
import { onMount } from 'svelte';
// ストアの購読
$: console.log('カウントが変更されました:', $count);
// コンポーネントマウント時の処理
onMount(() => {
console.log('コンポーネントがマウントされました');
return () => {
console.log('コンポーネントがアンマウントされました');
};
});
</script>
<div>
<h3>ストアテスト</h3>
<p>カウント: {$count}</p>
<p>倖数: {$doubled}</p>
<button on:click={counter.increment}>+1</button>
<button on:click={counter.decrement}>-1</button>
<button on:click={counter.reset}>リセット</button>
<!-- 直接値を設定 -->
<input type="number" bind:value={$count} />
</div>
アニメーションとトランジション
<script>
import { fade, fly, scale, slide } from 'svelte/transition';
import { flip } from 'svelte/animate';
import { elasticOut } from 'svelte/easing';
let visible = true;
let items = [
{ id: 1, name: 'アイテム1' },
{ id: 2, name: 'アイテム2' },
{ id: 3, name: 'アイテム3' }
];
function toggle() {
visible = !visible;
}
function addItem() {
const newId = Math.max(...items.map(i => i.id)) + 1;
items = [...items, { id: newId, name: `アイテム${newId}` }];
}
function removeItem(id) {
items = items.filter(item => item.id !== id);
}
function shuffle() {
items = items.sort(() => Math.random() - 0.5);
}
</script>
<div class="animation-demo">
<h3>アニメーションデモ</h3>
<div class="controls">
<button on:click={toggle}>
{visible ? '非表示' : '表示'}
</button>
<button on:click={addItem}>アイテム追加</button>
<button on:click={shuffle}>シャッフル</button>
</div>
{#if visible}
<div class="box"
in:fly="{{ y: 200, duration: 2000, easing: elasticOut }}"
out:fade="{{ duration: 300 }}">
フェードイン/アウトボックス
</div>
{/if}
<div class="item-list">
{#each items as item (item.id)}
<div class="item"
in:scale="{{ duration: 300 }}"
out:slide="{{ duration: 300 }}"
animate:flip="{{ duration: 300 }}">
<span>{item.name}</span>
<button on:click={() => removeItem(item.id)}>×</button>
</div>
{/each}
</div>
</div>
<style>
.animation-demo {
max-width: 500px;
margin: 0 auto;
padding: 2rem;
}
.controls {
display: flex;
gap: 1rem;
margin-bottom: 2rem;
}
.controls button {
padding: 0.5rem 1rem;
background: #ff3e00;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.box {
background: linear-gradient(45deg, #ff3e00, #ff8c00);
color: white;
padding: 2rem;
border-radius: 8px;
text-align: center;
margin: 2rem 0;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.item-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
background: #f9f9f9;
border-radius: 4px;
border: 1px solid #e0e0e0;
}
.item button {
background: #dc3545;
color: white;
border: none;
width: 24px;
height: 24px;
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
</style>
HTTPリクエストとデータ取得
<script>
import { onMount } from 'svelte';
let posts = [];
let loading = false;
let error = null;
let newPost = { title: '', body: '' };
// データ取得
async function loadPosts() {
loading = true;
error = null;
try {
const response = await fetch('https://jsonplaceholder.typicode.com/posts?_limit=5');
if (!response.ok) {
throw new Error('データの取得に失敗しました');
}
posts = await response.json();
} catch (err) {
error = err.message;
} finally {
loading = false;
}
}
// 新しい投稿を作成
async function createPost() {
if (!newPost.title.trim() || !newPost.body.trim()) {
alert('タイトルと本文を入力してください');
return;
}
loading = true;
error = null;
try {
const response = await fetch('https://jsonplaceholder.typicode.com/posts', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
title: newPost.title,
body: newPost.body,
userId: 1
})
});
if (!response.ok) {
throw new Error('投稿の作成に失敗しました');
}
const post = await response.json();
posts = [post, ...posts];
newPost = { title: '', body: '' };
} catch (err) {
error = err.message;
} finally {
loading = false;
}
}
// 投稿削除
async function deletePost(postId) {
if (!confirm('この投稿を削除しますか?')) {
return;
}
try {
const response = await fetch(`https://jsonplaceholder.typicode.com/posts/${postId}`, {
method: 'DELETE'
});
if (response.ok) {
posts = posts.filter(post => post.id !== postId);
}
} catch (err) {
console.error('削除エラー:', err);
}
}
// コンポーネントマウント時にデータを読み込み
onMount(() => {
loadPosts();
});
</script>
<div class="posts-app">
<h2>ブログ投稿管理</h2>
<!-- 新しい投稿フォーム -->
<form on:submit|preventDefault={createPost} class="post-form">
<h3>新しい投稿</h3>
<input
type="text"
bind:value={newPost.title}
placeholder="タイトル"
disabled={loading}
/>
<textarea
bind:value={newPost.body}
placeholder="本文"
rows="4"
disabled={loading}
></textarea>
<button type="submit" disabled={loading}>
{loading ? '作成中...' : '投稿作成'}
</button>
</form>
<!-- エラー表示 -->
{#if error}
<div class="error">
エラー: {error}
<button on:click={loadPosts}>再試行</button>
</div>
{/if}
<!-- ローディング表示 -->
{#if loading}
<div class="loading">読み込み中...</div>
{/if}
<!-- 投稿一覧 -->
<div class="posts-list">
{#each posts as post (post.id)}
<article class="post">
<h4>{post.title}</h4>
<p>{post.body}</p>
<div class="post-actions">
<small>ID: {post.id} | User: {post.userId}</small>
<button class="delete-btn" on:click={() => deletePost(post.id)}>
削除
</button>
</div>
</article>
{/each}
{#if posts.length === 0 && !loading && !error}
<p class="no-posts">投稿がありません。</p>
{/if}
</div>
</div>
<style>
.posts-app {
max-width: 800px;
margin: 0 auto;
padding: 2rem;
}
.post-form {
background: #f9f9f9;
padding: 1.5rem;
border-radius: 8px;
margin-bottom: 2rem;
}
.post-form input,
.post-form textarea {
width: 100%;
padding: 0.75rem;
margin-bottom: 1rem;
border: 1px solid #ddd;
border-radius: 4px;
font-family: inherit;
}
.post-form button {
background: #ff3e00;
color: white;
border: none;
padding: 0.75rem 1.5rem;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
}
.post-form button:disabled {
background: #ccc;
cursor: not-allowed;
}
.error {
background: #ffe6e6;
color: #d00;
padding: 1rem;
border-radius: 4px;
margin-bottom: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.error button {
background: #d00;
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
}
.loading {
text-align: center;
padding: 2rem;
color: #666;
font-style: italic;
}
.posts-list {
display: grid;
gap: 1rem;
}
.post {
background: white;
padding: 1.5rem;
border: 1px solid #e0e0e0;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.post h4 {
margin: 0 0 0.5rem 0;
color: #333;
}
.post p {
margin: 0 0 1rem 0;
color: #666;
line-height: 1.5;
}
.post-actions {
display: flex;
justify-content: space-between;
align-items: center;
}
.delete-btn {
background: #dc3545;
color: white;
border: none;
padding: 0.25rem 0.75rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.875rem;
}
.no-posts {
text-align: center;
color: #666;
font-style: italic;
padding: 3rem;
}
</style>
フォームとバリデーション
<script>
let formData = {
name: '',
email: '',
age: '',
gender: '',
interests: [],
newsletter: false,
bio: ''
};
let errors = {};
let touched = {};
// バリデーションルール
function validateField(field, value) {
switch (field) {
case 'name':
return value.trim().length < 2 ? '名前は2文字以上で入力してください' : '';
case 'email':
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return !emailRegex.test(value) ? '有効なメールアドレスを入力してください' : '';
case 'age':
const ageNum = parseInt(value);
return isNaN(ageNum) || ageNum < 1 || ageNum > 120 ? '有効な年齢を1-120で入力してください' : '';
case 'gender':
return !value ? '性別を選択してください' : '';
default:
return '';
}
}
// リアルタイムバリデーション
$: {
errors = {};
Object.keys(formData).forEach(field => {
if (touched[field]) {
const error = validateField(field, formData[field]);
if (error) errors[field] = error;
}
});
}
// フォームの有効性チェック
$: isValid = Object.keys(errors).length === 0 &&
formData.name &&
formData.email &&
formData.age &&
formData.gender;
function handleFieldBlur(field) {
touched[field] = true;
}
function handleInterestChange(interest) {
if (formData.interests.includes(interest)) {
formData.interests = formData.interests.filter(i => i !== interest);
} else {
formData.interests = [...formData.interests, interest];
}
}
function handleSubmit() {
// 全フィールドをtouched状態に
Object.keys(formData).forEach(field => {
touched[field] = true;
});
if (isValid) {
console.log('フォーム送信:', formData);
alert('フォームが正常に送信されました!');
} else {
alert('エラーを修正してください');
}
}
</script>
<div class="form-container">
<h2>ユーザー登録フォーム</h2>
<form on:submit|preventDefault={handleSubmit}>
<!-- 名前 -->
<div class="field">
<label for="name">名前 *</label>
<input
type="text"
id="name"
bind:value={formData.name}
on:blur={() => handleFieldBlur('name')}
class:error={errors.name}
/>
{#if errors.name}
<span class="error-message">{errors.name}</span>
{/if}
</div>
<!-- メール -->
<div class="field">
<label for="email">メールアドレス *</label>
<input
type="email"
id="email"
bind:value={formData.email}
on:blur={() => handleFieldBlur('email')}
class:error={errors.email}
/>
{#if errors.email}
<span class="error-message">{errors.email}</span>
{/if}
</div>
<!-- 年齢 -->
<div class="field">
<label for="age">年齢 *</label>
<input
type="number"
id="age"
bind:value={formData.age}
on:blur={() => handleFieldBlur('age')}
class:error={errors.age}
min="1"
max="120"
/>
{#if errors.age}
<span class="error-message">{errors.age}</span>
{/if}
</div>
<!-- 性別 -->
<div class="field">
<label>性別 *</label>
<div class="radio-group">
<label class="radio-label">
<input
type="radio"
bind:group={formData.gender}
value="male"
on:change={() => handleFieldBlur('gender')}
/>
男性
</label>
<label class="radio-label">
<input
type="radio"
bind:group={formData.gender}
value="female"
on:change={() => handleFieldBlur('gender')}
/>
女性
</label>
<label class="radio-label">
<input
type="radio"
bind:group={formData.gender}
value="other"
on:change={() => handleFieldBlur('gender')}
/>
その他
</label>
</div>
{#if errors.gender}
<span class="error-message">{errors.gender}</span>
{/if}
</div>
<!-- 興味 -->
<div class="field">
<label>興味 (複数選択可)</label>
<div class="checkbox-group">
{#each ['programming', 'design', 'music', 'sports', 'reading'] as interest}
<label class="checkbox-label">
<input
type="checkbox"
checked={formData.interests.includes(interest)}
on:change={() => handleInterestChange(interest)}
/>
{interest}
</label>
{/each}
</div>
</div>
<!-- ニュースレター -->
<div class="field">
<label class="checkbox-label">
<input
type="checkbox"
bind:checked={formData.newsletter}
/>
ニュースレターを購読する
</label>
</div>
<!-- 自己紹介 -->
<div class="field">
<label for="bio">自己紹介</label>
<textarea
id="bio"
bind:value={formData.bio}
rows="4"
placeholder="自己紹介を書いてください..."
></textarea>
</div>
<!-- 送信ボタン -->
<button type="submit" class:disabled={!isValid}>
登録する
</button>
</form>
<!-- フォームデータプレビュー -->
<div class="preview">
<h3>入力データプレビュー</h3>
<pre>{JSON.stringify(formData, null, 2)}</pre>
</div>
</div>
<style>
.form-container {
max-width: 600px;
margin: 0 auto;
padding: 2rem;
}
.field {
margin-bottom: 1.5rem;
}
label {
display: block;
margin-bottom: 0.5rem;
font-weight: bold;
color: #333;
}
input[type="text"],
input[type="email"],
input[type="number"],
textarea {
width: 100%;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
transition: border-color 0.2s;
}
input:focus,
textarea:focus {
outline: none;
border-color: #ff3e00;
}
input.error {
border-color: #dc3545;
}
.error-message {
color: #dc3545;
font-size: 0.875rem;
margin-top: 0.25rem;
display: block;
}
.radio-group,
.checkbox-group {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.radio-label,
.checkbox-label {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: normal;
cursor: pointer;
}
button {
background: #ff3e00;
color: white;
border: none;
padding: 1rem 2rem;
border-radius: 4px;
font-size: 1rem;
cursor: pointer;
transition: background-color 0.2s;
}
button:hover:not(.disabled) {
background: #e03400;
}
button.disabled {
background: #ccc;
cursor: not-allowed;
}
.preview {
margin-top: 2rem;
padding: 1rem;
background: #f9f9f9;
border-radius: 4px;
}
.preview pre {
background: white;
padding: 1rem;
border-radius: 4px;
overflow-x: auto;
font-size: 0.875rem;
}
</style>