やりたいこと

2025年現在、web業界は何らかのフレームワークにのっとった開発が多いだろう。 そんな中でフレームワークなしのWebページに部分的にsvelteを適用する必要が出てきたので方法をご紹介する。 ページ全体をsveltekitで一から作る情報は公式ドキュメントも含め豊富にあるが、既に出来上がったページに部分的に適用する方法は意外と少ない。 例として、input要素を1つもち、そこへの入力内容に応じてspan要素が変化するページがあるとする。 このinput要素とspan要素をsvelteコンポーネントで置き換えることを考えてみよう。

ディレクトリ構成とソース

まずはsvelte適用前のプロジェクトを作る。viteでビルドを行うように下記のディレクトリ構成で開発をしたと仮定する。

top-|-index.html
    |-src-main.ts
    
indexとmainの内容は下記。

index.html

<!doctype html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>progressive svelte</title>
  </head>
  <body>
    <form name="titleInput">
      input title: <input id="titleInput" type="text" maxlength="20" /><br>
      title: <span id="title"></span>
    </form>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

main.ts

class App {
  title: string = ''
  input = document.getElementById('titleInput') as HTMLInputElement
  output = document.getElementById('title') as HTMLElement

  constructor () {
    this.input.addEventListener('input', this.onInput, {passive: true})
  }

  onInput = (event: InputEvent) => {
    this.title = this.input.value
    this.updateUI()
  }

  updateUI = () => {
    document.title = this.title
    this.output.innerText = this.title
  }
}

globalThis.app = new App()

ビルド

viteとtypescriptは以下の要領でインストールしておく。

        
    npm i -D vite typescript
        
    
また、packages.jsonのscriptsに
        
    "dev": "vite"
        
    
と記述して、npm run devでテストサーバーが起動できるようにしておく。

内容は単純で、input要素にイベントリスナを設定して入力に対して変化するようにしてある。 動作テストはこちら。 このサンプルの事例のように入力と出力が1:1だとさほど苦労はないが、入力に対して変化させなければならない表示が多数になるといかにも苦労しそうである。 これらの動作をsvelteを使って換装しようというわけだ。

svelteを適用する

ライブラリの追加

まずは必要なライブラリを追加する。 typescript, viteと併せて使うので下記のように追加。 vite以外でビルドしている場合はそれに応じたプラグインを入れる。


npm i -D sveltejs/vite-plugin-svelte tsconfig/svelte svelte svelte-check

各種設定ファイル

各種設定ファイルをトップディレクトリに配置する。このへんはほぼ定型文なのでsveltekitで適当に新規プロジェクトを作ってとってきても良いだろう。

vite.config.json

import { defineConfig } from 'vite'
import { svelte } from '@sveltejs/vite-plugin-svelte'

// https://vite.dev/config/
export default defineConfig({
  plugins: [svelte()],
})

svelte.config.js

import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'

/** @type {import("@sveltejs/vite-plugin-svelte").SvelteConfig} */
export default {
  // Consult https://svelte.dev/docs#compile-time-svelte-preprocess
  // for more information about preprocessors
  preprocess: vitePreprocess(),
}

index.html

inputとspan要素をsvelteコンポーネントで置換するために以下のようにidを割り振る

<!doctype html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>progressive svelte</title>
  </head>
  <body>
    <form name="titleInput">
      input title: <span id="titleInput"></span><br>
      title: <span id="title"></span>
    </form>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

コンポーネントの作成

top-src-libに以下のコンポーネントを置く。それぞれ入力のinput要素と出力のspan要素を生成するsvelteコンポーネントである。

TitleView.svelte

まずは簡単なspan要素から

<script lang="ts">
    let title = $state('untitled')
    const {store} = $props()
    store.subscribe((v: string) => title = v)
</script>

<span>{title}</span>

scriptの箇所を簡単に解説すると、titleという変数に対して画面更新がかかるように$stateを使って宣言し、 titleはpropsに保存されたstoreを購読する、となっている。 storeは後述するsvelteコンポーネントが反応するデータを格納する共通領域である。 今回はtitleの文字列1個のデータしか保存されていないのであまりありがたみはないが、複雑なオブジェクトを置くと管理の楽さがお分かりいただけると思う。 そこに保存されている値をもとにtitleを更新し、titleが更新されると画面が書き換わる仕組みである。

TitleInput.svelte

<script lang="ts">
    let title = $state('untitled')
    const {store} = $props()
    store.subscribe((v: string) => title = v)
</script>

<input bind:value={
    () => title,
    (v: string) => store.set(v)
} maxlength="10" />

こちらは出力だけでなく入力も扱うinput要素を生成する。 先ほどと同様にtitleを$stateを使って宣言し、storeを購読してtitleが更新されるようにしている。 ただし、inputでユーザーが入力したときのイベントを扱わなければならないので その処理をbind:value以下に記述してある。

bind:value=の一行目がtitleが更新されたらそのまま表示せよ、という意味で、 2行目以降は入力が変更されたらstoreの内容を更新せよ、という意味である。 そしてsotreの内容が更新されると自分自身も含め、それを購読しているすべてのコンポーネントが更新される。 先ほど定義したTitleView.svelteも更新がかかってくれるだろう。

main.ts

main.tsは先ほど定義したsvelteコンポーネントをimportし、moutメソッドでhtml要素と置換する。 このとき、propsにstoreを渡しておけば各コンポーネントが参照するようになる。 storeの定義はwritableで宣言するだけで良い。

import { mount} from 'svelte'
import { writable } from 'svelte/store'
import TitleInput from './lib/TitleInput.svelte'
import TitleView from './lib/TitleView.svelte'

export class App {
  title = writable('untitled')

  constructor () {
    const input = document.querySelector<HTMLInputElement>('#titleInput')
    if (input) {
      mount(TitleInput, {
        target: input,
        props: { store: this.title }
      })
    }  
    const output = document.querySelector<HTMLElement>('#title')
    if (output) {
      mount(TitleView, {
        target: output,
        props: { store: this.title }
      })
    }
  }
}

globalThis.app = new App()


子や孫要素が多くなってくるとpropsで引き渡して逆に辿っていくのが面倒になるかもしれない。 これはprop drillingと呼ばれるこの手のライブラリユーザーの間では有名な問題だ。 あまりにも階層が深い場合は直接storeを渡す方が良い。 その場合propsではなくstoreを定義したファイルをimportするか、またはcontextを使うことになる。 公式のサンプルはstoreを定義した別ファイルをimportする方法を使っているので、この方法が一番推奨なのかもしれない。

以上で完成。テストページはこちら