前編(こちらを読まなくても問題ないです)
サンプル
See the Pen 漢気割り勘ツール by terao takumi (@teraotakumi) on CodePen.
金額と参加者を入力し、計算モードを選んで「計算する」をクリックすると、アラートウィンドウが表示されてそれぞれの参加者がいくら支払うのか表示されるようになっています。これを、バニラjavascriptを使ってオブジェクト指向で書いてみた、というのがこの記事の趣旨になります。
登場オブジェクト
まずは全体感を理解するため、ざっくり登場オブジェクトとその役割について見ていきます。
・漢気計算機(OtokogiCalculator)
入力された内容を元に計算を行うオブジェクト。constructorで支払金額と参加者名簿のインスタンスをもらう。
・支払金額(PaymentAmmount)
入力された支払金額を司る。
・参加者名簿(ParticipantList)
後述する参加者インスタンスを複数保持するアグリゲータ。参加者を増やしたり、減らしたり、名前の一覧を返したりするのが役目。
参加者(Participant)
参加者を表現するエンティティ。インスタンス1つ1つが独自のidを持つ。
と、ざっくり役者がわかったところで、まずはわかりやすい「支払金額」から設計を見ていこうと思います。
支払金額クラスの設計
class PaymentAmmount {
#inputField = document.querySelector('.js-target-inputPaymentAmmount');
#errorMessage = document.querySelector('.js-target-paymentAmmountError');
// 入力された値を取得
getValue() {
return Number(this.#inputField.value);
}
// バリデーション
isValid() {
this.#clearError();
const value = this.getValue();
// 6桁以下の正の整数の場合のみtrue
if (Number.isInteger(value) && value > 0 && value.toString().length <= 6) {
return true;
} else {
this.#showError();
return false;
}
}
#showError() {
// メッセージ表示
this.#errorMessage.textContent = 'ちゃんと入力しろよな';
this.#errorMessage.style.display = null;
// input要素のボーダーを赤くする
this.#inputField.classList.add('invalid');
}
#clearError() {
// メッセージ非表示
this.#errorMessage.textContent = '';
this.#errorMessage.style.display = 'none';
// input要素のボーダーをもとにもどす
this.#inputField.classList.remove('invalid');
}
}
こいつの役割は、
- 入力された支払金額を整数値として返す
- 入力値のバリデーションを行い、エラーを表示する
これだけです。が、このシンプルなクラスには外部に自身のプロパティをむやみに公開しないというオブジェクト指向の大原則が詰まっています。
基本的にプロパティはプライベート化
javascriptでは、気を抜くとすぐにパブリックなプロパティを宣言してしまって、外部から操作されたり直接参照されたりする危険性をはらんでいます。
class A {
constructor() {
this.text = '文字列'
// →外部から.textで参照も変更もされてしまう
}
}
現場レベルでは、直接値を書き換えて不正な値が混入するケースは稀ですが、プライベート化するもう一つの利点は、読み手(使い手)がプライベートプロパティを気にしなくてもいい点です。
このPaymentAmmount
では、外部に公開されているのはgetValue()
とisValid()
の2つの関数のみ。すると読み手は、
・PaymentAmmount
はgetValue()
で支払金額を返す
・isValid()
で値のチェックをする
この2つのみに関心を絞ることができます。特に、jsをコンポーネント化して様々な箇所で使うように設計するのであれば、他人が使いやすいように「何を見るべきか」ということをプロパティの公開範囲で理解できるようにすべきではないかと思っています。
エラーの表示・非表示に責任を持つ
最終的に計算機クラスですべての値のバリデーションを行いますが、バリデーションの方法やエラーの表示・非表示についてはPaymentAmmount
クラスの責任領域だと考え、こちらでバリデーションを行っています。
なぜそうすべきなのか、なぜ計算機クラスで具体的なバリデーションを行うべきでないのかについては前回の記事に詳しく書いているので、興味があればご覧ください。
参加者クラスの設計
class Participant {
#element;
constructor(id) {
this._id = id;
this.#element = document.querySelector(`[data-participant-id="${id}"]`);
}
// 入力された名前を取得
getName() {
const inputField = this.#element.querySelector('.js-target-participant');
return inputField.value;
}
// バリデーション
isValid() {
this.#clearError();
const name = this.getName();
// 名前が空の場合false
if (name === '' ) {
this.#showError();
return false;
} else {
return true;
}
}
#showError() {
// エラーメッセージ表示
const errorMessage = this.#element.querySelector('.js-target-participantErrorMessage')
errorMessage.textContent = '名前くらい入れてやれよ';
// 入力欄を赤くする
const inputField = this.#element.querySelector('.js-target-participant');
inputField.classList.add('invalid');
}
#clearError() {
// エラーメッセージ表示
const errorMessage = this.#element.querySelector('.js-target-participantErrorMessage')
errorMessage.textContent = '';
// 入力欄を赤くする
const inputField = this.#element.querySelector('.js-target-participant');
inputField.classList.remove('invalid');
}
}
IDで一意のインスタンスとして定義する
今回のツールでは、参加者を削除することができるようになっています。当然、削除するときは「どの参加者を消すのか」を指定しないといけませんね。削除する参加者を特定する方法は色々ありますが、Participant
にIDを持たせることで指定できる方針でいきます。
const participant = new Participant(1)
インスタンス化はこんなかたち。このクラスもPaymentAmmount
と同じく
・getName()
で名前を返す
・isValid()
で値のチェックをする
という2つの機能を備えています。さらに、participant._id
でこのインスタンスのIDを読み取ることもできます。(変更はできない)
プレフィクスによるプロパティの公開範囲の制限
hoge -> パブリック。外部から参照と代入が可能
_hoge -> 読み取り専用。外部から参照のみ可能
#hoge -> プライベート。外部から参照不可
参加者リストクラスの設計
次は、参加者リストです。これは、参加者インスタンスをまとめたアグリゲータとして振る舞います。
class ParticipantList {
#initialParticipantsCount; // 初期表示の参加者人数
#participantIdIndex = 1;
#participantList = [];
#participantElementTemplate = document.querySelector('#participant-template');
#element = document.querySelector('.js-target-participantList');
#addButton = document.querySelector('.js-click-addParticipant');
constructor(initialParticipantsCount) {
this.#initialParticipantsCount = initialParticipantsCount;
this.#setEvents()
// 最初に任意の人数の参加者入力欄を表示させる
this.#addInitialParticipants();
}
// 参加者の名前を配列で返却
getNames() {
return this.#participantList.map(participant => participant.getName());
}
// バリデーション
// すべての参加者インスタンスに対してバリデーション実行を命令し、すべてtrueのときのみtrueを返却
isValid() {
const boolArray = this.#participantList.map(participant => participant.isValid());
return boolArray.every((result) => result === true);
}
#addInitialParticipants() {
// #initialParticipantsCount回ループさせる
[...Array(this.#initialParticipantsCount)].forEach(() => {
this.#add(this.#participantIdIndex);
})
}
#setEvents() {
//参加者追加ボタンを押すと、参加者を追加する
this.#addButton.addEventListener('click', () => this.#add(this.#participantIdIndex));
}
// 参加者追加
#add(id) {
// domに参加者入力欄を追加
const clone = this.#participantElementTemplate.content.cloneNode(true);
clone.querySelector('.js-target-participant').dataset.participantId = this.#participantIdIndex
this.#element.append(clone);
// 削除イベント登録
const appendedElement = document.querySelector(`[data-participant-id="${this.#participantIdIndex}"`);
appendedElement.querySelector('.js-click-deleteParticipant').addEventListener('click', (e) => this.#delete(e))
// 自身の#participantListに参加者を追加
this.#participantList.push(new Participant(id));
// idIndexをインクリメントさせる
this.#participantIdIndex ++;
}
// 参加者削除
#delete(e) {
const element = e.target.parentNode;
const id = element.dataset.participantId;
// #participantListから参加者インスタンスを削除
this.#participantList = this.#participantList.filter((participant) => participant._id != id);
// dom element削除
element.remove();
}
}
こいつも冒頭からプライベート・プロパティ祭ですね。外部に公開しているプロパティは1つもありません(公開しているのは関数のみ。)ここは処理が少しややこしいので、1つづつ説明していきます。
コンストラクタでやっていること
constructor(initialParticipantsCount) {
this.#initialParticipantsCount = initialParticipantsCount;
this.#setEvents()
// 最初に任意の人数の参加者入力欄を表示させる
this.#addInitialParticipants();
}
まず、インスタンス化するときに初期表示する参加者入力欄の数をもらいます。
const participantList = new ParticipantList(2);
次に、クリックイベントの登録を行い、「参加者を追加する」ボタンを押すと#add()
を実行するようにセット。最後に、初期表示する入力欄を生成します。
クリックイベントの登録
#setEvents() {
//参加者追加ボタンを押すと、参加者を追加する
this.#addButton.addEventListener('click', () => this.#add(this.#participantIdIndex));
}
このクラス自身が、this.#participantIdIndex
で参加者IDのインクリメントを持っています。追加ボタンをクリックすると、それを引数にthis.#add()
を実行するというわけですね。ちなみに、アロー関数で書いていますが、そのまま関数を渡すとthis
がずれるのでこのあたりは.bind()
などで工夫する必要があります。
初期表示セット
#addInitialParticipants() {
// #initialParticipantsCount回ループさせる
[...Array(this.#initialParticipantsCount)].forEach(() => {
this.#add(this.#participantIdIndex);
})
}
javascriptにはrubyで云う3.times
のような指定回数ループを回す構文が存在しないので、...
(スプレッド構文)を用いて空の配列(正確にはundifined
が詰まった配列)をループするという方法で乗り切っています(色々ググりましたがこれが一番可読性高そうです。とはいえ、普通に指定回数のループを回せるようにしてほしいところ...)
参加者入力欄と参加者インスタンスの追加
#add(id) {
// domに参加者入力欄を追加
const clone = this.#participantElementTemplate.content.cloneNode(true);
clone.querySelector('.js-target-participant').dataset.participantId = this.#participantIdIndex
this.#element.append(clone);
// 削除イベント登録
const appendedElement = document.querySelector(`[data-participant-id="${this.#participantIdIndex}"`);
appendedElement.querySelector('.js-click-deleteParticipant').addEventListener('click', (e) => this.#delete(e))
// 自身の#participantListに参加者を追加
this.#participantList.push(new Participant(id));
// idIndexをインクリメントさせる
this.#participantIdIndex ++;
}
一番最初から少し厄介ですが、html側にコンテンツテンプレート要素を埋めています。
<template id="participant-template">
<div class="lay-input-field js-target-participant" data-participant-id="">
<input type="text" maxlength="20" class="obj-input js-target-participant">
<p class="obj-delete-button js-click-deleteParticipant">削除</p>
<p class="obj-error-message js-target-participantErrorMessage"></p>
</div>
</template>
これは、レンダリングされたページに表示されない要素で、javascript側で文字通りこれをクローンしてdomに追加するものになります。基本的にフロントエンドのコンポーネント化はVue.jsなどのフレームワーク、バックエンド言語のフレームワーク(htmlのパーシャル化など)によって行いますが、html側もこういったウェブコンポーネントをサポートしているので、「どうしてもバニラな状態で書きたい!!」という場合は使ってみるのもありかと思います。
詳しくはこちら↓を参照してください。
後の処理は、まあ見ればわかるレベルなので「ふーん」くらいに思っていただければOKです。
ここで大事なポイントは、自身が持つ#participantListを外部に操作させないという点です。これを外部に公開してしまうと、例えば、、、
const participantList = new ParticipantList(2);
participantList.participantList.push('不正データ');
このような形で不正データを入れられたり、意図しない使い方をされてしまってぐちゃぐちゃな設計になったりします。「オブジェクトはみだりに内部構造を公開するのではなく、内部構造の操作方法を外部に公開すべき」というオブジェクト指向の原則ですね。
さらに、#add()
もプライベートメソッドで、自身が定義したイニシャライズと「参加者を追加する」ボタンのクリック以外でこのメソッドが呼ばれることはないので、参加者を追加する手続きはこの#add()
だけが関与することができる状態になっています。
(補遺)
Rubyの記事ですが、なぜ内部構造をむやみに公開してはいけないのか?についての記事はこちら↓を参照してみてください。
このカプセル化の原則は、getNames()
にも適用されています。このクラスが持つ参加者の配列を直接操作させるのではなく、「名簿」を渡すだけにすることで、外部からの操作を防いでいます。
計算機クラスの設計
class OtokigiCalculator {
#paymentAmmount; // 総支払金額
#participantList; // 参加者リスト
#calculateButton = document.querySelector('.js-click-calculate'); // 「計算する」ボタン
constructor(paymentAmmount, participantList) {
this.#paymentAmmount = paymentAmmount;
this.#participantList = participantList;
this.#setEvents();
}
// イベント登録
#setEvents() {
// 「計算する」ボタンクリックで計算メソッドを実行する
this.#calculateButton.addEventListener('click', () => {
const result = this.#calculate();
if (result === null) return;
// calculateイベントをdocumentで発火。resultはe.detailで呼び出せる
const customEvent = new CustomEvent('calculate', {detail: result});
document.dispatchEvent(customEvent);
})
}
#calculate() {
if (this.#isInvalid()) return null;
const otokogiMode = this.#getOtokogiMode();
const calculateMethod = this._calculateMethodMap[`${otokogiMode}`];
return calculateMethod.calculate(this.#participantList.getNames(), this.#paymentAmmount.getValue());
}
// モード別の計算方法の出し分けで使用する
_calculateMethodMap = {
'otokogi': OtokogiMethod,
'two-times-payment': TwoTimesPaymentMethod,
'normal': NormalMethod
}
#getOtokogiMode() {
return document.querySelector('.js-target-OtokogiMode').value;
}
#isValid() {
const results = []
results.push(this.#participantList.isValid());
results.push(this.#paymentAmmount.isValid());
return results.every((result) => result === true);
}
#isInvalid() {
return !this.#isValid();
}
}
長いので、やっていることを時系列に書くと、、、
- 「計算する」ボタンクリックで、
#calculate()
を実行 -
PaymentAmmount
とParticipantList
にバリデーションを行うように命令し、すべて正常値だったときだけ計算を開始する - モードを取得し、どの計算メソッドを使うのか決定
- 計算メソッドを実行
- 計算メソッドの結果があれば、カスタムイベントを発行し、結果をイベントの
detail
プロパティにセット - カスタムイベント発火。
特筆すべきは、#calculate()
の以下の部分。
const calculateMethod = this._calculateMethodMap[`${otokogiMode}`];
~~~ 略 ~~~
_calculateMethodMap = {
'otokogi': OtokogiMethod,
'two-times-payment': TwoTimesPaymentMethod,
'normal': NormalMethod
}
~~~ 略 ~~~
// 一人だけが支払う割り勘
class OtokogiMethod {
static calculate(participantNames, paymentAmmount) {
// 処理
}
}
class TwoTimesPaymentMethod {
static calculate(participantNames, paymentAmmount) {
// 処理
}
}
class NormalMethod {
static calculate(participantNames, paymentAmmount) {
// 処理
}
}
ここで、呼び出すメソッドを備えたクラスをマッピングし、選択されたモードによって計算メソッドクラスを出し分けるようにしています。いわゆるストラテジーパターンです。これの何がいいかというと、、、case文を排除できるという点に尽きます。今後どれだけモードが増えたとしても、_calculateMethodMap
に呼び出すクラスを追加するだけで、他のコードを一切触らなくていいですし、クソ長いcase文を見なくていいというのは精神衛生上も良い。
ただ、ここはちょっと作りが甘く、calculate()
という静的メソッドを備えた各クラスがちゃんと統一感のある値を返すように、テンプレートメソッドを実装したスーパークラスを継承させたほうがベターでしょう。(詳しくは「テンプレートメソッドパターン」で検索してみてください。)
最後に
バニラjsでも、ここまでオブジェクト指向な書き方ができるということがわかり、個人的には実りのある実験でした。
javascriptは本当に手続き型の宣言で書き進めてしまいがちで、可読性が低いコードばかりが量産されてしまっているのが現状だと思うのですが、少しでもそれに抗う勢力の力になれれば幸いでございます。
js全文
/*
支払額クラス
*/
class PaymentAmmount {
#inputField = document.querySelector('.js-target-inputPaymentAmmount');
#errorMessage = document.querySelector('.js-target-paymentAmmountError');
// 入力された値を取得
getValue() {
return Number(this.#inputField.value);
}
// バリデーション
isValid() {
this.#clearError();
const value = this.getValue();
// 6桁以下の正の整数の場合のみtrue
if (Number.isInteger(value) && value > 0 && value.toString().length <= 6) {
return true;
} else {
this.#showError();
return false;
}
}
#showError() {
// メッセージ表示
this.#errorMessage.textContent = 'ちゃんと入力しろよな';
this.#errorMessage.style.display = null;
// input要素のボーダーを赤くする
this.#inputField.classList.add('invalid');
}
#clearError() {
// メッセージ非表示
this.#errorMessage.textContent = '';
this.#errorMessage.style.display = 'none';
// input要素のボーダーをもとにもどす
this.#inputField.classList.remove('invalid');
}
}
/*
参加者クラス
*/
class Participant {
#element;
constructor(id) {
this._id = id;
this.#element = document.querySelector(`[data-participant-id="${id}"]`);
}
// 入力された名前を取得
getName() {
const inputField = this.#element.querySelector('.js-target-participant');
return inputField.value;
}
// バリデーション
isValid() {
this.#clearError();
const name = this.getName();
// 名前が空の場合false
if (name === '' ) {
this.#showError();
return false;
} else {
return true;
}
}
#showError() {
// エラーメッセージ表示
const errorMessage = this.#element.querySelector('.js-target-participantErrorMessage')
errorMessage.textContent = '名前くらい入れてやれよ';
// 入力欄を赤くする
const inputField = this.#element.querySelector('.js-target-participant');
inputField.classList.add('invalid');
}
#clearError() {
// エラーメッセージ表示
const errorMessage = this.#element.querySelector('.js-target-participantErrorMessage')
errorMessage.textContent = '';
// 入力欄を赤くする
const inputField = this.#element.querySelector('.js-target-participant');
inputField.classList.remove('invalid');
}
}
/*
参加者リストクラス
*/
class ParticipantList {
#initialParticipantsCount; // 初期表示の参加者人数
#participantIdIndex = 1;
#participantList = [];
#participantElementTemplate = document.querySelector('#participant-template');
#element = document.querySelector('.js-target-participantList');
#addButton = document.querySelector('.js-click-addParticipant');
constructor(initialParticipantsCount) {
this.#initialParticipantsCount = initialParticipantsCount;
this.#setEvents()
// 最初に任意の人数の参加者入力欄を表示させる
this.#addInitialParticipants();
}
// 参加者の名前を配列で返却
getNames() {
return this.#participantList.map(participant => participant.getName());
}
// バリデーション
// すべての参加者インスタンスに対してバリデーション実行を命令し、すべてtrueのときのみtrueを返却
isValid() {
const boolArray = this.#participantList.map(participant => participant.isValid());
return boolArray.every((result) => result === true);
}
#addInitialParticipants() {
// #initialParticipantsCount回ループさせる
[...Array(this.#initialParticipantsCount)].forEach(() => {
this.#add(this.#participantIdIndex);
})
}
#setEvents() {
//参加者追加ボタンを押すと、参加者を追加する
this.#addButton.addEventListener('click', () => this.#add(this.#participantIdIndex));
}
// 参加者追加
#add(id) {
// domに参加者入力欄を追加
const clone = this.#participantElementTemplate.content.cloneNode(true);
clone.querySelector('.js-target-participant').dataset.participantId = this.#participantIdIndex
this.#element.append(clone);
// 削除イベント登録
const appendedElement = document.querySelector(`[data-participant-id="${this.#participantIdIndex}"`);
appendedElement.querySelector('.js-click-deleteParticipant').addEventListener('click', (e) => this.#delete(e))
// 自身の#participantListに参加者を追加
this.#participantList.push(new Participant(id));
// idIndexをインクリメントさせる
this.#participantIdIndex ++;
}
// 参加者削除
#delete(e) {
const element = e.target.parentNode;
const id = element.dataset.participantId;
// #participantListから参加者インスタンスを削除
this.#participantList = this.#participantList.filter((participant) => participant._id != id);
// dom element削除
element.remove();
}
}
/*
漢気計算クラス
*/
class OtokigiCalculator {
#paymentAmmount; // 総支払金額
#participantList; // 参加者リスト
#calculateButton = document.querySelector('.js-click-calculate'); // 「計算する」ボタン
constructor(paymentAmmount, participantList) {
this.#paymentAmmount = paymentAmmount;
this.#participantList = participantList;
this.#setEvents();
}
// イベント登録
#setEvents() {
// 「計算する」ボタンクリックで計算メソッドを実行する
this.#calculateButton.addEventListener('click', () => {
const result = this.#calculate();
if (result === null) return;
// calculateイベントをdocumentで発火。resultはe.detailで呼び出せる
const customEvent = new CustomEvent('calculate', {detail: result});
document.dispatchEvent(customEvent);
})
}
#calculate() {
if (this.#isInvalid()) return null;
const otokogiMode = this.#getOtokogiMode();
const calculateMethod = this._calculateMethodMap[`${otokogiMode}`];
return calculateMethod.calculate(this.#participantList.getNames(), this.#paymentAmmount.getValue());
}
// モード別の計算方法の出し分けで使用する
_calculateMethodMap = {
'otokogi': OtokogiMethod,
'two-times-payment': TwoTimesPaymentMethod,
'normal': NormalMethod
}
#getOtokogiMode() {
return document.querySelector('.js-target-OtokogiMode').value;
}
#isValid() {
const results = []
results.push(this.#participantList.isValid());
results.push(this.#paymentAmmount.isValid());
return results.every((result) => result === true);
}
#isInvalid() {
return !this.#isValid();
}
}
// 一人だけが支払う割り勘
class OtokogiMethod {
static calculate(participantNames, paymentAmmount) {
const participantCount = participantNames.length
// 支払う人ランダムに一人ピックアップ
const otokogiPersonName = participantNames[Math.floor(Math.random() * participantCount)];
// 他の参加者の名前を取得
const otherNamesArray = participantNames.filter(name => name != otokogiPersonName);
const result = {};
result[`${otokogiPersonName}`] = paymentAmmount;
otherNamesArray.forEach(name => {
result[`${name}`] = 0;
});
return result;
}
}
class TwoTimesPaymentMethod {
static calculate(participantNames, paymentAmmount) {
const participantCount = participantNames.length
let splitAmmount = paymentAmmount / (participantCount + 1);
// 3桁未満を切り捨てる
splitAmmount = Math.floor(splitAmmount / 100) * 100;
// 不足金額
const shortage = paymentAmmount - splitAmmount * (participantCount + 1);
// 2倍支払う人の金額(不足金額も含めて支払う)
const twoTimesPaymentAmmount = (splitAmmount * 2) + shortage;
// 2倍支払う人ランダムに一人ピックアップ
const otokogiPersonName = participantNames[Math.floor(Math.random() * participantCount)];
// 他の参加者の名前を取得
const otherNamesArray = participantNames.filter(name => name != otokogiPersonName);
const result = {};
result[`${otokogiPersonName}`] = twoTimesPaymentAmmount;
otherNamesArray.forEach(name => {
result[`${name}`] = splitAmmount;
});
return result;
}
}
class NormalMethod {
static calculate(participantNames, paymentAmmount) {
const participantCount = participantNames.length
let splitAmmount = paymentAmmount / participantCount;
// 3桁未満を切り捨てる
splitAmmount = Math.floor(splitAmmount / 100) * 100;
// 不足金額
const shortage = paymentAmmount - splitAmmount * participantCount;
// 不足金額込で支払う金額
const shortageIncludedAmmount = splitAmmount + shortage;
// 不足金額を支払う人をランダムに一人ピックアップ
const otokogiPersonName = participantNames[Math.floor(Math.random() * participantCount)];
// 他の参加者の名前を取得
const otherNamesArray = participantNames.filter(name => name != otokogiPersonName);
const result = {};
result[`${otokogiPersonName}`] = shortageIncludedAmmount;
otherNamesArray.forEach(name => {
result[`${name}`] = splitAmmount;
});
return result;
}
}
const paymentAmmount = new PaymentAmmount
const participantList = new ParticipantList(2);
const otokigiCalculator = new OtokigiCalculator(paymentAmmount, participantList);
document.addEventListener('calculate', (e) => {
const result = e.detail;
console.log(result);
let text = '漢気結果発表!!!!\n\n'
Object.keys(result).forEach(name => {
text += `${name}: ${result[name]}円\n`
});
window.alert(text)
})