• LIGの広告成功事例
WEB

フォームバリデーションを楽にするライブラリを車輪の再発明してみた

フォームバリデーションを楽にするライブラリを車輪の再発明してみた

こんにちは、フロントエンドエンジニアのいなばです。

去年はひたすらAngularJSをやっていましたが、今年はAngular2への乗り換えのためにいろいろと苦労しています。Angular2づくしの1年になりそうです。そして、去年の暮れからbabel教からTypeScript教へと改宗ました。

まだジェネリクスなどをあまり理解していない部分もありますが、TypeScriptの練習がてら、よくあるお問い合わせフォームのバリデーションライブラリを実装してみました。

リポジトリはこちら。
https://github.com/i78s/ValidateJS

対応ブラウザはモダンブラウザ(IEは10以上)、モバイルも対応しているつもりです。(なにか不具合があれば、issueを立ててもらえるとありがたいです)

jQueryのバリデーションプラグインなどではデザインによっては仕様が合わず、使いずらいことが多かったので、エラーの表示周りをHTML構造に依存させないようにしています。

また、既存のバリデーションライブラリでは、古いブラウザの対応のために設定が複雑になりがち。しかし、IE10以下はそろそろ切ってもよさそうな雰囲気を踏まえて、HTMLタグの属性で済むことはHTMLに任せることで実装をシンプルにしています。

デモ&ドキュメントはこちらです。npmにも公開したので、webpackやBrowserifyなどで適宜requireして使ってください。

インストール

必要であればsudoをつけて実行してください。

$ npm install i78s.validatejs

使い方

webpackやBrowserifyなどで適宜requireして使ってください。コンストラクタの第一引数はform要素、もしくは formについているid名を受け取ります。
第二引数は後述するオプションをオブジェクトで受け取ります。

import Validate from '../../lib/Validate';

let $form = document.getElementById('form');
let validate = new Validate($form, option);

オプション

オプションには3つのcallback関数を指定することができます。

option = {
    customValidate?: {
        [key: string]: (element: HTMLInputElement | HTMLSelectElement, form: HTMLFormElement) => {};
    }
    onCheckHandler?(element: HTMLInputElement | HTMLSelectElement, validity: ValidityState): void;
    onSubmitHandler?(): void;
}

onCheckHandler

formのinput / changeイベントが発火した際に呼ばれるcallbackを指定できます。同梱されているValidateMessagesと組み合わせることで、HTML構造に依存しないエラー文言の制御が可能です。

import Validate from '../../lib/Validate';
import ValidateMessages from '../../lib/ValidateMessages';

let validateMessages = new ValidateMessages('.js-validate-messages');
let validate = new Validate('form', {
    onCheckHandler: function(element, validity) {
        let parent = element.parentNode;
        validateMessages.update(element.name, validity);

        validateMessages.toggleClass(parent, 'has-success', validity.valid);
        validateMessages.toggleClass(parent, 'has-error', !validity.valid);
    }
});

onSubmitHandler

formのsubmitイベントが発火した際に呼ばれるcallbackを指定できます。onSubmitHandlerが未指定の場合は、form.submitが呼ばれます。

import Validate from '../../lib/Validate';

let validate = new Validate('form', {
    onSubmitHandler: function() {
        // 例
        // this.disabled(true);
        // フォームの値を取得してajaxを投げる
        // 通信完了時に this.disabled(false); で送信ボタンのdisabledを解除
    }
});

customValidate

customValidateのkeyと同じname属性を持つinput / select要素が変更された際に、カスタムバリデーションを設定することができます。

import Validate from '../../lib/Validate';

let validate = new Validate('form', {
    customValidate: {
        password: function(element, form) {
            this.trigger('change', form['passwordConfirm']);
        },
        passwordConfirm: function(element, form) {
            if (element.value !== form['password'].value) {
                element.setCustomValidity('パスワードが一致しません');
                return;
            }
            element.setCustomValidity('');
        }
    }
});

デモ

デモはこちらです。

実装

ここには貼りませんが今回はユニットテストも書いてみました。既存のライブラリよりは大分使いやすいのではないか? と思っているのですが、それ微妙じゃない? みたいなツッコミも、こっそり教えてもらえると嬉しいです。

formの制御をするクラス

src/Validate.ts

interface ValidateOption {
    customValidate?: {
        [key: string]: (element: HTMLInputElement | HTMLSelectElement, form: HTMLFormElement) => {};
    }
    onCheckHandler?(element: HTMLInputElement | HTMLSelectElement, validity: ValidityState): void;
    onSubmitHandler?(): void;
}

export default class Validate {

    static noop(): void { }

    private form: HTMLFormElement;
    private submitBtn: HTMLButtonElement;
    option: ValidateOption;
    private _submitHandler: (e: Event) => void;
    private _changeHandler: (e: Event) => void;
    private _inputHandler: (e: Event) => void;

    constructor(element: HTMLFormElement, option: ValidateOption);
    constructor(element: string, option: ValidateOption);
    constructor(
        element: any,
        option: ValidateOption = {}
    ) {

        this.form = element;

        if (typeof element === "string") {
            this.form = <HTMLFormElement>document.getElementById(element);
        }

        this.submitBtn = <HTMLButtonElement>this.form.querySelector('button');

        this.option = this.extend({
            customValidate: {},
            onCheckHandler: Validate.noop,
            onSubmitHandler: Validate.noop
        }, option);

        this._changeHandler = (e: Event) => {
            this.update(e);
        };
        this._inputHandler = (e: Event) => {
            this.update(e);
        };
        this._submitHandler = (e: Event) => {
            if (!this.isValid()) return;
            e.preventDefault();
            this.submit();
        };

        this.init();
    }

    /**
     * formの監視を開始する
     */
    init() {
        this.form.addEventListener('change', this._changeHandler);
        this.form.addEventListener('input', this._inputHandler);
        this.form.addEventListener('submit', this._submitHandler);
    }

    /**
     * formのバリデーションが通っているかを返す
     * @returns {boolean}
     */
    isValid(): boolean {
        return this.form.checkValidity();
    }
    /**
     * formの変更を元に画面を更新する
     * @param e
     */
    update(e: Event) {
        let target: any = e.target;
        target.classList.add('is-dirty');

        let customValidate = this.option.customValidate[target.name];
        if (customValidate) {
            customValidate.apply(this, [target, this.form]);
        }

        this.option.onCheckHandler.apply(this, [target, target.validity]);

        if (this.form.checkValidity()) {
            this.disabled(false);
            return;
        }
        this.disabled(true);
    }

    /**
     * changeイベントを強制発火させる
     * @param element
     */
    trigger(event: string, element: HTMLInputElement | HTMLSelectElement) {
        let e = document.createEvent('HTMLEvents');
        e.initEvent(event, true, true);
        element.dispatchEvent(e);
    }

    /**
     * 送信ボタンのdisabledを切り替える
     * @param bool
     */
    disabled(bool: boolean) {
        if (bool) {
            this.submitBtn.setAttribute('disabled', 'disabled');
            return;
        }
        this.submitBtn.removeAttribute('disabled');
    }

    /**
     * formを送信する
     * オプションでonSubmitHandlerが設定されていなければform.submitを発火する
     */
    submit() {
        if (!this.isValid()) {
            return;
        }

        if (this.option.onSubmitHandler !== Validate.noop) {
            this.option.onSubmitHandler.apply(this, []);
            return;
        }
        this.form.submit();
    }

    destroy() {
        this.form.removeEventListener('change', this._changeHandler);
        this.form.removeEventListener('input', this._inputHandler);
        this.form.removeEventListener('submit', this._submitHandler);
    }

    extend(obj: any = {}, ...src: any[]): any {
        if (arguments.length < 2) {
            return obj;
        }
        for (var i = 1; i < arguments.length; i++) {
            for (var key in arguments[i]) {
                if (arguments[i][key] !== null && typeof (arguments[i][key]) === "object") {
                    obj[key] = this.extend(obj[key], arguments[i][key]);
                } else {
                    obj[key] = arguments[i][key];
                }
            }
        }
        return obj;
    }
}

エラー表示の制御をするクラス

src/ValidateMessages.ts

/**
 * ValidityStateオブジェクトを受け取り
 * formのエラー表示をする
 */
export default class ValidateMessages {

    private elements: NodeList;
    private error: {
        key?: string;
        element?: HTMLElement;
    };
    private messages: {
        [key: string]: HTMLElement;
    };

    constructor(selector: string) {

        this.elements = document.querySelectorAll(selector);
        this.error = {};
        this.messages = {};

        this.init();
    }

    init() {
        Array.prototype.forEach.call(this.elements, (el: HTMLElement) => {
            let key = el.getAttribute('data-messages');
            this.messages[key] = el;
        });
    }

    update(key: string, validity: ValidityState) {
        let target = this.messages[key];
        if (!target) {
            return;
        }

        let messages: NodeList = target.children;
        let len = messages.length;
        let isValid: boolean = validity.valid;

        this.error = {};

        while (len--) {
            let message = <HTMLElement>messages[len];
            this.toggleClass(message, 'is-show', false);

            if (isValid) continue;
            let key = message.getAttribute('data-message');
            if (!validity[key]) continue;
            this.error = {
                key: key,
                element: message
            };
        }

        if (this.error.key) {
            this.toggleClass(this.error.element, 'is-show', true);
        }
    }

    toggleClass(element: HTMLElement, className: string, force?: boolean) {
        if (typeof force === 'undefined') {
            element.classList.toggle(className);
            return;
        }
        let method = force ? 'add' : 'remove';
        element.classList[method](className);
    }
}

終わりに

AngularJSなどを入れてしまえばフォームのバリデーションは楽に実装できてしまうのですが、フォームのバリデーションのためだけにAngularJSを入れるのはちょっと……というときに、ぜひ導入を検討していただけると幸いです。
(そしてGithubスターをください)

この記事を書いた人

いなば
いなば フロントエンドエンジニア 2014年入社
フロントエンドエンジニアのいなばです。
LIGではAngularJSを使ったWebアプリケーションの開発をしています。
ゴリゴリ動くサイトよりSPAが得意です。
好きなものはカフェインとカプサイシン。
趣味はランニングと一眼レフです。