モダンなWebアプリを異なるJavaScriptフレームワークを使う複数チームで開発するためのテクニック
この記事は翻訳記事です。
原著者の許可をとって翻訳・掲載しています。
原文はこちらです。
マイクロフロントエンドという言葉は2016年の終わりにThoughtWorks Technology Radarで言及されました。
それはマイクロサービスの考え方をフロントエンドに拡張したものです。
現在のWebのトレンドは多機能でパワフルなSPAです。
SPAはフロントエンドとバックエンドを切り離すという、マイクロサービスの考え方に基づいています。
開発をすすめていくと、特に複数のチームで管理している場合
フロントエンド層が肥大化して管理が難しくなりがちです。
これを「モノリシックなフロントエンド」と呼びます。
マイクロフロントエンドの背景には
ページを独立したチームによって管理された機能の集合体と捉える考え方があります。
それぞれのチームは特定の領域やミッションに特化しています。
チームは機能横断的な作りになっていて、その機能をend-to-end
つまりデータベースからUIに至るまでをすべて実装します。
この考え方は特に新しいものではありません。
過去にもFrontend Integration for Verticalised SystemsやSelf-contained Systemsといったものがありました。
しかしマイクロフロントエンドは明らかにそれらの用語よりもライトでとっつきやすい概念です。
モノリシックなフロントエンド
マイクロフロントエンド
前文で「モダンなWebアプリ」という言葉を使いましたが、その意味をはっきりさせておきましょう。
自分のアプリがモダンかどうかの立ち位置を俯瞰的に見るために
Alan Balkanは「Documents‐to‐Applications Continuum」という考え方を取り入れました。
これは左側ほどリンクや文字のような静的なものを、右側ほど写真の編集ツールのような
動的なものを配置するというWebサイトの指標です。右側に行くほどモダンなWebアプリということになります。
もしあなたのUIがこの指標で左側寄りなら、サーバーサイドに完結しているのが良いでしょう。
サーバサイドで完結する場合、ページはすべてのコンポーネントのHTML文字列を集めて組み合わせるだけです。
コンテンツの更新はページの再読み込みか、Ajaxで行われます。
Gustaf Nilsson Kotteがこれについてまとめた記事を書いています。
もしあなたのUIがユーザーの動作に対してすぐにフィードバックをしてあげたいなら
前述のサーバーで完結しているモデルは、不十分です。
Optimic UIや
Skelton Screensのようなテクニックを使いたいなら
UIをデバイス上で更新する必要があります。
また、Googleの
Progressive Web Apps
では高速なパフォーマンスと
すべての人にコンテンツを届けることの両立(Progerssive Enhancement)を説明しています。
このようなアプリケーションは先ほどの指標の真ん中あたりに位置しています。
これもまたサーバー完結モデルでは不十分で、アプリケーションをブラウザと統合する必要があります。
これが今記事のテーマです。
- テクノロジーとの分離
各チームは実装するのに使う技術を、他のチームとの調整をせずに選ぶべきです。
Custom Elementはその技術の実装を隠して、技術に依存しないインターフェースを提供してくれます。
- チームをまたいでコードを共有しない
たとえ使っている技術が同じでもコードを共有してはいけません。
それ自身で完結しているアプリケーションを作りましょう。
共有の状態や、グローバルの変数に依存してはいけません。
- チームのprefixを決める
チームが上手く分離されていない場合、チームごとに変数やファイルのprefixを決めておきましょう。
CSS, Event, LocalStorageやCookieを使う場合に
所属するチームをはっきりさせたり、衝突を避けたりするのに役立ちます。
- ブラウザネイティブのAPIを使う
コンポーネント間のデータのやりとりには、できるだけブラウザネイティブのイベントを使いましょう。
もしコンポーネント間をつなぐ自作のAPIが必要ならできるだけシンプルにしましょう。
- 不測の事態に強い(Resilientな)サイトにする
JavaScriptが動かなくかったり、途中で止まったりしても、そのサイトは役立つようにしましょう。
体感パフォーマンスを上げるためにサーバーサイドレンダリングやProgressive Enhancementの考え方を取り入れましょう。
Custom Elementはアプリケーションをブラウザに統合するのに役立ちます。
それぞれのチームは選択したフレームワークを用いて、コンポーネントを実装して
それをCustom Elementの中に隠蔽します。(例: <order-minicart></order-minicart>)
このタグの名前が他のチームがコンポーネントを使う際のAPIとしての役割を果たします。
こうすることで、このコンポーネントがどんなライブラリを使って作られたのかを知る必要なく使えます。
ただDOMとやりとりすればいいのです。
しかし、Custom Elementだけではすべての問題を解決できません。
Progressive Enhancementやサーバーサイドレンダリング、ルーティングなどを実現するためには
別のテクノロジーが必要になります。
以下のページは二つのセクションに分かれています。
最初にページの組み立て(どのような別々のチームに実装されたコンポーネントを組み合わせるか)について、
その後クライアントサイドでのページ遷移の例を説明します。
色んなフレームワークで書かれたコードを、クライアントとサーバー両方に統合する以外にも
JavaScriptの分離、CSSの衝突の避け方、コンポーネントの遅延読み込み、チーム間のリソース共有、
データ取得、ローディングなど議論すべきことたくさんがあります。
これらのトピックを一度にまとめて紹介します。
トラクターの販売店の販売ぺージが以降の説明のモデルになります。
このページには3つのトラクターを切り替えるセレクターがついています。
切り替えるとトラクターの画像、名前、値段、リコメンドが更新されます。
また、クリックすると選択されているトラクターをバスケットにいれる購入ボタンがあります。
さらに上部にはバスケットの状態に応じて表示が切り替わるミニバスケットがついています。
すべてのHTMLはクライアントサイドでJavaScriptとes6のテンプレートを使って生成しています。
ライブラリは使っていません。
マークアップと状態を分離して、何か変更があった際にはすべてのHTMLを再描画してるだけです。
DOMの変更分だけを再描画したり、サーバーサイドレンダリングといったものもありません。
また、タスクをチームごとに分割といったこともしていません。ひとつのjs/cssにすべてが書かれています。
以下の例では、ページを3つのコンポーネントに分離して、それぞれの実装をチームごとに担当します。
Checkoutチーム(青) は購入に関するすべてのプロセスを担当します。ここでは購入ボタンとミニバスケットです。
Inspireチーム(緑) はこのページのレコメンドを担当します。
Productチーム(赤) はページ全体を担当します。どの機能が必要で、どこに置くかを決めます。
ページはProductチームが決めた商品名や画像などを表示しますが
それだけでなく他のチームが作ったコンポーネントも含まれます。
例として購入ボタンを考えてみましょう。
Productチームは、ボタンを表示したい位置に<blue-buy sku="t_porsche"></blue-buy>と書けばページにボタンを追加できます。
Checkoutチームはblue-buyコンポーネントをこのページに登録する必要があります。
class BlueBuy extends HTMLElement {
constructor() {
super();
this.innerHTML = `<button type="button">buy for 66,00 €</button>`;
}
disconnectedCallback() { ... }
}
window.customElements.define('blue-buy', BlueBuy);
ブラウザはblue-buyを見つけるたびに上のコードのconstructorを呼び出します。
thisは定義されたCustom Element自身への参照を表します。
innerHTMLやgetAttribute()といったDOMのプロパティは、すべて使用可能です。
コンポーネントの名前に関するルールが一つだけあります。
将来追加されるHTML要素の名前との衝突を避けるために、コンポーネント名に-を含まなければなりません。
以降の例では[チームカラー]-[機能名]という命名規則が用いられます。
これはチーム間でのコンポーネントの名前衝突を避け、責任を明確にするという目的があります。
ユーザーがセレクターでトラクターを切り替えたときに
それに応じて購入ボタンも更新しなければなりません。
Productチームは単に古いコンポーネントを削除して、新しいものを挿入すれば十分です。
container.innerHTML;
// => <blue-buy sku="t_porsche">...</blue-buy>
container.innerHTML = '<blue-buy sku="t_fendt"></blue-buy>';
削除する際に、古いコンポーネントのdisconnectedCallbackが同期的に呼び出されます。
その後、新しく作られたコンポーネント(t_fendt)のconstructorが呼び出されます。
よりパフォーマンスの良い方法は、skuプロパティを書き換えることです。
document.querySelector('blue-buy').setAttribute('sku', 't_fendt');
もしコンポーネント内でReactのようなDOMの変更を検知するフレームワークを使っていた場合は
内部で自動的に再描画されます。
このプロパティが書き換わった際に再描画される機能を自前で実装する時は
その挙動をattributeChangedCallbackに、ウォッチするプロパティを
observedAttributesに定義しておきます。
const prices = {
t_porsche: '66,00 €',
t_fendt: '54,00 €',
t_eicher: '58,00 €',
};
class BlueBuy extends HTMLElement {
static get observedAttributes() {
return ['sku'];
}
constructor() {
super();
this.render();
}
render() {
const sku = this.getAttribute('sku');
const price = prices[sku];
this.innerHTML = `<button type="button">buy for ${price}</button>`;
}
attributeChangedCallback(attr, oldValue, newValue) {
this.render();
}
disconnectedCallback() {...}
}
window.customElements.define('blue-buy', BlueBuy);
コードの重複を避けるために、render()メソッドが定義されています。
(constructorとattributeChangedCallbackから呼び出されます)
このメソッドは再描画に必要な情報を集めてきます。
Custom Element内でテンプレートエンジンやライブラリを使う場合に
初期化コードを書くのもこのrender()内部です。
上の例で使われているCustom ElementはCustom Element V1 Specに基づいています。
これはChromeとSafariとOperaでサポートされています。
ただしdocument-register-element に関して言えば主要ブラウザで動かすためにpolyfillが必要です。
Custom Element内部では主要ブラウザでサポートされているMutation Observer APIを使っているため
裏側でトリッキーなDOMの変更検知をしている、といったことはありません。
Custom ElementはWeb標準なのでAngular, React, Preact, Vue, Hyperappなど
主要なJavaScriptフレームワークはこれをサポートしています。
しかし細かい点を見ると、対応できていなかったり、バグがあったりします。
Custom Elements Everywhere Rob Dodsonでは
これらの問題をまとめてくれています。
親から子にデータを渡すだけでは不十分です。
先ほどの例では、購入ボタンを押したときにミニバスケットの表示を
変更する必要があり、これは親から子へのデータの受け渡しだけでは実現できません。
この2つのコンポーネントはCheckoutチーム(青)の担当です。
購入ボタンとミニバスケットを繫ぐAPIを実装することも可能ですが
その方法では、購入ボタンとミニバスケットが密結合し、コンポーネントの分離原則が破られてしまいます。
クリーンな方法は、PubSubパターンを使うことです。
コンポーネントはメッセージを発行し、他のコンポーネントは
メッセージを(どのコンポーネントから来たかは知らずに)受け取ります。
ラッキーなことにブラウザには初めからこの機能が組み込まれています。
click, select, mouseoverといったイベントをハンドリングする時と
全く同じようにこの機能をCustom Elementで使うことができます。
また new CustomEvent(...)とすることで、独自のイベントも使うことが可能です。
Eventは常にそれが発火された、もしくは渡された場所と紐づいています。
ほとんどのイベントはbubblingの機能を実装しているため
特定のDOMのサブツリー内のすべてのイベントを検知するということも可能です。
以下はblue:basket:changedイベントを発火する例です。
class BlueBuy extends HTMLElement {
[...]
connectedCallback() {
[...]
this.render();
this.firstChild.addEventListener('click', this.addToCart);
}
addToCart() {
// maybe talk to an api
this.dispatchEvent(new CustomEvent('blue:basket:changed', {
bubbles: true,
}));
}
render() {
this.innerHTML = `<button type="button">buy</button>`;
}
disconnectedCallback() {
this.firstChild.removeEventListener('click', this.addToCart);
}
}
このミニバスケットはwindowからイベントを購読して
内部のデータをリフレッシュします。
class BlueBasket extends HTMLElement {
connectedCallback() {
[...]
window.addEventListener('blue:basket:changed', this.refresh);
}
refresh() {
// fetch new data and render it
}
disconnectedCallback() {
window.removeEventListener('blue:basket:changed', this.refresh);
}
}
ミニバスケットコンポーネントは外部のwindowにリスナーを追加します。
この方法が気持ち悪ければ、ページがコンポーネントの変更を検知して
ミニバスケットのrefresh()メソッドを呼び出すことで再描画する、という書き方も可能です。
// page.js
const $ = document.getElementsByTagName;
$('blue-buy')[0].addEventListener('blue:basket:changed', function() {
$('blue-basket')[0].refresh();
});
DOMのメソッドを呼び出すということは普通はやりませんが video-elemt apiではよく使われます。
可能なら宣言的な方法(属性が変わった際に自動で更新される方法)の方が望ましいでしょう。
Custom Elementはコンポーネントとブラウザを統合するすばらしい技術ですが初期描画が遅いという欠点があります。
ユーザーはすべてのJavaScriptがロードして実行されるまで、白い画面を見続けることになるでしょう。
JavaScriptがロードや実行に失敗したら何が起こるかを考えてみるのもよいでしょう。
Jeremy KeithがResilient Web Designでこのことについてまとめています。
よって大事なコンテンツをサーバーサイドでレンダリングすることが大切になるのですが
悲しいことにWeb Componentの仕様では、サーバーサイドレンダリングに関する言及はありません。
前の例でサーバーサイドレンダリングを有効にするためにはリファクタが必要です。
各チームはexpressサーバーからコンポーネントを配信します。
こうすることで、URL経由でコンポーネントのrender() メソッドを呼び出せます。
$ curl http://127.0.0.1:3000/blue-buy?sku=t_porsche
<button type="button">buy for 66,00 €</button>
Custom Elementのタグの名前はURLのパスとして、属性はGETパラメータとして表現されます。
この方法は、すべてのコンポーネントをサーバーサイドレンダリングすることが可能で
Universal Web Componentにかなり近いものが実現できます。
<blue-buy sku="t_porsche">
<!--#include virtual="/blue-buy?sku=t_porsche" -->
</blue-buy>
#include コメントはServer Side Includesです。
これは昔のWebサイトで現在の時間を表示するのに用いられたテクニックと全く同じです。
他にも色々な方法(
ESI,
nodesi,
compoxure,
tailor)がありますが、SSIが最もシンプルで安定している方法です。
#include コメントはサーバーがページ全体を送信する前に、/blue-buy?sku=t_porsche の中身に置き換えられます。
この時のnginxの設定ファイルは以下のようになります。
upstream team_blue {
server team_blue:3001;
}
upstream team_green {
server team_green:3002;
}
upstream team_red {
server team_red:3003;
}
server {
listen 3000;
ssi on;
location /blue {
proxy_pass http://team_blue;
}
location /green {
proxy_pass http://team_green;
}
location /red {
proxy_pass http://team_red;
}
location / {
proxy_pass http://team_red;
}
}
ssi: on; の部分でSSIを許可しています。
またupstream とlocation の部分でそれぞれのチームにURLを割り当てています。
例えば/blue で始まるURLは青チームのサーバー(team_blue:3001)にマッピングされます。
またルートページ(/)はページ全体の担当である赤チームのサーバーが割り当てられています。
以下のアニメーションはJavaScriptを無効化した例です。
トラクターの切り替えセレクターはただのリンクでクリックする度にページがリロードされます。
右側のコンソールはリクエストがどのように処理されているかを示しています。
まず赤チームが担当するページ全体のURLが読み込まれ
その後に青チームと緑チームが作ったコンポーネントが読み込まれています。
JavaScriptを有効にすると、最初のページ全体のロードだけが行われます。
トラクターの切り替えは一番最初の例と同じように、クライアントサイドで行われます。
このサンプルコードはローカルマシンで試せます。
(docker-composeをインストールする必要があります)
git clone https://github.com/neuland/micro-frontends.git
cd micro-frontends/2-composition-universal
docker-compose up --build
DockerはNginxを3000ポートで起動して、さらにそれぞれのチームのサーバーのイメージを起動します。
http://127.0.0.1:3000/にアクセスすると赤のトラクターが表示されます。
docker-composeのログはネットワークで何が起こっているのかを分かりやすくしてくれます。
残念ながらログの色の指定はできないので、青チームが緑色で表示されるかもしれませんが。
ソースコード内のsrc ディレクトリはそれぞれのチームのコンテナにマッピングされています。
srcディレクトリ内のコードに変更があると、そのサーバーは再起動されます。
自由に中のコードを変えて遊んでみてください。
SSI/ESIの方法の欠点は一番描画の遅いコンポーネントがページ全体の描画パフォーマンスのボトルネックになることです。
だから、それぞれのコンポーネントをキャッシュすることがとても大切です。
描画に時間がかかるし、キャッシュもできないようなコンポーネントは初期描画から外すことも検討しましょう。
ブラウザから非同期で取得して描画する方が良いかもしれません。
緑チーム担当のgreen-recos はこの方法を使う候補になります。
単にSSIを外せば、これを実現できます。
Before
<green-recos sku="t_porsche">
<!--#include virtual="/green-recos?sku=t_porsche" -->
</green-recos>
After
<green-recos sku="t_porsche"></green-recos>
重要事項として、Custome Elementはself-closingができません。
そのため<green-recos sku="t_porsche" /> とした場合は正しく動作しない可能性があります。
このようにしたコンポーネントの描画はブラウザでのみ行われます。
アニメーションを見てわかる通り、描画する際にはブラウザ内でスタイルの再計算 が行われます。
レコメンドの部分は最初はブランクで、APIにリクエストして緑チームのコンポーネントを取得した後で描画されます。
描画後にはより多くの領域が必要になるので、ページはレイアウトを更新する必要があります。
この鬱陶しい再計算を避ける方法はいくつかあります。
まず、赤チームがレコメンドの枠の高さを固定する方法が考えられます。
レスポンシブ対応のサイトでは高さを固定する時点でトリッキーですが
もっと大きな問題点は、このようなチーム間のルールが
赤チームと緑チームの間に密結合を作ってしまう、ということです。
もし緑チームがレコメンドのコンポーネントにサブヘッダーを入れた時には
赤チームは枠の高さを調整しなくてはなりません。
レイアウトが壊れないように両チームが同時に調整する必要が生まれてしまいます。
ベターな方法はSkelton Screensと呼ばれる方法を使うことです。
赤チームはgreen-recos コンポーネントのSSIをそのままにしておきます。
さらに緑チームはサーバーサイドレンダリングの描画メソッドを
コンポーネントの中身ではなく、そのコンテンツのスケルトンに置き換えます。
このスケルトンを使うことででは、本物のコンテンツのレイアウトを使いまわせます。
この方法なら、最初にスペースが確保され 、実際の描画時にガクガク動くことはなくなります。
スケルトンはクライアントサイドでも役立ちます。
ユーザーのなんらかのアクションに対してCustom Elementを差し込む場合などは
とりあえずスケルトンを表示しておいて、データがきたら本当のコンポーネントを表示する
といった使い方が可能です。
属性の変更のような単純な場合にもスケルトンを表示するかどうかを選べます。
何かが起こっていることをユーザーに伝えることはできるのですが
APIがすぐにレスポンスを返した場合に、スケルトンとコンテンツが短時間で切り替わり画面がちかちかしてしまいます。
データを取得しても、少しの間あえてスケルトンを表示しておくなど
ユーザーのフィードバックをもらいながら賢く実装していきましょう。
To be continued... そのうち書くのでGithubのリポジトリをチェックしてください。
-
トーク: Break Up With Your Frontend Monolith - JS Kongress 2017 Elisabeth Engel
gutefrage.netの実装でのマイクロフロントエンド -
記事: Micro frontends - a microservice approach to front-end web development Tom Söderlund
マイクロフロントエンドの主要コンセプトと、リンク集 -
記事: Microservices to Micro-Frontends Sandeep Jain
マイクロサービス・マイクロフロントエンドの主要コンセプト -
リンク集: Micro Frontends by Elisabeth Engel
マイクロフロントエンド関連の記事、トーク、ツール、その他のリソースがまとまったリスト -
Custom Elements Everywhere
Custom Elementとフレームワークが一緒に問題なく動くかのチェック -
トラクターはここで買えます。
なおこのサイトは、この記事で紹介されているテクニックを用いた2つのチームによって実装されています
- ユースケース
- ページ遷移
- ハード遷移とソフト遷移
- サーバーサイドレンダリングでのルーティング
- ページ遷移
- その他
- CSSの分離 / UIの一貫性 / スタイルガイド & パターンライブラリ
- 初期描画時のパフォーマンス
- サイトを使用中のパフォーマンス
- CSSのロード
- JavaScriptのロード
- 統合テスト
Michael Geers (@naltatis)
neuland Büro für Informatikで働くソフトウェアエンジニア。
E-コマースのよりよいフロントエンドを追及している。
Micro Frontends
なお、原文のページはGithub Pagesでホスティングされています。
ソースコードはこちらで見ることができます。
小池貴之 (@koiketakayuki)
プログラムが好きなプログラマ(Github)
なお、翻訳版のサイトもGithub Pagesでホスティングされています。
ソースコードはこちらで見ることができます。
修正等がありましたら、Pull RequestかIssueもしくはメール(koike.takayuki0907@gmail.com)で連絡を頂けると助かります。








