2020.10.04

オブジェクト指向JavaScriptによるフロントエンドコンポーネント開発


オブジェクト指向とは、一言で言うと、「データとデータに対する演算をひとまとめにするプログラミング手法」だと思います。

JavaScript も、オブジェクト指向の機能を備えています。ただし、Java や Python などの他言語で採用されるクラスベースの仕組みではなく、「プロトタイプオブジェクト」をベースとした独特な仕組みなため、少しとっつきにくいかもしれません。

しかし、プロトタイプベースの仕組みは、理解すれば(やはり JavaScript らしく)かなり単純です。そして、オブジェクト指向のプログラミングスタイルは、再利用可能な UI コンポーネントを表現するのに役に立ちます。

この記事では、そんな JavaScript のオブジェクト指向とその活用法を紹介します。前半はざっくり文法的な説明をして、後半でコンポーネントの実装例を載せています。最後に、ES2015 で導入されたクラス記法についても触れています。

JavaScript のオブジェクト指向

クラスベースのオブジェクト指向言語に入門した方なら、「クラスは設計図で、インスタンスはその設計図を元に…」なんていう説明を聞いたことがあるかもしれません。

JavaScript の場合も、そう言われればそうなのですが、おそらくそういう中途半端な例え話は忘れて、実際の仕組みを理解したほうが早いと思います。

prototype オブジェクト

JavaScript におけるオブジェクト指向は、prototype オブジェクトを通して実現されます(だから「プロトタイプベース」と呼ばれます)。

もっともシンプルな、オブジェクト指向の実践コード例を見てみましょう。

function Foo(name) {
  this.name = name;
}

Foo.prototype.hello = function() {
  console.log(`Hello, I'm ${this.name}.`);
};

const foo = new Foo('John');
foo.hello(); // -> "Hello I'm John."

Foo オブジェクトを作成して、インスタンスを生成しています。ブロックごとに見ていきましょう。

まず、「コンストラクタ関数」を定義しています。コンストラクタ関数は、new キーワードをつけて実行することで、新しいオブジェクトを得ることができます。

function Foo(name) {
  this.name = name;
}

コンストラクタ関数のなかで、this.name というプロパティを初期化しています。ここでの this は、new してできるオブジェクト(インスタンス)を表します。

次に、コンストラクタ関数の prototype プロパティに、hello メソッドを追加しています。

Foo.prototype.hello = function() {
  console.log(`Hello, I'm ${this.name}.`);
};

prototype プロパティがいきなり出てきても未定義例外などは発生していません。なぜなら、prototype は特別なプロパティで、new できるオブジェクトは必ず持っているからです。つまり、ObjectArray などの標準組み込みオブジェクトや、独自定義のコンストラクタ関数には、prototype が備わっています。

続いて、new キーワードをつけてコンストラクタ関数を呼び出しています。ここで得られるオブジェクトを「インスタンス」とも言います。引数は、this.name に代入されるはずです。

const foo = new Foo('John');

インスタンスから、hello メソッドを呼び出しています。

foo.hello(); // -> "Hello I'm John."

Fooprototype に追加したhello を、自らのメソッドとして実行できています。さらに、hello 内で参照される this.name は、引数で与えた値になっています。

ここまでの挙動は、以下の図で説明できます。

まず、図の左側は、new でオブジェクトを作るときの挙動を示しています。

  • コンストラクタ関数を new をつけて呼び出すと、新しいオブジェクト(インスタンス)が得られます。
  • インスタンスが生成されるときに、コンストラクタ関数の prototype プロパティの値であるオブジェクトが、インスタンスの __proto__ プロパティにコピーされます。
  • さらにコンストラクタ関数で(this.name のように)プロパティが定義されていれば、そちらも初期化されて設定されます。
  • prototypeconstructor プロパティがありますが、これは自動的に定義されるもので、コンストラクタ関数そのものを示します。

そして、図の右側が、インスタンスに対して、プロパティの参照やメソッドの呼び出しが行われるときの挙動を示しています。

  • まず、そのオブジェクトそのものが持つプロパティやメソッドから、参照対象を探します。この例では、hello メソッドを呼び出しています。インスタンス自体は name プロパティしか持っていないので、この時点ではメソッドは見つかりません。
  • つぎに、__proto__ オブジェクトの中から、参照対象を探します。今度は hello メソッドが見つかるので、それが実行されます。

つまりポイントは、以下の2点です。

  1. prototype が、インスタンスの __proto__ プロパティにコピーされる。
  2. オブジェクトに対して、プロパティの参照やメソッドの呼び出しが行われるとき、__proto__ プロパティの中も検索対象になる。

これこそが、プロトタイプベースのオブジェクト指向の中心的な仕組みです。特に難解、高尚な話ではなく、単なるオブジェクト生成とプロパティ検索の際の決まりごとです。

冒頭の例えに沿って強いて言えば、prototype が「設計図」に当たるでしょう。しかし、実際、ただオブジェクトがコピーされるだけのことなので、そのまま理解したほうが話が早いのではないでしょうか。(この単純さが、個人的には JavaScript らしいと思います。)

prototype__proto__ の存在は、コンソールログでも確かめることができるので、やってみてください。

継承

次に継承の仕組みを見ていきます。

先に言ってしまうと、継承も単なるオブジェクトのコピーで実現されます。つまり、親オブジェクトの prototype の内容を、子オブジェクトの prototype にコピーすれば OK です。

オブジェクト指向特有の特別な方法があるわけではなく、普通の JavaScript プログラミングにおけるオブジェクトの操作として書いて大丈夫です。

さらに言うと、そもそも継承はそこまで多用されるパターンでもないです。サーバサイドの世界でも「継承より委譲」と言われるように、処理を共通化したいからといって無分別に継承を多用すると、余計に複雑なコードになってしまいます。

前置きが長くなりましたが、使いたくなったときの参考に、一応やり方を書いておきます。以下の例が、先ほどの Foo を継承する Bar オブジェクトを作成するコード例です。

function Bar(name) {
  Foo.call(this, name);
}

// Foo を継承
Bar.prototype = Object.create(Foo.prototype);
Bar.prototype.constructor = Bar;

Bar.prototype.goodbye = function() {
  console.log('Goodbye!');
};

const bar = new Bar('Paul');
bar.hello(); // -> "Hello I'm Paul."
bar.goodbye(); // -> "Goodbye!"

ブロックごとに見ていきましょう。

まず、コンストラクタ関数を定義します。内容は Foo と同じ(this.name を定義する)にしたいので、call メソッドの第一引数に自らの this を指定して呼び出しています。つまり、親のコンストラクターを呼び出しているということです。

function Bar(name) {
  Foo.call(this, name);
}

次が継承を実現するコードです。Object.create メソッドで、オブジェクトのコピーを作って、Barprototype に代入しています。

Bar.prototype = Object.create(Foo.prototype);
Bar.prototype.constructor = Bar;

constructor を書き換えていますが、これをやらないと、Foo.prototype をコピーしてきたため、生成されたインスタンスから見た constructorFoo になってしまいます。このプロパティはほとんど使うことはありませんが、念のため、Bar に書き換えています。

この継承コードは、つまり prototype をコピーすればいいわけなので、以下のように書いても動作します。

Bar.prototype = Object.assign({}, Foo.prototype, { constructor: Bar });

さらに、スプレッド構文を用いると以下のように記述できます。

Bar.prototype = { ...Foo.prototype, constructor: Bar };

次に、Bar 独自のメソッドを追加しています。

Bar.prototype.goodbye = function() {
  console.log('Goodbye!');
};

最後に、new でインスタンスを生成してメソッドを呼び出しています。

const bar = new Bar('Paul');
bar.hello(); // -> "Hello I'm Paul."
bar.goodbye(); // -> "Goodbye!"

継承の目的通り、goodbye だけでなく Foohello も呼び出せています。

以下は、継承の挙動を表現した図です。

prototype が親から子にコピーされる点から先は、一つ目の図と同様です。

簡単な例

さて、プロトタイプベースのオブジェクト指向を、UI 開発にどのように活かすのか、イメージをしてもらうために、まず簡単な例を示します。

クリックすると data 属性に指定されたメッセージを出力するボタンです。

<button data-message="Hello world">Click me</button>
/**
 * メッセージボタン
 * @constructor
 * @param {HTMLElement} element 対象のボタン要素
 */
function MessageButton(element) {
  /** @member {HTMLElement} target ボタン */
  this.target = element;

  /** @member {string} message メッセージ文言 */
  this.message = el.dataset.message;

  // prototype に追加したメソッドは this 経由で呼び出せる
  this.handleClick();
}

/**
 * クリックイベントのリスナーを登録する
 */
MessageButton.prototype.handleClick = function() {
  this.target.addEventListener('click', this.greeting.bind(this));
};

/**
 * メッセージを出力する
 */
MessageButton.prototype.greeting = function() {
  console.log(this.message);
};

const btn = document.querySelector('button');
new MessageButton(btn);

このように、再利用可能なコンポーネントを定義します。これくらいなら、シンプルに関数で書いたほうが早い、と思われるかもしれませんが、現実的にはこんなにシンプルなコンポーネントは存在しません。

コンポーネントの機能が増え、複雑になるにつれ、オブジェクト指向が威力を発揮します。細かい処理単位でメソッドを分けておくと、修正時に変更箇所を特定しやすくなります。さらに this が導入されることにより、引数地獄から解放され、コードを簡潔に保てます。

this に注意

ここで、ありがちな this の注意点を紹介しておきます。

以下で、bind メソッドを用いている点に注目してください。

MessageButton.prototype.handleClick = function() {
  this.target.addEventListener('click', this.greeting.bind(this));
};

↑ は、↓ だとうまく動きません。

MessageButton.prototype.handleClick = function() {
  this.el.addEventListener('click', this.greeting); // bind を使わない
};

メッセージの代わりに、undefined が出力されます。

なぜでしょうか?これは this.greeting を「展開」してみると分かりやすいです。メソッドを分けていますが、結局は以下と同じことです。

MessageButton.prototype.handleClick = function() {
  this.el.addEventListener('click', function() {
    console.log(this.message); // this は、イベント登録対象を示してしまう!
  });
};

イベントリスナーの中では、this はイベント登録対象を表すのでしたね。そのため、this.message が、undefined と評価されるのです。

オブジェクト指向のプログラミングパターンでは、this が生成後のインスタンスを差すものとして扱いたいので、bind を使って、greeting の中の this の意味を置き換えています。

ちなみに、イベントリスナーをメソッドとして分けない場合の書き方を2種類紹介します。

まずは古い(ES2015以前の)書き方。一度別の変数(ここでは that )に this を代入して、リスナーの中ではその変数を参照します。

var that = this;
this.el.addEventListener('click', function() {
  console.log(that.message);
});

ES2015 以降ではアロー関数を使って問題を解決できます。

this.el.addEventListener('click', () => {
  console.log(this.message);
});

オブジェクト指向コンポーネント開発

ここまで説明してきたオブジェクト指向の文法を、UI コンポーネント開発に活用する例をいくつか紹介します。

アコーディオンコンポーネントを作る

コンテンツが開閉するアコーディオンコンポーネントの例です。

UI コンポーネントをオブジェクト指向的に実装するメリットは、「コンポーネントが持つデータ」をプロパティとして、「コンポーネントができること」をメソッドとして、それぞれ整理して定義できる点です。

  • 開閉のトリガーとなるタイトル部分の要素、開閉する部分の要素、コンテンツの高さ、の3つをプロパティとして定義しています。
  • 「開く」「閉じる」などの働きを、メソッドとして定義しています。

See the Pen OOP Accordion by Masahiro Harada (@MasahiroHarada) on CodePen.

タブコンポーネントを作る

このサンプルも「開閉する」という意味ではアコーディオンとほぼ同じですが、グルーピングされたタブ全てを取りまとめる TabGroup オブジェクトと、一個のタブを表す Tab オブジェクトの二種類で実装してみました。

See the Pen OOP Tab by Masahiro Harada (@MasahiroHarada) on CodePen.

外部ライブラリのラッパーを作る

外部ライブラリを使う場合でも、独自のラッパーオブジェクトを定義することをお勧めします。なぜなら:

  • 独自の前処理などを追加できる。
  • デフォルトのオプションをラッパーに隠せる。
  • (起こる可能性は低いが)外部ライブラリが入れ替え可能になる。

以下は、flatpickr ライブラリを用いた、期間入力コンポーネントのサンプルです。

See the Pen DateRange by Masahiro Harada (@MasahiroHarada) on CodePen.

クラス記法

最後に、ES2015 から導入されたクラス記法を紹介します。

冒頭の FooBar のサンプルは、以下のように書き換えられます。

class Foo {
  constructor(name) {
    this.name = name;
  }

  hello() {
    console.log(`Hello, I'm ${this.name}.`);
  }
}
class Bar extends Foo {
  constructor(name) {
    super(name);
  }

  goodbye() {
    console.log('Goodbye!');
  }
}

重要な点は、これは構文糖衣(シンタックスシュガー)、つまり書き方だけの話であって、裏側で動いているのはプロトタイプベースの仕組みです。クラスベースのオブジェクト指向が導入されたわけではありません。

昔ながらの記法で書いても、クラス記法で書いても、処理的に全く同じだということです。

とは言っても、クラス記法のほうが、視覚的に整理されている印象ですね。個人的には、prototype の書き方と仕組みを理解することがまず重要だと思いつつ、仕事の道具としては使えるときは必ずクラス記法を使います。

上で紹介したタブコンポーネントの例も、クラス記法で書き換えた例を載せておきます。

See the Pen OOP Tab (Class) by Masahiro Harada (@MasahiroHarada) on CodePen.


以上、この記事では、JavaScript におけるオブジェクト指向と、その文法を UI 開発に活用する方法を紹介しました。

React や Vue.js が UI 開発のスタンダードとなった昨今ですが、素の JavaScript で開発できる力は、様々なシーンや時代に対応できる基礎的なスキルだと思います。

ダイナミックな DOM 書き換えが頻繁に起こるアプリケーションでは、前述のようなフレームワークの利用がまず検討されるでしょう。しかし、利用しないプロジェクト、または現に利用していない既存システムでも、オブジェクト指向を導入すれば、少なくとも構造化の面では改善が見られるはずです。