🐥

SvelteとSapperを使ったWebアプリケーション開発

2020/12/04に公開

これはSupershipグループ Advent Calendar 2020 の4日目の記事です。Momentum株式会社の阿部が担当します。MomentumのWebアプリケーションで利用している Svelte と Sapper という2つのライブラリについて紹介します。

要約

  • Svelte と Sapper を簡単に紹介しました。とりあえず触ってみたいという人には参考になると思います。
  • Svelte や Sapper のすごいテクニックや内部設計や仕組みに関する情報はありません。
  • Svelte は良いものなので、新しいものが好きな人や Rich Harris さんが好きな人はぜひ触ってみてください

UI Components ライブラリ

最近クライアント向けの Web サービスを提供することになり技術選定を行いました。Rails や Django のようなモノリシックなフレームワークを使うことも考えましたが、このサービスに専属で関わることができる開発者が多くなかったためフロントエンドとバックエンドをきっちりと分けることにしました。どちらか一方に明るければ手を出しやすくなるようにシステムを構成することが狙いです。
フロントエンドはバックエンドサーバーと通信を行い HTML + CSS + JavaScript + DATA で画面のレンダリングを行います。バックエンドサーバーは要求によって必要な DATA をデータベースなどから集めてフロントエンドに返します。

構成を決めたので次はフロントエンドのUIライブラリを選択したいのですが、JavaScript の UI ライブラリは本当に沢山あってどれを選べば良いのか分かりません。
こちらの GitHub リポジトリにはさまざまな UI ライブラリのベンチマークを見ることができます。速ければ良いと言うものでもありませんが面白いです。

https://github.com/krausest/js-framework-benchmark

それぞれ得意分野が異なるので選択に迷います。

サンプル1: React

React の名前は良く聞きます。React のドキュメントを見ると以下のようなサンプルが見つかります。

reactjs.org/docs/add-react-to-a-website.html
<script src="https://unpkg.com/react@17/umd/react.development.js" crossorigin></script>
<script src="https://unpkg.com/react-dom@17/umd/react-dom.development.js" crossorigin></script>

<div id="like_button_container"></div>

<script>
'use strict';

const e = React.createElement;

class LikeButton extends React.Component {
  constructor(props) {
    super(props);
    this.state = { liked: false };
  }

  render() {
    if (this.state.liked) {
      return 'You liked this.';
    }

    return e(
      'button',
      { onClick: () => this.setState({ liked: true }) },
      'Like'
    );
  }
}

const domContainer = document.querySelector('#like_button_container');
ReactDOM.render(e(LikeButton), domContainer);
</script>

すごくシンプルで良さそうですし、コンポーネントとして使えるのは面白いです。Viewの部分が JavaScript で書く以外にも JSX と言うHTMLと良く似た書き方もできます。

サンプル2: Vue.js

もう 3 年くらい前になると思いますが、当時のぼくの周りの人たちは Vue.js を推していました。Vue.js のドキュメントを見ると以下のようなサンプルが見つかります。

vuejs.org/v2/guide/
<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>

<div id="app-5">
  <p>{{ message }}</p>
  <button v-on:click="reverseMessage">Reverse Message</button>
</div>

<script>
var app5 = new Vue({
  el: '#app-5',
  data: {
    message: 'Hello Vue.js!'
  },
  methods: {
    reverseMessage: function () {
      this.message = this.message.split('').reverse().join('')
    }
  }
})
</script>

よりシンプルに見えます。

Rect では RactDOM.render で明示的にDOMにレンダリングしているような文法に見えますが、Vue.js では HTML で DOM を定義して new Vue することで対象の DOM を Vue.js の管理対象にするようなイメージでしょうか。

State 管理

Web アプリケーションを作り始めると当然1つの画面では収まり切らず、さまざまなステートがいろんなところで参照されるような複雑な構成になります。それぞれの UI Component で自由に値を書き換えるようなことが発生するため、UI Component が増えれば増えるほど管理は大変になります。

React では早くから FLUX という考え方とライブラリを提供し React を使ったアプリケーションの作成手法を広めてきました。

https://github.com/facebook/flux

「けっきょくMVCだよね」みたいに揶揄する表現もあったりしますが、React とセットで広めたことの功績は大きいと思いますし、当時のSPA (Single Page Application) の開発に一石を投じたと思います。

FLUX はアーキテクチャであり FLUX を実現するためのライブラリが複数あります。ぼくが唯一使ったことがあるのが Redux です。

https://redux.js.org/

FLUX では State を保持する Store の数を規定していませんが、Redux では Store を1つだけ使うスタイルだったと思います。ActionとReducerを定義して宣言的に書けるのはとても好印象でした。ただ、当時は JavaScript で書いていたこともあり、ActionやReducerをたくさん書いているととても辛くなったのを覚えています。「DSLでさっと書いて生成するプログラムがあれば楽なのになあ」といつも思っていました。

Cocoa Binding

以前に趣味で macOS のアプリを作っていた時に Cocoa Binding というのを使っていました。リッチなUIが提供されていて、簡単に View と State を結びつけられるのは衝撃でした。iOS でも使えると良いのになあとずっと思っていたのですが、Swift UI に @Binding が導入されましたし、どうやら Cocoa Binding が導入されることはなさそうです。(内部実装は知りませんが)

Scoped CSS

UI Component を作っているとコンポーネント毎に CSS を書きたくなるケースが多いです。ぼくはBEMなどの複雑な命名規則が苦手なので、ライブラリが Scoped CSS をサポートしてくれていると助かります。

Virtual DOM

React が出た当初からよく聞かれたキーワードにVirtual DOM というのがあります。
https://reactjs.org/docs/faq-internals.html

Virtual DOM について詳しくないですが、表示されているDOMツリー (TREE1) とは別にDOMツリー (TREE2) を作っておいて Stateの変更などをキャッチしたら TREE2 に変更を反映してから TREE1 と TREE2 で差分をとって必要な部分だけ TREE1 を書き換える、みたいなテクニックという認識です。

差分とるところが大変そうです。

TypeScript

静的型付けなしに大きなプログラムを書くのは大変です。TypeScriptに対応していれば VSCode などのエディタのサポートを最大限に受けることもできます。

何を使うべきか?

React が情報も多いので、特に不満がなければ React を使うので良いのではないでしょうか?
一方で、ある程度ダウンロード数のあるようなライブラリであればどのようなものを使っても目的は達成できると思います。
保守性やモチベーションなどプロダクトにとって必要な要件が満たされれば、気に入ったものを使っても良いと思います。

https://github.com/gothinkster/realworld

Svelte

Svelte は rollup.jsbuble などでおなじみの Rich Harris さんが関わっているプロジェクトです。

https://svelte.dev/

今回のプロジェクトでは Svelte を使うことにしました。理由は以下です。

  • Rich Harris さんが作っている
  • 書き心地が良い
  • Scoped CSS に対応している
  • Store が標準で用意されている
App.svelte
<script>
let count = 0;

function handleClick() {
	count += 1;
}
</script>

<style>
button {
	background-color: white;
}
</style>

<button on:click={handleClick}>
	Clicked {count} {count === 1 ? 'time' : 'times'}
</button>

Svelteでは .svelte ファイルを編集してコンポネーントを作成します。HTML に {} を使ったテンプレート記法を追加したシンプルな構成です。イベントハンドラも on:click={handleClick} のように自然に書けます。<style> はコンポーネント内にのみ適応されるため、非常に気楽にスタイルを記述できます。

Svelte がどんな書き心地でどんな風に動くのかをぜひ Tutorial を試してみてください。

https://svelte.dev/tutorial/

Tutorial は REPL として実装されており、実際に画面を書き換えながら Svelte を試すことができます。また、コンパイル後の JS コードや CSS コードを見ることもできるので、実装に興味のある方にも参考になると思います。

Svelte は Runtime を読み込んでダイナミックにDOMツリーを生成するのではなく、予めコンパイルして実行時に読み込んで使うというちょっと変わった UI Component ライブラリです。このため、React や Vue.js のように runtime を <script> で読み込んで、HTML1ページで試す、みたいなことはできません。

環境を構築する

Svelte のビルドには rollup.js または webpack をベースに各種 plugin を組み合わせます。0 から rollup.config.js を書くのは辛いので degit を使ってテンプレートをダウンロードするのがおすすめです。

$ npx degit sveltejs/template my-svelte-project
$ cd my-svelte-project
$ npm install
$ npm run dev

これで svelte の開発が始められます。

.
├── package.json
├── public/
│   ├── index.html
│   └── build/
│       ├── bundle.js
│       └── bundle.css
├── rollup.config.js
└── src/
    ├── App.svelte
    └── main.js

App.svelte はコンポーネントですが、main.js はプログラムのエントリーポイントになります。

main.js
import App from './App.svelte';

const app = new App({ target: document.body });

export default app;

main.js では App コンポーネントを document.body に append します。

TypeScript

Svelte は関連する rollup plugin を導入することで TypeScript で記述できるようになります。 sveltejs/template には scripts/setupTypeScript.js が用意されており、簡単に TypeScript の環境を構築できます。

$ cd my-svelte-project
$ node scripts/setupTypeScript.js
$ npm install
$ npm run dev

<script><script lang="ts"> に書き換える必要があります。

svelteserver

Svelte では開発に便利なツールも用意されています。

https://github.com/sveltejs/language-tools

svelteserver は npm install -g svelte-language-server でインストールできます。 Svelte の Language Server として動作します。

VS Code であれば Svelte for VS Code extension で利用できると思います。もちろん Vim でも vim-lsp などを使って動かせます。

lsp#register_server({
\ 'name': 'svelteserver',
\ 'cmd': {server_info->['svelteserver', '--stdio']},
\ 'root_uri':{server_info->lsp#utils#path_to_uri(lsp#utils#find_nearest_parent_file_directory(lsp#utils#get_buffer_path(), 'package.json'))},
\ 'whitelist': ['svelte'],
\ })

Routing

Svelte の FAQ にはいくつか紹介されていますが、ここでは page.js を使ってみます。

以下のような構成で Home ページと About ページを作りたいとします。

src/
├── main.js
└── components/
    ├── App.svelte
    ├── Home.svelte
    └── About.svelte

App.svelte では page.js を使って Routing を書きます。

App.svelte
<script>
import page from 'page';
import Home from './Home.svelte';
import About from './About.svelte';

let component = Home;

page('/', () => component = Home);
page('/about', () => component = About);

page();
</script>

<svelte:component this={component}/>

page(path, callback) はパスに対するコールバックを定義することができます。パスによって component の値を変えることで <svelte:component> タグを使ってページを切り替えています。

これで page(path) のように呼び出せばパスを変更してコンポーネントを切り替えることができます。しかし、これだと <a> タグが使えませんし / 以外でリロードした時に 404 になってしまいます。

sveltejs/template で使っている sirv コマンドには SPA 用のオプションがあり、全てのパスで index.html を返してくれるようになります。 package.json の start を次のように変えます。

package.json
  ...
  "start": "sirv public --single"
  ...

npm run dev では内部で npm run start しているので、start を書き合えるだけで npm run dev でも利用できます。

Store

Svelte には標準で Store が用意されるため、Reduxなどの他のライブラリを使う必要がありません。また、Reducer や Action のようなものを使わずに、とても気楽に使うことができます。

Store の定義は以下のように簡単です。

stores.js
import { writable } from 'svelte/store';

export const user = writable({
  name: "Abe",
  org: "Momentum"
});

値を参照するには subscribe 関数を使います。

App.svelte
<script>
  import { user } from './stores.js';
  
  let name, org;

  user.subscribe(u => {
    name = u.name;
    org = u.org;
  });
</script>

<div> name: {name} </div>
<div> org: {org} </div>

特に .svelte ファイルであれば、以下のように $ を使ってアクセすることもできます。

App.svelte
<script>
  import { user } from './stores.js';
</script>

<div> name: {$user.name} </div>
<div> org: {$user.org} </div>

update 関数もあり、とても簡単に扱えます。詳しくは Tutorial を見てください。

CSS Framework

デザインができないため、Webページの見た目は CSS Framework に頼りたいところです。
DOMを扱うようなJSライブラリを使うときはいつも感じるのですが、複数の JS ライブラリがDOMを触っている場合、何らかの期待しない振る舞いが発生した時に自分のコードに問題があるのかいずれかのJSライブラリに問題があるのか原因を探すのにとても時間を費やします。Svelte ではアニメーションも一部実装されているので、変に凝ったことができるJSライブラリを入れるとぼくの技量では手に負えなくなることが想定されます。
過去の嫌な思い出もあり、CSS Framework にはJSライブラリに依存しない Bulma を気に入って使っています。

https://bulma.io/

カスタマイズしやすいですし、「CSS Framework はウェブサイト全体の統一感を出してまとめてくれるくらいで良い」みたいな人にはおすすめです。

Hosting

Svelte でビルドされたファイルは全て public ディレクトリに含まれているため、これを任意のHTMLをホストしてくれるサービスにアップロードすれば SPA として動作させることができます。
ただし、SPA では多くの場合HTMLファイルは index.html 1つであるため、Routing で作成したパスにダイレクトにアクセスされたり、index.html 以外で再読み込みされると実際のHTMLファイルがないため404エラーを返します。上で sirv の設定を行ったように、ファイルがないような場合に index.html を返すように設定できる柔軟な仕組みがあれば良いのですが、例えば Google Cloud Storage ではそのような機能はありません。
SPAで Web アプリケーションを作成する際には、ファイルをホストする先で対応できるかを確認しておく必要があります。

Sapper

Svelte はぼくがUI Componentに求めるものをほとんど提供してくれました。page.js も使いやすく、シンプルな構成にするならばこれで十分だと思います。

しかしながら、実際に Svelte で開発していると以下のような問題に直面しました。

  1. ディレクトリ構成をどうするか悩ましい
  2. .svelte ファイルを複数に分けたくなった場合に <meta> などの共通項をどうやって共有するか (コンポーネントではちょっとやりにくい)
  3. ブラウザがトップ以外のURLにアクセスした場合にどうするか。(<a> が使えないのも辛い)

1 は自由に配置すれば良いのですがファイルが増えた時や共通で使うコンポートが出てきて共有する必要が出てくるなど、配置をやり直したりすることがままありました。この辺は Rails のように決まったルールがあった方が、自分もそうですが他のメンバーも開発しすいと感じました。

2 の解決策はあまり良いものが浮かばず、シンプルに .svelte のベースをコピーするような方法で複数ファイルを作っていました。

3 が決定的に辛く、自分でパスに合わせてディレクトリをほって、index.html をあちこちにコピーすることで対応は可能だったのですが、パスやファイルが多くなるに連れて辛くなりました。

何か良いものはないのかと探していたらSvelteオフィシャルに Sapper というものがあることに気づきました。

https://sapper.svelte.dev/

Sapper は React の Next.js や Vue.js の NuxtJS のようなものです。ぼくはどちらも使ったことがないのでピンときていませんし、Sapper 含め3者にどのような違いがあるのかも分かっていません。ただ、以下のような Sapper の機能が気に入っています。

  • ファイルを routes/ 配下に公開したいURLに合わせて配置するだけで、良い感じにビルドしてくれる
  • _layout.svelte で共通項を簡単に共有できる
  • _layout.svelte<slot> がサイドバーなどを表示するときにとても便利
  • GCSなどでホストするのに必要なファイルを全てExportしてくれる

環境を構築する

Sapper は Svelte よりも一段複雑になっており、Svelte ファイルのビルドに独自の sapper コマンドを使います。Svelte と同様に 0 から設定ファイルを描くのは難しいので degit でテンプレートをダウンロードします。

$ npx degit "sveltejs/sapper-template#rollup" my-sapper-app
$ cd my-sapper-app
$ npm install
$ npm run dev

Svelte と同様にテンプレートを使えばとりあえず動かすのは簡単です。また、Sapper のテンプレートにも scripts/setupTypeScript.js が用意されているため、TypeScriptでの開発も問題なく始められます。

Sapper では Svelte ともだいぶ構成が変わります。

├── package.json
├── __sapper__/
│   └── dev/
├── rollup.config.js
└── src/
    ├── node_modules/
    │   └── @sapper/
    ├── client.js
    ├── server.js
    ├── template.html
    └── routes/
        ├── _layout.svelte
        ├── index.svelte
        └── service1/
            ├── _layout.svelte
            ├── index.svelte
            ├── save.svelte
            └── service1/

rollup.config.js が変わらずありますが、これは rollup コマンドではなく sapper コマンドによって参照されます。

Svelte の時にはなかった __sapper__/dev ディレクトリがありますが、これは sapper dev した際にビルドされたファイルが格納され、Webサーバーに参照されます。

src/ 配下にずいぶんとファイルが増えています。client.js は Svelte の main.js のような役割だと思います。Sapper のコードを読んだわけではないので外しているかもしれません。server.js はビルドしたファイルをホストします。Webサーバーとして polka を使ってますが、これは express などでも良いようです。Svelte ではシンプルに sirv コマンドを実行してファイルをサーブしていましたが、Sapper ではいろいろやることがあるようで、ファイルのサーブも Sapper 自身が行います。

src/template.html は Svelte を書いている時に欲しかった <meta> などの共通項を記載できる HTML ファイルです。Sapper のテンプレート記法が書かれており、ページ毎に中身が変わります。

src/routes 配下はURLのパスと同じ階層になります。例えば http://localhost:3000/service1/ にアクセスすると src/routes/service1/index.svelte が参照されます。また、各 _layout.svelte はそのディレクトリで共通の項目をまとめることができるので、パス毎にページの構成が異なるような場合も書きやすいです。

src/node_modules

Sapperを始めた頃に src 配下に node_modules が作られてとても気持ち悪かったです。これは、ぼくが Node.js 初級者であるため、package.json と node_modules ディレクトリは npm のためにあるものだと思い込んでいたためです。このため、一方だけあるのがとても気持ち悪かったです。また、node_modules ディレクトリが知らない間に src 配下に作られるのもすごく嫌でした。
このように感じたのはぼくだけではなかったようで、非常に長いディスカッションが行われました。
https://github.com/sveltejs/sapper/issues/551#issuecomment-647183491

Rich Harris さんの説明と Node.js のドキュメントを読んで、ぼくは次のように理解して納得しています。

  • node_modules は Node.js のライブラリサーチパスに関する仕様であり、npm はこの仕様を活用しているだけである。当然、個々人がこの仕様を利用することは問題ない。
  • Node.js には node_modules の他に妥当なサーチパスを追加する方法がない (NODE_PATH は古い手法で node_modules の使用が進められている ref)
  • node_modules 配下はどんなに深い階層からでも参照できるため routes ディレクトリのように階層が深くなりやすいケースでも import が書きやすい
  • src/node_modules/@sapper には Sapper で利用する自動生成されたコードが入っている。このため import {goto} from "@sapper" のように、src 配下であればどこからでも簡単に Sapper の機能を利用することができる(以前は __sapper__ ディレクトリにあったため、参照するのがとても大変だった)
  • src/node_modules/app に自身のコードを保存すれば routes 配下からでも簡単にアクセスできる

Export

Sapper では sapper export --entry / のように実行すると、 / から辿って全てのパスに対してアクセスしてファイルを出力してくれる機能があります。ファイルは標準では __sapper__/export 出力されます。全てのパスについてファイルを出力するので、SPA の 404 エラーの問題もありません。そのまま GCS 等にアップロードすることで、ローカル環境と同様にアクセスできるサイトをデプロイできます。

Sapper のうまく理解できていないところ

Sapper は Session を管理する機能が用意されており、Node.js を動かせる環境であれば Sapper の枠組みの中で各ページ間で State を共有できます。ただし、GCS などで静的ファイルをホストするような

Sapper の嫌いなところ

概ね納得している Sapper ですが1点だけ気に入らないところがあります。導入してしまえば全部良い感じにしてくれるSapperなのですが、rollup.config.js に rollup -c で処理できない記述をするのは気に入りません。 sapper.config.js にでもすれば良いと思うのですが。
あと、Sapperは何も考えずに使うのにとても良いのですが、、ビルドされたコードは読む気がおきない。


Svelte と Sapper による Web アプリケーション開発について見てきました。Svelte と Sapper は日本語の情報は多くないですが、精力的に開発されていて十分に信頼できてとても使いやすいフレームワークだと思います。特に Sapper を使えば面倒な構成を考える必要もなく、直ぐにWebアプリケーションの開発を始めることができます。

導入の簡単な部分にしか触れることはできませんでしたが、Svelte や Sapper に興味を持ってくれる人が1人でも増えてくれたら嬉しいです。

Discussion