はじめに
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-superforms の CRUDチュートリアル を参考にして、ユーザー情報の入力チェック、という体で実装してみます。
作成・修正したファイルは4つです。
まずは、バリデーションスキーマと、ユーザーのCRUDを担当する 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
に実装します。
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
に実装します。
<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>/</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 を動導入しておきます。
<!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
今回は Name
は末尾が「!」でなけれなならない仕様にしました。また、E-mail
はメールアドレス形式でなければならない仕様です。
というわけで、エラーを発生させてみます。
エラーメッセージが表示されましたね!
エラーメッセージを日本語化
2023/4/19 追記:以下の記事で日本語化できましたー。
日本語化できなかった頃の話
エラーメッセージが英語のままなので、日本語化したいですよね。[zod-i18n-map] というライブラリでいい感じにできそうなのでやってみましたが、エラーメッセージが表示されなくなっちゃいました。バージョンの違い等が原因かもしれませんね。
例えば以下の様に検証用の 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 という多言語化ライブラリを直接使って、独自にメッセージ変換の仕組みを構築すれば何とかなりそうでした。
試しに、不完全ながら以下のように修正してみました。
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 解決済み) - 複数項目を跨いだバリデーション
今後はこれらについても調査してみようと思います。::
補足: .js
vs .ts
の検証
今回は、以下の検証も兼ねていました。
今回作成したコードでは複雑な型は必要なかったので .d.ts
ファイルは使いませんでした。
それを踏まえて、JSDocs で型定義してみた感想は以下になります。
- 型定義の書きやすさは .ts の方がよさそう。
- コードの見やすさは .js の方が良かった。
- VSCode で
> Fold All Block Comments
(CTRL+K CTRL+/) を実行すればコメントが折りたたまれるので見やすくなります。コメントを再表示させるには> Unfold All
(CTRL+K CTRL+J)を実行します。
- VSCode で
- import() の書き方は JSDoc 形式の方が良かった。いちいちファイルの上部に移動して import しなくて良いので。
※5段階評価
比較対象 | .js | .ts |
---|---|---|
型定義の書きやすさ | 3 | 4 |
コードの見やすさ | 3 | 2 |
こちらの検証はちょっとずつ継続しようと思います。