何をするか
フロントでよくある入力フォームを実装します。
簡単な仕様なので何も考えずHTMLにJavaScriptを埋め込んで実装してもいいのですが、OOP学習のためにフォーム実装に関係する登場人物をクラスで表現したいと思います。
アーキテクチャとしてMVVMモデルLiekに作りたいと思います。
(Model・View・ViewModel)のView層はHTMLに当たるので、本記事ではModel層・ViewModel層を実装します。
実装はTypeScriptで行い、JavaScriptにトランスパイルして使います。
TypeScripteで書くことで静的型付けやInterfaceなどを利用できるためOOPの学習になるからです。
全ての実装はGithubで公開しています ↓
Github
仕様
- 項目は入力時にバリデーションエラーがスタイルで分かる(赤くなる)
- 送信するとポップアップが出て、項目が更新される(そのまま連続で入力・送信できる)
- 2種類のフォームを切り替えられる。
- メール送信:フォーム入力内容を指定のメールアドレスに送信
- Nodemailerを使う
- PUSH通知:フォーム入力内容をブラウザでプッシュ通知
- Push.jsを使う
- メール送信:フォーム入力内容を指定のメールアドレスに送信
入力項目
- 名前
- 年齢
- 性別(optionタグ)
- Eメール
- コメント(textareaタグ)
値オブジェクト
MVVMのModel層としてmodel.ts内に今回のフォーム項目に対応する概念をまとめています。
以下のクラスを定義しています。
- Name:名前
- Age:年齢
- Sex:性別
- Email:メールアドレス
- Comment:コメント
フォームという大きなクラスを作り、そこにインスタンス変数としてこれらを定義することも考えられますが、各概念によって取りうる値の範囲や大きさ、型などが異なるため、フォームクラスでのバリデーションが肥大化します。
またバリデーションを追加・削除・変更することになった際やある概念に特有の処理を追加したくなった場合、フォームクラスが更に肥大化する可能性がありますし、コードの見通しが悪くなってバグが混入する可能性が高まります。
そこで値オブジェクトを使います。
各概念をインスタンス変数valueを持った値オブジェクトとしてクラス化し、関係する処理をメソッドとしてカプセル化します。
ここでのポイントは3つです。
- 完全コンストラクタ
- インスタンス変数の不変化
- null安全
完全コンストラクタ
完全コンストラクタは不正値を防ぐガード節を追加することで、インスタンス生成時に不正な値が入り込むのを防ぎます。
インスタンス変数の不変化
インスタンス変数をreadonly
で不変化しています。イミュータブルにすることで一度作成したインスタンスの変数を直接変更することができなくなります。そこでchangeValue
メソッドでは新しいインスタンスを生成し直しています。ここでは再度コンストラクタが呼び出されるため値を変更する際にも(実際には変更ではなく新規作成ですが)不正値の混入を防ぐことが出来ます。
null安全
最後にnull安全です。このクラスではインスタンス変数でも、メソッドの引数でもデータ型としてnullを許容していません。 代わりにインスタンス変数EMPTY
を定義しています。これは初期状態(値未設定状態)を表す"状態"で、まだフォームに何も打ち込まれていない時のデータの状態を表現する特別なインスタンスです。
import { RequirementError, ValidationError } from "./error";
// 値オブジェクト
export class Name {
// valueを不変にするためにreadonlyを使用
private readonly value: string;
static readonly EMPTY = new Name("EMPTY");
// 完全コンストラクタ
constructor(value: string) {
if (value == "") {
throw new RequirementError("Name cannot be empty");
}
if (value.length > 10) {
throw new ValidationError("Name cannot be longer than 10 characters");
}
if (typeof value !== "string") {
throw new ValidationError("Name must be a string");
}
this.value = value;
}
changeValue(value: string) {
return new Name(value);
}
getValue() {
return this.value;
}
}
// --- その他のクラス ----
ここからは画面の表示や操作に責任を負うクラスを見ていきます。MVVMのViewModel層にあたります。
input.tsではフォームを更に分割し、各入力欄(Inputタグetc...)を管理するクラスを定義しています。こちらもクラスを分割可能な最小概念・単位まで切り分けることで、クラスの責務と影響範囲を明確にしています。これを単一責任の原則といいます。
ここでインスタンス変数name
の初期値がName.EMPTY
になっています。先述したName
クラスの初期状態です。
import { Name, Age, Sex, Email, Comment } from "./model";
import { RequirementError, ValidationError } from "./error";
// 名前入力欄の表示と入力に責務を持つクラス
export class NameInput {
private readonly element: HTMLInputElement;
private name: Name = Name.EMPTY;
constructor() {
this.element = document.getElementById("name") as HTMLInputElement;
this.element.addEventListener("input", () => {
this.changeName(this.element.value, this.name);
});
}
changeName(value: string, name: Name) {
try {
this.name = name.changeValue(value);
} catch (e) {
if (e instanceof RequirementError) {
alert(e.message);
} else if (e instanceof ValidationError) {
alert(e.message);
} else {
alert("予期せぬエラーが発生しました");
console.log(e);
}
}
}
getName() {
return this.name;
}
}
// --- その他のクラス ----
Interfaceとストラテジーパターン
今回は2種類のフォームがあります。 クラスで言えば、EmailForm
とNotificationForm
です。そのためこの2つを束ねる抽象型としてinterfaceForm
を定義しました。Interfaceを定義することで今後新しいメソッドや3・4種類目のフォームを定義する際に、Interfaceに定義されているメソッドを実装していないクラスがあるとコンパイルエラーが発生します。実装の漏れを防ぐことができると言う意味で保守性が高まります。
import Push from "push.js";
import { createTransport } from "nodemailer";
import {
NameInput,
AgeInput,
SexInput,
EmailInput,
CommentInput,
} from "./input";
import { EmailFetchFailedError } from "./error";
export interface Form {
submitForm(): void;
}
// メールフォームの表示と送信に責務を持つクラス
class EmailForm implements Form {
private readonly element: HTMLFormElement;
private nameInput: NameInput;
private ageInput: AgeInput;
private sexInput: SexInput;
private emailInput: EmailInput;
private commentInput: CommentInput;
private readonly FROM_EMAIL = "example@gmail.com";
private readonly TRANSPORTER = createTransport({
host: "smtp.gmail.com",
port: 587,
secure: true,
auth: {
user: "example@gmail.com",
pass: "example",
},
});
constructor() {
this.nameInput = new NameInput();
this.ageInput = new AgeInput();
this.sexInput = new SexInput();
this.emailInput = new EmailInput();
this.commentInput = new CommentInput();
this.element = document.getElementById("emailForm") as HTMLFormElement;
}
submitForm() {
try {
const mailData = {
from: this.FROM_EMAIL,
to: this.emailInput.getEmail().getValue(),
subject: `【メールフォーム】${this.nameInput.getName().getValue()}より`,
text: `名前: ${this.nameInput.getName().getValue()}\n
年齢: ${this.ageInput.getAge().getValue()}\n
性別: ${this.sexInput.getSex().getValue()}\n
メールアドレス: ${this.emailInput.getEmail().getValue()}\n
コメント: ${this.commentInput.getComment().getValue()}`,
};
this.TRANSPORTER.sendMail(mailData, (err, info) => {
if (err) {
throw new EmailFetchFailedError("メール送信が失敗しました");
}
alert("メール送信が完了しました - " + info.response);
});
} catch (e) {
if (e instanceof EmailFetchFailedError) {
alert(e.message);
} else {
alert("予期せぬエラーが発生しました");
console.log(e);
}
}
}
}
// PUSH通知フォームの表示と送信に責務を持つクラス
class NotificationForm implements Form {
private readonly element: HTMLFormElement;
private nameInput: NameInput;
private ageInput: AgeInput;
private sexInput: SexInput;
private emailInput: EmailInput;
private commentInput: CommentInput;
private readonly TIMEOUT: number = 5000;
constructor() {
this.nameInput = new NameInput();
this.ageInput = new AgeInput();
this.sexInput = new SexInput();
this.emailInput = new EmailInput();
this.commentInput = new CommentInput();
this.element = document.getElementById(
"notificationForm"
) as HTMLFormElement;
}
submitForm() {
const title = "PUSU通知 - " + this.nameInput.getName().getValue();
Push.create(title, {
body: JSON.stringify({
name: this.nameInput.getName().getValue(),
age: this.ageInput.getAge().getValue(),
sex: this.sexInput.getSex().getValue(),
email: this.emailInput.getEmail().getValue(),
comment: this.commentInput.getComment().getValue(),
}),
timeout: this.TIMEOUT,
onError: function (e: Error) {
alert("PUSH通知が失敗しました");
console.log(e);
},
});
}
}
Interfaceによる恩恵は実装の強制だけではありません。インスタンスの利用側ではEmailForm
もNotificationForm
も同じForm
として扱うことが出来ます。
これにより下のようにRadioボタンでフォームを切り替え、同じForm
としてsubmitForm
メソッドを呼び出せます。これにより今後3・4種類目のフォームが追加されても呼び出し側の実装を変更する必要はなく、フォーム自身のクラスの実装とフォームインスタンスを管理するMapの修正だけ済みます。
このような実装パターンをGoFのデザインパターンでストラテジーパターンといいます。
import { Form, EmailForm, NotificationForm } from "./form";
const formMap = new Map<string, Form>();
formMap.set("emailFormChecked", new EmailForm());
formMap.set("notificationFormChecked", new NotificationForm());
let form;
const switchFormRadio = document.getElementsByName("switchForm");
switchFormRadio.forEach((radio) => {
radio.addEventListener("change", () => {
if (radio.getAttribute("checked") === "checked") {
form = formMap.get(radio.id);
}
});
});
form.element.addEventListener("submit", (e) => {
e.preventDefault();
form.submitForm();
});
2種類の機能を切り替えられるWebフォームでOOPを実践してみました。
今回はViewModel層を自前実装しましたが、JavaScriptフレームワークを使うともっとシンプルに実装できると思います。ただModel層はフレームワークを使う場合でもそのまま利用できます。