Rails+Vue.jsによるフォームの作例、Rails+Reactによるフォームの作例のSvelte版を作ってみました。
サンプルプログラムはこちら。
https://github.com/kazubon/blog-rails6-svelte
Rails側の作り方や全体的なポイントはVue版と同じですので、Vue版の記事を参照してください。
環境
- Rails 6.0、Webpacker 4.2、Svelte 3.18。
- 非SPA、Turbolinksあり。
- jQueryとBootstrapあり。
Svelteの導入
作成済みのアプリケーションに導入するときは、次のコマンドで。Vueが入っている環境に上書きしたところ、問題なさそうでした。
% bin/rails webpacker:install:svelte
Railsアプリケーションを作成するときに --webpack=svelte
の指定もできます。こちらは未確認。
% rails new myapp --webpack=svelte
application.js
applicaiton.jsは、Vue版、React版と同じように書けました。HTML要素をid属性で探して、対応するSvelteコンポーネントを new App({ target: HTML要素, props })
のようにマウントします(この例では new app.object
のところ)。
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アプリケーションを削除します。
document.addEventListener('turbolinks:visit', () => {
if(window.svelteApp) {
window.svelteApp.$destroy();
window.svelteApp = null;
}
});
Svelteを埋め込むHTMLのテンプレートでは、propsに渡すためにグローバル変数jsPropsを作っています。
<script>
var jsProps = <%= { entryId: @entry.try(:id) }.to_json.html_safe %>;
</script>
<div id="entry-form"></div>
編集ページのフォーム
記事の編集フォームだけかいつまんで紹介します。
propsとstate
VueやReactのpropsに当たるものは、次のようにexportを使って記述します。
export let entryId;
.svelteファイルがJavaScriptに変換されるときは、ソース中の<script></scirpt>
の内容も変換されます。exportは次のように変換されていました。
function instance($$self, $$props, $$invalidate) {
let { entryId } = $$props;
Vueのdata、Reactのstateに当たるものは、letで変数を作ります。
let entry = {
title: null, body: null, tags: [], published_at: null, draft: false
};
let alert = null;
値を代入するところは、次のように変換されてました。
$$invalidate(1, entry = res.data.entry);
テンプレート
テンプレートはこんな感じです。特に難しいところはありません。いくつかリンクを貼っておきます。
条件分岐 | 繰り返し | イベント | 入力欄のバインディング
<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作者のセンスはすごい。
コンポーネントが初期化されたときに呼び出すものは、<script></scirpt>
の直下に書けばいいだけです(この例ではgetEntry()
)。
<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>
イケていないところ
-
Firefoxでは、ページを表示した時に入力欄に赤い枠が出る(inputにrequiredを付けているため)→ entry.title などの初期値をnullにすることで解消。 -
<a href="#">
と書くとwarningが出る。 - 属性の値に
{
}
が書けない(サンプルでは、変数publishedAtPatternで回避)。 -
$
が書けない(サンプルではjQueryで回避)。 -
{#if}...{:else if}...{/if}
という書式はダサいと思う。