Webフロントエンドの入力とバリデーションは、煩雑なのに手を抜きづらいという、なかなか難しいテーマとして長らく存在し続けています。
最近のSPA開発では、ReactやVue向けに、専用のライブラリが提供されていることも少なくありません。しかしながら、それらは得てして特定のフレームワークに依存しており、かつ解決したい課題に対してオーべースペックであることが少なくありません。
そうして簡単なフォーム作成の時に使える丁度良いフレームワークはないか悩んでいたところ、サーバーサイドJS向けのバリデーションライブラリである hapijs/joi が非常にマッチしていること、そして、その joi が、ブラウザ向けに joi-browser として移植されていることを知り、実際に案件に投入したところ丁度良い塩梅で便利だったのでご紹介いたします。
Joi (オリジナルNode.js版) の特徴と使い方
Joi は、シンプルなオブジェクトのスキーマ定義を行い、それをエンティティの形式として扱うことができるライブラリです。
例えば、ユーザーID, メールアドレス, 12文字以上のパスワードをもつ「User」のデータを定義したい場合は、以下のように定義します。
import Joi from 'joi'
const User = Joi.object().keys({
user_id: Joi.string().required(),
email: Joi.string().email(),
password: Joi.string().min(12).required()
})
こうすることで、全ての要素が存在し、かつemailがメールアドレスの正規表現にマッチし、passwordが12文字以上という条件を満たした場合のみValidとするUserのスキーマを定義することができます。
「全ての要素が存在し」という条件を required() で、メールアドレス形式を email() で、最小文字数を min() で定義しており、それぞれの条件をメソッドチェーンでつなぐことにより、複合条件でのバリデーションまで担保してくれます。
そして、実際のチェックは下記のように行います。
const Joi = require('joi')
const User = Joi.object().keys({
user_id: Joi.string().required(),
email: Joi.string().email(),
password: Joi.string().min(12).required()
})
const payload = {
user_id: 'foo',
email: 'invalid_string',
password: '12345678'
}
const result = Joi.validate(payload, User, { abortEarly: false })
if (result.error) {
console.log(result.error.details)
}
エラーが有る場合はresult.errorがObjectに、ない場合はnullとなるmixedなので、このように判定してやることで検証を通ったかどうかを確認することができます。
Joi.validateの第二引数はオプションとなっており、abortEarlyをfalseにしないと、何か一つでも検証に引っかかった段階でabortされてしまうので、通常はfalseにしておくと良いでしょう。
この場合、実行結果は以下のようになります。
$ node index.js | jq
[
{
"message": "\"email\" must be a valid email",
"path": [
"email"
],
"type": "string.email",
"context": {
"value": "invalid_string",
"key": "email",
"label": "email"
}
},
{
"message": "\"password\" length must be at least 12 characters long",
"path": [
"password"
],
"type": "string.min",
"context": {
"limit": 12,
"value": "12345678",
"key": "password",
"label": "password"
}
}
]
デフォルトでのメッセージは英語ですが、外部注入できるほか、そこまでコストも重くないので、typeから確認して独自で表示層を作ってやっても良いでしょう。
フロントエンドでの利用方法
さて、肝心のフロントエンドでの利用方法です。試しにVue.jsで利用してみます。
なお、今回はCodePenでのデモのため、Single File Componentスタイルではなく、単純にHTML/CSS/JSと分けて書いています。
まずはデモとソースコードから。
See the Pen Vue.js and joi example by Potato4d (@potato4d) on CodePen.
<div id="app">
<form>
<p>
<label for="user_id">UserID</label>
<input @blur="validate('user_id')" type="text" v-model="formData.user_id" id="user_id">
<span class="error" v-if="errors.user_id"><br>ユーザーIDが不正です</span>
</p>
<p>
<label for="email">Eメールアドレス</label>
<input @blur="validate('email')" v-model="formData.email" type="text" id="user_id">
<span class="error" v-if="errors.email"><br>メールアドレスが不正です</span>
</p>
<p>
<label for="password">パスワード</label>
<input @blur="validate('password')" v-model="formData.password" type="text" id="password">
<span class="error" v-if="errors.password"><br>パスワードが不正です</span>
</p>
<p>
<input type="submit" :disabled="!isValid">
</p>
</form>
</div>
new Vue({
el: '#app',
data () {
return {
formData: {
user_id: '',
email: '',
password: ''
},
errors: {
user_id: false,
email: false,
password: false
},
touched: {
user_id: false,
email: false,
password: false
}
}
},
methods: {
validate (name) {
this.touched[name] = true
Object.keys(this.errors).forEach((k)=>{
this.errors[k] = false
})
const result = Joi.validate({...this.formData}, this.schema, { abortEarly: false })
console.log(result)
if (result.error) {
result.error.details.forEach((detail) => {
const name = detail.path[0]
if(this.touched[name]) this.errors[name] = true
})
}
}
},
computed: {
isValid () {
let isValid = true
Object.entries(this.errors).forEach(([k,v]) => {
if(v) isValid = false
})
Object.entries(this.touched).forEach(([k,v]) => {
if(!v) isValid = false
})
return isValid
},
schema () {
return Joi.object().keys({
user_id: Joi.string().required(),
email: Joi.string().email(),
password: Joi.string().min(12).required()
})
}
}
})
joi-browser の利用
冒頭で書きましたが、ブラウザで利用する際は joi ではなく joi-browser を使うこととなります。
このデモではCDNから読まれていますが、実際は
$ yarn add joi-browser
import Joi from 'joi-browser'
という形でパッケージを呼び出して使うことになるでしょう。なお、一部サーバーサイドでしか取れない値についてのみ互換性を失っていますが、使い方は本家と同じです。
touched 判定の実装
joi はもともとサーバーサイドで利用するためのライブラリであったため、 touched (一度でもその項目を編集しようとしたかどうか) の判定を持ちません。
これに関しては少々不便ですが、自分で実装してやる必要があります。
この実装を行わない場合、「まだクリックしていないフォームも一緒にバリデーションが走ってエラーメッセージが出てしまう」などの不具合の原因となりますので、touchedは用意しておくと良いでしょう。
Vue.js の場合、 watch フックを利用する、もしくは @blur
をかませると良いでしょう。
今回はわかりやすさを優先して @blur
としています。
エラーの出力
joi の使い方で書いたとおり、 result.error.details
にエラーの配列が返ってくるので、これを forEach で回してやって touched 判定だけをして表示するのが簡単でしょう。
終わりに
このように、 joi を利用することでTypeScriptのインターフェースを定義する程度のライトさで、手軽にエンティティのスキーマを定義し、それに基づいたバリデーションを作成することができます。
joi だけの知識があれば、フロントエンド/サーバーサイド、そして、それぞれのフロントエンドフレームワークに依存せず、どこでも汎用的に利用できるバリデーションを作成できます。
実装量が増えれば増えるほど、専用のバリデーターの恩恵を受けやすくなることには間違いありませんが、ある程度の規模までは joi を使うことで非常に手軽かつ柔軟なバリデーションを使うことでラクに開発を進めることができます。