来るかも知れないSvelteの最小ハンズオン

[TechConnect！2022年2月のリレー記事](https://qiita.com/hs-lis/items/69eee14fd7bec92a854f)です。
[リンク情報システム](https://www.lis.co.jp/)のengineer.hanzomon のグループメンバでリレーしています（[Facebook](https://ja-jp.facebook.com/lis.co.jp/)はこちら）。

---

少し古いWEB+DB PRESS（Vol.122 2021年5月）を見たらSvelte（スヴェルト）というフロントのフレームワークが載っていました。
その記事があったことをサッパリ覚えていなかったのですが、ちょいと調べてみると、JavaScriptの利用動向に関する年次調査 [The State of JavaScript Survey](https://2021.stateofjs.com/en-US/libraries/front-end-frameworks/)にて、2021年はSolidとSvelteが90%の満足度であり、ReactとVueを抜いています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/272860/b043edcd-dc3c-7854-63a8-bb50923250de.png)

#### Svelteの所感
- 公式が言っているとおり、ビルド、ホットリロードが速いです。
- コード量も確かに少なくて済む感じがした。
- コード記述は、Vueと比較して、より直感的と感じました。

そんなSvelteを勉強がてら、ハンズオンで使えるような記事を書いてみました。

####作るもの
SvelteKitを使って簡易なTODOアプリを作っていきます。
（SvelteKitは、VueでいうところのNuxtにあたるもの）

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/272860/f5266654-55d0-e0b6-59ba-dba767b264c8.png)

---

### バージョン
- svelte 3.46.4
- sveltekit	1.0.0-next.260

### 前提
- SveleteとSveleteKitの学習に重きをおいて、CSSは完全排除。
- Svelte、SvelteKitの多くの機能を説明するハンズオンではないです（ストアやモーション等々）。
- 入力チェック等々は範囲外としています。

##1. プロジェクト作成
SvelteKitのプロジェクトを作成します。
nodeのバージョン変更は[nodist](https://github.com/nullivex/nodist/releases)で行っています。

```
nodist 14.15.4
nodist npm match

npm init svelte@next svelte-handson

# 構築オプション
√ Which Svelte app template? » Skeleton project
√ Use TypeScript? ... No
√ Add ESLint for code linting? ... Yes
√ Add Prettier for code formatting? ... Yes

cd svelte-handson
# ライブラリインストール
npm install
# 起動
npm run dev -- --open
```

` npm run dev ` に ` --open `（または`-o`） オプションを付けることで、自動でブラウザが起動、画面表示が行われます。

正常に起動すると、この画面が表示されます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/272860/6c4f5c30-ef2b-77c9-3bf4-edbbbc3af2d1.png)

##2. 自動生成されたファイルを見てみる
VSCodeでsvelte-handsonフォルダを開いてみましょう。
初期状態は以下のフォルダ、ファイル構成になります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/272860/e26fb45f-8d16-05b1-5840-d9016d66345c.png)
`src`には、ページやコンポーネントを格納します。
`static`は、静的な画像などを配置する場所になります。

`/svelte.config.js`を覗いてみましょう。

``` js:/svelte.config.js
import adapter from '@sveltejs/adapter-auto';

/** @type {import('@sveltejs/kit').Config} */
const config = {
	kit: {
		adapter: adapter()
	}
};

export default config;
```
SvelteKitの設定は`svelte.config.js`に記載することになります。

`import adapter`の箇所はデプロイ先に合わせたビルド物の生成を制御します。
今回はローカル実行なので、よきに計らってくれる`@sveltejs/adapter-auto`のままにします。

他に以下のようなアダプタがあります。

- Netlify（adapter-netlify）
- Vercel（adapter-vercel）
- SSR向け（adapter-node）
- SSG向け（adapter-static）

SPAについては、fallbackを設定することでSPAになるそうです。
https://github.com/sveltejs/kit/tree/master/packages/adapter-static


次に`/src/routes`の中を見てみます。

- `/src/routes/index.svelte`が最初に表示されているページです。
- `/src/routes`にページやエンドポイントを作成していくことになります。
- エンドポイントも`/src/routes`に作っていくことになります。
- 巷のサンプルでは、フロントのページ系ファイルと、サーバサイド系のファイルが同一フォルダに配置さているものが多いようですが、整理が悪い感じがするので、このハンズオンでは、サーバサイド系は`/src/routes/api`に配置していきます。

##3. 追加ボタン
トップページに「追加」ボタンを追加してみます。
ついでに見出しも「TODO」に変更します。

``` html
<h1>TODO</h1>
<a href="/todo/add">追加</a>
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/272860/7e5424d2-f87e-b2e8-608d-c95028b96063.png)

##4. 追加ページの作成
`/src/routes/todo/add.svelte`を新規作成します。

```html:/src/routes/todo/add.svelte
<script>
	import { goto } from '$app/navigation';
	import * as api from '$lib/api_client.js';
	let todo = '';

	// TODOを新規登録する
	async function submit(event) {
		await api.post('/api/todo', { todo });
		goto('/');
	}
</script>

<h1>TODO追加</h1>

<form on:submit|preventDefault={submit}>
	<input type="text" placeholder="TODO" bind:value={todo} />
	<br />
	<button> 追加 </button>
</form>
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/272860/0e9dbb7a-a3f6-09d9-09b1-78daaa42f2f0.png)

####追加ページの説明

```html:/src/routes/todo/add.svelte（JS部）
<script>
	import { goto } from '$app/navigation';
	import * as api from '$lib/api_client.js';
	export let todo = '';

	// TODOを新規登録する
	async function submit(event) {
		await api.post('/api/todo', { todo });
		goto('/');
	}
</script>
```

`$app/navigation`は、SvelteKitの組込みモジュールです。
先頭が`$`のものはSvelteKitの組込みモジュールと思ってください（`$lib`だけ例外）。
単純なページ遷移で`goto()`を利用します。

`export let`でtodoを宣言していますが、これはプロパティーになります。
`export`を付けることでコンポーネント外から当該プロパティーにアクセスが可能になります。
また、同一ページ内の`load()`（後述）から、プロパティーに値を設定する場合も`export`付与が必要です。
従って、外のコンポーネントや、`load()`からプロパティーにアクセスせず、ページ内でのバインドのみで使用する場合は、`export`は不要です。
（なので、実はこの追加ページでは、`export`は不要で`let todo = '';`で十分だったりします）

`submit()`では、TODO追加のエンドポイントをコールしています。


```html:/src/routes/todo/add.svelte（HTML部）
<form on:submit|preventDefault={submit}>
```

formのsubmitイベント発動時にsubmit()をコールする場合の書き方です。

``` html
<input type="text" placeholder="TODO" bind:value={todo} />
```

bind:value={todo} で、入力値をプロパティーにバインドさせます。
バインドさせることでスクリプト側で値が参照可能になります。

##5. 追加エンドポイントの作成
`/src/routes/api/todo/index.svelte`を新規作成します。

```javascript:/src/routes/api/todo/index.svelte
import todoDb from '$lib/TodoDb';

// TODOを追加する
export async function post({ request }) {
	const data = await request.json();

	todoDb.add(data.todo);

	return { body: {} };
}
```
`http://localhost:3000/api/todo`のエンドポイントです。
function名は、HTTPメソッド名で記述することで、当該リクエストに準じたfunctionがコールされます。

ここでは、postでTODOの新規登録を受け付け可能とします。
引数で`request`をもらえるので、JSONとして受け取り、`todoDb`に登録しています。

postのみを実装しているので、`http://localhost:3000/api/todo`をブラウザでアクセスすると、404になります。


APIのクライアントを作っていきましょう。

###6. APIクライアントの作成
エンドポイントをコールするためのクライアントを作成します。

`/src/lib/api_client.js`を新規作成します。

```javascript:/src/lib/api_client.js
// リクエスト送信先
const BASE_HOST = 'http://localhost:3000';

// リクエストを送信する
async function send({ method, path, data }) {
	const opts = { method, headers: {} };

	opts.headers['Content-Type'] = 'application/json';
	if (data) {
		opts.body = JSON.stringify(data);
	}

	return fetch(`${BASE_HOST}${path}`, opts)
		.then((r) => r.text())
		.then((json) => {
			try {
				return JSON.parse(json);
			} catch (err) {
				return json;
			}
		});
}

// GETリクエストを送信する
export function get(path) {
	return send({ method: 'GET', path });
}

// POSTリクエストを送信する
export function post(path, data) {
	return send({ method: 'POST', path, data });
}

// PUTリクエストを送信する
export function put(path, data) {
	return send({ method: 'PUT', path, data });
}
```
get、post、putを行うためのfunctionと、そこからコールする`send()`の構成です。
`send()`では、HTTPメソッド、URL、およびデータを元にHTTPリクエストを投げています。

###7. TodoDbの作成
今回は、RDBなどは用いず、JSの配列でデータを保持させています。
よって、サーバ再起動でデータはクリアされます。

`/src/lib/TodoDb.js`を新規作成します。

```javascript:/src/lib/TodoDb.js
// TODOを保持するクラス
class TodoDb {
	constructor() {
		// データ
		this.data = [];
	}

	// 追加する
	add(todo) {
		const entity = { id: this.data.length, todo, finished: false };
		this.data.push(entity);
	}

	// 更新する
	update(todoEntity) {
		const entity = {
			id: todoEntity.id,
			todo: todoEntity.todo,
			finished: todoEntity.finished
		};
		this.data[todoEntity.id] = entity;
	}

	// 全件返却
	getAll() {
		return this.data;
	}

	// 指定IDのデータを返す
	get(id) {
		return this.data[id];
	}
}

const todoDb = new TodoDb();
export default todoDb;
```

`/src/lib`配下にJSを作成することで、外部から`$lib/xxx`で参照可能となります。
TodoDb自体はSvelteの話ではないので、詳細は割愛します。
Todoデータを配列で管理しているだけです。

###8. トップページ（TODO一覧）
データの追加ができるようになったので、一覧ページを作っていきます。

`/src/routes/index.svelte`を開きます。
以下のように修正します。

```html:/src/routes/index.svelte
<script context="module">
	import * as api from '$lib/api_client.js';

	export async function load({ params, session }) {
		// TODOを全件取得する
		const res = await api.get('/api/todos');
		return {
			props: { todos: res }
		};
	}
</script>

<script>
	import { invalidate } from '$app/navigation';
	export let todos = [];

	// TODOを完了にする
	async function compClick(id) {
		await api.put(`/api/todo/${id}/complete`);
		invalidate('/');
	}
</script>

<h1>TODO</h1>
<a href="/todo/add">追加</a>

<ul>
	{#each todos as todo}
		<li>
			<a href="/todo/edit/{todo.id}">{todo.todo}</a>
			{#if !todo.finished}
				<button on:click={compClick(todo.id)}>完了</button>
			{:else}
				済
			{/if}
		</li>
	{/each}
</ul>
```

画面表示時にコールされる`load()`でTODOデータ取得を行い、一覧を生成しています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/272860/d213f141-af2b-58f5-c55e-5d4fc06e9f45.png)

####一覧ページの説明
ブロックごとに説明します。

```html:/src/routes/index.svelte（データの取得）
<script context="module">
	import * as api from '$lib/api_client.js';

	export async function load({ params, session }) {
		// TODOを全件取得する
		const res = await api.get('/api/todos');
		return {
			props: { todos: res }
		};
	}
</script>
```

`load()`はコンポーネントが作成される前に実行されます。
データをエンドポイントから取得する場合は、[`load()`で行う必要はない](https://kit.svelte.jp/docs#loading)ようですが、統一的に`load()`で書くようにするのが良いと思われます。

上記では、エンドポイント`/api/todos`からTODOを全件取得し、returnしています。
`props`に格納してreturnすることで、このページのプロパティーに自動でセットしてくれます。

```html:/src/routes/index.svelte（イベント処理部）
<script>
	import { invalidate } from '$app/navigation';
	export let todos = [];

	// TODOを完了にする
	async function compClick(id) {
		await api.put(`/api/todo/${id}/complete`);
		invalidate('/');
	}
</script>
```

`load()`で取得したデータは`todos`に自動でセットされます。
`compClick()`はTODOを完了にするエンドポイントをコールします。その後、SvelteKitの組込みである`invalidate()`によって、一覧データを再描画させています。
`invalidate()` は、`load()`でfetchしてるものについて、すべて再実行してくれます。

```html:/src/routes/index.svelte（HTML部）
<h1>TODO</h1>
<a href="/todo/add">追加</a>

<ul>
	{#each todos as todo}
		<li>
			<a href="/todo/edit/{todo.id}">{todo.todo}</a>
			{#if !todo.finished}
				<button on:click={compClick(todo.id)}>完了</button>
			{:else}
				済
			{/if}
		</li>
	{/each}
</ul>
```

Svelteは`#each`でループ処理を記述します。
分岐は`#if`、`:else`、`/if`です。
`#`、`:`、`/`と、記号の使い方が少々気持ち悪いですが、Svelteでは、`#`はブロックの始まり、`/`はブロックの終わりを意味し、`:`はブロックの継続を示します。

buttonの`on:click`で`compClick`をコールしています。

###9. 編集ページの作成
`/src/routes/todo/edit/[id].svelte`を新規作成します。
`[id]`の部分はRESTのパラメータで置換される部分となります。

例えば、ブラウザから`/todo/edit/5`にアクセスすると、上記のファイルが実行されます。

```html:/src/routes/todo/edit/[id].svelte
<script context="module">
	import * as api from '$lib/api_client.js';

	export async function load({ params }) {
		// TODOの取得
		const res = await api.get(`/api/todo/${params.id}`);
		return {
			props: { todo: res }
		};
	}
</script>

<script>
	import { goto } from '$app/navigation';

	export let todo;

	// 編集したTODOを更新登録する
	async function submit(event) {
		await api.put(`/api/todo/${todo.id}`, { todo });
		goto('/');
	}
</script>

<h1>TODO編集</h1>

<form on:submit|preventDefault={submit}>
	<input type="text" placeholder="TODO" bind:value={todo.todo} />
	<br />
	<button> 更新 </button>
</form>
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/272860/989a0724-08bb-0752-b13a-ce47d50ffb26.png)


編集ページは、一覧ページと追加ページで説明した内容と構成が同じため、詳細は割愛します。
```const res = await api.get(`/api/todo/${params.id}`);```の、`params.id`の部分がRESTパラメータを取得している箇所となります。
ファイル名を`[id].svelte`としているので、`params`から`id`の名前で値を取得することができます。

###10. 1件データ取得と更新登録のエンドポイント
`/src/routes/api/todo/[id]/index.js`を新規作成します。

```javascript:/src/routes/api/todo/[id]/index.js
import todoDb from '$lib/TodoDb';

// TODOを更新する
export async function put({ request }) {
	const data = await request.json();

	todoDb.update(data.todo);

	return { body: {} };
}

// TODOを1件返す
export async function get({ params }) {
	// RESTパラメータからIDを抽出
	const { id } = params;

	const todo = todoDb.get(id);
	return { body: todo };
}
```
`put()`はrequestからデータを取得し、todoDbの更新処理に渡しています。
`get()`はRESTパラメータからidを取得し、当該idのデータをtodoDbから受け取っています。
RESTパラメータの取得は、前述の`[id].svelte`と同じ考え方です。
このケースでは、フォルダ名でRESTパラメータを定義しています。

###11. TODO完了のエンドポイント作成
`/src/routes/api/todo/[id]/complete.js`を新規作成します。

```javascript:/src/routes/api/todo/[id]/complete.js
import todoDb from '$lib/TodoDb';

// TODOを完了にする
export async function put({ params }) {
	const { id } = params;

	const todoEntity = todoDb.get(id);
	todoEntity.finished = true;
	todoDb.update(todoEntity);

	return { body: {} };
}
```
`put()`にて、指定のIDのデータを一旦取得し、`finished`に`true`を代入、todoDbの更新を行っています。

---

おしまい。
お疲れさまでした!