12
14

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 5 years have passed since last update.

TypeScriptでStateパターンな電卓アプリを作る

Last updated at Posted at 2019-08-20

新たにTypeScriptの勉強を始めたので、知識定着の為にTypeScriptを用いて電卓を作成しました。
特にライブラリやフレームワークは利用していません。
TypeScriptの基本知識は一通り目を通した方向けです。

参考

環境

  • Windows 10
  • Node.js 10.15.1
  • TypeScript 3.5.3
  • webpack 4.37.0

※コンパイル後のコードはes2015です。

最新版TypeScript+webpack 4の環境構築まとめ

Stateパターンな電卓

完成図

電卓.gif

実物はこちらです。キーボード入力にも対応しています。
https://beebow6.github.io/typescript-calculator/dist/

全てのソースはこちらです。
https://github.com/BeeBow6/typescript-calculator

状態遷移表

TypeScriptの基本的なところをなるべく一通り体験すべく、Javaで電卓アプリを作成している下記サイトを参考に作成しました。
サンプルアプリでおぼえる実践的Android入門※要事前登録

今回の電卓の状態遷移表は下記のとおりです。

A:左辺入力前
BeforeLeftSideState
B:数値入力
NumberState
C:演算子入力
OperatorState
D:右辺入力後
AfterRightState
E:結果表示
ResultState
F:エラー
ErrorState
数値
0-9
.
数値表示
⇒Bへ
数値追加表示 数値表示
⇒Bへ
乗算演算子登録
数値表示
⇒Bへ
演算履歴削除
数値表示
⇒Bへ
-
円周率
π
円周率表示

⇒Bへ
円周率表示 円周率表示
⇒Bへ
乗算演算子登録
円周率表示
⇒Bへ
演算履歴削除
円周率表示
⇒Bへ
-
演算子
+-×÷
0表示
数値確定
演算子登録
⇒Cへ
数値確定
演算実行
演算子登録
⇒Cへ
演算子差し替え 演算子登録
⇒Cへ
演算履歴削除
数値確定
演算子登録
⇒Cへ
-
左括弧
(
左括弧登録 数値確定
乗算演算子登録
左括弧登録
⇒Aへ
左括弧登録
⇒Aへ
乗算演算子登録
左括弧登録
⇒Aへ
すべて削除
左括弧登録
⇒Aへ
-
右括弧
)
- 数値確定
右括弧登録
演算実行
⇒Dへ
- 右括弧登録 - -
符号反転
+/-
符号反転
⇒Bへ
符号反転 表示削除
符号反転
⇒Bへ
乗算演算子登録
符号反転
⇒Bへ
演算履歴削除
符号反転
⇒Bへ
-
結果表示
=
- 数値確定
演算実行
⇒Eへ
演算子破棄
演算実行
⇒Eへ
演算実行
⇒Eへ
- -
一つ削除
- 一つ削除 - - 演算履歴削除
一つ削除
⇒数値なし⇒Aへ
⇒数値あり⇒Bへ
-
削除
C
- 表示削除
⇒Aへ
表示削除 - すべて削除
⇒Aへ
-
全て削除
AC
すべて削除 すべて削除
⇒Aへ
すべて削除
⇒Aへ
すべて削除
⇒Aへ
すべて削除
⇒Aへ
すべて削除
⇒Aへ

State パターン | TECHSCORE

定数を列挙型(enum)で定義

入力される数値および小数点と各種演算子、ボタンタイプを列挙型(enum)で定義します。

/**
 * ボタンからの入力値およびボタンタイプを列挙型で定義
 */

// 演算子
enum OPERATOR {
  ADD = '+',
  SUBTRACT = '-',
  MULTIPLY = '*',
  DIVIDE = '/',
  LEFT_PAREN = '(',
  RIGHT_PAREN = ')'
}

// 数値
enum NUMBER {
  ZERO = '0',
  ONE = '1',
  TWO = '2',
  // ...
  EIGHT = '8',
  NINE = '9',
  POINT = '.'
}

// ボタンの種類
enum BUTTON_TYPE {
  NUMBER = '10',
  PI = '11',
  OPERATOR = '20',
  LEFT_PAREN = '21',
  RIGHT_PAREN = '22',
  INVERSION = '23',
  EQUAL = '80',
  CLEAR = '90',
  ALL_CLEAR = '91',
  BACKSPACE = '92'
}

/src/setting.ts | GitHub

数値で宣言した場合、定義外の数値もすり抜けるようですね。
typescript_enum.png

VIEWコンポーネント

抽象クラス

ReactのComponentみたいなVIEWコンポーネントの基底クラスを抽象クラスで定義し、ボタンやディスプレイ等の各コンポーネントはこれを継承するようにします。

継承.png

基底クラスの定義

DOM要素を生成・保持します。
生成した要素は読み取り専用で参照可能です。
抽象メソッドgetTemplate()では、各派生クラスでHTMLソースを定義させるようにしています。
このソースがプレースフォルダを含む場合、constructorで受け取ったマップ情報をもとに置換します。

/**
 * コンポーネントの基底クラス
 */

interface ReplaceMap {
  [key: string]: string | number;
}

export default abstract class Component<T> {
  // このクラス内でのみ参照可。派生クラスでも参照不可。
  private _element: T;

  constructor(map: ReplaceMap = {}) {
    const div: HTMLElement = document.createElement('div');
    div.innerHTML = this.replaceTemplate(this.getTemplate(), map);
    this._element = div.firstElementChild as unknown as T;
  }

  // 派生クラスで要実装
  protected abstract getTemplate(): string;
  // このクラス内でのみ参照可。派生クラスでも参照不可。
  private replaceTemplate(temp: string, map: ReplaceMap = {}) {
    return temp.replace(/\{\{(.+?)\}\}/g, (_, key) => map[key] as string || '');
  }
  // どこからも参照可能。読み取り専用。
  get element(): T {
    return this._element;
  }
}

/src/components/component.ts | GitHub

ジェネリック型(Generics)

DOM要素を保持するメンバ_elementジェネリック型で定義しています。
一律でHTMLElementとしてしまうと、<input>valueプロパティなど、一部の要素しか実装していないプロパティは存在しないプロパティとみなされてしまいます。
そのため、派生クラス側でHTMLInputElementHTMLButtonElementなどの具体的な型を指定させます。
div.firstElementChildで返される要素の型はElementnullなので、Type Assertions (キャスト)で指定の型に変換する必要があるのですが、直接div.firstElementChild as Tとすると、間違っていないかい?と駄目だしされました。
意図したものであることを伝えるには、div.firstElementChild as unknown as Tと、間にunknownを入れる必要があるそうです。

派生クラスで実装

ボタン

ボタンは下記リスト情報をもとに生成されます。

interface ButtonParams {
  text: string,
  type: BUTTON_TYPE,
  order: number,
  keyCodeList: string[],
  value?: NUMBER | OPERATOR,
  size?: number
}

const BUTTONS: ButtonParams[] = [
  {
    text: NUMBER.ZERO,
    value: NUMBER.ZERO,
    type: BUTTON_TYPE.NUMBER,
    order: 21,
    keyCodeList: ['48', '96']
  },
  {
    text: NUMBER.ONE,
    value: NUMBER.ONE,
    type: BUTTON_TYPE.NUMBER,
    order: 17,
    keyCodeList: ['49', '97']
  },
  // ...
};

Componentクラスを継承したボタンクラスでは、オーバーライドしたメソッドgetTemplate()でHTMLソースを定義しています。
ボタン情報リストの型定義をしたButtonParamsを拡張したButtonPropsを、コンストラクタの引数のインターフェースとしています。

/**
 * ボタンコンポーネント
 */

interface ButtonHandler {
  (type: BUTTON_TYPE, value?: NUMBER | OPERATOR): void;
}

interface ButtonProps extends ButtonParams {
  onClick: ButtonHandler;
}

class Button extends Component<HTMLButtonElement> {

  private type: BUTTON_TYPE;
  private value: NUMBER | OPERATOR;
  private keyCodeList: string[];
  private onClick: ButtonHandler;

  constructor({ text, order, size, ...props }: ButtonProps) {
    super({ text, order });

    this.element.style.gridColumnEnd = size ? 'span ' + size : '';

    if (props.type === BUTTON_TYPE.NUMBER) {
      this.element.classList.add('btn-num');
    }

    this.type = props.type;
    this.value = props.value;
    this.keyCodeList = props.keyCodeList;
    this.onClick = props.onClick;

    this.element.addEventListener('click', this.handleClick.bind(this));
    // ...
  }

  // Override
  getTemplate() {
    return `<button type="button" class="btn" style="order:{{order}}">
      {{text}}
    </button>`;
  }

  private handleClick() {
    this.onClick(this.type, this.value);
  }

  // ...
}

/src/components/button.ts | GitHub

パネル

ボタン等の設置先となるパネルコンポーネントは、パネル上にComponentを追加する為のadd()メソッドを持ちます。
このときもジェネリック型は明示する必要があるのですが、HTMLElementとすることで、複数種類の要素を受付けられるようにしています。

/**
 * 派生クラス:パネルコンポーネント
 */
import Component from './component';

class Panel extends Component<HTMLFormElement> {
  // Override
  getTemplate() {
    return '<form class="panel"></form>';
  }

  add(parts: Component<HTMLElement>) {
    this.element.appendChild(parts.element);
  }

  // ...
}

/src/components/panel.ts | GitHub

ディスプレイクラス

入力中の数値を表示するMainDisplayクラスと、演算のプロセスを表示するProcessDisplayクラスの2種があります。
どちらもButtonPanel同様、Componentクラスの派生クラスですが、入力値を制御・保持する為のロジックを持ちます。

MainDisplay

内部で入力値、小数点の有無、正負を管理しています。
小数点ボタンが重複してクリックされても、2回目以降の入力は受付けません。
また、12文字以上の数値は受付けません。(符号除く)
入力値は文字列で管理されていますが、パブリックメソッドgetNumber()で数値として取得できます。

const LENGTH_MAX: number = 12;

class MainDisplay extends Component<HTMLInputElement>{

  private data: string = '';
  private isDecimal: boolean = false;
  private isNegative: boolean = false;

  constructor() {
    super();
    this.clear();
  }

  get isEmpty(): boolean {
    return this.data === '';
  }

  getTemplate() {
  }
  // 末尾に数字を追加
  addNumber(value: NUMBER) {
    if (this.data.length >= LENGTH_MAX) return;

    // ...
    this.data += value;
    this.displayNumber();
  }
  // 既存データを削除して数字表示
  setNumber(value: NUMBER | number) {
    this.reset();

    // ...
    this.displayNumber();
    return;
  }
  // 符号反転
  invertSign() {
    this.isNegative = !this.isNegative;
    this.displayNumber();
  }

  getNumber() {
    return Number.parseFloat(this.element.value) || 0;
  }
  // 末尾の一文字削除
  removeLastNumber() {
    this.data = this.data.slice(0, -1);
    // ...
    this.displayNumber();
  }
  // 削除
  clear() {
  }
  // エラーメッセージ表示
  setError(message: string) {
  }
  // 表示
  private displayNumber() {
    const value = (this.isNegative ? MINUS : '') + this.data;
    this.element.value = value || NUMBER.ZERO;
  }
  // リセット
  private reset() {
    this.data = '';
    this.isDecimal = this.isNegative = false;
  }
  // エラーモード
  private toggleErrorMode(flg = false) {
    this.element.classList.toggle('is-error', flg);
  }
}

/src/components/mainDisplay.ts | GitHub

ProcessDisplay

入力された数値と演算子を順番に配列で保持しています。
左括弧の入力数を管理しており、余分な右括弧が入力されないようにしています。
配列を出力する際に右括弧が不足している場合は追加します。
負数は見やすいよう括弧で括ります。

class ProcessDisplay extends Component<HTMLInputElement> {

  private stack: (number | OPERATOR)[] = [];
  private currentOperator: OPERATOR | null = null;
  private countParen: number = 0;

  constructor() {
    super();
    this.clear();
  }

  getTemplate() {
  }
  // 括弧利用中か
  get isParenMode(): boolean {
    return this.countParen > 0;
  }
  // 数値登録
  setNumber(number: number) {
    this.setStack(number);
  }
  // 演算子登録
  setOperator(operator: OPERATOR = null) {
    this.currentOperator = operator;
    this.display();
  }
  // 左括弧登録
  setLeftParen() {
    this.countParen++;
    // ...
  }
  // 右括弧登録
  setRightParen() {
    if (!this.countParen) return;
    this.countParen--;
    // ...
  }
  // 履歴リスト取得
  getStack(): (number | OPERATOR)[] {
    const rightParens = Array(this.countParen).fill(OPERATOR.RIGHT_PAREN);
    return [...this.stack, ...rightParens];
  }
  // 結果表示
  setResult() {
    this.stack = this.getStack();
    this.display();
  }
  // 履歴削除
  clear() {
  }
  // 履歴追加
  private setStack(value: number | OPERATOR) {
    // ...
    this.display();
  }
  // 表示
  private display() {
    this.element.value = [...this.stack, this.currentOperator].join('');
  }
}

/src/components/processDisplay.ts | GitHub

四則演算

演算を実際に行う部分はクラスではなく関数で定義しています。
JavaScriptは仕様上、小数点の演算に誤差が発生してしまうのですが、一旦整数に直して演算することでそれを回避しています。
また、この電卓は括弧にも対応している為、逆ポーランド記法を用いて演算しています。
引数の型チェックの手間が省けている事意外は、ほぼ普通のJavaScriptの関数なので割愛いたします。
(別記事で書くかも。。。)

/src/arithmetic.ts | GitHub
/src/calculation.ts | GitHub

JavaScriptで、できるかぎり小数演算の誤差を少なくする方法
日曜プログラミングで電卓を作ってみる

電卓クラス

電卓の基本機能を備えたクラスAppを定義します。
各コンポーネントのインスタンスを生成してPanel上に設置します。
ボタンクリックイベント発生時に、後述するStateクラスに転送します。
入力値の管理は2つのディスプレイクラスに委譲しているため、必要に応じてこれらのメソッドを呼び出すだけです。

// 型チェック
const isNUMBER = (test: any): test is NUMBER => {
  return Object.values(NUMBER).includes(test);
};

class App {

  private panel: Panel;
  private process: ProcessDisplay;
  private display: MainDisplay;
  private state: State;

  constructor(rootElement: HTMLElement) {
    // コンポーネントの設置
    this.panel = new Panel();
    this.process = new ProcessDisplay();
    this.display = new MainDisplay();

    this.panel.add(this.process);
    this.panel.add(this.display);

    this.handleClick = this.handleClick.bind(this);
    
    BUTTONS.forEach(props => {
      this.panel.add(
        new Button({
          ...props,
          onClick: this.handleClick
        })
      );
    });

    rootElement.appendChild(this.panel.element);

    this.switchState(InitialState.instance);
  }

  /**
   * ボタンクリック時に、ボタンのタイプに応じてStateの各メソッド呼び出し
   * @param {BUTTON_TYPE} type
   * @param {String} value 
   */
  private handleClick(type: BUTTON_TYPE, value: NUMBER | OPERATOR) {
    switch (type) {
      case BUTTON_TYPE.NUMBER:
        if (isNUMBER(value)) {
          this.state.inputNumber(this, value);
          break;
        }
      case BUTTON_TYPE.PI:
        this.state.inputPi(this, Math.PI);
        break;
    // ...
  }

  // 状態切替
  switchState(nextState: State) {
    this.state = nextState;
  }
  // 数値表示
  displayNumber(value: number | NUMBER) {
    this.display.setNumber(value);
  }
  // 数値追加表示
  addDisplayNumber(value: NUMBER) {
    this.display.addNumber(value);
  }
  // 演算子登録
  setOperator(operator?: OPERATOR) {
    this.process.setOperator(operator);
  }
  // 数値確定
  determineNumber() {
    /**
     * MainDisplayクラスが保持する入力値を取得して、
     * ProcessDisplayクラスの入力履歴に登録する
     */ 
    const num: number = this.display.getNumber();
    this.process.setNumber(num);
  }
  // 括弧利用中確認
  checkParenMode() {
  }
  // 左括弧登録
  setLeftParen() {
  }
  // 右括弧登録
  setRightParen() {
  }
  // 符号反転
  invertSign() {
  }
  // 演算実行
  executeCalculation() {
    /**
     * ProcessDisplayクラスが保持する入力履歴を取得して、四則演算関数へ渡す
     * 演算結果を取得して、MainDisplayクラスで表示させる
     */ 
    const result: number = Calc(this.process.getStack());
    this.display.setNumber(result);
  }
  // 表示1文字クリア
  backSpaceDisplay(): boolean {
  }
  // 表示クリア
  clearDisplay() {
  }
  // 履歴クリア
  clearHistory() {
  }
  // 全てクリア
  clearAll() {
  }
  // エラー!!!
  setError(e: Error) {
  }
  // ...
}

/src/app.ts | GitHub

型ガード(Type Guard)

Appクラスにて、クリック時にボタンから呼ばれるコールバック関数handleClick()の第二引数valueは、列挙型のNUMBERもしくはOPERATORが渡されます。
一方、転送先のStateクラスの各メソッドは、どちらかしか受付けません。
NUMBERであるかを確認する関数isNUMBER()で、事前に型ガードを施しています。

TypeScriptでみかける"is"というキーワードについて

状態(State)クラス

TypeScriptにあってJavaScriptにない、Interfaceを利用して上記の状態遷移を実現します。

Stateパターン.png

StateのInterfaceの定義

それぞれのボタンごとに、クリック時の振る舞いを定義するメソッドを用意します。
AppクラスのhandleClick()メソッドから呼び出されます。

interface State {
  // 数値ボタンクリック
  inputNumber(app: App, value: NUMBER | number): void;
  // 円周率ボタンクリック
  inputPi(app: App, value: number): void;
  // 演算子ボタンクリック
  inputOperator(app: App, value: OPERATOR): void;
  // 左括弧ボタンクリック
  inputLeftParen(app: App): void;
  // 右括弧ボタンクリック
  inputRightParen(app: App): void;
  // 符号反転ボタンクリック
  inputInversion(app: App): void;
  // イコールボタンクリック
  inputEqual(app: App): void;
  // 一つ削除(←)ボタンクリック
  inputBack(app: App): void;
  // クリアボタンクリック
  inputClear(app: App): void;
  // オールクリアボタンクリック
  inputAllClear(app: App): void;
}

StateInterfaceの実装

上記インターフェースを実装する数値入力状態NumberStateは下記のとおりです。
渡されたAppクラスのインスタンスを必要に応じて操作するだけで、内部でデータは持ちません。
インスタンスを複数作成する必要が無い為、シングルトンで作成しています。

class NumberState implements State {
  // シングルトン
  private static _instance: NumberState;
  // コンストラクタがプライベートなので、外部からnewできない
  private constructor() { }
  // 静的プロパティからのみ取得可能
  static get instance(): NumberState {
    if (!this._instance) {
      this._instance = new NumberState();
    }
    return this._instance;
  }

  inputNumber(app: App, value: NUMBER) {
    app.addDisplayNumber(value);
  }
  inputPi(app: App, value: number) {
    app.displayNumber(value);
  }
  inputOperator(app: App, value: OPERATOR) {
    try {
      app.determineNumber();
      app.executeCalculation();
      app.setOperator(value);
      app.switchState(OperatorState.instance);
    } catch (e) {
      app.setError(e);
    }
  }
  inputLeftParen(app: App) {
    app.determineNumber();
    app.setOperator(OPERATOR.MULTIPLY);
    app.setLeftParen();
    app.switchState(BeforeLeftSideState.instance);
  }
  inputRightParen(app: App) {
    if (!app.checkParenMode()) return;

    try {
      app.determineNumber();
      app.setRightParen();
      app.executeCalculation();
      app.switchState(AfterRightSideState.instance);
    } catch (e) {
      app.setError(e);
    }
  }
  inputInversion(app: App) {
    app.invertSign();
  }
  inputBack(app: App) {
    if (app.backSpaceDisplay()) {
      app.switchState(BeforeLeftSideState.instance);
    }
  }
  inputEqual(app: App) {
    try {
      app.determineNumber();
      app.executeCalculation();
      app.toggleAnswerMode(true);
      app.switchState(ResultState.instance);
    } catch (e) {
      app.setError(e);
    }
  }
  inputClear(app: App) {
    app.clearDisplay();
    app.switchState(BeforeLeftSideState.instance);
  }
  inputAllClear(app: App) {
    app.clearAll();
    app.switchState(BeforeLeftSideState.instance);
  }
}

未入力もしくは左括弧入力直後の状態BeforeLeftSideStateは下記の様になります。
右括弧などの入力不可のイベントは、空の関数でオーバーライドしています。

class BeforeLeftSideState implements State {

  private static _instance: BeforeLeftSideState;
  private constructor() { }
  static get instance(): BeforeLeftSideState {
    if (!this._instance) {
      this._instance = new BeforeLeftSideState();
    }
    return this._instance;
  }

  inputNumber(app: App, value: NUMBER | number) {
    app.displayNumber(value);
    app.switchState(NumberState.instance);
  }
  inputPi(app: App, value: number) {
    this.inputNumber(app, value);
  }
  inputOperator(app: App, value: OPERATOR) {
    app.displayNumber(NUMBER.ZERO);
    app.determineNumber();
    app.setOperator(value);
    app.switchState(OperatorState.instance);
  }
  inputLeftParen(app: App) {
    app.setLeftParen();
  }
  inputInversion(app: App) {
    app.invertSign();
    app.switchState(NumberState.instance);
  }
  inputRightParen() { /* 対応しない */ }
  inputEqual() { /* 対応しない */ }
  inputBack() { /* 対応しない */ }
  inputClear() { /* 対応しない */ }
  inputAllClear(app: App) {
    app.clearAll();
  }
}

State側で必要に応じてapp.switchState()を呼び出して他のStateへの切り替えを行い、App側ではStateの変更を意識する必要はありません。
便利ですね。他所でも使えそう。

TypeScript 2ではシングルトン(Singleton)パターンが短く書ける

おわりに

以前はVBAをよく書いており、その際は必ず型を指定していました。
その為JavaScriptを始めたばかりの頃は、型指定が出来ないことを気持ち悪いな~と感じていました。
いつの間にか慣れてしまってはいたものの、型はそれとなく意識しながら書いていたので、Typescriptにさほど戸惑うことはありませんでした。
やはり、型を指定できるほうが便利だし楽しいな~と感じました。

12
14
1

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
12
14

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?