Nuxt 3 アップデートで VeeValidate v3 から v4 に移行するには

Nuxt 3 アップデートで VeeValidate v3 から v4 に移行するには|ANDPAD Advent Calendar 2022

この記事は ANDPAD Advent Calendar 2022 の 5日目の記事となります。

ANDPADでフロントエンドエンジニアをしている小泉(@ykoizumi0903)です。Vue / Nuxt.js での開発を行っています。

さて、Vue・Nuxt 界隈でこの年末ホットな話題と言えば、何といっても Nuxt 3 が正式リリースされたことではないでしょうか。

nuxt.com

非常に多くの新機能や改善が盛り込まれた魅力的なメジャーアップデートとなっており、個人的にもNuxt3の魅力をまとめた記事(祝・正式リリース!5つのテーマで理解する Nuxt3 の魅力)を自発的に書くくらいにテンションが上がる内容となっていました。

アンドパッド社内でも、早速 Nuxt 2 を採用している各プロジェクトそれぞれで、Nuxt 3 バージョンアップに向けた取り組みが徐々にスタートしています。

Slack上での問題の相談や知見の共有も活発に交わされており、Googleで検索しても出てこないことが社内ドキュメントに載っていると言われるほどの細かいノウハウが蓄積されつつあります。

今回はそんな知見の1つとして、Nuxt 2 で利用していた VeeValidate というライブラリを Vue3 対応バージョンにアップデートし、それを Nuxt 3 のプラグインに登録して利用可能にするためのアプローチについてまとめてみました。

VeeValidate の バージョンアップ

VeeValidate は、Vue でフォーム入力を行う際のバリデーション補助ライブラリです。

github.com

Vue 公式ドキュメントでも言及されているくらいのメジャーなライブラリなので、利用したことがある、または利用している方も多いのではないでしょうか。

こちらのライブラリ、ありがたいことに既に Vue 3 に対応した v4 が提供されているのですが、かなり破壊的な変更が含まれています。

ライブラリ開発者自身が「全く別のライブラリだと思ってください」とコメントしているほどにAPIが変わっており、移行ガイドも用意されていません。

しかし、ライブラリを利用している私たちがアップデートに際して行うことは、別のライブラリであろうとなかろうと、できる限りアプリケーション上の振る舞いを変えることなくコードを移行すること、ただそれだけです。

というわけで、 VeeValidate を V3 から V4 に上げる際にどうすれば良いのかを試行錯誤することにしました。

※ここからの記述は、あくまで私自身の使い方で vee-validate v3 から v4 に上げる場合に「こうすれば動いた」という話であり、ライブラリの想定する使い方を外れている場合がありますので、必ず公式ドキュメントを参照してください。

前提

Nuxt 2 から 3 に上げる方法自体を説明するとキリがないので、この記事では紹介していません。

VeeValidate を登録していたプラグインを一度削除し、Nuxt 3 で起動できるように Nuxt Config などの設定周りを全て直して、 [Vue warn]: Failed to resolve component: validation-provider のメッセージがコンソールに出るようになったところがスタート地点となっています。

また、ドキュメントを読む限り、VeeValidate の v3 は基本的にコンポーネント上でバリデーションロジックを記述していたのに対し、 v4 では useForm といった関数が提供され、Composition API によって script タグにロジックを移動する記法が推奨されているようです。

個人的にも、バリデーションロジックは本来 script タグに移動させるべきだと思います。この記事はあくまで「最小の書き換え工数で v3 から v4 に移行する」場合の方法と考えてください。

プラグインファイルの修正

まず、Vue2 の Nuxt Plugin では以下のようなファイルで、初期化とグローバルコンポーネント登録を行っていました。

plugins/vee-validate.ts

import Vue from 'vue'
import {
  ValidationProvider,
  ValidationObserver,
  configure,
  localize,
  extend,
} from 'vee-validate'
import ja from 'vee-validate/dist/locale/ja'
import * as rules from 'vee-validate/dist/rules'

Object.entries(rules).forEach(([id, validator]) => {
  extend(id, validator)
})
Vue.component('ValidationProvider', ValidationProvider)
Vue.component('ValidationObserver', ValidationObserver)

localize('ja', ja)
  1. VeeValidate 自体と、日本語の設定ファイルの読み込み
  2. 基本的なルールセットをまとめて登録する
  3. <ValidationProvider> / <ValidationObserver> をグローバル登録する
  4. localize で日本語をデフォルト設定にする

個別のカスタムルールの登録がある場合は2と3の間でさらに extend() しているかもしれませんが、概ねこのような記述になっているのではないかと思います。

こちらを Vue 3 / Nuxt 3 向けのプラグインに書き換えていきます。

まず、vee-validate v4 では i18n と rules が別パッケージに分かれているので、それぞれインストールします。

npm i -D vee-validate @vee-validate/i18n @vee-validate/rules

vee-validate は version 4 がインストールされることを確認してください。

プラグインを以下のように書き換えます。

import { configure, defineRule, Form, Field, ErrorMessage } from 'vee-validate'
import { localize, setLocale } from '@vee-validate/i18n'
import ja from '@vee-validate/i18n/dist/locale/ja.json'
import AllRules from '@vee-validate/rules'

export default defineNuxtPlugin((nuxtApp) => {
  Object.entries(AllRules).forEach(([id, validator]) => {
    defineRule(id, validator)
  })
  // カスタムルールがある場合はここで defineRule

  nuxtApp.vueApp.component('ValidationForm', Form)
  nuxtApp.vueApp.component('ValidationField', Field)
  nuxtApp.vueApp.component('ValidationErrorMessage', ErrorMessage)
  configure({
    generateMessage: localize({ ja }),
  })
  setLocale('ja')
})

Nuxt 3 のプラグインの書き方の説明は割愛します。詳しくは Nuxt 3 の プラグインの説明を参照してください。

基本的には、これまでファイルのルートに書いていた処理を、export default defineNuxtPlugin の中に移せば良いと考えれば大丈夫です。(defineNuxtPlugin は Auto Import です)

それ以外、VeeValidateの記述としては、関数名やインポート元がいろいろ変わっているものの、全体的な記述としては大きくは変わっていないので、見れば理解できるのではないでしょうか。

validation-provider validation-observer というコンポーネントはなくなりました。テンプレートの修正方法は後述しますが、ここでは <Form> <Field> <ErrorMessage> という3つに再編されたと理解してください。

https://vee-validate.logaretm.com/v4/guide/components/validation/

グローバル登録するにあたって <Form> の1単語では良くないので、Vue 2 までの <ValidationProvider> などに合わせる形で Validation を接頭辞に付けています。

グローバルコンポーネントの型宣言

これだけで使えるようになっているのですが、このままだとグローバルコンポーネントは any 型となってしまうので、 <template> タグ上の TypeScript サポートも効くようにしておきます。

import { configure, defineRule, Form, Field, ErrorMessage } from 'vee-validate'
import { localize, setLocale } from '@vee-validate/i18n'
import ja from '@vee-validate/i18n/dist/locale/ja.json'
import AllRules from '@vee-validate/rules'

declare module '@vue/runtime-core' {
  export interface GlobalComponents {
    ValidationForm: typeof Form
    ValidationField: typeof Field
    ValidationErrorMessage: typeof ErrorMessage
  }
}

といっても、上記のプラグインファイルにこのような記述を追加するだけです。(tsファイルに型定義を書くのが気になる方は、 d.ts ファイルを別の場所に作って書くなどしてください)

これだけで、どのVueファイルからでも型推論され、足りない props などがあるとエラーが出るようになりました。素晴らしいです。

コンポーネントの変更点を見ていく

<template> タグを書き換える前に、VeeValidate V3でのコンポーネントがどのように変わったかを見ていきます。

<validation-observer>

バリデーションの親となるコンポーネントでした。基本的には v3 で <validation-observer> コンポーネントを使っていた箇所は v4 でそのまま <Form> に置き換えれば良さそうです。

違いとして、HTMLタグをデフォルトから変更したい場合に、 v3 では tag="div" のように指定していましたが、v4では as="div" となりました。

また、v3 では明示的にバリデーションをリセットする場合に、 reset() を呼び出せば良かったのですが、v4 では resetForm() に変更されています。

このあたりの変更は、const observerRef = ref<InstanceType<typeof Form>>() というようにテンプレート上のコンポーネントと紐づけるref に InstanceType で型引数を渡してあげると、有効なメソッドかどうかがTSエラーで判別できるようになって便利です。

参照:Vue Component の型に困ったらとりあえず InstanceType を使おう

<validation-provider>

ここからがそもそもの設計から変わってくる箇所になります。

※以下、「inputタグ」と呼んでいるものは、「inputタグに限らず、selectタグやその機能を持つUIコンポーネントなど、v-modelに紐づけられるタグ全般」と考えてください。

まず、v3 における validation-provider は、「inputタグを囲むことで、その中でエラーメッセージやバリデーション状況を変数として呼び出せる」ものでした。

<ValidationProvider rules="required" v-slot="{ errors }" name="メールアドレス">
  <input v-model="value" type="email" />
  <span>{{ errors[0] }}</span>
</ValidationProvider>

validation provider 内の要素を監視することで、そのエラーを取得できる、というものです。

例えば上記のコードだと、inputタグを編集するか blur したタイミングで未入力なら「メールアドレスは必須です」というメッセージが表示されます。

これに対し、v4 における <Field> コンポーネントは、inputタグそのものに使うことで値を監視するためのものです。

<div>
  <ValidationField rules="required" v-model="value" type="email" name="メールアドレス" />
  <!-- <span>{{ errors[0] }}</span> -->
</div>

provider 内部のどの input 要素を監視しているのかを探しに行かなくて良くなり、個人的には v3 よりもわかりやすくなったように感じます。

ではエラーメッセージはどうやって表示するのか? というところで、勘の良い方はプラグインで3つのグローバルコンポーネントを登録していたことを思い出すでしょう。

<div>
  <ValidationField rules="required" v-model="value" type="email" name="メールアドレス" />
  <ValidationErrorMessage name="メールアドレス" /> 
</div>

コンポーネントに渡す props として name が必須となっており、これによってエラーメッセージと紐づくフィールドを特定しているようです。(従来通り label プロパティでメッセージと出し分けることも可能です。)

と思いきや……

<Field> コンポーネントに関するドキュメント(https://vee-validate.logaretm.com/v4/api/field)を読み進めていると、Rendering Complex Fields with Scoped Slots という項目が登場します。

<ValidationField name="password" v-slot="{ field }">
  <input v-bind="field" type="password" />
  <p>Hint: Enter a secure password you can remember</p>
</ValidationField>

ValidationField と input 要素を分離し、 :="field" で input 要素を指定するという記述方法です。

これは V3 までの <validation-provider> の記述方法により近く、こちらへの書き換えの方がスムーズに進みそうに見えます。

また、v-model を使う場合は、input要素ではなく <Field> に指定する、という記述もあります。

<!-- DONT: ⛔️  v-model on input tag -->
<Field type="text" name="name" v-slot="{ field }">
  <!-- Conflict between v-model and `v-bind=field` -->
  <input v-bind="field" v-model="name" />
</Field>
<!-- DO: ✅  v-model on field tag -->
<Field v-model="name" type="text" name="name" v-slot="{ field }">
  <input v-bind="field" />
</Field>

型定義ファイルを見ると、v-bindに渡している field こと FieldBindingObject が v-model を包含しているので、これだけ渡せば十分ということのようです。

interface FieldBindingObject<TValue = unknown> {
    name: string;
    onBlur: (e: Event) => unknown;
    onInput: (e: Event) => unknown;
    onChange: (e: Event) => unknown;
    'onUpdate:modelValue'?: ((e: TValue) => unknown) | undefined;
    value?: unknown;
    checked?: boolean;
}

ValidationProvider から ValidationField へ

方針が決まったところで、実際に <template> タグを書き換えていきます。

<ValidationProvider v-slot="{ errors }">
  <input v-model="value" type="text" />
  <span>{{ errors[0] }}</span>
</ValidationProvider>

v3でこのように記載していたものは、v4では以下のようになりました。

<ValidationField v-slot="{ field, errors }" v-model="value">
  <input type="text" :="field" />
  <span>{{ errors[0] }}</span>
</ValidationField>

v3に倣って errors[0] と書いていますが、errorMessage という string に変えることも可能です。

rules などの props は共通しているので、結構スムーズに移行できるはずです。

Validation State

v3 では invalid validated touched など、特定の状態が変化した場合のみバリデーションエラーを表示できるような validation state が用意されていました。

<ValidationProvider
  name="field"
  rules="required"
  v-slot="{ errors, invalid, validated }"  >
  <input v-model="value" />
  <span v-show="invalid && validated">{{ errors[0] }}</span>
</ValidationProvider>

v4 ではこちらは Validation Metadata として、metaオブジェクトに入っています。

また、さすがに多すぎると思われたのか、 touched, dirty, valid, validated, pending の5つのみのサポートに縮小されています。

とはいえ、例えば invaliduntouched はそれぞれ valid / touched が false の時と同じですし、ほとんどのユースケースには対応できそうに見えます。今回のアプリケーションでは invalid と validated しか使っていないので助かりました。

<ValidationField
  name="field"
  rules="required"
  v-slot="{ field, errors, meta: { valid, validated } }"  >
  <input :="field" />
  <span v-show="!valid && validated">{{ errors[0] }}</span>
</ValidationField>

element-plus と組み合わせる

通常の <input> ではなく、UIライブラリが提供しているコンポーネントを使っていると、 :="field" を渡すだけでは上手く動作しない場合がありました。

今回のアプリケーションでは Element UI の Vue3 バージョンである Element Plus を利用しているため、 <el-input> に渡す場合にタイプエラーが発生しました。

これについては以下のissueでも触れられていますが、例えば :value ではなく :model-value を要求している場合に起きます。(Vue 3 ではカスタムコンポーネントとそれ以外で v-model に紐づくプロパティが変わります)

github.com

<ValidationField
  name="field"
  rules="required"
  v-slot="{ field, handleChange, errors, meta: { valid, validated } }"  >
  <el-input
    :="field"
    :model-value="field.value"
    @update:model-value="handleChange"
  />
  <span v-show="!valid && validated" >{{ errors[0] }}</span>
</ValidationField>

このように、 modelValueonUpdate:modelValue を明示的に渡すことができます。

(ただし、"onUpdate:modelValue" については field に含まれているので、明示的に渡す必要があるのは :model-value の方だけです)

v-bind="field" という書き方が、単に複数の v-bind / v-on をまとめて渡しているだけだということを理解すれば、何が不足しているかは案外気づきやすいのではないかと思います。

おわりに

最初に書いた通り、VeeValidate ライブラリは V3 から V4 で大きく変更されており、どれほど大変なのか戦々恐々としていたのですが、読み込んでいくと想像よりは少ない変更量で書き換えることができ、また修正後のコードは以前よりも型安全で読みやすいコードになりました。

このライブラリに限らず、Nuxt 2 から 3 への移行では、ドキュメントや知見がまだ少なくハマりどころが多い一方、開発環境そのものは間違いなく以前よりも改善されています。

紹介したようにグローバルコンポーネントの型チェックも簡単に効くようになっていたりと、その恩恵を感じられる機会も多く、頑張ってバージョンアップするだけの価値は十分に得られそうだと考えています。

今後も社内で協力しながら Nuxt 3 バージョンアップに向けて取り組んでいく予定です。

アンドパッドではエンジニアの積極採用中です。このような取り組みや開発環境に興味がありましたら、ぜひお気軽にカジュアル面談などご参加ください!

engineer.andpad.co.jp

明日は社内で行われている建築・建設業界解像度アップ勉強会についてのブログが公開されるそうです。
エンジニアからもデザイナーからも人気の勉強会です。お楽しみに!