Rails+Svelteによるフォームの作例

[Rails+Vue.jsによるフォームの作例](https://qiita.com/kazutosato/items/38caffdbd21508a5c126)、[Rails+Reactによるフォームの作例](https://qiita.com/kazutosato/items/b046ea203533af08acdb)の[Svelte](https://svelte.dev/)版を作ってみました。

サンプルプログラムはこちら。
https://github.com/kazubon/blog-rails6-svelte

Rails側の作り方や全体的なポイントはVue版と同じですので、[Vue版の記事](https://qiita.com/kazutosato/items/38caffdbd21508a5c126)を参照してください。

## 環境

- Rails 6.0、Webpacker 4.2、Svelte 3.18。
- 非SPA、Turbolinksあり。
- jQueryとBootstrapあり。

## Svelteの導入

作成済みのアプリケーションに導入するときは、次のコマンドで。Vueが入っている環境に上書きしたところ、問題なさそうでした。

```zsh
% bin/rails webpacker:install:svelte
```

Railsアプリケーションを作成するときに `--webpack=svelte` の指定もできます。こちらは未確認。

```zsh
% rails new myapp --webpack=svelte
```

## application.js

applicaiton.jsは、[Vue版](https://qiita.com/kazutosato/items/38caffdbd21508a5c126)、[React版](https://qiita.com/kazutosato/items/b046ea203533af08acdb)と同じように書けました。HTML要素をid属性で探して、対応するSvelteコンポーネントを `new App({ target: HTML要素, props })`のようにマウントします（この例では `new app.object` のところ）。

```javascript:app/javascript/packs/application.js
import "core-js/stable";
import "regenerator-runtime/runtime";

require("@rails/ujs").start();
require("turbolinks").start();

import EntryIndex from '../entries/index';
import EntryForm from '../entries/form';
import EntryStar from '../entries/star';
import Flash from '../flash';

document.addEventListener('turbolinks:visit', () => {
  if(window.svelteApp) {
    window.svelteApp.$destroy();
    window.svelteApp = null;
  }
});

document.addEventListener('turbolinks:load', () => {
  Flash.show();

  let apps = [
    { elem: '#entry-index', object: EntryIndex },
    { elem: '#entry-form', object: EntryForm },
    { elem: '#entry-star', object: EntryStar }
  ];

  let props = window.jsProps || {};
  apps.forEach((app) => {
    if($(app.elem).length) {
      window.svelteApp = new app.object({ target: $(app.elem)[0], props });
    }
  });
});
```

Turbolinksとの相性の問題があります。ブラウザーの「戻る」を使ったときに以前のSvelteアプリケーションが残る、というものです。そこで、`turbolinks:visit`イベントで既存のSvelteアプリケーションを削除します。

```javascript:app/javascript/packs/application.js
document.addEventListener('turbolinks:visit', () => {
  if(window.svelteApp) {
    window.svelteApp.$destroy();
    window.svelteApp = null;
  }
});
```

Svelteを埋め込むHTMLのテンプレートでは、propsに渡すためにグローバル変数jsPropsを作っています。

```erb:app/views/entries/edit.html.erb
<script>
var jsProps = <%= { entryId: @entry.try(:id) }.to_json.html_safe %>;
</script>
<div id="entry-form"></div>
```

## 編集ページのフォーム

記事の編集フォームだけかいつまんで紹介します。

### propsとstate

VueやReactのpropsに当たるものは、次のようにexportを使って記述します。

```javascript:app/javascript/entries/form.svelte
export let entryId;
```

.svelteファイルがJavaScriptに変換されるときは、ソース中の`<script></scirpt>` の内容も変換されます。exportは次のように変換されていました。

```javascript
function instance($$self, $$props, $$invalidate) {
    let { entryId } = $$props;
```

Vueのdata、Reactのstateに当たるものは、letで変数を作ります。


```javascript:app/javascript/entries/form.svelte
let entry = {
  title: null, body: null, tags: [], published_at: null, draft: false
};
let alert = null;
```

値を代入するところは、次のように変換されてました。

```javascript
$$invalidate(1, entry = res.data.entry);
```

### テンプレート

テンプレートはこんな感じです。特に難しいところはありません。いくつかリンクを貼っておきます。

[条件分岐](https://svelte.dev/docs#if) | [繰り返し](https://svelte.dev/docs#each) | [イベント](https://svelte.dev/docs#on_component_event) | [入力欄のバインディング](https://svelte.dev/docs#bind_component_property)


```app/javascript/entries/form.svelte
<div>
  <form on:submit|preventDefault={submit}>
    {#if alert}
      <div class="alert alert-danger">{alert}</div>
    {/if}
    <div class="form-group">
      <label for="entry-title">タイトル</label>
      <input type="text" bind:value={entry.title} id="entry-title"
        class="form-control" required maxlength="255" pattern=".*[^\s]+.*">
    </div>
    <div class="form-group">
      <label for="entry-body">本文</label>
      <textarea bind:value={entry.body} id="entry-body" cols="80" rows="15"
        class="form-control" required maxlength="40000"></textarea>
    </div>
    <div class="form-group">
      <label for="entry-tag0">タグ</label>
      <div>
        {#each entry.tags as tag}
          <input bind:value={tag.name}
            class="form-control width-auto d-inline-block mr-2" style="width: 17%"
            maxlength="255" >
        {/each}
      </div>
    </div>
    <div class="form-group">
      <label for="entry-published_at">日時</label>
      <input type="text" bind:value={entry.published_at} id="entry-published_at"
        class="form-control"
        pattern={publishedAtPattern}>
    </div>
    <div class="form-group mb-4">
      <input type="checkbox" bind:checked={entry.draft} id="entry-draft" value="1">
      <label for="entry-draft">下書き</label>
    </div>
    <div class="row">
      <div class="col">
        <button type="submit" class="btn btn-outline-primary">
          {#if entryId}更新{:else}作成{/if}
        </button>
      </div>
      {#if entryId}
        <div class="col text-right">
          <button type="button" class="btn btn-outline-danger" on:click={destroy}>削除</button>
        </div>
      {/if}
    </div>
  </form>
</div>
```

### そのほかのコード

そのほかのコードは、`<script></scirpt>` の間に関数を並べて記述します。コンポーネントの全体はこんな感じです。素晴らしい簡潔さです。これを思い付いた[Svelte作者](https://twitter.com/Rich_Harris)のセンスはすごい。

コンポーネントが初期化されたときに呼び出すものは、`<script></scirpt>` の直下に書けばいいだけです（この例では`getEntry()`）。

```app/javascript/entries/form.svelte
<script>
import Axios from 'axios';
import Flash from '../flash';

export let entryId;

let entry = {
  title: null, body: null, tags: [], published_at: null, draft: false
};
let alert = null;

let publishedAtPattern = '\\d{4}(-|\\/)\\d{2}(-|\\/)\\d{2} +\\d{2}:\\d{2}';

function fillTags(srcTags) {
  let tags = [];
  for(let i = 0; i < 5; i++) {
    tags[i] = srcTags[i] || { name: '' };
  }
  return tags;
}

function getEntry() {
  let path = entryId ? `/entries/${entryId}/edit` : '/entries/new';
  Axios.get(path + '.json').then((res) => {
    entry = res.data.entry;
    entry.tags = fillTags(entry.tags);
  });
}

function validate() {
  if(!(entry.body && entry.body.match(/[^\s]+/))) {
    alert = '本文を入力してください。';
    window.scrollTo(0, 0);
    return false;
  }
  return true;
}

function submit() {
  if(!validate()) {
    return;
  }
  let path = entryId ? `/entries/${entryId}` : '/entries';
  Axios({
    method: entryId ? 'patch' : 'post',
    url: path + '.json',
    headers: {
      'X-CSRF-Token' : jQuery('meta[name="csrf-token"]').attr('content')
    },
    data: { entry }
  }).then((res) => {
    Flash.set({ notice: res.data.notice });
    Turbolinks.visit(res.data.location);
  }).catch((error) => {
    if(error.response.status == 422) {
      alert = error.response.data.alert;
    }
    else {
      alert = `${error.response.status} ${error.response.statusText}`;
    }
    window.scrollTo(0, 0);
  });
}

function destroy() {
// 略
}

getEntry();
</script>

<div>
  <form on:submit|preventDefault={submit}>
<!-- 略 -->
  </form>
</div>
```

## イケていないところ

- <del>Firefoxでは、ページを表示した時に入力欄に赤い枠が出る（inputにrequiredを付けているため）</del> → entry.title などの初期値をnullにすることで解消。
- `<a href="#">` と書くとwarningが出る。
- 属性の値に `{` `}` が書けない（サンプルでは、変数publishedAtPatternで回避）。
- `$` が書けない（サンプルではjQueryで回避）。
- `{#if}...{:else if}...{/if}` という書式はダサいと思う。