JavaScriptの設計について考える - 設計の応用例(EFO)

はじめに

本稿ではJavaScriptにおける弊社の基本的な設計方針についてご紹介しています。前回はコンストラクタを利用した設計方法についてご紹介致しました。単純な機能であれば、オブジェクトとコンストラクタを適切に使い分けることにより、保守性の高いコードを記述することができるようになるでしょう。しかし機能が複雑になってくると、オブジェクトやコンストラクタ単体で制御しようとすると、実装が複雑になり、保守性が下がってしまう可能性があります。このような場合は、機能全体を適切なブロックに分割することを検討する必要があります。そしてブロックごとに個別のオブジェクトを生成して制御させます。各ブロックは互いに関連性を持っているはずなので、機能全体を制御するためには、これらのオブジェクトを連携させるための仕組みが必要になります。今回はEFOを例に挙げながら、複数のオブジェクトを連携させて制御するための考え方を説明致します。

EFOとは

EFO(Entry Form Optimization)とは、ユーザに対してエントリーフォームに効率よく入力させるための仕組みです。エントリーフォームの入力項目が多くなると、入力の手間が増えるため、ユーザのストレスが大きくなります。更に入力が完了してサブミットしても、入力情報に不備があると差し戻されて修正する手間がかかります。修正と差し戻しを何度も繰り返すと、ユーザが途中で離脱する可能性が高くなります。これを防ぐためには、ユーザの労力と入力時間を減らすための仕組みが重要になります。EFOの機能は厳密に決められているわけではありませんが、次のような機能を提供することが多いと思います。

  • リアルタイムバリデーション : 入力情報の妥当性を判定し、不備がある場合はその理由をツールチップ等で表示する
  • サブミット制御 : 全ての入力項目に対して適切な情報が入力されるまで、サブミットボタンが押下できないようにする
  • 残数表示 : 残りの入力項目数を表示して、ユーザが最後まで入力するためのモチベーションを高める

今回は上記の3つの機能を実装することを前提に設計を考えることにします。まずはEFOを実装するHTMLの例を挙げます。

HTMLその1

<div>入力項目残り<span id="jsi-remaining-count"></span></div>
<form action="/" method="post">
  <table>
    <tr>
      <th>氏名</th>
      <td><input type="text" name="name"/></td>
    </tr>
    <tr>
      <th>年齢</th>
      <td><input type="text" name="age"/></td>
    </tr>
    <tr>
      <th>メールアドレス</th>
      <td><input type="text" name="mail"/></td>
    </tr>
  </table>
  <input type="submit" value="送信" id="jsi-submit"/>
</form>

上記のフォームでは、テキストボックスだけを使用して入力項目を作っています。一般的にはテキストボックスだけではなく、チェックボックスやラジオボタン、セレクトボックス等、様々な種類のフォーム要素を使用して作成することが多いと思います。フォーム要素の種類が増えるとバリデーションの処理は複雑になりますが、基本的な設計にはほとんど影響を与えません。今回は設計に焦点を充てているので、それ以外の要素は極力シンプルにしています。

EFOの機能の1つであるバリデーションは、ユーザの入力した情報の妥当性を判定し、その結果をユーザに対して分かり易く表示するための機能です。入力情報が妥当であるかどうかの判断基準は入力項目によって異なりますが、次のような判定をすることが多いと思います。

  • 必須判定:項目に対して情報が入力されているか
  • 型判定:入力した情報が、適切な型(数値、ローマ字等)で指定されているか
  • 文字数判定:文字列で入力する場合、文字数が適切な範囲に収まっているか
  • 範囲判定:数値で入力する場合、数値の値が適切な範囲に収まっているか
  • 形式判定:入力した情報が適切な形式(メールアドレス、URL等)で指定されているか

ここで問題になるのは、入力項目ごとに必要とする判定の種類が異なることです。このような場合、どのように設計すれば良いのでしょうか?

入力項目ごとに個別に制御する

この問題に対処するためには、各入力項目を判別するための仕組みが必要になります。すぐに思いつくのは、次のように入力項目ごとに個別のIDを付与することです。

HTMLその2
........
<td><input type="text" name="name" id="jsi-name"/></td>
........
<td><input type="text" name="age" id="jsi-age"/></td>
........
<td><input type="text" name="mail" id="jsi-mail"/></td>
........

これにより入力項目の情報を個別に取得することができるようになります。そして入力項目ごとに個別にイベントハンドラを設定すれば、ユーザが情報を入力した時に適切なバリデーション用のメソッドを実行させることができます。

コードその1

var LEIHAUOLI = LEIHAUOLI || {};
LEIHAUOLI.SAMPLE_CODE = {};

LEIHAUOLI.SAMPLE_CODE.VALIDATOR = {
    init : function(){
        this.setParameters();
        this.bindEvent();
    },
    setParameters : function(){
        /*
         * IDを指定して、入力項目の情報を個別に取得する
         * 入力項目を容易に判別できる反面、変数の数は入力項目の数に比例して増加する
         */
        this.$name = $('#jsi-name');
        this.$age = $('#jsi-age');
        this.$mail = $('#jsi-mail');
        ........
    },
    bindEvent : function(){
        /*
         * 入力項目ごとに、イベントハンドラを個別に設定する
         */
        this.$name.on('blur', $.proxy(this.validateName, this));
        this.$age.on('blur', $.proxy(this.validateAge, this));
        this.$mail.on('blur', $.proxy(this.validateMail, this));
        ........
    },
    /*
     * 入力項目ごとに、バリデーション用のメソッドを個別に定義する
     * 変数と同様、メソッドの数も入力項目数に比例して増加する
     */
    validateName : function(){
        ........
    },
    validateAge : function(){
        ........
    },
    validateMail : function(){
        ........
    },
    ........
};

$(function(){
    LEIHAUOLI.SAMPLE_CODE.VALIDATOR.init();
});

入力項目の数が少ない場合はこのような設計でも、それほど大きな問題にはならないかもしれません。しかし数が多くなると、それに比例して変数やメソッドの数が増えるため、プログラムのコードが肥大化します。更に仕様変更が発生して入力項目の種類や数が変わった場合、HTMLだでけではなくプログラムの処理も大きく変更しなければならないため、保守性が低くなってしまいます。

全ての入力項目を一括して制御する

HTMLはともかく、プログラムは入力項目の種類や数になるべく依存しない作りにしたいところです。そのためには入力項目ごとに個別のIDを付与するのではなく、同じクラスを付与して一括して取得する必要があります。但しこの方法を採った場合、そのままでは各入力項目を判別することできないため、どのような判定をすれば良いのか分かりません。このような場合に役に立つのがHTML5から導入されたdata属性です。まず、次のように各入力項目に対して共通のクラスとdata属性を付与します。

HTMLその3
........
<td><input type="text" name="name" class="jsc-target" data-check="required"/></td>
........
<td><input type="text" name="age" class="jsc-target" data-check="number"/></td>
........
<td><input type="text" name="mail" class="jsc-target" data-check="mail"/></td>
........

data属性は一般的に「data-*」の形式で記述することができます。「*」には任意の文字列(但しHTMLの規格には従う必要があります)を指定することができますが、指定した文字列によって挙動が変わることはありません。またHTMLタグにdata属性を付与しても、見た目や機能には全く影響を与えません。

この属性の目的はHTMLタグに対して任意の情報を関連付けることです。よって「*」の部分には、どのような情報を関連付けているか分かるような文字列を指定すべきです。今回は、どの入力項目に対してどのような判定が必要なのか判別するための情報を関連付けています。例えば氏名を入力する項目に対してはdata属性値として「required」を指定していますが、これはこの項目に対して必須判定が必要であることを示しています。

data属性値は他の属性と同様、プログラムから参照することができます。ユーザが情報を入力した時に、その項目に付与されているdata属性値を読み取ることにより、どのような判定処理を実行するべきか判別することができます。この仕組みを活用すると、バリデーション用のコードは次のように記述することができます。

コードその2

var LEIHAUOLI = LEIHAUOLI || {};
LEIHAUOLI.SAMPLE_CODE = {};

LEIHAUOLI.SAMPLE_CODE.VALIDATOR = {
    init : function(){
        this.setParameters();
        this.bindEvent();
    },
    setParameters : function(){
        /*
         * 全ての入力項目に対して同じクラスを付与し、一括して取得する
         * そのため入力項目数が増えても、変数の数は1つ定義するだけで済む
         */
        this.$target = $('.jsc-target');
        ........
    },
    bindEvent : function(){
        var myself = this;
        /*
         * 一括して取得した入力項目をeachメソッドでばらばらにする
         * 各入力項目ごとにdata属性の値を調べ、イベントハンドラの引数に渡している
         * これにより、どのような判定をすれば良いか判別することができる
         * 尚、data属性値は、attrメソッドかdataメソッドを使用することで取得することができる
         * 但し、各々のメソッドで引数の指定の仕方が異なる
         * data-check属性値を取得する場合、前者は'data-check'後者は'check'と指定する
         */
        this.$target.each(function(){
            var $self = $(this),
                check = $self.data('check');

           $self.on('blur', function(){
               myself.validate($self, check);
           });
        });
        ........
    },
    validate : function($target, check){
        /*
         * 入力項目に付与されたdata属性値に従って、適切な判定処理を実行する
         */
        switch(check){
        case 'required':
            ..........
            break;
        case 'number':
            ..........
            break;
        case 'mail':
            ..........
            break;
        }
        ........
    },
    ........
};

$(function(){
    LEIHAUOLI.SAMPLE_CODE.VALIDATOR.init();
});

上記のコードは入力項目の数が変わっても変更する必要はありません。また判定の種類が増えた場合でも、validateメソッドに対して適切な判定処理を追加するだけで対応することができます。「コードその2」は「コードその1」に比べて、保守性ははるかに高くなっていますが、まだ改良の余地はあります。

「コードその2」では、複数の入力項目を1つのオブジェクトで管理しています。そのためバリデーションの対象になっている入力項目を判別するための処理が必要になります。バリデーションの処理が複雑になると、判別するための処理も複雑になるため、保守性が下がる可能性があります。このような場合は前回紹介したコンストラクタを使用することで、オブジェクトの制御対象を1つに限定することができるはずです。しかし今回のケースは、前回紹介したケースとは多少状況が異なります。

前回は複数のカルーセルを制御する状況を想定しました。そのケースでは1つ1つのカルーセルは完全に独立しています。そのためコンストラクタを使用して、カルーセルごとに個別に制御する方法が有効に機能しました。

それではEFOはどうでしょうか?各入力項目の間には特に関連性はないので、EFOの要件がバリデーションだけであれば、カルーセルと同様にコンストラクタを使用することで効率的に制御することができるはずです。しかしEFOには他にもサブミット制御と、残数表示の機能があります。これら2つの機能はバリデーションの結果に依存するため、各機能の間に関連性を持たせるための仕組みが必要となります。

機能全体を適切なブロックに分割して制御する

オブジェクト設計の基本は、関連性のある処理を1つのオブジェクトにまとめることです。EFOを構成する3つの機能の間には関連性があるので、この方針に従って、1つのオブジェクトでまとめて管理するというのも1つの選択肢です。その場合は「コードその2」のVALIDATORオブジェクトに対して、更に残りの機能を追加することになります。設計の方針はシンプルですが、実装はシンプルにならないかもしれません。「コードその2」は全ての入力項目をまとめて制御しているため、仕様によっては処理が複雑になる可能性があります。このコードに対して更に別の機能を追加した場合、この傾向が更に助長されることになります。

3つの機能は関連性があるとは言っても、機能的には独立しています。このような場合、機能全体を適切なブロックに分割し、各ブロックごとに専用のオブジェクトやコンストラクタで制御させることを検討した方が良いでしょう。そして必要に応じてオブジェクト間の関連づけを行うことで、機能全体を連携させるのです。

今回は次のような方針で設計を進めることにします。

  • バリデーションの機能は、コンストラクタを使用して入力項目ごとに個別に制御する
  • サブミット制御と残数表示の機能はロジックの大半を共有できるため、1つのオブジェクトでまとめて制御する
  • 上記のコンストラクタとオブジェクトを連携させる

まずは「コードその2」のVALIDATORオブジェクトを、コンストラクタに作り直すことにしましょう。

コードその3

var LEIHAUOLI = LEIHAUOLI || {};
LEIHAUOLI.SAMPLE_CODE = {};

LEIHAUOLI.SAMPLE_CODE.VALIDATOR = function($target){
    this.$target = $target;
    this.init();
};
LEIHAUOLI.SAMPLE_CODE.VALIDATOR.prototype = {
    init : function(){
        this.setParameters();
        this.bindEvent();
    },
    setParameters : function(){
        this.check = this.$target.data('check');
    },
    bindEvent : function(){
        this.$target.on('blur', $.proxy(this.validate, this));
    },
    validate : function(){
        switch(this.check){
        case 'required':
            ..........
            break;
        case 'number':
            ..........
            break;
        case 'mail':
            ..........
            break;
        }
        ........
    },
    ........
};

$(function(){
    $('.jsc-target').each(function(){
        new LEIHAUOLI.SAMPLE_CODE.VALIDATOR($(this), myself);
    });
});

入力項目を判別するための処理が不要になったため、「コードその3」は「コードその2」に比べて大分すっきりしました。尚、説明の便宜上、今後はVALIDATORコンストラクタから生成したオブジェクトをVALIDATORオブジェクトと呼ぶことにします。次にサブミット制御と残数表示の機能を作成します。

コードその4

var LEIHAUOLI = LEIHAUOLI || {};
LEIHAUOLI.SAMPLE_CODE = {};

LEIHAUOLI.SAMPLE_CODE.EFO_MANAGER = {
    init : function(){
        this.setParameters();
        this.displayRemainingCount();
        this.bindEvent();
    },
    setParameters : function(){
        this.$target = $('.jsc-target');
        this.$count = $('#jsi-remaining-count');
        this.$submit = $('#jsi-submit');
    },
    bindEvent : function(){
        /*
         * サブミットボタンが押下されたタイミングで残りの入力項目数を調べ、適切に制御する
         */
        this.$submit.on('click', $.proxy(this.controlToSubmit, this));
        /*
         * 入力項目からフォーカスが外れたタイミングで残りの入力項目数を調べ、画面上に表示する
         */
        this.$target.on('blur', $.proxy(this.displayRemainingCount, this));
    },
    controlToSubmit : function(event){
        /*
         * 残りの入力項目がある場合はサブミットボタンの挙動を抑制する
         */
        if(this.getRemainingCount() > 0){
            event.preventDefault();
        }
    },
    displayRemainingCount : function(){
        this.$count.text(this.getRemainingCount());
    },
    getRemainingCount : function(){
        /*
         * 残りの入力項目数を返す
         * そのためには各入力項目ごとにバリデーションを実行する必要がある
         * バリデーションに失敗した項目数が、残りの入力項目数となる
         */
        ........
        return 残りの入力項目数;
    }
};

$(function(){
    LEIHAUOLI.SAMPLE_CODE.EFO_MANAGER.init();
});

上記の機能を実装する際にポイントになるのが、残りの入力項目数を取得する方法です。この値を取得することができれば、画面上に表示することも、サブミット制御をすることもできます。そのためにはEFO_MANAGERオブジェクトからVALIDATORオブジェクトの機能を呼び出し、その実行結果を集計するための仕組みが必要となります。

複数のオブジェクトを連携させる

EFO_MANAGERオブジェクトとVALIDATORオブジェクトを連携させるための考え方は、設計する人によって大きく異なる可能性があります。今回は筆者がこのようなケースでしばしば採用する考え方に基づいた設計例を示すことにします。

コードその5

var LEIHAUOLI = LEIHAUOLI || {};
LEIHAUOLI.SAMPLE_CODE = {};

LEIHAUOLI.SAMPLE_CODE.EFO_MANAGER = {
    init : function(){
        this.setParameters();
        this.setupValidators();
        this.displayRemainingCount();
        this.bindEvent();
    },
    setParameters : function(){
        this.$target = $('.jsc-target');
        this.$count = $('#jsi-remaining-count');
        this.$submit = $('#jsi-submit');
        /*
         * VALIDATORオブジェクトを格納するための配列を定義する
         */
        this.validators = [];
    },
    setupValidators : function(){
        var myself = this;
        /*
         * VALIDATORオブジェクトは、EFO_MANAGERオブジェクトの中で生成する
         * 生成したVALIDATORオブジェクトはvalidators配列に格納する
         * この配列を経由することでVALIDATORオブジェクトの機能を利用することができる
         * またVALIDATORオブジェクト生成時に、自分自身への参照を引数として渡している
         */
        this.$target.each(function(){
            myself.validators.push(
                new LEIHAUOLI.SAMPLE_CODE.VALIDATOR($(this), myself)
            );
        });
    },
    bindEvent : function(){
        this.$submit.on('click', $.proxy(this.controlToSubmit, this));
    },
    controlToSubmit : function(event){
        if(this.getRemainingCount() > 0){
            event.preventDefault();
        }
    },
    displayRemainingCount : function(){
        this.$count.text(this.getRemainingCount());
    },
    getRemainingCount : function(){
        var count = 0;
        /*
         * 全てのVALIDATORオブジェクトの判定結果を集計し、残りの入力項目数を取得する
         */
        for(var i = 0, length = this.validators.length; i < length; i++){
            if(!this.validators[i].isValid()){
                count++;
            }
        }
        return count;
    }
};

LEIHAUOLI.SAMPLE_CODE.VALIDATOR = function($target, manager){
    this.$target = $target;
    /*
     * 引数managerには、EFO_MANAGERオブジェクトへの参照が渡されている
     * この変数を経由することにより、EFO_MANAGERオブジェクトの機能を利用することができる
     */
    this.manager = manager;
    this.init();
};
LEIHAUOLI.SAMPLE_CODE.VALIDATOR.prototype = {
    init : function(){
        this.setParameters();
        this.bindEvent();
    },
    setParameters : function(){
        this.check = this.$target.data('check');
        this.valid = false;
    },
    bindEvent : function(){
        this.$target.on('blur', $.proxy(this.validate, this));
    },
    validate : function(){
        switch(this.check){
        case 'required':
            ..........
            break;
        case 'number':
            ..........
            break;
        case 'mail':
            ..........
            break;
        }
        /*
         * バリデーションの結果を、インスタンス変数に保存しておく
         * この値は、isValidメソッドを経由してEFO_MANAGERオブジェクトから参照される
         */
        if(バリデーション成功){
            this.valid = true;
        }else{
            this.valid = false;
        }
        /*
         * バリデーションの実行により残りの入力項目数が変化する可能性がある
         * 最新の状態を表示に反映させるため、EFO_MANAGERオブジェクトの機能を呼び出す
         */
        this.manager.displayRemainingCount();
    },
    /*
     * 残りの入力項目数を表示する際にEFO_MANAGERオブジェクトから呼び出される
     */
    isValid : function(){
        return this.valid;
    }
};

$(function(){
    LEIHAUOLI.SAMPLE_CODE.EFO_MANAGER.init();
});

サブミットの制御を行うためには、ユーザがサブミットボタンを押下した時に、全ての入力項目のバリデーションの結果を集計する必要があります。サブミット制御自体はEFOMANAGERオブジェクトが行いますが、バリデーションはVALIDATORオブジェクトが行います。そのためEFOMANAGERオブジェクトからVALIDATORオブジェクトへの参照が必要となります。

また残りの入力項目数の表示はEFOMANAGERオブジェクトが行いますが、その値はバリデーション実行後に変化する可能性があります。この結果を効率よく反映させるためには、VALIDATORオブジェクトからEFOMANAGERオブジェクトへの参照も必要となります。

これら2つの参照を設定しているのが、EFO_MANAGERオブジェクトのsetupValidatorsメソッドです。このメソッドの処理のポイントは次の2点です。

  • VALIDATORオブジェクトを生成して配列(validators)に格納する
  • VALIDATORオブジェクトを生成する際に、引数として自分自身(EFO_MANAGERオブジェクト)への参照を渡す

これによりEFO_MANAGERオブジェクトは、validators配列を経由することによりVALIDTORオブジェクトの機能を利用することができます。残りの入力項目数を返すgetRemainingCountメソッドでは、この配列から1つずつVALIDATORオブジェクトを取り出して、バリデーションの結果を問い合わせています。

また各VALIDATORオブジェクトは、EFOMANAGERオブジェクトへの参照を保存した変数(manager)を経由することで、このオブジェクトの機能を利用することができます。validateメソッドではバリデーション実行後に、この変数を経由して、EFOMANAGERオブジェクトの持つ残り項目数表示機能を呼び出しています。

終わりに

今回はEFOを題材にして、オブジェクトとコンストラクタを組み合わせた設計の応用例を説明致しました。EFOの仕様は多少複雑ではありますが、これよりも複雑な仕様はまだまだあります。しかし、機能全体を適切なブロックに分割することで、1つ1つの機能ブロックの設計を単純化することができます。設計が単純であるということは、設計時間の短縮と、バグの出にくさに繋がります。後は分割した機能ブロックを適切に連携させることにより、保守性の高いコードを記述することができるようになります。

これまで何回かに分けて、弊社におけるJavaScriptの設計の考え方についてご紹介させて頂きました。この設計の考え方は、カルーセルやEFOのような、通常のHTMLタグの組み合わせで構成された機能に対してだけではなく、canvasのようなDOMオブジェクトを生成せずに描画するような機能に対しても適用することができます。次回は設計のもう1つの応用例として、このcanvasを使用した機能に対する設計例をご紹介する予定です。