この記事はトラストバンク Advent Calender 2021の24日目の記事です。
メリークリスマス
12月24日、クリスマスイヴです。しかも金曜日!まさにThanks God, it's Friday.って感じですね。まあ特に予定はありませんが…
この記事では、JSフレームワークとか無しで力技でコンポーネント指向を実現したという体験談になります。
イマドキのフロントエンドの話ではないので、そこら辺を期待されている方にとっては参考にならないだろうなという感じの内容になります。
TL;DR
- 生JSでWeb Componentsを使用せず、コンポーネント指向っぽい感じで設計と実装をやってみた
- classでコンポーネントを作り、子から親へのstateの送信は、CustomEventとdispatchEvent()で無理やり作った
- コード量は増えたが、メンテナンス性は上がった(多分)
前提
ここからは前提として、ふるさとチョイスのフロントエンドについて少し紹介します。
JSフレームワークは使っていない
2021年12月現在、ふるさとチョイスのフロントエンドはJSフレームワークを使用しておらず、Babelを噛ませてES2015↑を使用しているものの、イマドキなイケてるフロントエンドではまだありません。
しかしjQueryに関してはほとんど依存を無くし、ごく一部のページ以外はES2015↑をWebpackとBabelでバンドル+トランスパイルしており、モダナイズド・レガシー(造語)といった状態にあります。
ちなみにIE11に関してはサポート対象としていますので、トランスパイルは必須要件になっています。
実際、IE11ユーザーも多いサービスです。ヒーー
メンテナンス性がネックになっている
フレームワークを使用して実装した場合はコンポーネント単位での実装が容易にできるため、コンポーネントの独立性と再利用性を担保でき、可読性にも寄与することができます。また、スコープ制御もしやすいです。
しかし通常のJSのみで実装すると、ページ全体をひとまとめで制御することになりがちです。
機能実装やバグ修正が日々活発に行われていますが、やはりメンテナンス性が悪いなと感じる場面は多いです。
// documentオブジェクトを頻繁に使用することになる
const sendButton = document.getElementById('send-button');
// グローバルスコープが汚染されたりでスコープが制御しづらい
const listItems = document.querySelectorAll('.list-item');
sendButton.addEventListener('click', () => {
const items = document.querySelectorAll('.item');
doSomething(listItems); // バグは出ないけど取るべき引数はitemsでは!?みたいなことになる
});
こういったコードは書くときこそ楽ですが、後々のメンテナンスが結構大変になりがちです(実体験)
コンポーネント指向の生JS
前述のような課題があり、メンテナンス性を担保したいという考えと、将来的なフレームワーク導入を見込んで、まずはコンポーネント指向の考え方から取り入れることにしました。
DOMとそれに紐づくイベントハンドラやメソッドを1つのコンポーネントに見立てて、スコープをコンポーネントに閉じるようにし、コンポーネント間のデータのやりとりに関してはイベントドリブンめいた実装をとりました。詳細を見ていきます。
コンポーネント分割
まずBabelを通せばWeb Componentsを使用しても問題ないかと設計開始してすぐに思いつき、調べてみたもののあまり上手くいったような情報もなく、Web Componentsでの実装は一旦やめました。もしかしたら普通にできていたかもしれませんが……。
ということで、機能を持たせたいUIパーツそれぞれをclassとして実装しました。
// お礼の品のサムネイル単品
class Thumbnail {
constructor(element) {
this.element = element
}
}
// 複数サムネイルのまとまり
class ThumbnailList {
constructor(element) {
this.element = element;
this.items = element.querySelectorAll('.item');
this.thumbnails = Array.from(this.items, (item, index) => new Thumbnail(item, index));
}
}
// 汎用ボタン
class Button {
constructor(element) {
this.element = element;
}
}
// 次へボタン
class NextButton extends Button {
constructor(element) {
super(element);
}
}
メソッドの実装
コンポーネントには機能を持たせたいので、クラス内にメソッドとして用意しました。
このとき、メソッドのスコープは自分か子コンポーネント1に絞ります。
親コンポーネントに直接触られたくないプロパティがあったりするので、getter/setterも活用します。
class Thumbnail {
// constructor省略
get image() {
const img = this.element.querySelector('img');
return { dataSrc: img.dataset.src, src: img.getAttribute('src'), alt: img.getAttribute('alt') };
}
// 自分に対してはいくらでも触っていい
setBorder() {
this.element.classList.add('active');
}
}
class ThumbnailList {
// constructor省略
// 子コンポーネントに対してはメソッドの呼び出しとかもOK
set active(index) {
for (const thumbnail of this.thumbnails) {
thumbnail.removeBorder();
}
this.thumbnails[index].setBorder();
}
}
子で発生したイベントを利用して、データを親に渡す
さて、Reactなどでは子から親へのデータの受け渡しはstateを使うことでサクッとできますが、生のJSだと同じようにはいきません。
また依存関係を考えると、親に対して子が直接アクセスすることはあまり好ましくありません。
そこで、CustomEvent
2とEventTarget.dispatchEvent()
3によるイベント送信を使いました。
バブリングを上手く使えばCustomEventは使用しなくてもいけるのかもしれませんが、なんとなくコントロールしづらそうだったのでそちらは考えませんでした。
class Thumbnail {
constructor(element, index) {
this.element = element;
this.index = index;
// 通常のpointerイベントを起点に独自のイベントを発火させる
this.element.addEventListener('click', event => {
// 第二引数のオブジェクトで親コンポーネントにデータを渡す
const customEvent = new CustomEvent('thumbnailClick', { detail: { baseEvent: event, index: this.index } });
this.element.dispatchEvent(customEvent);
});
}
class ThumbnailList {
constructor(element) {
this.element = element;
this.items = element.querySelectorAll('.item');
this.thumbnails = Array.from(this.items, (item, index) => new Thumbnail(item, index));
this.thumbnails.forEach(thumbnail => {
// イベントリスナを使用して子が発火したイベントを擬似的にキャッチして自分のメソッドを実行する
thumbnail.element.addEventListener('thumbnailClick', e => this.handleThumbnailClick(e));
});
}
}
ここまで実装することで、ようやくコンポーネントとして独立性を保ちつつ親子間でのデータのやりとりもできるようになりました。
コンポーネント指向……っぽい感じです。やったー!
メンテナンスの際も、
ThumbnailインスタンスクリックしたらThumbnailListインスタンスにイベントが行って、このメソッドが動くから、このメソッドに追加すれば良いよね!
と、データやイベントの流れ、もしくはスコープといったメンテナンス時に必ず考慮しなければならない点が把握しやすくなります。
しかしコード量は増えました。画像クリックしてそれに従って何かを実行するなら、以下だけで済みます。
const thumbnails = document.querySelectorAll('.thumbnail');
thumbnails.forEach(thumbnail => {
thumbnail.addEventListener('click', () => doSomething());
});
しかし、「クリックされた画像の左右隣にも何か別の処理を加えたいよー!」などの追加実装が積み重なると、スコープやデータの流れが複雑になり、バグの元になったりします。将来のバグを減らしつつ拡張性を持たせるためには、最初の設計と実装のときにしっかりと組んでおくことが大切なので、コードの長さには目を瞑りましょう
ちなみに実際の機能はもっと複雑になっているので、コンポーネント指向を取り入れなかったとしても結構なコード量になっていたと思います。
おわり
ということで、JSフレームワークやWeb Componentsを使用しなくてもコンポーネント指向っぽい実装は実現できたわけですが、特に縛りがないサービスにおいてはここまで面倒な実装にする必要はなく、前述のような実装を簡単にしてくれるものを使っていったほうがいいですね。
また、結局バンドルしてトランスパイルするならCLIでJSフレームワーク入れちゃった方が早かったかな〜とも思います。
とはいえ現状のルールに従いつつ、できるだけモダンな実装に近づくという、「今あるものでできるだけ工夫する」思想を実現できたので、個人的には満足しました。自己満足
それでは〜〜
Happy holiday
-
前述の例で言えばThumbnailListクラス(のインスタンス)から見たThumbnailクラス(のインスタンス) ↩
-
https://developer.mozilla.org/ja/docs/Web/API/CustomEvent/CustomEvent 初見ではWebWorker内でしか使えないのかと思ったが普通に使えた ↩
-
https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/dispatchEvent ↩