この記事はRust+SvelteKit+CDKでRSS要約アプリを作ってみる Advent Calendar 2025の5日目の記事になります。
また、筆者が属している株式会社野村総合研究所のアドベントカレンダーもあるので、ぜひ購読ください。
なぜSvelteを触ってみようと思ったのか
筆者は日頃の仕事ではVueをメインに使っています。Vueのリアクティブシステムは素晴らしいと思っており、非常に優れたフレームワークと感じています。
一方で、Svelte独自の記法(snippetなど)にも、その存在を知ってから興味を持っていました。筆者がSvelteを知ったのはSvelte4時代ですが、その頃からマークアップの書き方もVueに似ていながらも直感的で見やすい印象を受けていました。
また、メタフレームワークとしてのSveltekitのコンセプトも好きです。VueのメタフレームワークであるNuxtは、サーバサイド(APIなど)のルーティングとクライアントサイドのルーティングは全く別物であり、一つのプロダクトの中でフロントとバックを明確に分けてコーディングしている気分になります。一方で、Sveltekitは、フロントもバックも同じルーティングシステムの中に存在します。このあたりも触ってみたいと思う魅力でした。
Svelteのいいところ
Vueとリアクティビティシステムが似ている
リアクティビティを持たせるための記法がVueとよく似ています。
Svelte5では、リアクティビティを持たせるために、リアクティブな変数は$state()を使って宣言するようになりました。
<!-- Counter.svelte -->
<script lang="ts">
let count = $state(0);
const increment = () => {
count += 1;
};
</script>
<button onclick={increment}>
現在の値: {count}
</button>
これは、Vueでいうところのref()を使った宣言に非常によく似ています。
<!-- Counter.vue -->
<script setup lang="ts">
import { ref } from 'vue';
const count = ref(0);
const increment = () => {
count.value += 1;
};
</script>
<template>
<button @click="increment">
現在の値: {{ count }}
</button>
</template>
refで宣言した場合、その変数の値の変更や取得には.valueに対して行う必要がありましたが、Svelteの場合はletで宣言しており、直接変数に代入すればOKです。
Vueとマークアップが似ている
マークアップの書き方も似ています。
例えばVueでループで要素を出力したい場合は、v-forディレクティブを使用します。
<!-- UserList.vue -->
<script setup lang="ts">
const users = [
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' }
];
</script>
<template>
<ul>
<li v-for="user in users" :key="user.id">
{{ user.name }}
</li>
</ul>
</template>
同じことをSvelteで行うには、#eachブロックを使用します。
<!-- UserList.svelte -->
<script lang="ts">
const users = [
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' }
];
</script>
<ul>
{#each users as user (user.id)}
<li>{user.name}</li>
{/each}
</ul>
直接タグの中に埋め込むか、専用のブロックで囲むかの違いはありますが、構造自体は比較的似ており、その点で学習コストが低いと言えます。
パフォーマンスがいい
リアクティブな値を宣言する部分は、VueもSvelteも近しいものがありますが、リアクティビティを実現するための仕組みついては大きく異なります。
Svelteには仮想DOMという考えがありません。依存関係はコンパイル時にすべて把握され、必要最低限のコンパイル済みのコードが生成されます。そのため、ランタイムで依存関係を管理する必要がなく、パフォーマンスに優れているのです。
例えばcountという変数があり、その変数の値を<p>で囲って出力したいとします。
このとき、「count」の変更が発生した場合に、<p>の中身を更新する必要がでてきます。この関係性をここでは依存関係を呼んでいます。
Vueはどうなのかというと、ランタイムで依存関係を管理しています。すなわち、実際にアプリを実行していくなかで、依存関係が把握されていきます。依存関係に基づいてレンダリングを更新する必要が出た場合、仮想DOMを構築し、実際のDOMと比較して更新差分を特定する処理が走ります。Vueのパフォーマンスに優れたフレームワークではありますが、仮想DOMを内部で持つ以上、その分の計算コストが発生します。
#snippetと@render
Svelte独自のブロック・記法として、#snippetと@renderがあります。
#snippetブロックは、「コンポーネント化するほどではないが、ある程度再利用可能なマークアップのかたまり」を定義することができます(まさしく スニペット ですね)。名前をつけて管理することができ、引数を取って動的にマークアップを生成できます。
<!-- AlertParts.svelte -->
{#snippet Alert({ tone = 'info', message })}
<div class={`alert alert-${tone}`}>
<p>{message}</p>
</div>
{/snippet}
このsnippetの真価は、データと同じように子コンポーネントにpropsとして渡すことができる点にあります。Vueでいうところの<slot>とほぼ同じです。snippetは関数のように書ける上、TypeScriptではその引数の型も指定できるため、より型安全に書くことが可能です。
<!-- Parent.svelte -->
<script lang="ts">
import Dialog from './Dialog.svelte';
function handleConfirm() {
console.log('confirmed');
}
</script>
{#snippet Footer({ onConfirm })}
<button class="btn" onclick={onConfirm}>OK</button>
{/snippet}
<Dialog title="確認" footer={Footer} onConfirm={handleConfirm} />
<!-- Dialog.svelte -->
<script lang="ts">
import type { Snippet } from 'svelte';
let { title, onConfirm, footer } = $props<{
title: string;
onConfirm: () => void;
footer: Snippet<{ onConfirm: () => void }>;
}>();
</script>
<section class="dialog">
<h2>{title}</h2>
{@render footer({ onConfirm })}
</section>
snippetは名前をつけることができます。その場合は親から明示的に名前を指定してsnippetを渡す必要があります。一方で、単に子のタグの中に渡したものを参照したい場合はchildrenで参照することができます(いわゆる、Vueでいうところの<template #default>です)。
<!-- Card.svelte -->
<script lang="ts">
import type { Snippet } from 'svelte';
let { children } = $props<{ children: Snippet }>();
</script>
<div class="card">
{@render children()}
</div>
<!-- Parent.svelte -->
<Card>
<p>ここがデフォルトのスロットとして描画されます。</p>
</Card>
snippetやchildrenを受け取った側が、それらをレンダリングするには@renderを使用します。@renderブロックの中でsnippetやchildrenを呼び出すと、その箇所でそれらをレンダリングできます。
<!-- Timeline.svelte -->
<script lang="ts">
import type { Snippet } from 'svelte';
let { items, itemRenderer } = $props<{
items: { id: number; date: string }[];
itemRenderer: Snippet<{ date: string }>;
}>();
</script>
<ul class="timeline">
{#each items as item (item.id)}
<li>
{@render itemRenderer({ date: item.date })}
</li>
{/each}
</ul>
SvelteKitのいいところ
loadが使いやすい
SvelteKitのルーティングはサーバ・クライアントが同じ階層で並び、load関数を境界に責務を切り出せます。ルートごとに
- サーバサイドでデータフェッチ
- そのデータをクライアントサイドに渡してレンダリング
という流れをテンプレ的に書けるため、「いつどこでデータが取られているか」を常に意識できます。
// src/routes/+page.server.ts
import type { PageServerLoad } from './$types';
import { fetchFeeds } from '$lib/server/rss';
export const load: PageServerLoad = (async () => {
const feeds = await fetchFeeds();
return { feeds };
});
<!-- src/routes/+page.svelte -->
<script lang="ts">
type Feed = { id: string; title: string; summary: string };
let { data } = $props<{ data: { feeds: Feed[] } }>();
</script>
<section>
{#each data.feeds as feed (feed.id)}
<article>
<h2>{feed.title}</h2>
<p>{feed.summary}</p>
</article>
{/each}
</section>
このようにサーバー側ではfetchFeedsなどのビジネスロジックに集中し、クライアント側はdataから受け取った値を描画するだけなので見通しが良くなります。
form actionも使いやすい
GET相当の処理をloadで扱えるのと同様に、同じルート配下でactionsを宣言するとPOST(やPUT)的な書き込み処理を完結できます。Web標準の<form>をそのまま使えるので、状態管理やfetch呼び出しを自前で書かずに済みます。
// src/routes/contact/+page.server.ts
import type { Actions } from './$types';
import { fail } from '@sveltejs/kit';
import { saveInquiry } from '$lib/server/contact';
export const actions: Actions = {
default: async ({ request }) => {
const formData = await request.formData();
const email = formData.get('email');
const message = formData.get('message');
if (!email || !message) {
return fail(400, { error: 'メールアドレスと本文は必須です。' });
}
await saveInquiry({
email: email.toString(),
message: message.toString()
});
return { success: true };
}
};
<!-- src/routes/contact/+page.svelte -->
<script lang="ts">
import { enhance } from '$app/forms';
let { form } = $props<{ form?: { success?: boolean; error?: string } }>();
let message = $state('');
</script>
<form method="POST" use:enhance>
<label>
メールアドレス
<input type="email" name="email" required />
</label>
<label>
本文
<textarea name="message" bind:value={message} required rows={4} />
</label>
<button type="submit">送信</button>
</form>
{#if form?.success}
<p class="text-success">送信しました!</p>
{:else if form?.error}
<p class="text-error">{form.error}</p>
{/if}
use:enhanceを付けるだけでプログレッシブエンハンスメントなフォーム送信になり、サーバーで検証して戻したエラーもform経由で受け取れます。
Remote functionが便利
「サーバーで動かしたいが、専用のエンドポイントを作るほどでもない」というロジックはRemote functionに切り出すと便利です。*.remote.tsでremote関数を使ってエクスポートすると、クライアント側から通常の関数呼び出しのように扱えます(svelte.config.jsでexperimental.remoteを有効化する必要があります)。
// src/lib/server/summarize.remote.ts
import { remote } from 'sveltekit/experimental/remote';
export const summarizeArticle = remote(async ({ body }: { body: string }) => {
const sentences = body.split('。').filter(Boolean).slice(0, 2);
return sentences.join('。') + '。';
});
<!-- src/routes/summarize/+page.svelte -->
<script lang="ts">
import { summarizeArticle } from '$lib/server/summarize.remote';
let source = $state('');
let summary = $state('');
let pending = $state(false);
const handleSummarize = async () => {
pending = true;
try {
summary = await summarizeArticle({ body: source });
} finally {
pending = false;
}
};
</script>
<textarea bind:value={source} rows={6} placeholder="本文を入力" />
<button type="button" onclick={handleSummarize} disabled={pending || !source}>
{pending ? '要約中...' : '要約する'}
</button>
{#if summary}
<section>
<h3>要約結果</h3>
<p>{summary}</p>
</section>
{/if}
実際にはクライアントからサーバーへのRPCが走っていますが、呼び出し側は通常の関数のように扱えるため、補助的な処理を低コストに分離できます。