6
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

SvelteKit + sveltekit-superformsで入力チェック

Last updated at Posted at 2023-04-18

はじめに

Webアプリケーションを作成するにあたって、一番面倒くさい作業が入力チェックではないでしょうか。SvelteKit で簡単に入力チェックを実現できる方法を探していたところ sveltekit-superforms というライブラリを見つけました。

sveltekit-superforms を使った入力チェックがどんな感じになるのか試してみました。

sveltekit-superforms って何?

簡単に言うと、Zod というバリデーションライブラリを SvelteKit で利用しやすくしたライブラリです。

以下、ホームページからの引用です。

Superforms is a SvelteKit library that helps you with server-side validation and client-side display of forms.
It enables you to use a Zod validation schema as a single source of truth, with consistent handling of form data and validation errors.

訳:
Superformsは、フォームのサーバーサイドバリデーションとクライアントサイド表示を支援するSvelteKitのライブラリです。
Zodバリデーションスキーマを単一の真実のソースとして使用することができ、フォームデータとバリデーションエラーの一貫した処理が可能です。

導入

まずは SvelteKit のスケルトンアプリをセットアップします。

ターミナル
npm create svelte@latest sveltekit-validation

(こんな感じで選択)
┌  Welcome to SvelteKit!
│
◇  Which Svelte app template?
│  Skeleton project
│
◇  Add type checking with TypeScript?
│  Yes, using JavaScript with JSDoc comments
│
◇  Select additional options (use arrow keys/space bar)
│  Add ESLint for code linting, Add Prettier for code formatting, Add Playwright for browser testing, Add Vitest for unit testing
│
└  Your project is ready!

sveltekit-superforms をインストールします。

ターミナル
npm i -D sveltekit-superforms zod

入力チェックの実現

sveltekit-superformsCRUDチュートリアル を参考にして、ユーザー情報の入力チェック、という体で実装してみます。

作成・修正したファイルは4つです。

まずは、バリデーションスキーマと、ユーザーのCRUDを担当する users.js を作成します。

src/lib/users.js
import { z } from 'zod';

// See https://zod.dev/?id=primitives for schema syntax
/**
 * ユーザーのスキーマ
 */
export const userSchema = z.object({
  id: z.string().max(20),
  name: z.string().min(2).max(20).endsWith("!"),
  email: z.string().email()
});

/** @typedef {z.infer<typeof userSchema>} UserDB */

/**
 * ユーザーIDを生成する
 * 
 * @returns {string} id
 */
export const userId = () => String(Math.random()).slice(2);

/**
 * 全ユーザーを取得する
 * 
 * @returns {UserDB[]}
 */
export const getAll = () => users;

/** 
 * id に一致するユーザーを取得する
 * 
 * @param {string} id
 * @return {UserDB|undefined}
 */
export const get = (id) => {
  return users.find((u) => u.id == id);
}

/** 
 * ユーザーを登録する
 * 
 * @param {UserDB} user
 */
export const create = (user) => {
  users.push(user);
}

/** 
 * ユーザーを更新する
 * 
 * @param {UserDB} user
 */
export const update = (user) => {
  const currentUser = get(user.id);
  if (!currentUser) {
    return;
  }
  const index = indexOf(currentUser);
  users[index] = user;
}

/**
 * id に一致するユーザーを削除する
 * 
 * @param {string} id
 */
export const remove = (id) => {
  const currentUser = get(id);
  if (!currentUser) {
    return;
  }
  const index = indexOf(currentUser);
  users.splice(index, 1);
}

/**
 * @param {UserDB} user 
 * @returns {number}
 */
const indexOf = (user) => users.indexOf(user);

/**
 * 仮のユーザーデータベース
 * 
 * @type UserDB[]
 */
const users = ([
  {
    id: userId(),
    name: 'Important Customer',
    email: 'important@example.com'
  },
  {
    id: userId(),
    name: 'Super Customer',
    email: 'super@example.com'
  }
]);

サーバーサイドで入力チェックする処理を +page.service.js に実装します。

src/routes/+page.service.js
import { superValidate, message } from 'sveltekit-superforms/server';
import { error, fail, redirect } from '@sveltejs/kit';

import { getAll, get, create, update, remove, userId, userSchema } from '$lib/users';

/** @typedef {import('$lib/users').UserDB} UserDB */

/**
 * ユーザーフォームのスキーマ
 */
const schema = userSchema.extend({
	id: userSchema.shape.id.optional()
});

/** @type {import('./$types').PageServerLoad} */
export const load = (async ({ url }) => {
	const id = url.searchParams.get('id');
	const user = id ? get(id) : null;

	if (id && !user) throw error(404, 'User not found.');

	// バリデーション実施済みのフォームを取得
	const form = await superValidate(user, schema);

	return { form, users: getAll() };
});

/** @type {import('./$types').Actions} */
export const actions = {
	default: async ({ request }) => {
		const data = await request.formData();

		// バリデーション実施済みのフォームを取得
		const form = await superValidate(data, schema);

		if (!form.valid) return fail(400, { form });

		// ユーザーの登録・更新・削除
		const targetUser = form.data;
		if (!targetUser.id) {
			const id = userId();
			create({ ...targetUser, id: id });
			throw redirect(303, `?id=${id}`);
		} else {
			const user = get(targetUser.id);
			if (!user) throw error(404, 'User not found.');

			if (data.has('delete')) {
				remove(user.id);
				throw redirect(303, '?');
			} else {
				// @ts-ignore
				update(targetUser);
				return message(form, 'User updated!');
			}
		}
	}
};

画面表示機能を +page.svelte に実装します。

src/routes/+page.svelte
<script>
	import { superForm } from 'sveltekit-superforms/client';

	/** @type {import('./$types').PageData}*/
	export let data;

	const { form, errors, constraints, enhance, delayed, empty } = superForm(data.form);
</script>

<h3>User list</h3>

<!-- ユーザー一覧 -->
<div class="users">
	{#each data.users as user}
		<a class:current={$form.id === user.id} href="?id={user.id}">{user.name}</a><span>&#47;</span>
	{/each}
</div>

<!-- 作成ボタン -->
<form action="/">
	<button disabled={!$form.id}>Create new</button>
</form>

<h2>{!$form.id ? 'Create' : 'Update'} user</h2>

<!-- ユーザーフォーム -->
<form method="POST" use:enhance>
	<!-- id -->
	<input type="hidden" name="id" bind:value={$form.id} />

	<!-- Name -->
	<label>
		Name<br />
		<input name="name" data-invalid={$errors.name} bind:value={$form.name} {...$constraints.name} />
		{#if $errors.name}<span class="invalid">{$errors.name}</span>{/if}
	</label>

	<!-- E-mail -->
	<label>
		E-mail<br />
		<input
			type="email"
			name="email"
			data-invalid={$errors.email}
			bind:value={$form.email}
			{...$constraints.email}
		/>
		{#if $errors.email}<span class="invalid">{$errors.email}</span>{/if}
	</label>

	<button>Submit</button>

	{#if !$empty && !!$form.id}
		<button
			name="delete"
			value="delete"
			on:click={(e) => !confirm('Are you sure?') && e.preventDefault()}
			class="danger">Delete user</button
		>
	{/if}
	{#if $delayed}Working...{/if}
</form>

<style>
	.invalid {
		color: red;
	}

	.danger {
		background-color: brown;
	}

	.users > * {
		display: inline-block;
		white-space: nowrap;
		overflow-x: hidden;
		padding: 0 0.5rem;
	}

	.users > .current {
		background-color: rgb(237 191 191);
		border-radius: 1rem;
	}
</style>

最後に、必須ではないのですが見栄えを良くするために、CSS を動導入しておきます。

src/app.html
<!DOCTYPE html>
<html lang="en">
	<head>
		<meta charset="utf-8" />
		<link rel="icon" href="%sveltekit.assets%/favicon.png" />
+		<link rel="stylesheet" href="https://unpkg.com/normalize.css@8.0.1/normalize.css" />
+		<link rel="stylesheet" href="https://unpkg.com/sakura.css/css/sakura.css" />
		<meta name="viewport" content="width=device-width" />
		%sveltekit.head%
	</head>
	<body data-sveltekit-preload-data="hover">
		<div style="display: contents">%sveltekit.body%</div>
	</body>
</html>

動作確認

起動します。

ターミナル
npm run dev

こんな画面が表示されます。
image.png

今回は Name は末尾が「!」でなけれなならない仕様にしました。また、E-mail はメールアドレス形式でなければならない仕様です。
というわけで、エラーを発生させてみます。
image.png
エラーメッセージが表示されましたね!:blush:

エラーメッセージを日本語化

2023/4/19 追記:以下の記事で日本語化できましたー。

日本語化できなかった頃の話 エラーメッセージが英語のままなので、日本語化したいですよね。[zod-i18n-map] というライブラリでいい感じにできそうなのでやってみましたが、エラーメッセージが表示されなくなっちゃいました。:disappointed_relieved:

バージョンの違い等が原因かもしれませんね。

例えば以下の様に検証用の test.js ファイルを作成して、

test.js
import i18next from 'i18next'
import { z } from 'zod'
import { zodI18nMap } from "zod-i18n-map"
import translation from 'zod-i18n-map/locales/ja/zod.json' assert { type: "json" };

// i18nextの初期設定
i18next.init({
	lng: 'ja',
	resources: {
		ja: { translation }, // キーをzodからtranslationに変更
	},
});

z.setErrorMap(zodI18nMap);

// スキーマを定義
const schema = z.object({
	name: z.string().min(2).max(20).endsWith("!"),
});

// テスト用のデータを定義
const form = {
	name: "贅肉すぐる"
};

try {
	schema.parse(form);
} catch (e) {
	console.log("error message:", e.errors[0].message);
}

実行してみると、エラーメッセージが undefined になってしまいました。

ターミナル
node test.js
...
error message: undefined

ちなみに、z.setErrorMap(zodI18nMap); をコメントアウトすると、ちゃんとエラーメッセージが表示されます。

ターミナル
node test.js
...
error message: Invalid input: must end with "!"

いろいろ調査してみましたが、私の能力では簡単に直せそうではありませんでした。ChatGPT も聞いてみましたが解決せずでした。

ただし、zod-i18n-map は使わずに i18next という多言語化ライブラリを直接使って、独自にメッセージ変換の仕組みを構築すれば何とかなりそうでした。

試しに、不完全ながら以下のように修正してみました。

test.js
import i18next from 'i18next'
import { z } from 'zod'
-import { zodI18nMap } from "zod-i18n-map"
import translation from 'zod-i18n-map/locales/ja/zod.json' assert { type: "json" };

// i18nextの初期設定
i18next.init({
	lng: 'ja',
	resources: {
		ja: { translation }, // キーをzodからtranslationに変更
	},
});

+const getMessage = (issue, _ctx) => {
+	return i18next.t(`errors.${issue.code}.endsWith`, { endsWith: issue?.validation?.endsWith });
+}
+
+const myZodErrorMap = (issue, _ctx) => ({
+	message: getMessage(issue, _ctx)
+});

-z.setErrorMap(zodI18nMap);
+z.setErrorMap(myZodErrorMap);

// スキーマを定義
const schema = z.object({
	name: z.string().min(2).max(20).endsWith("!"),
});

// テスト用のデータを定義
const form = {
	name: "贅肉すぐる"
};

try {
	schema.parse(form);
} catch (e) {
	console.log("error message:", e.errors[0].message);
}

すると以下のように、日本語が表示されました。

ターミナル
node test.js
...
error message: "!"で終わる文字列である必要があります。

今回は完全な状態で日本語化はできませんでしたが、引き続き調査してみます。

まとめ

sveltekit-superforms を使うことで、SvelteKit で簡単にバリデーションを実現できることが分かりました。ただし、いくつか課題があります。

  • エラーメッセージの日本語化 (2023/4/19 解決済み)
  • 複数項目を跨いだバリデーション

今後はこれらについても調査してみようと思います。::smiley::

補足: .js vs .ts の検証

今回は、以下の検証も兼ねていました。

今回作成したコードでは複雑な型は必要なかったので .d.ts ファイルは使いませんでした。
それを踏まえて、JSDocs で型定義してみた感想は以下になります。

  • 型定義の書きやすさは .ts の方がよさそう。
  • コードの見やすさは .js の方が良かった。
    • VSCode で > Fold All Block Comments(CTRL+K CTRL+/) を実行すればコメントが折りたたまれるので見やすくなります。コメントを再表示させるには > Unfold All(CTRL+K CTRL+J)を実行します。
  • import() の書き方は JSDoc 形式の方が良かった。いちいちファイルの上部に移動して import しなくて良いので。

※5段階評価

比較対象 .js .ts
型定義の書きやすさ 3 4
コードの見やすさ 3 2

こちらの検証はちょっとずつ継続しようと思います。

6
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?