案件で JavaScript 使ってて思ったことを忘れないうちにメモ。
個人的な考えなので、一般的に正しいかは不明。
※前提として、 jQuery を使用する。
はじめに
画面例
以下のような入力項目があったとする。
<div>
姓名:
苗字<input id="family-name" type="text" />
名前<input id="first-name" type="text" />
</div>
<div>
年齢:<input id="age" type="text" />
</div>
<div>
性別:
<label>
男<input name="sex" type="radio" value="male" />
</label>
<label>
女<input name="sex" type="radio" value="female" />
</label>
</div>
<div>
連絡先:
<label>
不要<input name="contact" type="radio" value="unnecessary" checked />
</label>
<label>
必要<input name="contact" type="radio" value="necessary" />
</label>
メールアドレス:<input id="mail-address" type="text" disabled />
</div>
<button id="submit">送信</button>
入力・制御ルール
さらに、上記の入力項目に、以下のような入力・制御ルールがあったとする。
-
姓名
- 苗字・名前ともに必須入力
-
年齢
- 必須入力
- 半角数値のみ入力可
- 0 ~ 150 まで入力可
-
性別
- 必須入力
-
連絡先
- 必要を選択した場合は、メールアドレスが必須入力
- メールアドレスは半角英数記号のみ入力可
- 必要を選択したら、メールアドレスが活性化する
- 不要を選択したら、メールアドレスが不活性になる
実装
「送信」ボタンをクリックしたときに各入力項目をチェックしたとすると、普通に jQuery を使って JavaScript で実装すると次のようになると思う。
※チェックの厳密性は今は重要ではないので適当です。
// 連絡先ラジオボタンの切り換えイベントハンドリング
$('[name="contact"]').change(function() {
var selected = $('[name="contact"]:checked').val();
if (selected === 'unnecessary') {
$('#mail-address').attr('disabled', true);
} else {
$('#mail-address').attr('disabled', false);
}
});
// 送信ボタンクリックのイベントハンドリング
$('#submit').click(function() {
// 名前の必須入力チェック
var familyName = $('#family-name').val();
if (!familyName) {
alert('苗字は必須入力です。');
return;
}
var firstName = $('#first-name').val();
if (!firstName) {
alert('名前は必須入力です。');
return;
}
// 年齢の必須入力チェック
var age = $('#age').val();
if (!age) {
alert('年齢は必須入力です。');
return;
}
// 年齢の数値チェック
if (!$.isNumeric(age)) {
alert('年齢は半角数値で入力してください。');
return;
}
age = age - 0;
if (age < 0 || 150 < age) {
alert('年齢は 0 以上 150 以下で入力してください。');
return;
}
// 性別入力チェック
var sex = $('[name="sex"]:checked').val();
if (!sex) {
alert('性別を選択してください。');
return;
}
// 連絡先必須入力チェック
if ($('[name="contact"]:checked').val() === 'necessary') {
var mailAddress = $('#mail-address').val();
if (!mailAddress) {
alert('メールアドレスを入力してください。');
return;
}
if (!mailAddress.match(/^[a-zA-Z0-9@._-]+$/)) {
alert('メールアドレスは半角英数記号で入力してください。');
return;
}
}
});
問題点
この実装方法には、以下のような問題点があると思っている。
- チェック内容が増えるにつれてコード量が増えて、読みにくさが爆発する。
- 他の画面にも同じ項目があった場合、そちらにも同じコードを書いていると、メンテナンスが大変になる。
- 「UI 制御」「入力チェック」という単位で括っているので、「ある項目に関するコード」が追いにくい(上の例だと、連絡先のラジオボタン制御と、入力チェックが離れたところにあって見つけにくい)
1と2は、チェック用の関数を別途共通関数として抽出すれば解決しそうだが、3つ目は相変わらず解決できない。
考えた解決策
これらを解決するために、 項目ごとに JavaScript のクラスを用意する と良い気がしている。
クラスは、次のものを用意する。
- プリミティブな UI 部品クラス
- アプリケーションに実際に現れる入力項目クラス
プリミティブなクラス
テキストボックス・ラジオボタンなど、個々の UI 部品をラップしたクラス。
値の取得や、実際の UI の表示状態の制御、イベントハンドラの登録など、低レイヤー?な処理を隠蔽して UI 部品を使いやすくするためのクラス。
例えば以下のような感じ。
function TextBox(options) {
var opt = $.extend({}, options);
var $textbox = $(opt.selector);
this.getValue = function() {
return $textbox.val();
};
this.setValue = function(value) {
$textbox.val(value);
};
}
var textbox = new TextBox({selector: '#hoge'});
textbox.setValue('hoge');
使う側は、 TextBox クラスが提供しているメソッド(インターフェース)を介してのみテキストボックスにアクセスする。
UI の制御といったややこしい話は、このクラスの中に隠蔽してしまう。
入力項目クラス
入力項目クラスは、実際にアプリケーションに現れる項目を表すクラス。
前述の例だと、「姓名」や「年齢」、「連絡先」などが該当する。
このクラスはプリミティブなクラスを内包していて、具体的な UI の制御はそちらに任せる。
このクラスは、項目ごとに存在している入力ルールや、場合によっては UI 部品間の連携動作を制御するのがお仕事。
例えば前述の例で出た「連絡先」は、以下のような感じで実装する。
// テキストボックス
function TextBox(options) {
var opt = $.extend({}, options);
var $textbox = $(opt.selector);
this.getValue = function() {
return $textbox.val();
};
this.setEnable = function(enable) {
var disabled = enable ? false : true;
$textbox.attr('disabled', disabled);
};
}
// ラジオボタン
function RadioButton(options) {
var opt = $.extend({}, options);
var $radio = $('[name="' + opt.name + '"]');
this.change = function(callback) {
$radio.change(function() {
var value = $('[name="' + opt.name + '"]:checked').val();
callback(value);
});
};
}
// 連絡先
function Contact(options) {
var opt = $.extend({}, options);
var necessaryRadio = new RadioButton({name: opt.radioName});
var mailAddress = new TextBox({selector: opt.mailSelector});
necessaryRadio.change(function(selectedValue) {
mailAddress.setEnable(selectedValue === opt.necessary);
});
}
new Contact({
radioName: 'contact',
mailSelector: '#mail-address',
necessary: 'necessary'
});
Contact クラスが知っているのは、
「ラジオボタンの選択が変わって、「必要」が選ばれていたらメールアドレスを有効にして、「不要」が選ばれていたら無効にする」
ということだけで、 UI の具体的な操作方法までは知らない。
解決案で再度実装してみる
この方法で、最初の入力フォームを実装すると以下のようになる。
/* ======================================================= */
// プリミティブなクラス
/* ======================================================= */
// テキストボックス
function TextBox(options) {
var opt = $.extend({}, options);
var $textbox = $(opt.selector);
this.is = function(predicate) {
return predicate($textbox.val());
};
this.setEnable = function(enable) {
var disabled = !enable;
$textbox.attr('disabled', disabled);
};
}
// ラジオボタン
function RadioButton(options) {
var opt = $.extend({}, options);
var $radio = $('[name="' + opt.name + '"]');
this.change = function(callback) {
$radio.change(function() {
callback(getValue());
});
};
/**
* ラジオボタンが選択されているかどうかを確認する。
* 引数が指定されている場合は、その値が選択されているかどうかを確認する。
*/
this.isSelected = function(expected) {
if (arguments.length === 0) {
return getValue() ? true : false;
} else {
return getValue() === expected;
}
};
this.isNotSelected = function() {
return !this.isSelected();
};
function getValue() {
return $('[name="' + opt.name + '"]:checked').val();
}
}
// ボタン
function Button(options) {
var opt = $.extend({}, options);
var $button = $(opt.selector);
this.click = function(callback) {
$button.click(function() {
callback();
});
};
}
/* ======================================================= */
// 入力項目クラス
/* ======================================================= */
// 姓名
function FullName(options) {
var opt = $.extend({}, options);
var familyName = new TextBox({selector: opt.familyNameSelector});
var firstName = new TextBox({selector: opt.firstNameSelector});
this.validate = function() {
ValidationUtil.isTrue(familyName.is(not(empty())), '苗字は必須入力です。');
ValidationUtil.isTrue(firstName.is(not(empty())), '名前は必須入力です。');
};
}
// 年齢
function Age(options) {
var opt = $.extend({}, options);
var age = new TextBox({selector: opt.selector});
this.validate = function() {
ValidationUtil.isTrue(age.is(not(empty())), '年齢は必須入力です。');
ValidationUtil.isTrue(age.is(number()), '年齢は半角数値で入力してください。');
ValidationUtil.isTrue(age.is(rangeOf(0, 150)), '年齢は 0 以上 150 以下で入力してください。');
};
}
// 性別
function Sex(options) {
var opt = $.extend({}, options);
var sex = new RadioButton({name: opt.radioName});
this.validate = function() {
ValidationUtil.isTrue(sex.isSelected(), '性別を選択してください。');
};
}
// 連絡先
function Contact(options) {
var opt = $.extend({}, options);
var necessaryRadio = new RadioButton({name: opt.radioName});
var mailAddress = new TextBox({selector: opt.mailSelector});
necessaryRadio.change(function(selectedValue) {
mailAddress.setEnable(selectedValue === opt.necessary);
});
this.validate = function() {
if (necessaryRadio.isSelected(opt.necessary)) {
ValidationUtil.isTrue(mailAddress.is(not(empty())),
'メールアドレスを入力してください。');
ValidationUtil.isTrue(mailAddress.is(match(/^[a-zA-Z0-9@._-]+$/)),
'メールアドレスは半角英数記号で入力してください。');
}
};
}
// 送信ボタン
function SendButton(options) {
var opt = $.extend({}, options);
var sendButton = new Button({selector: opt.selector});
var forms = options.forms;
checkForms(forms);
sendButton.click(function() {
ValidationUtil.validate({
test: function() {
$.each(forms, function(i, form) {
form.validate();
});
},
success: function() {
alert('success');
},
error: function(message) {
alert(message);
}
});
});
function checkForms(forms) {
if (!$.isArray(forms)) {
throw 'forms には配列を指定してください。';
}
$.each(forms, function(i, form) {
if (!$.isFunction(form.validate)) {
throw 'forms の要素は、validate() 関数を実装している必要があります。';
}
});
}
}
// 入力値検証用ユーティリティ
var ValidationUtil = {
isTrue: function(check, errorMessage) {
if (check === false) {
throw {
validateError: true,
message: errorMessage
};
}
},
validate: function(options) {
var test = options.test;
var success = options.success;
var error = options.error;
try {
test();
if ($.isFunction(success)) {
success();
}
} catch (e) {
if (e.validateError === true) {
if ($.isFunction(error)) {
error(e.message);
}
} else {
throw e;
}
}
}
};
/* ======================================================= */
// 各入力項目オブジェクトの生成
/* ======================================================= */
// 各入力項目の生成
var fullName = new FullName({familyNameSelector: '#family-name', firstNameSelector: '#first-name'});
var age = new Age({selector: '#age'});
var sex = new Sex({radioName: 'sex'});
var contact = new Contact({mailSelector: '#mail-address', radioName: 'contact', necessary: 'necessary'});
var forms = [fullName, age, sex, contact];
// 送信ボタンの生成
new SendButton({selector: '#submit', forms: forms});
/* ======================================================= */
// 検証用関数
/* ======================================================= */
// 否定
function not(predicate) {
return function(value) {
return !predicate(value);
};
}
// 値が空である
function empty() {
return function(value) {
return value ? false : true;
};
}
// 値が数値である
function number() {
return function(value) {
return $.isNumeric(value);
};
}
// from <= value <= to である
function rangeOf(from, to) {
return function(value) {
var num = value - 0;
return (from <= num) && (num <= to);
};
}
// 値が pattern にマッチする
function match(pattern) {
return function(value) {
return value.match(pattern) ? true : false;
};
}
コード量は増えたが、入力項目ごとにクラスが分けられたおかげで、個人的には前よりも読みやすく、かつメンテナンスもしやすくなったと思う。
クラスごとにファイルを分割しておけば、別の画面に同じ入力項目があった場合も、既存のクラスを利用すれば良い。
場合によっては、複数の入力項目クラスをまとめあげた、さらに1段階上のクラスもあり得ると思う。
その他思ったこと
画面に関わる情報はパラメータで渡す
こういう UI に強く関係する JavaScript クラスを作成するときに注意しとかないといけないのは、 セレクタのような画面に依存する値はパラメータとして渡すようにする 、という点。
クラス内部にセレクタをハードコーディングしていると、画面の変更に影響を受けて逆にメンテナンスが困難になるから。
なるべくクラス内部の変数は外部に公開しない
JavaScript は final な変数を宣言できないので、
内部の変数を外部に公開する
= 外部から自由に書き換えられる
= コード量が増えると、どこが関係しているかわからなくなる
= メンテナンスしにくくなる
なので、なるべくクラス内部の変数は外部に公開しない方がいいと思う。
そもそもオブジェクト指向的に考えると、内部の変数を公開して相手に良しなに頑張ってもらうのではなく、便利なメソッドを公開してあげるべき。
以上、思ったことのメモ書き。