🧬

VuetifyのVFormバリデーションはどうやって作られている?OSS実例から学ぶ実装ナレッジ

に公開

こんにちは!!
株式会社ミラボ 開発部のSoraY677です。

早速ですがみなさま、Vuetifyをご存知でしょうか?

https://vuetifyjs.com/ja/

Vue用に開発されたOSSのUIライブラリであり、デザインがあまりわからなくてもVuetify側が用意したコンポーネントを配置していくだけでUI/UXを実現できる優れものです。今回はVFormのバリデーションに着目し、その仕組みや簡単なクローンの作成をしてみたいと思います。

便利なVFormのバリデーション機能

VFormは情報の登録などに使うフォームを開発する上で有用で、なんといってもバリデーション機能がとても便利です。

https://vuetifyjs.com/ja/components/forms/

例えば以下のように、VForm内にVTextFieldのような入力要素を入れ、バリデーション内容を書いたルールを指定することができます。このルールに基づいて、各入力に対してバリデーションを行いボタンの有効/無効を切り替えるという、わかりやすいフォームを作ることができます。

<template>
  <v-form v-model="isValid">
    <v-text-field
      :rules="[(value) => {
            if (value) return true
            return '入力必須です!'    
        }]"
    />
    <v-btn :disabled="!isValid">Submit</v-btn>
  </v-form>
</template>

<script setup lang="ts">
  import { ref } from 'vue'

  const isValid = ref(false)
</script>

上記では、VForm直下にVTextFieldが来る例を紹介しましたが、もっとコンポーネントが入れ子になっていてもバリデーションは正しく動きます。一見VFormとVTextFieldは変数などを共有していないように見えますが、どうやってバリデーションが動いているのでしょうか?

その実装の核となるポイントはVueが提供するprovide/injectです!

provide/inject

Vueあるあるですが、コンポーネントが入れ子になっている時、目的のコンポーネントまでバケツリレー式で値を渡さなければならない状況がありますよね。

コンポーネントが入れ子になっている例
parent.vue
<template>
  <child :value="value" />
</template>

<script setup lang="ts">
  import child from "@components/child.vue"

  const value="water" // 渡したい値
</script>
child.vue
<template>
  <grand-child :value="props.value" />
</template>

<script setup lang="ts">
  import grandChild from "@components/grand-child.vue"

  const props = defineProps<{
    value: string
  }>()
</script>
grand-child.vue
<template>
  give me {{ props.value }}!
  <!-- -> "give me water!" -->
</template>

<script setup lang="ts">
const props = defineProps<{
  value: string
}>()
</script>

Vueの提供するprovide/injectを使うと、深い入れ子になっても値の受け渡しをスムーズにできます。

https://ja.vuejs.org/guide/components/provide-inject

具体的には以下のように、値の受け渡しを行う元のコンポーネントと先のコンポーネントでprovideinjectを設定してやれば、階層を意識せず値のやり取りができるのです。

parent.vue
<template>
  <nest-component />
</template>

<script setup lang="ts">
  import { provide } from 'vue'
  import nestComponent from '@components/nest-component.vue'
  // ↑このコンポーネントでさらに入れ子になって、その先に渡したいコンポーネントが存在

  provide(
      'pass-key', // 渡す先と合わせる
      'water' // 渡したい値
  )
</script>
super-nested.vue
<template>
  give me {{ content }}!
  <!-- "give me water!" (注入失敗時: "give me any!") -->
</template>

<script setup lang="ts">
import { inject } from 'vue'

const content = inject(
    'pass-key', // 渡す元と合わせる
    'any' // 取得に失敗した時の値
)
</script>

上記の例ではシンプルに文字列の受け渡しを行いましたが、より複雑な関数の受け渡しなども可能で、Vuetifyではこの技術を活かしています。

実装

それでは実際にVuetifyのOSSコードを見ながら、どのようにVFormのバリデーションが動いているか見ていきましょう。

https://github.com/vuetifyjs/vuetify

まずは、provideの部分です。
実装としては、いくつかの関数を渡すようになっており、中でも注目すべきはregisterとunregisterです。それぞれの関数の実行により、VFormが監視対象にできるよう、各フィールドを登録・解除します。具体的にはVFormでは入力フィールドの情報保管用のrefオブジェクトを作り出すのですが、registerではそこに入力フィールド情報を追加し、unregisterでは入力フィールド情報を削除します。こうすることで、登録された入力フィールドに対してVFormがあれこれできるようになるわけです。

https://github.com/vuetifyjs/vuetify/blob/b0ea2d3725f6688be0a8f4c16df412f511511518/packages/vuetify/src/composables/form.ts#L145-L165

続いてinjectの部分です。provideと同じファイル内に定義されているのがわかりますが、useForm 内でinjectが実行されるのがわかると思います。

https://github.com/vuetifyjs/vuetify/blob/b0ea2d3725f6688be0a8f4c16df412f511511518/packages/vuetify/src/composables/form.ts#L195-L202
 
したがってバリデーションしたい要素からuseFormを呼び出し、registerやunregister関数を正しく設定すれば、VFormの入力フィールドとしてバリデーション対象に設定できるという仕組みになっています。
VFormはさらにいくつかのコンポーザブルを介しますが、大枠の作りとしてはそうなっています。

https://github.com/vuetifyjs/vuetify/blob/b0ea2d3725f6688be0a8f4c16df412f511511518/packages/vuetify/src/composables/validation.ts#L81-L149

簡素版クローンを作ってみる

ここまででVFormの作りを学びましたが、このバリデーションを動作させる一連の仕組みは流用できそうですよね!ここからは学んだことを活かして、簡素版のクローンを作ってみましょう。

provideとinjectを包括したコンポーザブルの作成

まずはprovideとinjectの実装です。共通部分が多く存在しているため、以下のようなフォーム用とフィールド用のコンポーザブルにそれぞれシンプルにまとめました。

form.ts
import {
  ref,
  watch,
  inject,
  provide,
  onBeforeMount,
  useId,
  onBeforeUnmount,
  onMounted,
  Ref,
} from "vue"

const FORM_KEY = "xxx"

/**
 * フォームコンポーネントが使うコンポーザブル
 */
export function useFormManager() {
  const items = ref<
    { id: string, validate: () => string, isValid: Ref<boolean> }[]
  >([])
  const isValidAll = ref<boolean>(true)

  watch(items, () => {
    isValidAll.value = items.value.every(item => item.isValid)
  }, {deep: true})

  provide(FORM_KEY, {
    register: (value: { id: string, validate: () => string }) => {
      items.value.push({
        ...value,
        isValid: false,
      })
    },
    unregister: (id: string) => {
      items.value = items.value.filter((item) => {
        return item.id !== id
      })
    },
    update: (id: string, isValid: boolean) => {
      const found = items.value.find(item => item.id === id)
      if (!found) return
      found.isValid = isValid
    },
    items,
  })

  return {
    isValidAll,
  }
}

/**
 * フィールドコンポーネントが使うコンポーザブル
 */
export const useValidation = <T>(
  model: Ref<T | undefined>,
  validate?: (value: T) => string
) => {
  const isValid = ref<boolean>(true)
  const errorMessage = ref<string>("")

  const form = inject<{
    register: (p: { id: string, validate: () => void }) => void,
    unregister: (id: string) => void,
    update: (id: string, isValid: boolean) => void,
  } | null>(FORM_KEY, null)

  // コンポーネント固有のID
  const id = useId()

  onBeforeMount(() => {
    form?.register?.({
      id,
      validate: () => {
        if (!validate || model.value == null) return
        const validatedResult = validate(model.value)
        errorMessage.value = validatedResult
        isValid.value = !validatedResult
      },
    })
  })

  onMounted(() => {
    if (!validate || model.value == null) return
    const validatedResult = validate(model.value)
    form?.update?.(id, !validatedResult)
  })

  onBeforeUnmount(() => {
    form?.unregister?.(id)
  })

  watch(model, () => {
    if (!validate || model.value == null) return
    const validatedResult = validate(model.value)
    errorMessage.value = validatedResult
    form?.update?.(id, !validatedResult)
  })

  return {
    errorMessage
  }
}

フォームコンポーネントの作成

作成したコンポーザブルをそれぞれ使って、以下のようにフォーム・フィールド用のコンポーネントを定義してみます。

CustomForm.vue
<template>
  <form>
    <slot /> 
  </form>
</template>

<script setup lang="ts">
import { watch } from 'vue'
import { useFormManager } from '@composables/form'

const { isValidAll } = useFormManager()
const model = defineModel()
watch(isValidAll, () => { 
  model.value = isValidAll.value  
})
</script>
CustomTextInput.vue
<template>
  <input v-model="model"/>
  <p>{{ errorMessage }}</p>
</template>

<script setup lang="ts">
import { useValidation } from '@composables/form'

const model = defineModel<string>()
const { rule } = defineProps<{
  rule: (value: string) => string
}>()

const { errorMessage } = useValidation(model, rule)
</script>

フォームを動かしてみる

必要なパーツが揃えられました。では、かなり簡単なフォームですがVuetifyと同じようにルールを作成してバリデーションを試してみます。

App.vue
<template>
  <CustomForm v-model="isValid">
    <CustomTextInput 
      v-model="input"
      :rule="(value) => value ? '' : 'required!'"
    />
    <button :disabled="!isValid" @click="clickHandler" type="button">実行!</button>
  </CustomForm>
</template>

<script setup>
import { ref } from 'vue'
import CustomForm from '@components/CustomForm.vue'
import CustomTextInput from '@components/CustomTextInput.vue'

const input = ref('')

const isValid = ref(true)
const clickHandler = () => {
  alert('実行!')
}
</script>

デモとして動かしてみたものが以下になります。
バリデーションの挙動がフィールドの入力によって正しく動作しているのがわかると思います。

かなり簡素化させた部分があり、本来のVuetifyの機能なども一部省略もしましたがVFormのバリデーションを再現できました!もちろんVuetifyと同様にコンポーネントのネストが深い場合でも正しく動作します。

おわりに

今回は今も広く使われているVuetifyのVFormについて、中身を見てみるところから実際のクローンの作成までを通して、その実装ナレッジを学んでみました。ドキュメントを読んでいてピンと来ていなかったようなナレッジも、実装例を観察して試してみることで現実味も出て、自分たちの開発にも活かせそうな気がしますね。

この記事が少しでもお役に立てば幸いです。では。

Milabo Engineers Blog

Discussion