114
135

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

javascriptもモダンなオブジェクト指向で書こうよ(サンプルコード有) 前編

Last updated at Posted at 2022-12-07

javascriptはオブジェクト指向言語なのに、手続き型で書いてしまいがち...

javascriptはオブジェクト指向言語であるものの、ちょっと特殊な言語です。Java、C++、C#、Rubyといったクラスベースの言語とは違い、プロトタイプベースのオブジェクト指向言語であるというのがオブジェクト指向で書きづらい要因になっているように思います。

クラスベースの言語であれば、まずはともあれクラスを作ってインスタンス化する...という一連の流れが自然と書ける一方で、javascriptでは「DOMを操作する」という任務を任されることが多いために、要素を取得して、計算して、要素を書き換えて、ということを手続き的に書いてしまう。

そして、記述量が多くなればなるほど待ち受けるのは、、、

「このjsはどこで何をやってるんだ...」
「同じようなコードがいろんなところでコピペされてる...」
「この処理と同じことを別のページでもやりたいけど、コンポーネント化されてないからコピペしてちょっと改変するしかない...」

という可読性・メンテンス性・拡張性の低さです。(私も1ページ1000行に及ぶ複雑怪奇なjavascriptを相手にしたときは絶望しました。読者諸兄におかれましては、もっとクレイジーなコードと戦った方もおられることでしょう。心中お察しします...。)

ただ、数年前まではプロトタイプという得体のしれない概念を理解し、オブジェクト指向を実現していくしかありませんでしたが、ECMAScript 6でクラス概念をプロトタイプのシンタックスシュガーとして利用できるようになりました。(IEサポート終了も強力な追い風のように感じます)

この記事では、「男気!!割り勘ツール」という仮想のプログラムをイチから組み立てていくことで、どういうふうにjavascriptでオブジェクトを設計していけばいいのか、そのエッセンスをお伝えできればと思います。

ベースとなるページ

See the Pen JS_簡単なhtmlまで by terao takumi (@teraotakumi) on CodePen.

最終的には男気を発揮できる割り勘ツールにしていくつもりですが、まずはアジャイル的に基本となる「割り勘」をjavascriptを使って実装していきます。

Formオブジェクトの実装

概観

See the Pen JS_2 by terao takumi (@teraotakumi) on CodePen.

実装

まず、このページでどこをオブジェクトにするかを考えます。まあ要素がほとんどないのでわかりやすいですが、入力する箇所と計算するボタンをあわせた「WarikanForm」というものをオブジェクトにしてみましょう。

では、どんな中身にしていくのがよいでしょうか?

オブジェクト指向の基本は「メッセージのやりとり」なので、このフォームオブジェクトがどんなメッセージに応答してほしいのかから逆算します。

今はひとまずwindowに表示させたいので、alertとして割り勘した金額を表示させたい。なので、、、

warikanForm.alertSplitAmmount();
// →画面に「割り勘金額はxxx円です」と表示

こんなメッセージとレスポンスがあればよさそうです。実装はこんな具合↓

class WarikanForm {
    // 割り勘金額をアラートで表示する
    alertSplitAmmount() {
        window.alert(`一人あたりの金額は${this.#splitAmmount()}円です。`);
    };
};

バッククオートと${}で変数展開しているあたりがモダンですね(笑)。このコードによって、

const warikanForm = new WarikanForm;
warikanForm.alertSplitAmmount();

こんなふうに、WarikanFormオブジェクトのインスタンスが応答するメソッドを実装することができました。が、肝心の割り勘金額である${this.#splitAmmount()}をまだ定義していません。ちなみに、#hoge()はプライベートメソッドを表します。今の所外部から直接割り勘金額を引き出すことを想定していないので、もしかすると将来的にはパブリックメソッドに昇格するかもしれません。

単一責任原則は常に意識しておく

今はalertというどこに責務を置くか際どいレスポンスを返していますが、これが「計算結果をページの特定の箇所に表示させる」ということまでやってしまうと、それはWarikanFormオブジェクトの責任範囲を逸脱してしまっている可能性が高いです。WarikanFormオブジェクトは、あくまで入力された値に基づき割り勘結果を返却するだけのオブジェクトであり、それ以上のことをやるべきではありません。

一連のプライベートメソッド群
class WarikanForm {

    ~~~  ~~~

    // 
    // private
    //

    // 支払金額取得
    #paymentAmmount() {
        return document.querySelector('[data-input-type="payment-ammount"]').value;
    }

    // 割り勘人数取得
    #denominator() {
        return document.querySelector('[data-input-type="denominator"]').value;
    }

    // 一人あたりの割り勘金額
    #splitAmmount() {
        return this.#paymentAmmount() / this.#denominator();
    }
};

ポイントは、支払金額や割り勘人数などの要素の取得と、それを用いた計算結果の出力を別メソッドとして定義している点です。オブジェクト指向では、このように具体メソッドを細分化し、それを呼び出すメソッドを抽象化させていきます。これによって、抽象メソッドはその具体的な実装にまで責任を持つ必要がなくなります。

ここでは#splitAmmount()が抽象メソッドにあたりますが、このメソッドは「ちゃんと計算して値を返す」ということだけに集中すればよくなります。このようにして責任範囲を明確化・分割化することで、コードのメンテナンスが発生したときに(例えば要素のデータ属性を変更するときなど)は、その要素を取得しているメソッドのみを変更するだけで済みます。

ここまでで、一応計算結果が出力されるようになりました。

責任・依存関係の確認

【WarikanFormオブジェクト】

・htmlに適切なエレメントを用意してもらう必要がある
・htmlから値を取得し、計算し、計算結果を返すのが責務

【グローバルスコープ内のjs】

・オブジェクトを手続き的に操作できる(ある程度)
・formオブジェクトが応答できるメッセージを知っている
・どの要素がクリックされたときに、formオブジェクトのどのメソッドを呼び出すかを設定する必要がある
・要素のクリックイベントとオブジェクトのメソッドを結びつけるのが責務

→今の所依存度は低いように思いますが、グローバルスコープ内のjsがあれもこれもやりすぎるとまずいので、やることが肥大化するとファクトリやアプリケーションサービスなどのクラスを用意する必要が出てきそうな予感がしますね。

バリデーション、エラー等の実装

上記のコードでは、inputに数字ではない文字列を入れることが可能になっています。これでは計算結果が「NaN」となってしまい、良いプログラムとは言えません。また、割り切れない値だと小数点がズラーッと並んでしまうのもツールとしてはよろしくないですね。

まずは割り勘計算で

・100円未満を切り捨てする
・不足額を表示する

この2つを実装してみます。

端数切捨て処理

class WarikanForm {
    // 割り勘金額をアラートで表示する
    alertSplitAmmount() {
        window.alert(`一人あたりの金額は${this.#splitAmmount()}円です。\n不足金額は${this.#shortage()}円です。`);
    };

    ~~~  ~~~

    // 一人あたりの割り勘金額(100円未満切り捨て)
    #splitAmmount() {
        // 単純に割る
        let splitAmmount = this.#paymentAmmount() / this.#denominator();
        // 3桁未満を切り捨てる
        splitAmmount = Math.floor(splitAmmount / 100) * 100;
        return splitAmmount
    }

    // 不足金額
    #shortage() {
        return this.#paymentAmmount() - this.#splitAmmount() * this.#denominator();
    }
};

#splitAmmount()が少しボリューミーになり、新たに不足金額を返す#shortage()を用意しました。が、現在様々な数値をメソッドで定義しているため、それを呼び出すたびに処理が走るのは、パフォーマンスの観点からあまりよろしくありません。このコードでは全く問題ないにしても、処理の量が多くなるとこの書き方だとスピードの低下につながってしまいます。何よりダサいです。後のリファクタ案件として考えておきましょう。

バリデーション

class WarikanForm {
    // 割り勘金額をアラートで表示する
    alertSplitAmmount() {
        if (this.#isInvalid()) {
            return window.alert('入力内容に不備があります');
        }
        window.alert(`一人あたりの金額は${this.#splitAmmount()}円です。\n不足金額は${this.#shortage()}円です。`);
    };

    // 
    // private
    //

    // 支払金額取得
    #paymentAmmount() {
        return Number(document.querySelector('[data-input-type="payment-ammount"]').value);
    }

    // 割り勘人数取得
    #denominator() {
        return Number(document.querySelector('[data-input-type="denominator"]').value);
    }

    ~~~  ~~~

    // バリデーション
    #isValid() {
        return Number.isInteger(this.#paymentAmmount()) && Number.isInteger(this.#denominator());
    }

    #isInvalid() {
        return !this.#isValid();
    }
};

ひとまず実装できましたが、#isValid()が何をどのようにバリデートするのか詳細に知っていないといけないために、例えばこのあと変数を追加すると条件がどんどん複雑化していく未来が見えます。そして、エラーメッセージを表示させるとなると、もはやこのメソッドが抱える詳細な処理が他と比べて多すぎることになるのは目に見えています。

そこで、責任の分離です。

バリデーションは、個々の要素に任せてしまって、WarikanFormはあくまですべてvalidなのかinvalidなのかを判断だけにし、責任を分離します。

支払額や割り勘人数を別オブジェクトとして定義する

//
// 支払額クラス
//
class PaymentAmmount {
    // インスタンス化時にdomの要素を指定する
    constructor(element) {
        this.element = element;
    }

    // 入力された値を取得
    getValue() {
        return Number(this.element.querySelector('[data-element-type="input"]').value);
    }

    // バリデーション
    isValid() {
        return Number.isInteger(this.getValue());
    }
}

// 
// 割り勘人数クラス
//
class NumberOfPeople {
    // インスタンス化時にdomの要素を指定する
    constructor(element) {
        this.element = element;
    }

    // 入力された値を取得
    getValue() {
        return Number(this.element.querySelector('[data-element-type="input"]').value);
    }

    // バリデーション
    isValid() {
        return Number.isInteger(this.getValue());
    }
}

インスタンス化時にdom要素を注入し、入力された値の取得とそのバリデーションを行う関数を備えたクラスです。今の所両者は全く同じプロパティ、関数を持っていますが、後々明らかにバリデーションの中身が異なってくるので別クラス(別ドメイン)として定義しています。

Form側は以下のように書き換えました。

class WarikanForm {
    constructor(numerator, denominator) {
        this.numerator = numerator; 
        this.denominator = denominator;
        this.splitAmmount = 0;
        this.shortage = 0;
    }


    // 割り勘金額をアラートで表示する
    alertSplitAmmount() {
        if (this.#isInvalid()) {
            return window.alert('入力内容に不備があります');
        }
        this.#calculate();
        window.alert(`一人あたりの金額は${this.splitAmmount}円です。\n不足金額は${this.shortage}円です。`);
    }

    // 
    // private
    //

    // 計算を実行して割り勘金額と不足額をプロパティに格納する
    #calculate() {
        let numerator = this.numerator.getValue();
        let denominator = this.denominator.getValue();

        // 単純に割る
        let splitAmmount = numerator / denominator;
        // 3桁未満を切り捨てる
        splitAmmount = Math.floor(splitAmmount / 100) * 100;
        this.splitAmmount = splitAmmount;

        this.shortage = numerator - splitAmmount * denominator;
    }

    // バリデーション
    #isValid() {
        // isValid()を実装しているオブジェクトのみvalidateする
        let boolArray = Object.keys(this).map(property => this[property].isValid?.());
    
        // 配列内をbooleanのみにする
        boolArray = boolArray.filter(value => typeof value === 'boolean');

        // バリデーションがすべてtrueのときのみtrueを返却
        const isTrue = (obj) => obj === true;
        return boolArray.every(isTrue);
    }

    #isInvalid() {
        return !this.#isValid();
    }
};
// 支払額オブジェクトのインスタンスを作成
const paymentAmmount = new PaymentAmmount(document.querySelector('[data-class="payment-ammount"]'));

// 割り勘人数のインスタンスを作成
const numberOfPeople = new NumberOfPeople(document.querySelector('[data-class="number-of-people"]'));

// WarikanFormオブジェクトのインスタンスを作成
const warikanForm = new WarikanForm(paymentAmmount, numberOfPeople);

// 「計算する」ボタンクリックで計算結果を表示
const calculateButton = document.querySelector('[data-submit-type="calculate"]');
calculateButton.onclick = function () { warikanForm.alertSplitAmmount(); }
point1: 依存性の注入(DI)

Formはコンストラクタで

    constructor(numerator, denominator) {
        this.numerator = numerator; 
        this.denominator = denominator;
        this.splitAmmount = 0;
        this.shortage = 0;
    }

このように、支払金額(numerator)と割り勘人数(denominator)を受け取ります。外部から具体的なオブジェクトを注入することによって、WarikanFormオブジェクトはより抽象的な存在になりました。どういうことかというと、このWarikanFormオブジェクトは、イニシャライズ時の引数に渡されるオブジェクトが

・getValue()メソッドに応答する
・isValid()メソッドに応答する

これを満たしていれば、どんなオブジェクトでも構わなくなったということです。抽象的に表現すると、WarikanFormは自身で持つnumeratorやdenominatorというインターフェースを介して具体的なクラスにアクセスすることになったと言えます。

このように、WarikanFormという抽象的なクラスが具体的なクラスに依存しないように設計する原則を、依存性逆転の原則と言います。そして、このWarikanFormクラスはFormというより計算を行うだけのクラスになっているので、クラス名も以下ではCaluculatorに変更しておきます。(クラスの命名はそのクラスの概念を表すので、適切な命名をすることは本当に大切です。命名から間違った方向で実装してしまうこともしばしば...)

point2: isValid()ではプロパティ自身がisValid()メソッドを備えている場合にのみバリデーションをかける
let boolArray = Object.keys(this).map(property => this[property].isValid?.());

ここでは、

1.Calculatorインスタンスが持つプロパティのキーをObject.keys(this)で配列として取り出す
2.mapメソッドでそれぞれのプロパティをthis[proerty]で呼び出す
3.オプショナルチェーン(?.)で、isValid()を備えているオブジェクトだけが関数を実行。それ以外はundifinedを返す
4.実行結果を配列に格納する

という一連の処理を1行で書いています。この書き方によって、バリデーションが必要なオブジェクトだけisValid()を独自に実装していれば、そのオブジェクト自身が評価を行う(=評価方法を知っている)ことができるようになります。

では、支払金額や割り勘人数のバリデーションをもっと厳密にやってみましょう。

バリデーションの厳密化

class PaymentAmmount {
    ~~~  ~~~
    // バリデーション
    isValid() {
        const value = this.getValue();
        // 6桁以下の正の整数の場合のみtrue
        return Number.isInteger(value) && value > 0 && value.toString().length <= 6;
    }
}

class NumberOfPeople {
    ~~~  ~~~
    // バリデーション
    isValid() {
        const value = this.getValue();
        // 2桁以下の正の整数の場合のみtrue
        return Number.isInteger(value) && value > 0 && value.toString().length <= 2;
    }
}

これで、かなり不正な入力値を弾くことができました。思い出してほしいんですが、このバリデーションを旧WarikanFormクラスでやろうとすると、、、

return Number.isInteger(this.#paymentAmmount()) && Number.isInteger(this.#denominator());

ここにダラダラと書き加えなくてはいけませんでした。その可読性の低さを考えると、バリデーションの具体的なやり方をそれぞれのオブジェクトに任せて正解だったと思えるんじゃないでしょうか。

それでは、このチャプタの最後に、エラーを実装してみます。

エラー表示の実装

class PaymentAmmount {
    // インスタンス化時にdomの要素を指定する
    constructor(element) {
        this.element = element;
        this.inputField = this.element.querySelector('[data-element-type="input"]');
        this.errorMessage = this.element.querySelector('[data-element-type="error"]');
    }

    // 入力された値を取得
    getValue() {
        return Number(this.inputField.value);
    }

    // バリデーション
    isValid() {
        this.#clearError();
        const value = this.getValue();
        // 6桁以下の正の整数の場合のみtrue
        if (value.toString().length <= 6 && value > 0 && Number.isInteger(value)) {
            return true;
        } else {
            this.#showError();
            return false;
        }
    }

    #showError() {
        // メッセージ表示
        this.errorMessage.style.display = null;

        // input要素のボーダーを赤くする
        this.inputField.classList.add('invalid');
    }

    #clearError() {
        // メッセージ非表示
        this.errorMessage.style.display = 'none';

        // input要素のボーダーを赤くする
        this.inputField.classList.remove('invalid');
    }
}

実装面で特に難しいことはありませんが、CalculatorがisValid()を実行したときに、それぞれのオブジェクトがそれぞれ自分の責任範囲の中でバリデーションをかけ、falseならエラーを表示するところまでを行うようになりました。

ここまでのコードと動き

See the Pen Untitled by terao takumi (@teraotakumi) on CodePen.

次回はいよいよ「男気」を発揮できるツールにしていくにあたって、さらに改造を施していきます。

追記

後編できました。

114
135
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
114
135

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?