diff --git "a/source/_posts/2025/20251016a_Vue.js\351\200\243\350\274\211_2025_\343\202\222\345\247\213\343\202\201\343\201\276\343\201\231.md" "b/source/_posts/2025/20251016a_Vue.js\351\200\243\350\274\211_2025_\343\202\222\345\247\213\343\202\201\343\201\276\343\201\231.md" new file mode 100644 index 00000000000..4ecc34e8dd9 --- /dev/null +++ "b/source/_posts/2025/20251016a_Vue.js\351\200\243\350\274\211_2025_\343\202\222\345\247\213\343\202\201\343\201\276\343\201\231.md" @@ -0,0 +1,33 @@ +--- +title: "Vue.js連載 2025 を始めます" +date: 2025/10/16 00:00:00 +postid: a +tag: + - Vue.js + - インデックス +category: + - Frontend +thumbnail: /images/2025/20251016a/thumbnail.png +author: admin +lede: "Vue.jsの魅力と可能性をさらに深く探るべく、フューチャーの有志エンジニアたちによるブログリレーを開催します!" +--- + + +近年、フロントエンド開発の世界は目まぐるしく進化を続けており、その中でもVue.jsは、学習のしやすさや機能性から、フューチャーでも多くの採用実績があります。この度、そんなVue.jsの魅力と可能性をさらに深く探るべく、フューチャーの有志エンジニアたちによるブログリレーを開催します! + +公開スケジュールです。 + +| 日付 | 担当者 | テーマ | +| :---- | :---- | :---- | +| 10/16(木) | 長谷川 寛人 | vee-validate \+ zod または vue-keycloak の話 | +| 10/17(金) | 小杉山 護 | Vitest Browser Mode x Vue (x Storybook x MSW) の話 | +| 10/20(月) | 中村 立基 | 【入門】Vueで始めるチャート分析 | +| 10/21(火) | 村田 靖拓 | TBD | +| 10/22(水) | 山本 竜玄 | Vueでモバイルアプリ開発 | +| 10/23(木) | 澁川 喜規 | DockerでVueアプリのビルド | +| 10/24(金) | 松本 朝香 | 初心者がSPA(Single Page Application)を実装してみた | +| 10/27(月) | 永井 優斗 | 脆弱なアプリを作って&使って学ぶ、Vue.jsのセキュリティ観点で気をつけたいポイント | + +[Vue Fes Japan 2025](https://vuefes.jp/2025/)も10/25と開催間近、[フューチャーもゴールドスポンサー](https://vuefes.jp/2025/sponsors/future)として協賛しております。一緒に盛り上げていきましょう。 + +このブログリレーが、Vue.jsを既に使いこなしている方はもちろん、これから学ぼうとしている方にとっても、新たな発見や学びのきっかけとなれば幸いです。 diff --git "a/source/_posts/2025/20251016b_Vue.js_+_vee-validate_+_Zod\357\274\210+_shadcn\357\274\217vue_+_@tanstack\357\274\217vue-query\357\274\211\343\201\247\343\201\256\345\256\237\350\267\265\347\232\204\343\201\252\343\203\225\343\202\251\343\203\274\343\203\240\351\226\213\347\231\272_at_2025\357\274\21710\357\274\21716_10\357\274\23209.md" "b/source/_posts/2025/20251016b_Vue.js_+_vee-validate_+_Zod\357\274\210+_shadcn\357\274\217vue_+_@tanstack\357\274\217vue-query\357\274\211\343\201\247\343\201\256\345\256\237\350\267\265\347\232\204\343\201\252\343\203\225\343\202\251\343\203\274\343\203\240\351\226\213\347\231\272_at_2025\357\274\21710\357\274\21716_10\357\274\23209.md" new file mode 100644 index 00000000000..782d87f1d42 --- /dev/null +++ "b/source/_posts/2025/20251016b_Vue.js_+_vee-validate_+_Zod\357\274\210+_shadcn\357\274\217vue_+_@tanstack\357\274\217vue-query\357\274\211\343\201\247\343\201\256\345\256\237\350\267\265\347\232\204\343\201\252\343\203\225\343\202\251\343\203\274\343\203\240\351\226\213\347\231\272_at_2025\357\274\21710\357\274\21716_10\357\274\23209.md" @@ -0,0 +1,545 @@ +--- +title: "Vue.js + vee-validate + Zod(+ shadcn/vue + @tanstack/vue-query)での実践的なフォーム開発" +date: 2025/10/16 00:00:00 +postid: b +tag: + - Vue.js + - バリデーション +category: + - Frontend +thumbnail: /images/2025/20251016b/thumbnail.jpg +author: 長谷川寛人 +lede: "私は普段Reactを触ることが多く、フォーム開発ではreact-hook-form + zodの組み合わせでの開発をよく行なっていました。今回Vueでプロジェクトを進めるにあたり、vee-validateを使ってフォーム開発を行いました。" +--- + + +## はじめに + +私は普段Reactを触ることが多く、フォーム開発では`react-hook-form` + `zod`(+ `shadcn/ui`)の組み合わせでの開発をよく行なっていました。 + +今回Vueでプロジェクトを進めるにあたり、[vee-validate](https://vee-validate.logaretm.com/v4/)を使ってフォーム開発を行ないました。コンポーネントや[Zod](https://zod.dev/)との組み合わせに試行錯誤したことに加え、アクセシビリティ対応にも力を入れたので、そこで得た知見を共有いたします。 + +**ここで紹介する内容を理解すれば、ほとんどのフォーム実装に対応できるはずです。** より複雑なケースでも、この記事の内容を組み合わせたり派生させることで実現できると思います。ぜひ参考にしてみてください! + +また、以下にサンプルとなるリポジトリを用意しましたので、こちらもご参照ください。 + +https://github.com/hasehiro0828/sample-vue-form + +## 技術スタック概要 + +### vee-validate + +https://vee-validate.logaretm.com/v4/ + +Vue.js向けのフォームライブラリです。Zodなどとの統合が容易で、Composition APIとの親和性も高いです。 + +Reactの場合はフォームライブラリにいくつか選択肢がある印象ですが、**Vueの場合は基本的にvee-validateを使うことになりそう**です(2025年10月時点)。 + +### Zod + +https://zod.dev/ + +TypeScript-firstなスキーマ定義ライブラリです。型安全性を担保しながらランタイムバリデーションを実現でき、フレームワークによらず同じように利用できます。 + +### vue-query + +https://tanstack.com/query/latest/docs/framework/vue/overview + +サーバーステートの管理を行なうライブラリで、Tanstack QueryのVue版です。実践的なアプリケーションを見据えて導入しましたが、今回のメイン要素ではないので詳細は割愛します。 + +### shadcn-vue + +https://www.shadcn-vue.com/ + +Tailwind CSSベースのUIコンポーネント集で、Reactのshadcn/uiのVue版です。こちらも今回のメイン要素ではないので詳細は割愛します。 + +取得したデータを `watch` で `resetForm` することで、フォームの初期値を設定します。 + +## アクセシビリティ対応 + +今回のプロジェクトではアクセシビリティ対応にも力を入れました。[デジタル庁のデザインシステム](https://design.digital.go.jp/)を参考に、日付入力の実装やボタン要素の扱い(`disabled`属性を利用しない)など、アクセシビリティの観点から工夫を行なっています。 + +本記事では**とくに試行錯誤した日付入力の実装について後述します。** + +## vee-validateの基本方針 + +vee-validateはさまざまなコンポーネントやAPIを提供してくれています。それらを利用するにあたり、利用するもの・利用しないものの基本方針を明確にしておくことで、チーム開発での一貫性を保ち、実装の迷いを減らすことができます。 +今回は以下のような基本方針を設けて実装を行ないました。 + +### 利用するもの + +- **Fieldコンポーネント** + - 単一のフォームフィールドを扱う際の基本となるコンポーネント + - `v-slot`でfield、meta、errorMessageを受け取り、入力欄とバリデーション結果を紐づけ + - ` + + +``` + +動的に追加・削除可能なフィールドです。 +ユーザー操作で追加・削除できるような入力に使用します。 + +**ポイント**: + +- `push`で配列に新しい要素を追加 +- `remove`で指定したインデックスの要素を削除 +- v-forのkeyには`{array}.key`(インデックスではなくkey)を使用 + +#### ネストした配列要素はコンポーネント分離する + +配列の中に配列があるような場合には、ネストされる配列を別コンポーネントに分離すると扱いやすくなります。 +サンプルでは以下のような構造にしています。 + +```txt +conditions(配列)← NestForm.vue で管理 + └── params(配列)← ConditionalParams.vue で管理 +``` + +##### 親コンポーネント(NestForm.vue) + +```html + + + +``` + +##### 子コンポーネント(ConditionalParams.vue) + +```html + + + +``` + +**この設計のメリット**: + +- `useFieldArray`を使用する際にパス名が必要だが、コンポーネント分離することでindexを渡すことが可能に +- ネストが深くなっても各コンポーネントは自分が管理する要素だけを見ればいいので、コードが読みやすくなる + +この構成は3階層以上の深いネストにも応用できます。各階層をコンポーネントに分離することで個々のコンポーネントの複雑性を増すことなく、複雑なフォームでも保守性の高いコードを維持できます。 + +ちなみに・・・ +nameの値は `hoge[index]` の形式と `hoge.index` の形式のどちらでも可能なようです。ただ、`errors`から値を取得する際には `hoge[index]`の形式で取得する必要があるため、基本的には `hoge[index]` の形式で指定するのが良さそうです。 + +#### useFieldを使ったカスタムコンポーネント + +```html + + + +``` + +複数の入力欄で1つのコンポーネントとするような場合に使用します。 +とくに、日付入力(年・月・日)のように、個別のフィールドでもバリデーションが必要かつ全体としてもバリデーションが必要な場合に適しています。 + +**ポイント**: + +- 各フィールドを個別に`useField`で定義することで、年・月・日それぞれのバリデーションが可能 +- `useFormContext`でフォーム全体のエラー情報を取得し、親要素のエラー(例:「年月日をすべて入力してください」)を取得 + - (`useField`でルートの要素を定義すると上手く動作しなかったのでこの形に) +- カスタムコンポーネント内で複数フィールドを統合し、外部からは1つのコンポーネントとして扱えるように + +#### エラー表示のタイミング + +```typescript +export const submittedOrTouchedAndDirty = (submitCount: number, meta: { touched: boolean; dirty: boolean }) => { + return submitCount > 0 || (meta.touched && meta.dirty); +}; + +export const shouldShowError = (submitCount: number, meta: { touched: boolean; dirty: boolean; valid: boolean }) => { + return submittedOrTouchedAndDirty(submitCount, meta) && !meta.valid; +}; +``` + +エラー表示の条件は `meta.valid` 以外にも以下のような条件を設けています。 + +- **`touched && dirty`:** フィールドにフォーカスを当て(touched)、かつ値を変更した(dirty)後にエラーを表示 +- **`submitCount > 0`:** 送信ボタンを押した後は、すべてのフィールドのエラーを表示 + - エラーがなくなるまでボタンを`disabled`にする実装も考えられるが、アクセシビリティを考慮し`disabled`を使用しない形にしている + - その場合`touched && dirty` のみだとsubmitしてもエラーが表示されないため、この条件を追加 + +以上のようにすることで、ユーザーが何も操作していない状態でエラーが表示されることや入力中にエラーが表示されてしまうことを避けられます。また、送信ボタンを押した際にはすべてのフィールドのエラーが表示されます。 + +## Zodスキーマ設計 + +### typeフィールドによる分岐 + +今回、配列形式かつ種類の違うフィールドを動的に管理する必要がありました。これを解決するために、typeフィールドによるスキーマの分岐を行なっています。こうすることで、画面表示の分岐を自動的に行ないつつ型安全にデータを扱えるようになります。 + +以下のように`base`のスキーマを拡張し`type`で分岐させるように実装しています。 + +```typescript +const baseParamSchema = z.object({ + required: z.boolean(), + type: z.unknown(), // 各サブスキーマで具体的な型に + value: z.unknown(), // 各サブスキーマで具体的な型に + readonly: z.object({ + // フォームで入力しない値 + title: z.string(), + description: z.string(), + }), +}); + +const textParamSchema = baseParamSchema.extend({ + type: z.literal("text"), // 👈 typeで分岐 + value: z.object({ + text: z.string(), + }), +}); + +const dateParamSchema = baseParamSchema.extend({ + type: z.literal("date"), // 👈 typeで分岐 + value: zodDateValue, +}); + +// Union型で統合 +export const paramSchema = z.union([ + textParamSchema, + dateParamSchema, + monthParamSchema, + yearParamSchema, + dateRangeParamSchema, + monthRangeParamSchema, + yearRangeParamSchema, +]); +``` + +コンポーネント側では以下のように利用します。 + +```html + + + +``` + +### readonlyフィールド(フォームで入力しない値)の活用 + +フォームでは入力しない値もスキーマに含めて定義することにしています。 + +```typescript +readonly: z.object({ + title: z.string(), // フィールドのラベル + description: z.string(), // フィールドの説明 +}), +``` + +この構成にしておくと、フォームデータと表示用データを1つのオブジェクトで管理できます。とくに配列操作において恩恵が大きく、追加・削除する際に表示情報も一緒に管理でき、画面表示やAPI送信時のデータの扱いが楽になります。 +一緒に管理しない場合、フォームの入力とAPIから取得したデータを突き合わせる必要が出てきてしまうのでこのようにしました。 + +```typescript +// 配列に要素を追加する際、表示情報も一緒に管理 +const handleAddParam = () => { + push({ + type: "text", + value: { text: "" }, + required: true, + readonly: { + title: "新しいフィールド", + description: "説明文", + }, + }); +}; +``` + +フォームの入力値との区別がつきやすいように `readonly` の下に定義することとしましたが、これは議論の余地があるかもしれません。 + +### グループ入力のバリデーション + +`refine`を使って複数のinputを組み合わせたチェックを行なっています。個々のinputではなくルートにエラーメッセージを登録し、画面側の表示ロジックを工夫することでエラーの優先順位を調整しています。 + +```typescript +const dateParamSchema = baseParamSchema + .extend({ + type: z.literal("date"), + value: zodDateValue, + }) + .refine( + (data) => { + if (!data.required) return true; + return data.value.year && data.value.month && data.value.day; + }, + { + message: "日付を入力してください", + path: ["value"], + } + ); +``` + +コンポーネントのエラー表示は以下のようにしています。 +ルートのエラーを優先し、ルートのエラーがない場合は個別のフィールドのエラーを表示するようにしました。 + +```html +
+

+ {{ rootErrorMessage }} +

+ +
+``` + +## 日付入力の実装について + +[デジタル庁のデザインシステム](https://design.digital.go.jp/dads/components/date-picker/accessibility/)では、年月日を個別の入力フィールドに分けることが推奨されており、今回はそれに準拠した形で実装を行ないました。 +しかし、この方式を採用すると、以下の点で実装が複雑になります。 + +- 3つのフィールドを1つのバリデーション対象として扱う必要もあり、個別のエラーとグループ全体のエラーを使い分ける必要がある +- 3つのフィールド全体からフォーカスが外れたことを検知するフォーカス管理も必要 + +この複雑さに対処するため、上で実装した`useField`を使ったカスタムコンポーネントを実装しています。 + +この章では、上では触れなかった部分についてもう少し補足します。 + +### `type="number"` ではなく `type="text"` を利用する + +年月日の入力には`type="text"`を採用しています。 + +```html + +/> +``` + +理由は以下の通りです。 + +- **ゼロ埋め対応** + - `type="number"`では先頭のゼロが自動的に削除されてしまう + - しかし日付では「03月」「09日」のようにゼロ埋めした入力を許容したい +- **そもそもの用途の違い** + - `type="number"`は本来、数量や個数など計算対象となる数値のためのもの + - [HTML Standard]()でも「数字のみで構成されているが、厳密には数値ではない入力には`type="number"`の使用は適さない」といったことが記載されている + - > The type=number state is not appropriate for input that happens to only consist of numbers but isn't strictly speaking a number + +### 日付入力のZodスキーマ定義 + +日付入力の具体的なZodスキーマ実装の一部を紹介します。 +年月日の各要素は以下のように定義することで、textとして入力しつつ数値としての妥当性をチェックしています。 + +```typescript +export const zodYear = z + .string() + .refine((val) => val === "" || !isNaN(Number(val)), { message: "年は数値で入力してください" }) + .refine((val) => val === "" || Number(val) >= DATE_CONFIG.year.min, { + message: `年は${DATE_CONFIG.year.min}以上で入力してください`, + }) + .refine((val) => val === "" || Number(val) <= DATE_CONFIG.year.max, { + message: `年は${DATE_CONFIG.year.max}以下で入力してください`, + }); + +export const zodMonth = z + .string() + .refine((val) => val === "" || !isNaN(Number(val)), { message: "月は数値で入力してください" }) + .refine((val) => val === "" || Number(val) >= DATE_CONFIG.month.min, { + message: `月は${DATE_CONFIG.month.min}以上で入力してください`, + }) + .refine((val) => val === "" || Number(val) <= DATE_CONFIG.month.max, { + message: `月は${DATE_CONFIG.month.max}以下で入力してください`, + }); + +export const zodDay = z + .string() + .refine((val) => val === "" || !isNaN(Number(val)), { message: "日は数値で入力してください" }) + .refine((val) => val === "" || Number(val) >= DATE_CONFIG.day.min, { + message: `日は${DATE_CONFIG.day.min}以上で入力してください`, + }) + .refine((val) => val === "" || Number(val) <= DATE_CONFIG.day.max, { + message: `日は${DATE_CONFIG.day.max}以下で入力してください`, + }); +``` + +上記は全画面で共通の基本的なスキーマを定義しましたが、各画面のスキーマでは、範囲チェックなど画面固有のスキーマを定義しています。 + +```typescript +const dateRangeParamSchema = baseParamSchema + .extend({ + type: z.literal("date_range"), + value: zodDateRangeValue, + }) + .refine( + (data) => { + // 3段階目: 範囲の制約チェック + if (!data.value.from.year || /* ... */) return true; + return isWithinYears(data.value.from, data.value.to, CONFIG.range.date.maxYears); + }, + { message: `範囲は${CONFIG.range.date.maxYears}年以内で入力してください`, path: ["value"] } + ); +``` + +他にも、「必須チェック」や「年月日すべて入力したかのチェック」なども実装しているので、より詳しくはサンプルリポジトリのコードを参照してください。 + +- https://github.com/hasehiro0828/sample-vue-form/blob/main/sample-vue-form-frontend/src/lib/schemas/date-schemas.ts +- https://github.com/hasehiro0828/sample-vue-form/blob/main/sample-vue-form-frontend/src/pages/NestForm/form-schema.ts + +## まとめ + +[vee-validate](https://vee-validate.logaretm.com/v4/)と[Zod](https://zod.dev/)を使ったVue.jsでのフォーム開発について紹介しました。 + +[@tanstack/vue-query](https://tanstack.com/query/latest/docs/framework/vue/overview)との組み合わせやユーザビリティを考慮したエラー表示、アクセシビリティを踏まえた実装など、かなり実践的な内容も盛り込みました。 + +この記事で紹介した内容をおさえていれば、基本的にはどのようなフォーム開発にも対応できるはずです。 + +ぜひ、サンプルリポジトリも参照して、実際の開発にご活用ください! + +https://github.com/hasehiro0828/sample-vue-form + +最後までお読みいただきありがとうございました。 diff --git a/source/images/2025/20251016a/thumbnail.png b/source/images/2025/20251016a/thumbnail.png new file mode 100644 index 00000000000..14babeaf408 Binary files /dev/null and b/source/images/2025/20251016a/thumbnail.png differ diff --git a/source/images/2025/20251016a/top.png b/source/images/2025/20251016a/top.png new file mode 100644 index 00000000000..0679cfb5e4b Binary files /dev/null and b/source/images/2025/20251016a/top.png differ diff --git a/source/images/2025/20251016b/thumbnail.jpg b/source/images/2025/20251016b/thumbnail.jpg new file mode 100644 index 00000000000..cd93939b1d4 Binary files /dev/null and b/source/images/2025/20251016b/thumbnail.jpg differ diff --git a/source/images/2025/20251016b/top.jpg b/source/images/2025/20251016b/top.jpg new file mode 100644 index 00000000000..c2bd338b135 Binary files /dev/null and b/source/images/2025/20251016b/top.jpg differ