30歳からのプログラミング

30歳無職から独学でプログラミングを開始した人間の記録。

React アプリのモーダルを dialog 要素で実装する

モーダルは、ウェブアプリケーションでよく使われる機能であり、実装する事が多い。
便利だし、利用者にとっても見慣れた UI なので導入しやすい。

だが、ブラウザや HTML には、モーダルという要素は用意されていない。
似て非なるものとしてalertがあるが、これはモーダルとして開発者が求めているものとは、多くの面で異なる。

そのためなのか、モーダルを実現するためのライブラリは多数公開されている。
だが個人的な方針として、依存ライブラリはあまり増やしたくない。
かといって自分で開発するのもそれなりに手間がかかる。

そこで、HTML 5.2 で標準化されたdialog要素を使うことにした。
標準化された機能なら、知識やノウハウが陳腐化しにくく、第三者が作ったライブラリより採用しやすい。

そう考え、React アプリのモーダルをdialog要素で実装することにした。
だが、DOM を操作するためにref属性を利用する必要があったり、そもそも対応しているブラウザが少ないため Polyfill が必要だったりと、思っていた以上に手間がかかった。

最終的には、主要なブラウザで動作する以下のモーダルが完成したので、手順をまとめておく。

f:id:numb_86:20200229144534p:plain

使用しているライブラリのバージョンは以下の通り。

  • react@16.13.0
  • typescript@3.8.2
  • dialog-polyfill@0.5.0

動作確認したブラウザとそのバージョンは以下の通り。

  • Google Chrome 80.0.3987.122
  • Firefox 73.0.1
  • Safari 13.0.5

最終的なコードは記事の最後に掲載している。

dialog 要素の概要

React で実装していく前にまず、dialog要素そのものの挙動を見ていく。
クロスブラウザ対応は後回しにして、ひとまず Chrome で動作確認していくことにする。

dialog要素は通常は表示されず、open属性を付与すると表示される。

<dialog open>
  dialog text
</dialog>

f:id:numb_86:20200229143704p:plain

そのため、以下の HTML ファイルを Chrome で開くと、ボタンのクリックによってdialogの表示非表示を切り替えることができる。

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>Document</title>
</head>
<body>
  <a href="https://example.com">https://example.com/</a>
  <br>
  <button id="open" type="button">open dialog</button>
  <dialog>
    dialog text
    <br>
    <button id="close" type="button">close dialog</button>
  </dialog>

  <script>
    const dialogElement = document.querySelector('dialog');
    const openButtonElement = document.querySelector('#open');
    const closeButtonElement = document.querySelector('#close');

    openButtonElement.addEventListener('click', () => {
      dialogElement.setAttribute('open', true);
    });

    closeButtonElement.addEventListener('click', () => {
      dialogElement.removeAttribute('open');
    });

  </script>
</body>
</html>

f:id:numb_86:20200229143729g:plain

だがこの動作には問題があり、dialog要素を表示しても、依然としてdialog要素の外にある要素を操作できてしまう。
そのため、https://example.comというリンクをクリックできてしまう。

f:id:numb_86:20200229143758g:plain

これは、ユーザーが一般的に想像する挙動とは、乖離している。
dialog要素を表示させたときは、dialog要素の外にある要素は操作できないようにしたい。
その挙動は予め用意されており、dialog要素が持っているshowModalメソッドを実行すると、dialog要素を表示させた上で、dialog要素の外にある要素を操作できないようになる。
そして、showModalと対になる、dialog要素を閉じるためのメソッドが、close

そのため、先程の HTML ファイルを以下のように書き換えると、意図した通りの挙動になる。
なぜかdialogの表示位置も変わるので、スタイルで調整している。

@@ -8,7 +8,7 @@
   <a href="https://example.com">https://example.com/</a>
   <br>
   <button id="open" type="button">open dialog</button>
-  <dialog>
+  <dialog style="top: 30px;">
     dialog text
     <br>
     <button id="close" type="button">close dialog</button>
@@ -20,11 +20,11 @@
     const closeButtonElement = document.querySelector('#close');

     openButtonElement.addEventListener('click', () => {
-      dialogElement.setAttribute('open', true);
+      dialogElement.showModal();
     });

     closeButtonElement.addEventListener('click', () => {
-      dialogElement.removeAttribute('open');
+      dialogElement.close();
     });

   </script>

f:id:numb_86:20200229143852g:plain

あとは CSS でデザインの調整さえ行えば、取り敢えず完成である。
このようにかなり手軽にモーダルを実装できるのだが、「dialog要素が持っているメソッドを実行しないといけない」という性質上、これを React で実装するためには、どうしてもref属性を使うことになる。

React アプリで実装する

早速実装を開始していく。
まずは、ボタンを押下してdialog要素を表示できるようにする。

showModalを実行するためにdialog要素を取得しなければならないので、useRefRefオブジェクトを作ってdialog要素のref属性に渡す。

import React, {useRef, useEffect} from 'react';

export const App = () => {
  const ref: React.MutableRefObject<HTMLDialogElement | null> = useRef(null);

  console.log('will mount', ref.current);

  useEffect(() => {
    console.log('mounted', ref.current);
  }, []);

  return (
    <>
      <dialog ref={ref}>dialog text</dialog>
    </>
  );
};

上記のコードを実行すると以下のログが流れるので、ref.currentの初期値はnullだが、マウントされるとdialog要素が入っているのが分かる。

will mount null
mounted <dialog>​dialog text​</dialog>​

次に、ボタンを用意してそのonClickの属性のなかでshowModalを実行すると、dialogが表示される。
dialogを閉じるのも、同じ要領でcloseを実行すればよい。

import React, {useRef, useCallback} from 'react';

export const App = () => {
  const ref: React.MutableRefObject<HTMLDialogElement | null> = useRef(null);

  const showModal = useCallback(() => {
    if (ref.current) {
      ref.current.showModal();
    }
  }, []);

  const closeModal = useCallback(() => {
    if (ref.current) {
      ref.current.close();
    }
  }, []);

  return (
    <>
      <button type="button" onClick={showModal}>
        open
      </button>
      <dialog ref={ref} style={{top: '30px'}}>
        dialog text
        <br />
        <button type="button" onClick={closeModal}>
          close
        </button>
      </dialog>
    </>
  );
};

これで React アプリでモーダルを実装できた。
しかし欲を言えば、モーダルの外をクリックしたときに、モーダルが閉じて欲しい。少なくとも、そのような挙動にしたいケースは存在する。
デフォルトだと、dialog要素の外側をクリックしても、閉じない。

f:id:numb_86:20200229143934g:plain

dialog要素のonClickcloseModalを設定すれば解決する。
dialog要素の外側をクリックすると、dialog要素に設定されたクリックイベントが実行されるからだ。

       <button type="button" onClick={showModal}>
         open
       </button>
-      <dialog ref={ref} style={{top: '30px'}}>
+      <dialog onClick={closeModal} ref={ref} style={{top: '30px'}}>
         dialog text
         <br />
         <button type="button" onClick={closeModal}>

しかしそうすると今度は、dialog要素をクリックした際もモーダルが閉じてしまう。
dialogonClickイベントにcloseModalを設定したのだから当然だが、この挙動は止めたい。

f:id:numb_86:20200229144145g:plain

どうしたものかと考えていたが、以下の記事に答えが書いてあった。

qiita.com

dialog直下の子要素を用意して、それがクリックされたときはイベントの伝播が行われないようにする。
そうすれば、dialog要素自体をクリックしても、モーダルは閉じない。

stopPropagation関数を作って、それをdialog直下のdivに渡した。
引き続きコンポーネントにスタイルを書いてしまっているが、これはあとで修正する。

+  const stopPropagation = useCallback(
+    (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
+      e.stopPropagation();
+    },
+    []
+  );
+
   return (
     <>
       <button type="button" onClick={showModal}>
         open
       </button>
-      <dialog onClick={closeModal} ref={ref} style={{top: '30px'}}>
-        dialog text
-        <br />
-        <button type="button" onClick={closeModal}>
-          close
-        </button>
+      <dialog
+        onClick={closeModal}
+        ref={ref}
+        style={{top: '30px', padding: '0px'}}
+      >
+        <div onClick={stopPropagation} style={{padding: '16px'}}>
+          dialog text
+          <br />
+          <button type="button" onClick={closeModal}>
+            close
+          </button>
+        </div>
       </dialog>

f:id:numb_86:20200229144225g:plain

カスタムフックとして処理を切り出す

次に、モーダルの表示に関するロジックをカスタムフックとして切り出す。
動作自体が変わるわけではないので、必須ではない。
だがカスタムフックにすることで、コンポーネントの記述がシンプルになるし、2 つ以上のモーダルを使い分けたいという場合にも簡単に対応できるようになる。

以下の内容のuseModal.tsというファイルを作る。

import React, {useRef, useCallback} from 'react';

export const useModal = () => {
  const ref: React.MutableRefObject<HTMLDialogElement | null> = useRef(null);
  const showModal = useCallback(() => {
    if (ref.current) {
      ref.current.showModal();
    }
  }, []);
  const closeModal = useCallback(() => {
    if (ref.current) {
      ref.current.close();
    }
  }, []);
  return {ref, showModal, closeModal};
};

そして、コンポーネント側で、useModalを利用する。

-import React, {useRef, useCallback} from 'react';
+import React, {useCallback} from 'react';
+
+import {useModal} from './useModal';

 export const App = () => {
-  const ref: React.MutableRefObject<HTMLDialogElement | null> = useRef(null);
-
-  const showModal = useCallback(() => {
-    if (ref.current) {
-      ref.current.showModal();
-    }
-  }, []);
-
-  const closeModal = useCallback(() => {
-    if (ref.current) {
-      ref.current.close();
-    }
-  }, []);
+  const {ref, showModal, closeModal} = useModal();

   const stopPropagation = useCallback(
     (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {

これで、可読性や再利用性が高まった。

スタイルを調整する

あとは、スタイルを調整していけばよい。
今日現在では CSS in JS の利用などが主流だと思うが、説明を簡略化するために、CSS ファイルを用意してそこに既述していく形にした。

まず、コンポーネントに直接書いてしまっていた部分を移植する。

       <button type="button" onClick={showModal}>
         open
       </button>
-      <dialog
-        onClick={closeModal}
-        ref={ref}
-        style={{top: '30px', padding: '0px'}}
-      >
-        <div onClick={stopPropagation} style={{padding: '16px'}}>
+      <dialog onClick={closeModal} ref={ref}>
+        <div onClick={stopPropagation} className="dialog-body">
           dialog text
           <br />
           <button type="button" onClick={closeModal}>
dialog {
  top: 30px;
  padding: 0;
}

.dialog-body {
  padding: 16px;
}

ここから先はデザインや UI の話になってしまうが、今回は以下のような構造とスタイルにした。

<dialog onClick={closeModal} ref={ref}>
  <div onClick={stopPropagation} className="dialog-body">
    <header>Header</header>
    <main>
      dialog text
      <br />
      <button type="button" onClick={closeModal}>
        close
      </button>
    </main>
  </div>
</dialog>
dialog {
  top: 30px;
  padding: 0;
  width: 400px;
  top: 70px;
  border: none;
  padding: 0;
  background: transparent;
  color: #000;
}

.dialog-body {
  padding: 16px;
}

header {
  background-color: #eaeff9;
  border-bottom: 2px solid #a3bce2;
  height: 40px;
  padding: 10px 0 0 30px;
  border-top-left-radius: 30px;
  border-top-right-radius: 30px;
  font-size: 23px;
}

main {
  background-color: #fff;
  padding: 30px;
  border-bottom-left-radius: 30px;
  border-bottom-right-radius: 30px;
}

dialog要素の外側に対しては、dialog::backdropで指定できる。
今回はデフォルトより暗くしたかったので、以下のようにした。

    dialog::backdrop {
      background-color: #000;
      opacity: 0.3;
    }

出来上がったのが、以下のモーダル。

f:id:numb_86:20200229143647p:plain

クロスブラウザ対応

これで完成、と言いたいところだが、まだクロスブラウザ対応が残っている。

冒頭で少し触れたが、dialogは標準化された機能ではあるのだが、ブラウザの対応はまだあまり進んでいない。

https://caniuse.com/#search=dialog

Firefox で動作確認してみると、ボタンを押下してもモーダルが表示されない。
Safari に至っては、ボタンを押す前から表示されてしまっている。

f:id:numb_86:20200229144320p:plain

幸い、Google が Polyfill を用意しているので、それを使って対応する。

github.com

$ npm i dialog-polyfill
$ npm i -D @types/dialog-polyfill

以下のように使うので、今回のケースだとregisterDialogメソッドの引数としてref.currentを渡せばよい。

import dialogPolyfill from 'dialog-polyfill';

dialogPolyfill.registerDialog(dialog 要素);

useEffectのなかで実行するが、dialog要素に対応しているブラウザ環境では不要な処理なので、次のようになる。

  useEffect(() => {
    if (ref.current && !ref.current.showModal) {
      dialogPolyfill.registerDialog(ref.current);
    }
  }, [ref]);

これで Polyfill の適用はできたが、この Polyfill は本来の挙動を完全に再現するものではない。
例えば、dialog要素の外側は、本来は::backdropという疑似要素のだが、Polyfill では.backdropというdialogに隣接する要素になる。
そのため、dialog::backdropで指定したスタイルが反映されない。

以下は Firefox のスクリーンショットだが、.backdropが高さ0の要素になってしまっている。そのため、dialog要素の外側をクリックしても、モーダルが閉じない。

f:id:numb_86:20200229144338p:plain

dialog + .backdropにスタイルを指定して、対応する。

dialog + .backdrop {
  background-color: #000;
  opacity: 0.3;
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
}

これで、Firefox でも問題なくなった。

では Safari はどうかというと、相変わらずボタンを押す前からdialogが表示されてしまっている。
これを修正するには、さらに CSS を追加する必要がある。

先程の Polyfill は CSS も提供しているので、それを参考にする。

dist/dialog-polyfill.css

具体的には、以下の差分を追加した。

@@ -7,6 +7,11 @@
   padding: 0;
   background: transparent;
   color: #000;
+  height: fit-content;
+  position: absolute;
+  left: 0;
+  right: 0;
+  margin: auto;
 }

 dialog::backdrop {
@@ -23,3 +28,6 @@
   width: 100%;
   height: 100%;
 }
+dialog:not([open]) {
+  display: none;
+}

これで、Safari でも動くようになる。

参考資料

最終的なコード

// App.tsx
import React, {useCallback, useEffect} from 'react';
import dialogPolyfill from 'dialog-polyfill';

import {useModal} from './useModal';

export const App = () => {
  const {ref, showModal, closeModal} = useModal();

  useEffect(() => {
    if (ref.current && !ref.current.showModal) {
      dialogPolyfill.registerDialog(ref.current);
    }
  }, [ref]);

  const stopPropagation = useCallback(
    (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
      e.stopPropagation();
    },
    []
  );

  return (
    <>
      <button type="button" onClick={showModal}>
        open
      </button>
      <dialog onClick={closeModal} ref={ref}>
        <div onClick={stopPropagation} className="dialog-body">
          <header>Header</header>
          <main>
            dialog text
            <br />
            <button type="button" onClick={closeModal}>
              close
            </button>
          </main>
        </div>
      </dialog>
    </>
  );
};
// useModal.ts
import React, {useRef, useCallback} from 'react';

export const useModal = () => {
  const ref: React.MutableRefObject<HTMLDialogElement | null> = useRef(null);
  const showModal = useCallback(() => {
    if (ref.current) {
      ref.current.showModal();
    }
  }, []);
  const closeModal = useCallback(() => {
    if (ref.current) {
      ref.current.close();
    }
  }, []);
  return {ref, showModal, closeModal};
};
dialog {
  top: 30px;
  padding: 0;
  width: 400px;
  top: 70px;
  border: none;
  padding: 0;
  background: transparent;
  color: #000;

  /* For Polyfill */
  height: fit-content;
  position: absolute;
  left: 0;
  right: 0;
  margin: auto;
}

/* For Native */
dialog::backdrop {
  background-color: #000;
  opacity: 0.3;
}

/* For Polyfill */
dialog + .backdrop {
  background-color: #000;
  opacity: 0.3;
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
}

/* For Polyfill */
dialog:not([open]) {
  display: none;
}

.dialog-body {
  padding: 16px;
}

header {
  background-color: #eaeff9;
  border-bottom: 2px solid #a3bce2;
  height: 40px;
  padding: 10px 0 0 30px;
  border-top-left-radius: 30px;
  border-top-right-radius: 30px;
  font-size: 23px;
}

main {
  background-color: #fff;
  padding: 30px;
  border-bottom-left-radius: 30px;
  border-bottom-right-radius: 30px;
}

TypeScript で抽象クラスと抽象メンバを使って変更に強いコードを設計する

TypeScript にはabstractキーワードという機能があり、これを使うことで抽象クラスや抽象メンバを宣言することができる。
この機能を上手く使ってクラスを作ることで、可読性が高く、変更にも強いコードを設計できる。

abstractキーワードを使ったクラスやメンバの詳細な挙動については、以下の記事に書いた。

numb86-tech.hatenablog.com

本記事では、架空のオンラインショップの開発現場を通して、具体的にabstractをどう使えばよいのか、どのようなケースで役に立つのか、といったことを説明していく。

動作確認はv3.8.2で行っている。

間違った共通化

このオンラインショップは書籍の販売を行っており、商品である書籍はProductというクラスで表現している。

class Product {
  productCode: string;
  title: string;
  author: string;
  unitPrice: number;
  private static CONSUMPTION_TAX_RATE = 10;

  constructor(arg: Omit<Product, 'getTaxIncludedPrice'>) {
    this.productCode = arg.productCode;
    this.title = arg.title;
    this.author = arg.author;
    this.unitPrice = arg.unitPrice;
  }

  getTaxIncludedPrice() {
    return Math.floor(
      this.unitPrice * ((100 + Product.CONSUMPTION_TAX_RATE) / 100)
    );
  }
}

このように書くことで、全ての書籍に共通するメソッドであるgetTaxIncludedPriceを一箇所で定義すれば済むようになる。
このメソッドの内容を変更したり、新しく別のメソッドを追加したりする際も、Productクラスにだけ手を加えればよいことになる。
ちなみに、小数点以下の計算を正確に行うためには様々な注意点があるのだが、本記事の主題ではないので割愛し、今回は簡単な実装で済ませている。

このコードに特に問題はなく、正しく動く。

const productList = [
  new Product({
    productCode: '0001',
    title: 'Just for Fun',
    author: 'Linus Torvalds',
    unitPrice: 1500,
  }),
  new Product({
    productCode: '0002',
    title: 'Nineteen Eighty-Four',
    author: 'George Orwell',
    unitPrice: 1000,
  }),
];

productList.forEach(p => {
  console.log(p.getTaxIncludedPrice());
});
// 1650
// 1100

しかしビジネスモデルに変更があり、書籍以外の商材も扱うことになった。
まずは缶ビールや缶コーヒーなどの飲料の取り扱いを開始するが、他の商材も今後追加していく可能性があるらしい。

飲料にauthortitleが存在しないのは自明である。
一方で、アルコールかどうかで消費税率が変わるため、新たに、アルコールか否かを表現するデータを持つ必要がある。そしてその情報は、書籍には存在しない。
それぞれの商材にどのようなメンバが必要なのかを精査し、まとめたのが、以下の表である。

書籍 飲料
productCode
unitPrice
getTaxIncludedPrice
title ×
author ×
name ×
isAlcohol ×

こうしてみると、共通するメンバがそれなりにある。そして今後も、全ての商品に共通する性質や機能を追加する可能性は、十分にある。また、書籍と飲料以外の商材の追加も検討中だ。
そう考えると、商材毎に完全に別々のクラスに分けてしまうのは、筋が悪そうである。
そこで、Producttypeという属性を持たせて、Productが複数の商材を表現できるようにした。書籍の場合はtypebookに、飲料の場合はtypedrinkにする。
これなら、共通の処理を一箇所にまとめつつ、複数の商材を表現できるようになる。
そうして出来上がったProductクラスが、こちら。

class Product {
  type: 'book' | 'drink';
  productCode: string;
  unitPrice: number;
  title?: string;
  author?: string;
  name?: string;
  isAlcohol?: boolean;
  private static DEFAULT_CONSUMPTION_TAX_RATE = 10;
  private static REDUCED_CONSUMPTION_TAX_RATE = 8;

  constructor(arg: Omit<Product, 'getTaxIncludedPrice'>) {
    this.type = arg.type;
    this.productCode = arg.productCode;
    this.unitPrice = arg.unitPrice;
    if (arg.type === 'book') {
      this.title = arg.title;
      this.author = arg.author;
    }
    if (arg.type === 'drink') {
      this.name = arg.name;
      this.isAlcohol = arg.isAlcohol;
    }
  }

  getTaxIncludedPrice() {
    let taxRate;
    if (this.type === 'book') {
      taxRate = Product.DEFAULT_CONSUMPTION_TAX_RATE;
    }
    if (this.type === 'drink') {
      taxRate = this.isAlcohol
        ? Product.DEFAULT_CONSUMPTION_TAX_RATE
        : Product.REDUCED_CONSUMPTION_TAX_RATE;
    }
    if (!taxRate) throw new Error('taxRate is undefined.');
    return Math.floor(this.unitPrice * ((100 + taxRate) / 100));
  }
}

取り敢えず、動作に問題はない。
税込価格の計算ロジックも正しく動いている。飲料は軽減税率の対象だが、アルコールは対象外となる。

const someBook = new Product({
  type: 'book',
  productCode: '0001',
  unitPrice: 1500,
  title: 'Just for Fun',
  author: 'Linus Torvalds',
});
const coffee = new Product({
  type: 'drink',
  productCode: '1001',
  unitPrice: 200,
  name: 'Coffee',
  isAlcohol: false,
});
const beer = new Product({
  type: 'drink',
  productCode: '1002',
  unitPrice: 300,
  name: 'Beer',
  isAlcohol: true,
});

console.log(someBook.getTaxIncludedPrice()); // 1650
console.log(coffee.getTaxIncludedPrice()); // 216
console.log(beer.getTaxIncludedPrice()); // 330

しかしこのコードは、少なくとも 3 つの問題を抱えている。

まず、生成されたインスタンスがどのようなメンバを持つのかが、定かではない。
同じProductから生成されたインスタンスでも、どのようなメンバを持つのかは、生成時に渡した引数によって変化する。
そのため、実際に中身にアクセスするまで、そのメンバが存在するかどうか分からない。
型推論の結果もそうなる。

// const beer: Product
const beer = new Product({
  type: 'drink',
  productCode: '1002',
  unitPrice: 300,
  name: 'Beer',
  isAlcohol: true,
});

// const isAlcohol: boolean | undefined
const {isAlcohol} = beer;

isAlcoholはオプショナルなメンバであるため、存在するかもしれないし、undefinedかもしれない。
後からこのプロジェクトに参加した開発者は特に、挙動の把握に苦労することになる。
生成されたインスタンスがどのメンバを持っているのかは、Productのコードを読んで調べなければ分からない。

その際に、2 つめの問題が影響してくる。
単純に、Productの実装が複雑で、可読性が低いという問題。
Productというひとつのクラスで複数種の商材を表現するために、typeによる条件分岐が行われている。
この程度の規模ならまだよいかもしれないが、今後商材が増えれば、その度に、条件分岐は増えていくことになる。

最後の問題は、isAlcoholなどをオプショナルにしたことで、型システムの恩恵を受けられなくなってしまったこと。
例えば、飲料にはisAlcoholが必須なのだが、忘れてしまってもエラーにならない。

// isAlcohol を忘れているがエラーにならない
const highball = new Product({
  type: 'drink',
  productCode: '1003',
  unitPrice: 300,
  name: 'Highball',
});

// 軽減税率が適用されて 324 になってしまっているが、型システム上はエラーにならない
console.log(highball.getTaxIncludedPrice()); // 324

逆に不要な情報を渡しても、それもエラーにはならない。

const highball = new Product({
  type: 'drink',
  productCode: '1003',
  unitPrice: 300,
  name: 'Highball',
  author: 'someone', // エラーにならない
});

継承を使った共通化

こうなってしまった原因は、異なる商材をひとつのクラスにまとめてしまったことにある。

共通するものをまとめてしまおう、という考え方は、ある程度は正しい。
共通するメンバがいくつかあるから、ではなく、書籍と飲料は別々の商材ではあるが、どちらも同じ「商品」であるためだ。
全く別の存在ではなく、「性質が異なる 2 種類のProduct」と考えることができる。
「インターフェイスが似ているだけで意味としては異なるもの」をまとめてしまうのはアンチパターンだが、この記事の主題から逸れるので、今回はそれについては扱わない。

書籍と飲料のようなケースでは、継承を使うことで、綺麗に書ける。
もちろん継承が唯一の選択肢というわけではないのだが、仕様の全体像や今後の事業計画を総合的に考えた結果、今回は継承を使うことにした。

まず、Productを親クラスとして設計し直して、書籍と飲料に共通する性質や機能を書く。
そして子クラスとしてBookDrinkを作り、商材毎に異なる性質や機能はそちらに書く。

この方針に基づいて、どのメンバをどのクラスに実装するか、検討した。

メンバ名 実装するクラス
productCode Product
unitPrice Product
title Book
author Book
name Drink
isAlcohol Drink
getTaxIncludedPrice ?

他のメンバは簡単だが、getTaxIncludedPriceが問題。
全ての商品が持っているべきメソッドなので、Productに書くべきかもしれない。だが、そのロジックは商材毎に微妙に異なる。
そこで、getTaxIncludedPriceは各商材に持たせ、ロジックの共通部分を抽出してそれをProductに書くようにした。

具体的には、Productの実装は以下のようになる。

class Product {
  productCode: string;
  unitPrice: number;
  protected static DEFAULT_CONSUMPTION_TAX_RATE = 10;
  protected static REDUCED_CONSUMPTION_TAX_RATE = 8;

  constructor(arg: Omit<Product, 'calculateTaxIncludedPrice'>) {
    this.productCode = arg.productCode;
    this.unitPrice = arg.unitPrice;
  }

  protected calculateTaxIncludedPrice(taxRate: number) {
    return Math.floor(this.unitPrice * ((100 + taxRate) / 100));
  }
}

商材毎に異なるのは消費税率の部分なので、その部分は、BookDrinkに書く。そしてそれを使ったロジックの大部分についてはcalculateTaxIncludedPriceとしてProductに書く。
BookDrinkgetTaxIncludedPriceを実装し、そのなかでcalculateTaxIncludedPriceを呼び出して消費税率を渡す。

以下が、BookDrinkの実装。

class Book extends Product {
  title: string;
  author: string;

  constructor(arg: Omit<Book, 'getTaxIncludedPrice'>) {
    const {productCode, unitPrice, title, author} = arg;
    super({productCode, unitPrice});
    this.title = title;
    this.author = author;
  }

  getTaxIncludedPrice() {
    return super.calculateTaxIncludedPrice(
      Product.DEFAULT_CONSUMPTION_TAX_RATE
    );
  }
}

class Drink extends Product {
  name: string;
  isAlcohol: boolean;

  constructor(arg: Omit<Drink, 'getTaxIncludedPrice'>) {
    const {productCode, unitPrice, name, isAlcohol} = arg;
    super({productCode, unitPrice});
    this.name = name;
    this.isAlcohol = isAlcohol;
  }

  getTaxIncludedPrice() {
    const taxRate = this.isAlcohol
      ? Product.DEFAULT_CONSUMPTION_TAX_RATE
      : Product.REDUCED_CONSUMPTION_TAX_RATE;
    return super.calculateTaxIncludedPrice(taxRate);
  }
}

処理の流れが理解しやすくなっており、動作も問題ない。

const someBook = new Book({
  productCode: '0001',
  unitPrice: 1500,
  title: 'Just for Fun',
  author: 'Linus Torvalds',
});
const coffee = new Drink({
  productCode: '1001',
  unitPrice: 200,
  name: 'Coffee',
  isAlcohol: false,
});
const beer = new Drink({
  productCode: '1002',
  unitPrice: 300,
  name: 'Beer',
  isAlcohol: true,
});

console.log(someBook.getTaxIncludedPrice()); // 1650
console.log(coffee.getTaxIncludedPrice()); // 216
console.log(beer.getTaxIncludedPrice()); // 330

生成されたインスタンスがどのようなメンバを持つのか、自明になった。
インスタンス生成時の引数によってメンバの有無が変化することはなく、同じクラスから生成されたインスタンスなら必ず同じメンバを持つ。
型推論も行われる。

// const beer: Drink
const beer = new Drink({
  productCode: '1002',
  unitPrice: 300,
  name: 'Beer',
  isAlcohol: true,
});

// const isAlcohol: boolean
const {isAlcohol} = beer;

インスタンス生成時の入力ミスも、TypeScript が検知してくれる。

// isAlcohol がないので Error
const beer = new Drink({
  productCode: '1002',
  unitPrice: 300,
  name: 'Beer',
});
const beer = new Drink({
  productCode: '1002',
  unitPrice: 300,
  name: 'Beer',
  isAlcohol: true,
  author: 'someone', // author を渡しているので Error
});

抽象クラスと具象クラス

新しく書き直されたProductは、全ての商品に共通する性質や機能をまとめたものであり、何か具体的な商品を表しているわけではない。非常に抽象的な存在である。
そのため、Productのインスタンスが作られることはない。Productは、それを継承した子クラスを作るためだけに存在すると言える。
このようなクラスのことを、抽象クラスという。
それに対して、抽象クラスを継承し、具体的な商品を表現するために作られたBookDrinkのようなクラスを、具象クラスという。

そして TypeScript は、abstractキーワードを使って、抽象クラスであるということを明示的に宣言することができる。
abstractをつけたクラスは抽象クラスになり、インスタンスを作ろうとするとエラーになる。

abstract class Product {
  productCode: string;
  unitPrice: number;
  protected static DEFAULT_CONSUMPTION_TAX_RATE = 10;
  protected static REDUCED_CONSUMPTION_TAX_RATE = 8;

  constructor(arg: Omit<Product, 'calculateTaxIncludedPrice'>) {
    this.productCode = arg.productCode;
    this.unitPrice = arg.unitPrice;
  }

  protected calculateTaxIncludedPrice(taxRate: number) {
    return Math.floor(this.unitPrice * ((100 + taxRate) / 100));
  }
}

// Cannot create an instance of an abstract class.
const someProduct = new Product({
  productCode: '9001',
  unitPrice: 1000,
});

この機能によって、誤って抽象クラスのインスタンスを作ってしまうことを防止できる。

さらに、abstractキーワードをつけることで、コードがドキュメントとして機能するようになる。
このプロジェクトに新しく入った開発者でも、「Productは抽象クラスなんだな」ということをすぐに理解できる。

抽象メンバ

開発は順調に進んでいたが、またも仕様の追加が発生した。書籍と飲料の他に、カレンダーも扱うことになったという。

だが商材の追加は予想されていたことであり、だからこそProductクラスを作ったのだ。
Productを継承することで、商品として必ず持つべき振る舞いと、カレンダーに固有の振る舞い、その両方を持ったクラスを簡単に作れる。
継承を使うことで、カレンダーを表現するクラスにはカレンダーに固有の振る舞いだけを書けばよく、商品全般に関する振る舞いを改めて記述せずに済む。
カレンダーが独自に持つべき振る舞いは何なのかを検討した結果、当面は「何年のカレンダーか」が分かればよいということになった。
そこで、Productを継承し、独自にyearメンバを持つCalendarクラスを実装した。

class Calendar extends Product {
  year: number;

  constructor(arg: Omit<Calendar, 'getTaxIncludedPrice'>) {
    const {productCode, unitPrice, year} = arg;
    super({productCode, unitPrice});
    this.year = year;
  }
}

const calendar = new Calendar({
  productCode: '2001',
  unitPrice: 800,
  year: 2020,
});

console.log(calendar.year); // 2020

一見問題なく動いているが、CalendargetTaxIncludedPriceを忘れてしまっている。
しかしこのコードはエラーにはならない。
なぜなら、「Productを継承した具象クラスはgetTaxIncludedPriceを実装しなければならない」とは、どこにも定義されていないからだ。

この規模のコードなら、getTaxIncludedPriceの実装を忘れてしまう可能性は低いかもしれない。
だが、実装が不可欠なメンバの数が増えたり、たくさんの具象クラスを書くようになったりすれば、ミスをすることは十分にあり得る。

そもそも、「getTaxIncludedPriceが必須である」という情報は、開発者の頭のなかにしかない。これまでの開発の経緯を把握している開発者でないと、知り得ない情報だ。
getTaxIncludedPriceは全ての具象クラスが実装すべきものなのか、それともBookDrinkに必要だっただけで必須ではないのか、現状の実装では判断がつかない。
整備された仕様書が存在すればよいが、そうでない場合、周囲の開発者に尋ねたり、コード全体の流れを追って調査したりしないといけない。

getTaxIncludedPriceは、抽象クラスであるProduct自体には実装しないが、Productを継承した具象クラスには必ず実装しないといけない。
このようなメンバを、抽象メンバという。
そして TypeScript では、抽象メンバの表現も可能になっている。
抽象メンバの表現にも、abstractキーワードを使う。

下記のgetTaxIncludedPriceは、「引数は受け取らず数値を返す抽象メンバ」として定義されている。
constructorの引数の型情報にgetTaxIncludedPriceを追記するのも忘れないようにする。

abstract class Product {
  productCode: string;
  unitPrice: number;
  protected static DEFAULT_CONSUMPTION_TAX_RATE = 10;
  protected static REDUCED_CONSUMPTION_TAX_RATE = 8;

  constructor(
    arg: Omit<Product, 'getTaxIncludedPrice' | 'calculateTaxIncludedPrice'>
  ) {
    this.productCode = arg.productCode;
    this.unitPrice = arg.unitPrice;
  }

  abstract getTaxIncludedPrice(): number;

  protected calculateTaxIncludedPrice(taxRate: number) {
    return Math.floor(this.unitPrice * ((100 + taxRate) / 100));
  }
}

これで、具象クラスにgetTaxIncludedPriceを実装することが必須となり、忘れたり、型が間違っていたりすると、エラーを出すようになった。

// Non-abstract class 'Calendar' does not implement inherited abstract member 'getTaxIncludedPrice' from class 'Product'.
class Calendar extends Product {
  year: number;

  constructor(arg: Omit<Calendar, 'getTaxIncludedPrice'>) {
    const {productCode, unitPrice, year} = arg;
    super({productCode, unitPrice});
    this.year = year;
  }
}

Productabstractをつけたときと同様にドキュメントとしての効果もあり、Productの実装を見るだけで、具象クラスには必ずgetTaxIncludedPriceメンバを実装しないといけないということが分かる。

これで、Calendarの実装も無事に終わった。

class Calendar extends Product {
  year: number;

  constructor(arg: Omit<Calendar, 'getTaxIncludedPrice'>) {
    const {productCode, unitPrice, year} = arg;
    super({productCode, unitPrice});
    this.year = year;
  }

  getTaxIncludedPrice() {
    return super.calculateTaxIncludedPrice(
      Product.DEFAULT_CONSUMPTION_TAX_RATE
    );
  }
}

const calendar = new Calendar({
  productCode: '2001',
  unitPrice: 800,
  year: 2020,
});

console.log(calendar.getTaxIncludedPrice()); // 880

抽象クラスと具象クラスの役割分担を見極める

これで盤石と思われたが、getTaxIncludedPriceの仕様変更をキッカケに、この実装にはまだ改善の余地があることが発覚した。

getTaxIncludedPriceの引数として商品の個数を渡して、その個数における税込価格を返すことになった。
引数を渡さなかった場合は、これまで通り 1 個あたり税込価格を返す。
つまり、以下のように動作することが求められる。

const coffee = new Drink({
  productCode: '1001',
  unitPrice: 200,
  name: 'Coffee',
  isAlcohol: false,
});

console.log(coffee.getTaxIncludedPrice()); // 216
console.log(coffee.getTaxIncludedPrice(1)); // 216
console.log(coffee.getTaxIncludedPrice(2)); // 432

getTaxIncludedPriceのインターフェイスが変わるときに何が起こるのか」をシンプルなコードで表現したくて、このような例にした。
getTaxIncludedPriceにこの機能をもたせることの是非は、今回は問わない。
だが、getTaxIncludedPriceに渡す引数が変わったり、返り値の構造が変わったりすることは、現実的に十分にあり得る。

変更自体は、大して難しくない。
getTaxIncludedPriceの引数としてquantityを受け取るようにして、そのデフォルト値を1にする。
そして、calculateTaxIncludedPriceの返り値にquantityを掛ければよい。

  // Drink の場合
  getTaxIncludedPrice(quantity = 1) {
    const taxRate = this.isAlcohol
      ? Product.DEFAULT_CONSUMPTION_TAX_RATE
      : Product.REDUCED_CONSUMPTION_TAX_RATE;
    return super.calculateTaxIncludedPrice(taxRate) * quantity;
  }

最後に、ProductでのgetTaxIncludedPriceの型定義にも、引数を追加しておく。

  abstract getTaxIncludedPrice(quantity?: number): number;

これで完了。複雑なことは何もない。
問題は、全ての具象クラスのgetTaxIncludedPriceを変更しなければならないことだ。
もっと具象クラスが増えていたタイミングで同様の変更が発生したら、具象クラスの数だけ手間が掛かる。変更の内容が複雑なものだった場合、相当な手間になってしまう。

getTaxIncludedPriceをそれぞれの具象クラスに実装してしまったために、このような状況になってしまった。
getTaxIncludedPriceの実装が一箇所にまとまっていれば、インターフェイスが変わることになっても、変更箇所は一箇所で済む。

getTaxIncludedPriceを抽象クラスではなく具象クラスに担当させたのは、設計ミスだった。
全ての商品に共通する振る舞いは抽象クラスに書く、という原則から考えても、Productに書くべきだ。
確かに、税率に関するロジックは商材毎に異なる。しかし、その異なる部分こそを具象クラスに切り出すべきであり、getTaxIncludedPrice自体はProductに持たせたほうがよい。

quantityオプションのことは一旦忘れ、getTaxIncludedPriceProductに移す。
代わってconsumptionTaxRateという抽象メンバを用意して、それをgetTaxIncludedPriceの中から参照する。

abstract class Product {
  productCode: string;
  unitPrice: number;
  protected static DEFAULT_CONSUMPTION_TAX_RATE = 10;
  protected static REDUCED_CONSUMPTION_TAX_RATE = 8;

  abstract consumptionTaxRate: number;

  constructor(
    arg: Omit<Product, 'consumptionTaxRate' | 'getTaxIncludedPrice'>
  ) {
    this.productCode = arg.productCode;
    this.unitPrice = arg.unitPrice;
  }

  getTaxIncludedPrice() {
    return Math.floor(this.unitPrice * ((100 + this.consumptionTaxRate) / 100));
  }
}

class Drink extends Product {
  name: string;
  isAlcohol: boolean;
  consumptionTaxRate: number;

  constructor(arg: Omit<Drink, 'getTaxIncludedPrice' | 'consumptionTaxRate'>) {
    const {productCode, unitPrice, name, isAlcohol} = arg;
    super({productCode, unitPrice});
    this.name = name;
    this.isAlcohol = isAlcohol;
    this.consumptionTaxRate = this.isAlcohol
      ? Product.DEFAULT_CONSUMPTION_TAX_RATE
      : Product.REDUCED_CONSUMPTION_TAX_RATE;
  }
}

// Book と Calendar も同様に、consumptionTaxRate を実装し getTaxIncludedPrice を削除すればよい
// consumptionTaxRate は抽象メンバなので、実装し忘れても TypeScript がそのことを検知してくれる

これで、商材毎に異なる部分をconsumptionTaxRateとして具象クラスに切り出し、getTaxIncludedPriceそのものはProductに書くことができた。

これなら、getTaxIncludedPriceの仕様変更が発生しても、Productだけを編集すれば済む。

  // Product の getTaxIncludedPrice を書き換える
  getTaxIncludedPrice(quantity = 1) {
    return (
      Math.floor(this.unitPrice * ((100 + this.consumptionTaxRate) / 100)) *
      quantity
    );
  }

具象クラスの抽象クラスへの依存を避ける

先程の設計の見直しによって、getTaxIncludedPriceProductに移した。
このメソッドは、具象クラスがconsumptionTaxRateメンバを持っていることを知っている。そしてこのメンバが各商品の消費税率を示していることも、知っている。
一方で具象クラス側は、抽象クラスがgetTaxIncludedPriceのなかでconsumptionTaxRateを使っていることを知らない。consumptionTaxRateが抽象クラスでどのように使われているのか、全く関知しない。

このように、具象クラスはできるだけ、抽象クラスに対する知識を持たないようにするのが望ましい。
抽象クラスがどのような実装になっているのか知るべきではない。

具象クラスが抽象クラスについて知っていればいるほど、依存関係が深くなる。そうなると、抽象クラスの変更の影響が、具象クラスにも波及しやすくなってしまう。
実は、まさにそのような状況になってしまっている箇所が既に存在する。それが、具象クラスのconstructorメソッドである。

例として、Bookconstructorを見てみる。

  constructor(arg: Omit<Book, 'getTaxIncludedPrice' | 'consumptionTaxRate'>) {
    const {productCode, unitPrice, title, author} = arg;
    super({productCode, unitPrice});
    this.title = title;
    this.author = author;
    this.consumptionTaxRate = Product.DEFAULT_CONSUMPTION_TAX_RATE;
  }

superの箇所が問題である。
このコードは、親クラス、つまりProductconstructor{productCode, unitPrice}を受け取ることを知ってしまっている。Productの知識を持ってしまっている。
このせいで、Productconstructorに変更が発生した場合、具象クラスもその影響を受けてしまう。

例えば、在庫数を表現するために、Productに新たにstockメンバを持たせることになったとする。

 abstract class Product {
   productCode: string;
   unitPrice: number;
+  stock: number;
   protected static DEFAULT_CONSUMPTION_TAX_RATE = 10;
   protected static REDUCED_CONSUMPTION_TAX_RATE = 8;


   ) {
     this.productCode = arg.productCode;
     this.unitPrice = arg.unitPrice;
+    this.stock = arg.stock;
   }

この変更によって、全ての具象クラスのconstructorでエラーが発生する。
superにはproductCodeunitPricestockの 3 つを渡すべきなのに、stockを渡していないからだ。
そのため、全ての具象クラスで以下の変更作業を行わなければならない。

   constructor(arg: Omit<Book, 'getTaxIncludedPrice' | 'consumptionTaxRate'>) {
-    const {productCode, unitPrice, title, author} = arg;
-    super({productCode, unitPrice});
+    const {productCode, unitPrice, stock, title, author} = arg;
+    super({productCode, unitPrice, stock});
     this.title = title;
     this.author = author;

これが、具象クラスが抽象クラスに依存することで生まれる弊害である。抽象クラスで変更が発生する度に、具象クラスもそれに振り回されてしまう。

この状況を改善し、依存を弱めたのが以下のコードである。
Bookを例にしたが、他の具象クラスも同じ要領で対応できる。

class Book extends Product {
  title: string;
  author: string;
  consumptionTaxRate: number;

  constructor(
    productProperty: Omit<
      Product,
      'consumptionTaxRate' | 'getTaxIncludedPrice'
    >,
    bookProperty: Omit<Book, keyof Product>
  ) {
    super(productProperty);
    this.title = bookProperty.title;
    this.author = bookProperty.author;
    this.consumptionTaxRate = Product.DEFAULT_CONSUMPTION_TAX_RATE;
  }
}

const book = new Book(
  {
    productCode: '0001',
    unitPrice: 1500,
  },
  {
    title: 'Just for Fun',
    author: 'Linus Torvalds',
  }
);

constructorの引数を 2 つに分け、第一引数で抽象クラス用のデータを、第二引数で具象クラス用のデータを受け取るようにした。
この設計の利点は、superにはproductPropertyをそのまま渡せばよく、その中身については関知せずに済むことだ。
そのため、先程のstockの例のように抽象クラスにメンバを増やしても、具象クラスには一切手を加えずに済む。

このように、具象クラスが抽象クラスの知識を持たないようにすることで、抽象クラスの変更の影響が具象クラスに波及しにくくなる。

まとめ

こうして、共通部分をProductクラスに共通化しつつ、変更にも強いクラスを設計できた。

全ての商品に共通する性質や機能を抽象クラスにまとめるようにしているため、stockのように全ての商品に対して新しくメンバを追加するようなケースでも、それを抽象クラスに追加するだけで対応できる。
そして適切に設計することで、抽象クラスを編集しても、その影響が具象クラスに波及することはない。getTaxIncludedPricestockの例で見た通りである。

逆に、具象クラスで変更が発生しても、その影響が抽象クラスや他の具象クラスに及ぶことはない。
例えば、Calendarクラスに、表紙のデザインに関する情報を持つためのcoverメンバを追加したとする。この変更の影響範囲はCalendarだけに留まり、Productクラスや他の具象クラスには影響しない。

TypeScript では、abstractキーワードを使うことで、抽象クラスや抽象メンバであることを明示的に宣言できる。そのため、抽象クラスと具象クラスを使った設計を JavaScript よりも行いやすいという利点がある。

今回説明した設計が常に正しいというわけでは勿論ないが、抽象クラスや抽象メンバの使い方や利点は、イメージできたと思う。

参考資料

この記事の内容は、『オブジェクト指向設計実践ガイド ~Rubyでわかる 進化しつづける柔軟なアプリケーションの育て方』の第 6 章をベースにしている。

この書籍の概要は以下のリポジトリにまとめており、今回もこれを読み返して参考にした。

github.com