TechConnect!2022年2月のリレー記事です。
リンク情報システムのengineer.hanzomon のグループメンバでリレーしています(Facebookはこちら)。
少し古いWEB+DB PRESS(Vol.122 2021年5月)を見たらSvelte(スヴェルト)というフロントのフレームワークが載っていました。
その記事があったことをサッパリ覚えていなかったのですが、ちょいと調べてみると、JavaScriptの利用動向に関する年次調査 The State of JavaScript Surveyにて、2021年はSolidとSvelteが90%の満足度であり、ReactとVueを抜いています。
Svelteの所感
- 公式が言っているとおり、ビルド、ホットリロードが速いです。
- コード量も確かに少なくて済む感じがした。
- コード記述は、Vueと比較して、より直感的と感じました。
そんなSvelteを勉強がてら、ハンズオンで使えるような記事を書いてみました。
####作るもの
SvelteKitを使って簡易なTODOアプリを作っていきます。
(SvelteKitは、VueでいうところのNuxtにあたるもの)
バージョン
- svelte 3.46.4
- sveltekit 1.0.0-next.260
前提
- SveleteとSveleteKitの学習に重きをおいて、CSSは完全排除。
- Svelte、SvelteKitの多くの機能を説明するハンズオンではないです(ストアやモーション等々)。
- 入力チェック等々は範囲外としています。
##1. プロジェクト作成
SvelteKitのプロジェクトを作成します。
nodeのバージョン変更はnodistで行っています。
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
) オプションを付けることで、自動でブラウザが起動、画面表示が行われます。
##2. 自動生成されたファイルを見てみる
VSCodeでsvelte-handsonフォルダを開いてみましょう。
初期状態は以下のフォルダ、ファイル構成になります。
src
には、ページやコンポーネントを格納します。
static
は、静的な画像などを配置する場所になります。
/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」に変更します。
<h1>TODO</h1>
<a href="/todo/add">追加</a>
##4. 追加ページの作成
/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>
####追加ページの説明
<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追加のエンドポイントをコールしています。
<form on:submit|preventDefault={submit}>
formのsubmitイベント発動時にsubmit()をコールする場合の書き方です。
<input type="text" placeholder="TODO" bind:value={todo} />
bind:value={todo} で、入力値をプロパティーにバインドさせます。
バインドさせることでスクリプト側で値が参照可能になります。
##5. 追加エンドポイントの作成
/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
を新規作成します。
// リクエスト送信先
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
を新規作成します。
// 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
を開きます。
以下のように修正します。
<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データ取得を行い、一覧を生成しています。
####一覧ページの説明
ブロックごとに説明します。
<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()
で行う必要はないようですが、統一的にload()
で書くようにするのが良いと思われます。
上記では、エンドポイント/api/todos
からTODOを全件取得し、returnしています。
props
に格納してreturnすることで、このページのプロパティーに自動でセットしてくれます。
<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してるものについて、すべて再実行してくれます。
<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
にアクセスすると、上記のファイルが実行されます。
<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>
編集ページは、一覧ページと追加ページで説明した内容と構成が同じため、詳細は割愛します。
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
を新規作成します。
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
を新規作成します。
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の更新を行っています。
おしまい。
お疲れさまでした!