📖

Vue.js利用者が新規プロジェクトでゼロからSvelteを使ってみて

に公開

はじめに

BEENOSの山岡です。
今年チームを異動し、現在は新規事業チームに所属しています。
(プライベートでは、リモートワークが続き体がおじいちゃんになっていたので、ヨガを習い始めました🧘‍♀️)

異動前に所属していたチームでは主にVueを使用してきましたが、一部の新規機能ではSvelteを採用することになりました。

Svelteの特徴を深堀しながら、実際にSvelteに触れてみた感想や、既存のVueを使用したプロジェクトと新規機能で使用したSvelteのコードの比較を通して共有します。
本記事がフロントエンド技術の選択肢を検討する上で、参考となれば幸いです🙏

Svelteに移行した背景

まず、Svelteを採用した主な理由は以下の通りです。

  • Svelteの文法がどちらかというとVue寄りなので、Vueをやっていたメンバーでも馴染みやすい
  • 仮想DOMを使わないのでパフォーマンスが良く、チューニングをほとんど気を遣わなくていい
  • Svelteの記述量が少なく済むシンプルな構文

Svelteの特徴

Svelteの特徴としては以下の点があります。

特徴1:バンドルサイズの削減
特徴2:コンパイル時の最適化

特徴1:バンドルサイズの削減

Svelteの核となる概念は、コンポーネントをバニラJavaScriptに最適化してコンパイルし、ランタイムにおける処理負荷を軽減することです。

VueやReactでは、アプリケーション実行中に差分を検知し、変更された箇所だけをDOMに反映する仕組みを採用しています。

これに対して、Svelteは「Svelteコンパイラ」が.svelte拡張子を持つコンポーネントファイルを解析し、必要最小限かつ効率的なHTML、CSS、JavaScriptコードに変換するといった、ビルド時にコンポーネントを解析・変換する戦略を取っています。

このコンパイル戦略により、Svelteでは差分計算などの複雑な処理をランタイムで行う必要がなくなります。代わりに、より直接的にDOMを操作する最小限のJavaScriptコードが生成され、結果として高速なレンダリングが実現されます。最終的に生成されるファイルサイズも小さくなり、アプリケーションのダウンロードサイズの軽量化にもつながります。

特徴2:コンパイル時の最適化

特徴1と被る内容でもありますが、Svelteでは、コンポーネントの実行時パフォーマンスを高めるために、ビルド時(コンパイル時)にコードを最適化しています。
一般的なフレームワークがランタイムで最適化を行うのに対し、Svelteは事前にコンパイルして最適なJavaScriptコードを生成するため、実行時の負荷を大幅に削減できます。

このコンパイル処理には、次の4つのステップがあります。

手順1:パース+抽象構文木を生成

まず、Svelteコンパイラは.svelteファイルを読み込み、HTML、CSS、JavaScriptの各パートに分解します。その分解されたパートに対して、JavaScript部分に「acorn」、CSS部分に「css-tree」と抽象構文木を生成します。

参照元:Tan Li Hau「The Svelte Compiler Handbook」

手順2:分析(情報の抽出)

次に、生成した抽象構文木を解析し、以下のような情報を収集します。

  • コンポーネント内で宣言された変数
  • 参照される変数や更新される変数
  • 変数同士の参照関係と依存関係
  • リアクティブステートメント($: )の存在と内容
  • イベントハンドラ(例: on:clickなど)

この情報をもとに、SvelteはComponentインスタンスを作成。
このComponentインスタンスには、コンポーネントのHTML構造を表現するHTMLフラグメントや、コンポーネント内で定義された変数情報などが含まれます。

コンパイラは、インスタンススクリプトとモジュールスクリプトそれぞれの抽象構文木を走査して、これらの情報を一元管理します。
変数のスコープもこの段階で決定され、抽出された情報は、後続の最適化処理のためのComponentインスタンスにまとめられます。

また、Svelteコンパイラはこの情報をもとに、不要な処理の削減も行っています。
たとえば、実際にはリアクティブである必要がない場合にリアクティブ更新処理を省略したり、コンポーネント内で一度も変更されない定数や関数はインスタンススコープの外に出して無駄な再評価を防止したりしています。


参照元:Tan Li Hau「The Svelte Compiler Handbook」

手順3:コードブロックとフラグメントの生成

Svelte はコンパイルされた出力を生成するために必要な情報を管理する Rendererインスタンス を作成します。compile optionsに応じて、DOMまたはSSR出力を判別し、それぞれに応じたRendererを生成します。


参照元:Tan Li Hau「The Svelte Compiler Handbook」

手順4:コード生成

Svelteコンパイラの最終段階である変換フェーズでは、分析フェーズで収集された情報をもとに、実際に実行されるJavaScriptコードが生成されます。

まず、コンポーネントの状態が変化した際にDOMを効率的に更新できるよう、create_fragment関数が生成されます。DOMレンダラーはレンダーツリーを走査し、各ノードのrender関数を呼び出しながら、Blockインスタンスを介して適切なcreate_fragmentコードを各ノードに挿入していきます。

サーバーサイドレンダリング(SSR)の場合はDOMではなく文字列を出力するため、異なるノードハンドラーが用いられます。その後、各ノードは最終的にテンプレートリテラル(HTML文字列や式)に変換されます。

以上のように、変換フェーズでは、コンポーネントの動的な更新やSSRに最適化された、効率的なJavaScriptコードが生成される仕組みになっています。

  • DOM生成の場合(イメージ)
function create_fragment(ctx) {
  let h1;
  return {
    c() {
      h1 = element("h1");
      h1.textContent = "Hello world";
    },
    m(target, anchor) {
      insert(target, h1, anchor);
    },
    ...
  };
}

SSRの場合(イメージ)

function render() {
  return `<h1>Hello world</h1>`;
}


参照元:Tan Li Hau「The Svelte Compiler Handbook」

実装でのVueとSvelteの比較

特徴を述べてきましたが、ここからは実際のコードベースでVueとSvelteだと、どのように書き方が変わるかについて見ていきます。

機能比較

  • 比較要素としてはいくつかありますが、簡単にVueとSvelteの違いとしては以下通りです。
項目 Svelte Vue.js
基本コンセプト コンパイラ (ビルド時中心) フレームワーク (ランタイム + コンパイラ)
レンダリング方式 直接DOM操作 Virtual DOM (実行時差分検出)
リアクティブ 変数の変更で自動的にUIが更新されるリアクティブシステム refreactiveを使用してリアクティブデータを管理
記述方法 HTML・CSS・JavaScriptを1ファイルにまとめて記述できる SFC(単一ファイルコンポーネント)で、1つのファイル内にテンプレート・スクリプト・スタイルを構造的に記述
バンドルサイズ コンパイル時最適化で小さい ランタイムライブラリを含むためやや大きい傾向
ユースケース 小規模〜中規模、パフォーマンス重視のプロジェクト向き 大規模・複雑なアプリにも対応可能

実際に、Vueを使っているプロジェクトと新規機能をSvelteのコードで書くとどんな違いがあるかを簡単なコードをもとに比較します。
既存のプロジェクトと新規機能のどちらにも「検索画面」は実装されたので、同じ画面を比較して書き方の違いについて見てみます。
※比較を分かりやすくするため、実際のコードから処理や変数名などを修正しています。書き方があまり綺麗ではない箇所もありますが、参考程度にご覧ください🙏
※本記事のSvelteはバージョン5の記法で記述します。

Svelte

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

  let images = $state([]); 
  let searchParams = $state({
    originalFilename: undefined,
  });

  const fetchImages = async (): Promise<void> => {
    console.log('Fetching images with params:', searchParams);
  };

  const search = async (): Promise<void> => {
    await fetchImages();
  };

  const inputClear = (): void => {
    searchParams.originalFilename = undefined;
  };

  onMount(() => {
    fetchImages();
  });
</script>

<div>
  <h1>画像管理</h1>

  <div>
    <div>
      <form on:submit|preventDefault={search}>
        <div>
          <div>
            <label for="filename">ファイル名</label>
            <input type="text" placeholder="photo-01.jpg" bind:value={searchParams.originalFilename} />
          </div>
        </div>
        <div>
          <button type="button" on:click={inputClear}>検索条件をクリア</button>
          <button type="submit">検索</button>
        </div>
      </form>
    </div>

    {#if images.length > 0}
      <div>
        <table>
          <thead>
            <tr>
              <th>画像</th>
              <th>ファイル名</th>
            </tr>
          </thead>
          <tbody>
            {#each images as image (image.id)}
              <tr>
                <td>
                  <a href={image.imageUrl} target="_blank" rel="noopener">
                    <img src={image.imageUrl} alt={image.alt} />
                  </a>
                </td>
                <td>{image.originalFilename}</td>
              </tr>
            {/each}
          </tbody>
        </table>
      </div>
    {/if}
  </div>
</div>

Vue

<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue';

const images = ref([]);
const searchParams = reactive({
  originalFilename: undefined,
});

const fetchImages = async (): Promise<void> => {
  console.log('Fetching images with params:', searchParams);
};

const search = async (): Promise<void> => {
  await fetchImages();
};

const inputClear = (): void => {
  searchParams.originalFilename = undefined;
};

onMounted(() => {
  fetchImages();
});
</script>

<template>
  <div>
    <h1>画像管理</h1>

    <div>
      <div>
        <form @submit.prevent="search">
          <div>
            <div>
              <label>ファイル名</label>
              <input type="text" placeholder="photo-01.jpg" v-model="searchParams.originalFilename" />
            </div>
          </div>
          <div>
            <button type="button" @click.prevent="inputClear">検索条件をクリア</button>
            <button type="submit">検索</button>
          </div>
        </form>
      </div>

      <div v-if="images.length">
        <table>
          <thead>
            <tr>
              <th>画像</th>
              <th>ファイル名</th>
            </tr>
          </thead>
          <tbody>
            <tr v-for="image in images" :key="image.id">
              <td>
                <a :href="image.imageUrl" target="_blank" rel="noopener">
                  <img :src="image.imageUrl" :alt="image.alt" />
                </a>
              </td>
              <td>{{ image.originalFilename }}</td>
            </tr>
          </tbody>
        </table>
      </div>
    </div>
  </div>
</template>

比較概要

コンポーネント定義のスクリプトの基本構造

  • Svelte:
    Svelteでは、<script>タグ内で$stateを使ってリアクティブな状態を宣言します。これにより、変数の値が変更されると自動的にUIが更新されます。ライフサイクルはonMountのような関数をインポートして使用します。
import { onMount } from 'svelte';

let images = $state([]); 
let searchParams = $state({
  originalFilename: undefined,
});

const fetchImages = async (): Promise<void> => { /* ... */ };

onMount(() => {
  fetchImages();
});
  • Vue:
    Vueでは、<script setup>を使用することで、Svelteと同様にトップレベルで変数や関数を定義できます。状態を保持するには、refreactiveといったリアクティビティAPIをインポートして使用します。ライフサイクルフックも同様に、onMountedなどをインポートして使用します。
import { ref, reactive, onMounted } from 'vue';

const images = ref([]);
const searchParams = reactive({
  originalFilename: undefined,
});

const fetchImages = async (): Promise<void> => {
  console.log('Fetching images with params:', searchParams);
};

onMounted(() => {
  fetchImages();
});

テンプレート構文とディレクティブ/制御構文

  • Svelte:
    Svelteは{#if},{#each},bind:value,on:clickのような特別なタグや属性を使ってテンプレート内に制御フローやデータバインディングを記述します。JavaScript式は{}で囲みます。
{#if images.length > 0}...{/if}
{#each images as image (image.id)}...{/each}
<input type="text" bind:value={searchParams.originalFilename}>
<button on:click={inputClear}>検索条件をクリア</button>
  • Vue:
    Vueは v-if,v-for,v-model,@click(v-on:clickの省略形) といったv-接頭辞を持つディレクティブを使ってHTML要素に動的な振る舞いを追加します。
<div v-if="images.length > 0">...</div>
<tr v-for="image in images" :key="image.id">...</tr>
<input type="text" v-model="searchParams.originalFilename">
<button @click="inputClear">検索条件をクリア</button>

リアクティビティ(状態の更新検知)

  • Svelte:
    Svelteでは、$stateで宣言された変数がリアクティビティのトリガーとなります。
    プロパティを直接変更するだけで変更を検知し、関連するUIを自動で更新します。
let searchParams = $state({
  originalFilename: undefined,
});
const inputClear = (): void => {
  searchParams.originalFilename = undefined;
};
  • Vue:
    Vueでは、reactiveで定義されたオブジェクトのプロパティ(例: searchParams.originalFilename)を変更すると、フレームワークがこれを検知して自動的に関連するテンプレート部分を更新します。
import { reactive } from 'vue';

const searchParams = reactive({
  originalFilename: undefined,
});

const inputClear = (): void => {
  // リアクティブオブジェクトのプロパティを変更すると自動で検知される
  searchParams.originalFilename = undefined;
};

まとめ

当初はSvelteを使いこなせるか不安だったものの、シンプルな書き方で学習コストが低く、Vueと近い感覚で記述できる部分も多く、予想以上に早く慣れたように感じます。
SvelteやVueに加え、最近ではRemixにも関心を持っており、いつかはこれらの技術も比較してみたいです🤔
技術選定はプロジェクトの特性やチームメンバーのスキル、時には個人の好みも影響するため、唯一の『正解』を見つけるのは難しいものです。今回の内容が皆さんの開発の一助となれば幸いです。

参考

Wanted!

BEENOSグループでは一緒に働いて頂けるエンジニアを強く求めております!
少し気になった方は、社内の様子や大事にしていることなどをThe BEENOSにて発信しておりますので、是非ご覧ください。

とても気になった方はこちらでも求人を公開しておりますので、お気軽にご応募ください!
「自分に該当する職種がないな...?」と思った方は オープンポジション としてご応募頂けると大変嬉しく思います🙌
世界で戦えるサービスを創っていきたい方、是非是非ご連絡ください!よろしくお願い致します!!

世界で戦えるサービスを創っていく

BEENOS Tech Blog

Discussion