2018年04月11日

Svelte で依存のないコンポーネントを作る

特定の UI ライブラリへの依存をなくしたい

アプリのフロントエンドを作っていると、どんなアプリでも使うようなコンポーネントが繰り返し出てくることに気がつきます。例えば、日付入力をカレンダーで選択させるようなコンポーネントは、日付入力機能をもつすべてのアプリで使用したいです。こういった汎用的なコンポーネントはだいたいライブラリが世の中にあるので、それを使うことが多いですが、最近よくあるのが、jQuery に依存しているから使えない (使いたくない) というケースです。最近のプロジェクトではモダンな UI ライブラリ (Vue とか React など) を使い、jQuery を使っていないことが多いので、そこにさらに jQuery 依存のコンポーネントを入れてしまうと、読み込むスクリプトサイズが大きくなってしまうのが主な理由です。

flatpickr の動作例
例えばこういうのを使いまわしたい (flatpickr の動作例)

UI のコンポーネントはまだまだ jQuery 依存のものが圧倒的に多く、そうでないコンポーネントを探そうとすると見つからなかったり、機能が十分ではない場合がよくあります。各 UI ライブラリ向けのコンポーネントも作られてはいますが、まだまだ数が多くないのが現状です。また、別の UI ライブラリ依存のコンポーネントがあったとして、それが使われなくなったときに jQuery コンポーネントのような状況になるのではないかという不安もあります。

このようなことから、UI ライブラリ非依存のコンポーネントを作っていくのが望ましいと考えるのですが、UI ライブラリを使わないと実装がつらいという面もあります。標準仕様で WebComponents がありますが、WebComponents はどちらかというと低レイヤーの機能を提供しているので、実装のつらさはあまり変わらないというのが筆者の印象です。

このような問題を解決するために、筆者は最近 Svelte に注目しています。この記事では、Svelte とは何なのか、なぜ上記のような課題を解決できるのか、といった点から、実際に Svelte をつかったコンポーネントの作り方とそれを npm に公開する方法を説明します。また、作ったコンポーネントを別の UI ライブラリで使う方法についても触れます。

Svelte とは?

本記事の解説は Svelte v1.60.0 時点のものです。v2 以降はテンプレートの構文など、変更されているものがあるのでご注意ください。

Svelte は UI ライブラリの 1 つですが、最大の特徴は、作成したコンポーネントがコンパイルによってライブラリ依存のない JavaScript ファイルに変換されるという点です。

Svelte のコンポーネントは HTML ファイルとして書かれ、その中に <script><style> も含めます。以下はコンポーネントの簡単な例です。

<!-- Mustache 記法で JS 内のデータを参照 -->
<h1>{{ message }}</h1>

<script>
  export default {
    data() {
      return {
        // コンポーネントの初期データ
        message: 'Hello World!'
      }
    }
  }
</script>

<style>
  h1 {
    font-size: 24px;
  }
</style>

おそらく構文は Vue の影響を強く受けているので、Vue を使ったことがある人にはとっつきやすいのではないかと思います。ただし、細かい部分の挙動が異なるので注意が必要です。

出力されるファイルのサイズ

ライブラリ依存のないファイルが吐き出されるとはいえ、そのファイルが外部ライブラリを読み込むのと変わらない量のコードを含んでいたら意味がありません。実際に使うとすれば、生成されるコンポーネントのサイズは一般的な UI ライブラリのサイズよりも小さくなるべきです。Svelte の公式サイトでは Svelte turns your templates into tiny, framework-less vanilla JavaScript と書かれています。小さなコードに変換すると書かれていますが、どれだけ小さいのでしょうか?

例えば筆者が作成したカレンダーコンポーネント (コードデモ) のコンパイル後のサイズを計測してみたところ、3.71 KB (minify + gzip 後サイズ、CSS は除く、以下同様) でした。UI ライブラリの中ではかなり小さいと言われている Riot.js のサイズが 10.71KB であることを考えると、十分に小さいと言えるのではないかと思います。

Svelte の機能

Svelte にはコンポーネントを作るのに十分な機能が備わっています。以降で Svelte の基本的な機能とその使い方を紹介します。

コンポーネントのインスタンス

HTML として定義されたコンポーネントを import すると、コンポーネントのコンストラクタを取得することができるので、それを new するとインスタンスを得ることができます。

// 上記のコンポーネントを読み込む
import Hello from './Hello.html'

// コンポーネントのインスタンスを生成
const component = new Hello({
  // コンポーネントをマウントする DOM 要素を指定
  target: document.querySelector('#app')

  // コンポーネントのデータを指定する
  data: {
    message: 'Foo'
  }
})

コンポーネントのデータは直接プロパティアクセスすることはできないので、get メソッドで取得し set メソッドで更新します。

// 上記の例に追記する

// `message` を取得
const message = component.get('message')

// `message` を更新
component.set({
  message: message + 'Bar'
})

コンポーネントで発生したイベントを監視するには on メソッドを使います。イベントの発火自体は fire メソッドで行うことができます。

// 上記の例に追記する

// `hello` イベントが発生したらコールバックを実行する
component.on('hello', event => {
  console.log(event) // -> world
})

// 'hello' イベントを発火する
component.fire('hello', 'world')

最後に、もう使用しないコンポーネントを削除したい時には destroy メソッドを使います。

// 上記の例に追記する

component.destroy()

最近は単方向データフローにするのが良いと言われていますが、Svelte コンポーネントにもそれができるだけの機能が備わっています。具体的には set メソッドでデータの入力、on メソッドでデータの出力の検知を行うことができます。例えば、以下のコンポーネントはテキスト入力を単方向のデータの流れにしています。

<!-- それぞれの構文の詳細な意味は後の節で説明します -->
<input value="{{ value }}" on:input="fire('input', event.target.value)">

上記のコンポーネントを使う側は以下のようなコードになります。

import TextInput from './TextInput.html'

const textInput = new TextInput({
  target: document.querySelector('#app'),
  data: {
    // 初期値として入力欄に `Foo` を表示
    value: 'Foo'
  }
})

// データの入力 - 入力欄が `Bar` になる
textInput.set({
  value: 'Bar'
})

// ユーザーがテキストを編集した時、'input' イベントが発生する
textInput.on('input', value => {
  console.log(value)
})

上記のコンポーネントは value データを持ち、それを <input> 要素の値にバインディングしています。valueset メソッドを使うことで更新することができ、これがコンポーネントへの入力となります。コンポーネントでは <input> 要素に値が入力されるたびにコンポーネントの input イベントを発火しており、このイベントは on メソッドで検知することができます。イベントには任意のデータも付与することができ、コールバックの引数から取得することができるため、コンポーネントの出力として使うことができます。

コンポーネント定義

Svelte では HTML 内に Mustache 記法でコンポーネントのデータをバインディングすることができます。例えば、以下の例は <h1> 要素内にコンポーネントのデータ message をバインディングしています。

<!-- message の内容が表示される -->
<h1>{{ message }}</h1>

Mustache は属性の値に対しても使うことができます。以下は <input> 要素の value 属性に foo というデータをバンディングしてます。

<input value="{{ foo }}">

データによって要素を切り替えたい場合には If blocks を使います。

{{#if message === 'Foo'}}
  <!-- message が Foo の時に表示される -->
  <p>message is Foo</p>
{{else}}
  <!-- message が Foo ではない時に表示される -->
  <p>message is not Foo</p>
{{/if}}

繰り返し要素を定義する時には Each blocks を使います。

<ul>
  <!-- list の中の各項目を item としてループする -->
  {{#each list as item}}
    <!-- 各 item を表示 -->
    <li>{{ item }}</li>
  {{/each}}
</ul>

テンプレート内の要素にイベントが発生したときになんらかの処理を行うには、on:[event] ディレクティブを使用します。[event] の部分に監視したいイベント名を書きます。値の部分にはコンポーネント内のメソッドの実行を記述します。以下はテキストフィールドに入力があるたびに onInput メソッドを実行しています。コンポーネントに methods プロパティを書くことで、コンポーネントのメソッドを新たに定義することができます。

<!-- input イベントが発火したら onInput メソッドを実行 -->
<input on:input="onInput(event)">

<script>
  export default {
    // onInput メソッドを定義
    methods: {
      onInput(event) {
        console.log(event.target.value)
      }
    }
  }
</script>

スコープ付きスタイル

コンポーネントの <style> ブロックに書かれた CSS はすべてコンポーネントの外部に影響を及ぼさない形に変換されます。具体的には、以下のような CSS が書かれているとき:

<style>
  .title {
    font-weight: bold;
  }
</style>

以下のような CSS に変換されます。一意なクラス名を付与することで、外部へのスタイルの適用を防いでいます。

.title.svelte-a0unpp {
  font-weight: bold;
}

Svelte コンポーネントを npm に公開する

Svelte コンポーネントを npm に公開する時は、依存のない状態にしたいので、コンパイル後の JS ファイルを公開します。そのようなビルド設定をすでにしてあるボイラープレートを作ってあります。

template-svelte

このボイラープレートをクローンし、npm install を実行した後、npm run build を実行することでコンポーネントをコンパイルします。コンパイル後の JS ファイルと、CSS ファイルは dist ディレクトリ以下に出力されます。その後 npm publish をすることで、dist 以下のコードを npm に公開することができます。(もちろんパッケージ名などの情報は適宜編集してください)

UI ライブラリへのバインディング

作ったコンポーネントを他の UI ライブラリで使う時、直接インスタンスを操作することもできますが、別途アダプターを書いて、その UI ライブラリにおいて自然な書き方で書くことができるようにすると良いでしょう。

筆者はすでに Vue 向けのアダプターを書いていて、まるで Vue のコンポーネントを扱うように Svelte のコンポーネントを扱うことができるようにしています。

vue-svelte-adapter

使い方ですが、例えば以下のような、ボタンを押すとカウントを増やす Svelte コンポーネントがある時:

<p>{{ count }}</p>
<button on:click="fire('inc')">+</button>

これを Vue コンポーネントに変換する処理を以下のように書きます。

// 上記のコンポーネントを読み込む
import Counter from 'your-svelte-counter'

// アダプターを読み込む
import { toVue } from 'vue-svelte-adapter'

// Counter を Vue のコンポーネントにする
export default toVue(Counter, {
  // Svelte には props がないので、ここで別途定義する
  props: {
    count: Number
  }
})

上記の変換後のコンポーネントは以下のように Vue のコンポーネントで使用することができます。

<template>
  <div>
    <!-- Svelte コンポーネントの count に値を指定し、inc イベントを監視する -->
    <Counter :count="value" @inc="inc(1)" />
  </div>
</template>

<script>
// 上記の変換後のコンポーネントを読み込む
import Counter from './Counter'

export default {
  components: {
    Counter
  },

  data() {
    return {
      // カウンターに渡す値
      value: 1
    }
  },

  methods: {
    // カウンターの inc イベントごとに呼ばれる
    inc(amount) {
      this.value += amount
    }
  }
}
</script>

まとめ

UI ライブラリ非依存のコンポーネントを作る手段として Svelte を紹介しました。Svelte によって出力されるコードは十分に小さく、様々なアプリで使いまわしやすいのではないかと思います。Svelte 自体の機能も欲しいものはだいたい網羅されており、コンポーネントの実装でつらさを感じることはあまりなさそうです。

Svelte は開発が活発で、この記事で紹介していないものでは CustomElements、SSR、状態管理のサポートなどがあり、今後もさらに進化していくことが期待されます。筆者はまだ小さなコンポーネントを作るためにしか使っていませんが、最近 Svelte でマストドンのクライアントアプリを作ったという記事が出てきており、そのうちアプリ開発にも広く使われる時がくるのかもしれません。

RELATED POSTS