CREATIVE X 第2弾
CREATIVE X 第2弾
2018.03.31
#138
それいけ!フロントエンド

疑似コードプログラミングプロセスでのJavaScriptクラスの実装例

ほりでー

こんにちは。フロントエンドエンジニアのほりでーです。先日、スティーブ・マコネルの『コードコンプリート』という本を読んでいて、「疑似コードプログラミングプロセス(PPP)」という章の内容に惹かれました。

疑似コードプログラミングプロセス(Pseudocode Programming Process: PPP)とは、特定の言語の実装に縛られずにあえて日本語でプログラミングすることで、実装の品質を高めるためのテクニックです。

でも、本当にそんなことをする意味ってあるんでしょうか? それを検証するために、ちょっとしたWebのUIパーツの実装をPPPでやってみることにしました。

今回作るUIの要件

今回作るUIは、RWDサイト向けのフィルタリング機能です。パソコンではタブ状のUIによって一覧をフィルタし、スマホではセレクトボックスによって同様の機能を実現します。どういったカテゴリの種類や数が来るかは事前に予想できないという前提があるため、あらかじめCSSで表示/非表示を決め打ちすることはできません。

CodePenの実装を動かすと、具体的にどういった機能であるかはすぐに分かると思います。

さらに具体的な仕様は次にまとめておきます。
  • HTML要素として、リスト・タブボタン・セレクトボックスがある。
  • リストには1件以上の項目が列挙されている。項目はそれぞれ何らかのカテゴリに所属している。
  • タブボタンとセレクトボックスは、カテゴリの種類と同じだけの選択肢と、どのカテゴリも指定しない選択肢がある。
  • タブボタンまたはセレクトボックスからあるカテゴリが選択されると、そのカテゴリに所属するリスト項目だけが表示に残り、それ以外は一時的に隠される。
  • タブボタンは、現在選択されているボタンがハイライト表示する。タブボタンは常にひとつだけどれかの項目が選択されており、同時に複数のボタンが有効になったり、ひとつも有効になっていなかったりする状態が起こらないとする。
  • タブボタンとセレクトボックスの状態の変化は同期する。たとえば、タブボタンを先に押した場合はセレクトボックスの選択状態も追従して変化する。同様に、先にセレクトボックスで選ばれている項目が変わったときはタブボタンの選択状態が追従する。
  • カテゴリはCMSで動的に管理されるため、事前にどのような名前や種類が来るかは予想できない。
  • カテゴリが指定されていないタブボタンやセレクトボックス項目が選ばれた場合、フィルタを解除してすべてのリスト内容を表示する。
  • タブボタン、セレクトボックスは複数セット存在することも想定する。
  • jQueryを使わずに実装する。

疑似コードプログラミングプロセスによる実装

1.疑似コードによる設計

とりあえずひとつのクラスに実装しようとしているので、クラスの宣言とコンストラクタだけ作ることにします。

class FilterUI {

  constructor() {

  }

}

ここへ実際にプログラミングしているのと同じつもりで、ただし実装はせず、やりたいことをコメントとして書いていきます。

この段階でどういった機能分割をするか、どんな方法で機能を実装するのかの検討はすでに始めています。日本語の姿をしていますが、頭の使い方は通常のプログラミングと同じです。

class FilterUI {

  constructor() {

  }

  // 初期化

    // (最初の状態では、タブとセレクタは静的に辻褄が合っている前提がある)

    // タブ要素を取得する

    // タブ要素にイベントリスナonClickを付与

    // select要素を取得する

    // select要素にイベントリスナonChangeを付与

  // onClickイベントリスナ

    // クリックされた要素のdata-categoryから次のカテゴリ名を取得する

    // 取得した値で カテゴリ変更 を実行

  // onChangeイベントリスナ

    // 変更されたselect要素から現在のvalue値を取得する

    // 取得した値で カテゴリ変更 を実行

  // カテゴリ変更

    // (タブ側、セレクト側の両方から変更が発生することを想定しておく)

    // 次のカテゴリ名と現在のカテゴリ名を比較する

    // 次のカテゴリ名と現在のカテゴリ名に差分がない場合は何もしない

    // 次のカテゴリ名を現在のカテゴリ名として保存する

    // タブの状態を更新する

      // eachで要素ごとに繰り返し

        // .is-current を除去

        // 次のカテゴリ名を持つ項目に .is-current をセット

    // select要素の状態を更新する

      // eachで要素ごとに繰り返し

        // valueに次のカテゴリ名をセットする

    // リストの状態を更新する

      // リスト要素を取得する

      // eachで要素ごとに繰り返し

        // .is-hidden を除去

        // カテゴリ名が すべて のときはここで終了

        // 対象物の data-category がカテゴリ名とマッチするかを比較

        // 一致しない場合は .is-hidden を付与

}

2.疑似コードによる設計の改善

さきほどの疑似コードを書きながら、いくつか改善するべき設計のポイントに気づきました。こうした改善箇所は疑似コードの段階でどんどん反映させてしまいます。

  • 「カテゴリ変更」メソッドの内容が多くなってきていると感じたので、一部の機能を別のメソッドへ分割。
  • 「eachで要素ごとに繰り返し」も、中身を別のメソッドとしてくくり出し。
  • 「リスト要素を取得する」処理は、他の要素の取得とあわせて初期化メソッドへ移動。
class FilterUI {

  constructor() {

  }

  // 初期化

    // (最初の状態では、タブとセレクタは静的に辻褄が合っている前提がある)

    // タブ要素を取得する

    // タブ要素にイベントリスナonClickを付与

    // select要素を取得する

    // select要素にイベントリスナonChangeを付与

    // リスト要素を取得する

  // onClickイベントリスナ

    // クリックされた要素のdata-categoryから次のカテゴリ名を取得する

    // 取得した値で カテゴリ変更 を実行

  // onChangeイベントリスナ

    // 変更されたselect要素から現在のvalue値を取得する

    // 取得した値で カテゴリ変更 を実行

  // カテゴリ変更

    // (タブ側、セレクト側の両方から変更が発生することを想定しておく)

    // 次のカテゴリ名と現在のカテゴリ名を比較する

    // 次のカテゴリ名と現在のカテゴリ名に差分がない場合は何もしない

    // 次のカテゴリ名を現在のカテゴリ名として保存する

    // タブの状態を更新する を実行

    // select要素の状態を更新する を実行

    // リストの状態を更新する を実行

  // タブの状態を更新する

    // eachで要素ごとに繰り返し

      // タブ(個別)の状態を更新する を実行

  // タブ(個別)の状態を更新する

    // .is-current を除去

    // 次のカテゴリ名を持つ項目に .is-current をセット

  // select要素の状態を更新する

    // eachで要素ごとに繰り返し

  // select要素(個別)の状態を更新する

    // valueに次のカテゴリ名をセットする

  // リストの状態を更新する

    // eachで要素ごとに繰り返し

      // リスト更新 を実行

  // リスト更新

    // .is-hidden を除去

    // カテゴリ名が すべて のときはここで終了

    // 対象物の data-category がカテゴリ名とマッチするかを比較

    // 一致しない場合は .is-hidden を付与

}

3.メソッドを定義

設計が固まってきたので、この疑似コードを実際のJavaScriptコードとして実装していきます。まずは実際のメソッド名を決めていきます。

class FilterUI {

  constructor() {

  }

  // 初期化
  init() {

    // (最初の状態では、タブとセレクタは静的に辻褄が合っている前提がある)

    // タブ要素を取得する

    // タブ要素にイベントリスナonClickを付与

    // select要素を取得する

    // select要素にイベンリスナonChangeを付与

  }

  // onClickイベントリスナ
  onClick() {

    // クリックされた要素のdata-categoryから次のカテゴリ名を取得する

    // 取得した値で カテゴリ変更 を実行
    this.changeCategory(nextCategory);
  }

  // onChangeイベントリスナ
  onChange() {

    // 変更されたselect要素から現在のvalue値を取得する

    // 取得した値で カテゴリ変更 を実行
    this.changeCategory(nextCategory);
  }

  // カテゴリ変更
  changeCategory(nextCategory) {

    // (タブ側、セレクト側の両方から変更が発生することを想定しておく)

    // 次のカテゴリ名と現在のカテゴリ名を比較する

    // 次カテゴリ名と現在のカテゴリ名に差分がない場合は何もしない

    // 次のカテゴリ名を現在のカテゴリ名として保存する

    // タブの状態を更新する を実行
    this.updateTabs(nextCategory);

    // select要素の状態を更新する を実行
    this.updateSelects(nextCategory);

    // リストの状態を更新する を実行
    this.updateItems(nextCategory);

  }

  // タブの状態を更新する
  updateTabs(nextCategory) {

    // eachで要素ごとに繰り返し

      // タブ(個別)の状態を更新する を実行
      this.updateTab(nextCategory);

  }

  // タブ(個別)の状態を更新する
  updateTab(nextCategory) {

    // .is-current を除去

    // 次のカテゴリ名を持つ項目に .is-current をセット

  }

  // select要素の状態を更新する
  updateSelects(nextCategory) {

    // eachで要素ごとに繰り返し

      // select要素(個別)の状態を更新する を実行
      this.updateSelect(nextCategory);

  }

  // select要素(個別)の状態を更新する
  updateSelect(nextCategory) {

    // valueに次のカテゴリ名をセットする

  }

  // リストの状態を更新する
  updateItems(nextCategory) {

    // eachで要素ごとに繰り返し

      // リスト更新 を実行
      this.updateItem(nextCategory);

  }

  // リスト更新
  updateItem(nextCategory) {

    // .is-hidden を除去

    // カテゴリ名が すべて のときはここで終了

    // 対象物の data-category がカテゴリ名とマッチするかを比較

    // 一致しない場合は .is-hidden を付与

  }

}

4.プロパティの抽出

これまでのコードで、メソッドをまたいで使われそうな変数。

  • タブ要素
  • select要素
  • リスト要素
  • 現在のカテゴリ名
  • data-category(データ属性名)

これらについては、このクラスのインスタンス変数とした方が良さそうなので、コンストラクトへプロパティを追加することにします。

また、以下のものについてもクラスの初期化時にパラメータとして外部から注入できた方が便利そうなので、プロパティとして実装する方が良さそうです。

  • タブ要素を検索するためのセレクタ名
  • select要素を検索するためのセレクタ名
  • リスト要素を検索するためのセレクタ名
  • is-hiddenクラス
  • is-currentクラス
  • 「すべて」を意味するカテゴリ名

さらに、このFilterUIを1ページ内に複数個インスタンス化することも考えると、要素の検索元となるルート要素も定義したいですね。

これらの宣言と初期化をコンストラクタへ実装します。

class FilterUI {

  constructor($container) {

    // ルート要素が無効または存在しないときはエラー
    if (typeof $container === 'undefined' || $container instanceof HTMLElement === false) {
      throw new Error('$container is not available!');
    }

    // ルート要素
    this.$container = $container;

    // タブ要素
    this.$tabs = null;

    // select要素
    this.$selects = null;

    // リスト要素
    this.$items = null;

    // 現在のカテゴリ名
    this.currentCategory = 'all';

    // データ属性名
    this.dataKey = 'category';

    // タブ要素を検索するためのセレクタ名
    this.tabsSelector = '.js-tab';

    // select要素を検索するためのセレクタ名
    this.selectsSelector = '.js-select';

    // リスト要素を検索するためのセレクタ名
    this.itemsSelector = '.js-item';

    // is-hiddenクラス
    this.hiddenClass = 'is-hidden';

    // is-currentクラス
    this.activeClass = 'is-current';

    // 「すべて」を意味するカテゴリ名
    this.allCategory = 'all';
  }

  // (略)

5.実装の詳細化

ここまで来れば、残りの実装をする上で迷う要因はほとんど残っていないはずです。適切なメソッドの分割がすでにされていて、プロパティも定義されているので、残りの処理を追記するだけです。

class FilterUI {

  constructor($container) { 
    // (略) 
  }

  // 初期化
  init() {
    // (最初の状態では、タブとセレクタは静的に辻褄が合っている前提がある)

    // ルート要素からタブ要素を取得する
    this.$tabs = this.$container.querySelectorAll(this.tabsSelector);

    // タブ要素にイベントリスナonClickを付与
    [].forEach.call(this.$tabs, ($tab) => {
      $tab.addEventListener('click', this.onClick.bind(this, $tab));
    });

    // ルート要素からselect要素を取得する
    this.$selects = this.$container.querySelectorAll(this.selectsSelector);

    // select要素にイベントリスナonChangeを付与
    [].forEach.call(this.$selects, ($select) => {
      $select.addEventListener('change', this.onChange.bind(this, $select));
    });

    // ルート要素からリスト要素を取得する
    this.$items = this.$container.querySelectorAll(this.itemsSelector);
  }

  // onClickイベントリスナ
  onClick($el, e) {
    e.preventDefault();

    // クリックされた要素のdata-categoryから次のカテゴリ名を取得する
    const nextCategory = $el.dataset[this.dataKey];

    // 取得した値で カテゴリ変更 を実行
    this.changeCategory(nextCategory);
  }

  // onChangeイベントリスナ
  onChange($el) {

    // 変更されたselect要素から現在のvalue値を取得する
    const nextCategory = $el.value;

    // 取得した値で カテゴリ変更 を実行
    this.changeCategory(nextCategory);
  }

  // カテゴリ変更
  changeCategory(nextCategory) {

    // (タブ側、セレクト側の両方から変更が発生することを想定しておく)

    // 次のカテゴリ名と現在のカテゴリ名を比較する
    // 次のカテゴリ名と現在のカテゴリ名に差分がない場合は何もしない
    if (nextCategory === this.currentCategory) return;

    // 次のカテゴリ名を現在のカテゴリ名として保存する
    this.currentCategory = nextCategory;

    // タブの状態を更新する を実行
    this.updateTabs(nextCategory);

    // select要素の状態を更新する を実行
    this.updateSelects(nextCategory);

    // リストの状態を更新する を実行
    this.updateItems(nextCategory);
  }

  // タブの状態を更新する
  updateTabs(nextCategory) {

    // eachで要素ごとに繰り返し
    [].forEach.call(this.$tabs, ($tab) => {

      // タブ(個別)の状態を更新する を実行
      this.updateTab(nextCategory, $tab);

    });
  }

  // タブ(個別)の状態を更新する
  updateTab(nextCategory, $tab) {

    // すべての項目から .is-current を除去
    $tab.classList.remove(this.activeClass);

    // 次のカテゴリ名を持つ項目に .is-current をセット
    if ($tab.dataset[this.dataKey] !== nextCategory) return;
    $tab.classList.add(this.activeClass);

  }

  // select要素の状態を更新する
  updateSelects(nextCategory) {

    // eachで要素ごとに繰り返し
    [].forEach.call(this.$selects, ($select) => {

      // select要素(個別)の状態を更新する を実行
      this.updateSelect(nextCategory, $select);

    });
  }

  // select要素(個別)の状態を更新する
  updateSelect(nextCategory, $select) {

    // valueに次のカテゴリ名をセットする
    $select.value = nextCategory;

  }

  // リストの状態を更新する
  updateItems(nextCategory) {

    // eachで要素ごとに繰り返し
    [].forEach.call(this.$items, ($item) => {

      // リスト更新 を実行
      this.updateItem(nextCategory, $item);

    });

  }

  // リスト更新
  updateItem(nextCategory, $item) {

    // .is-hidden を除去
    $item.classList.remove(this.hiddenClass);

    // カテゴリ名が すべて のときはここで終了
    if (nextCategory === this.allCategory) return;

    // 対象物の data-category がカテゴリ名とマッチするかを比較
    // 一致しない場合は .is-hidden を付与
    if ($item.dataset[this.dataKey] === nextCategory) return;
    $item.classList.add(this.hiddenClass);

  }

}

疑似コードで実装してみた感想

設計を早い段階で修正できる

プログラミングでは、さまざまな粒度での設計を繰り返して最終的なコードの実装へとたどりつくことになります。

  1. 要件の整理
  2. クラス/モジュールの設計
  3. クラス/モジュールの内部の設計
  4. メソッド/関数の内部の設計
  5. 実装

PPPによって改善されるのは、上のリストで言うところの3番から4番の粒度に相当します。PPPを使わない場合、3〜4番レベルの設計はなんとなく頭の中にイメージする程度で、実際は5番の実装をしている途中で、設計の変更をしたくなることが多いのではないでしょうか。

PPPを用いた場合、実装を書く前に、より粗い粒度で設計変更するべきポイントに気づくことができました。また、まだ実装を書いていないので設計変更に対する心理的な障壁(めんどくささ)も、ごく軽いものです。もしこれが実装半ばのコードであれば、捨てるのが惜しくなり結局良くない設計のまま推し進めてしまうかもしれません。

有益なコメントが残る

PPPでは、最初に書いた疑似コードがコメントとしてそのまま残ります。このコメントはほとんど実装と1:1で繋がっているため、初めてこのコードを読む開発者にとっても分かりやすく、実態に即したものとなっているはずです。このコメントをかき集めれば仕様書を作るのにも役立つかもしれません。

考えることの順番と粒度を適切に分割できる

メソッド/関数の分割の単位について悩むこと(粒度大)と、関数/変数の名前付けに悩むこと(粒度小)は、どちらも大事なことですが、考えることの粒度が異なります。

一般的に、細かい部分の設計を改善するよりも、影響度の大きい単位での設計を改善した方が全体の効用は大きくなります。

  1. 要件の整理
  2. クラス/モジュールの設計
  3. クラス/モジュールの内部の設計
  4. メソッド/関数の内部の設計
  5. 実装

PPPを使わない場合、頭の中で3〜5の粒度のことを同時に考えざるを得ません。本来、先に取り組むべきは、より粗い粒度の問題、つまり3番や4番について考えることです。しかし、実装を書きながらでは5番のレベルでの名前付けをしなければ前に進めません。これは非合理的です。

その点、PPPを使うと先に影響度の大きい部分を存分に設計してから実装に入れるため、自然と物事を正しい順番で考えることができるのです。結果的に実装後にリファクタリングをする機会も減らせるのではないでしょうか。

PPPはデメリットが少ないお勧めの手法

このように、自分でやってみたかぎり、PPPの導入には多くのメリットがあれど、あまりデメリットは感じられませんでした。プログラミング経験者であれば導入は簡単だと思うので、是非一度試してみてはいかがでしょうか。