🧩

コンポーネントを配信するシステムについて構想する

2023/05/07に公開

はじめに

この記事は、Cloudflare が提唱する Fragment Piercing (フラグメント・ピアシング) の記事(Cloudflare Workersによるマイクロフロントエンドの段階的な採用)を読んだ筆者が、そこから得たアイデアとそれをPoC(概念実証)している「コンポーネント配信システム」についてドキュメント化したものである。

この記事で取り上げられているシステムなどは、まだ実用段階に達していないものが多く含まれている。

デザインシステムとコンポーネントの配信

近年、デザインシステムを構築したり公開する企業や組織が増えている。
「デザインシステム」の価値は、Storybookのドキュメントに次のように示されている。

デザインシステムは複数のプロジェクトを横断してチームが複雑で、丈夫で、アクセシビリティの高いユーザーインターフェースを構築するための再利用可能な UI コンポーネントを包括します。デザイナーと開発者双方が UI コンポーネントに貢献するため、デザインシステムは分野間の架け橋としての役目を果たします。それはまた組織の共通コンポーネントにとって「信頼できる情報源」となります。

https://storybook.js.org/tutorials/design-systems-for-developers/react/ja/introduction/#:~:text=デザインシステムは複数のプロジェクトを横断してチームが複雑で、丈夫で、アクセシビリティの高いユーザーインターフェースを構築するための再利用可能な UI コンポーネントを包括します。デザイナーと開発者双方が UI コンポーネントに貢献するため、デザインシステムは分野間の架け橋としての役目を果たします。それはまた組織の共通コンポーネントにとって「信頼できる情報源」となります。


https://storybook.js.org/tutorials/design-systems-for-developers/react/ja/introduction/

実際の目的や理由は組織によってさまざまであるが、個人的には「デザインのルールや原則を可視化し、UIの再利用性を高め、システム利用者間の知識・経験の隔たりを埋めて、一貫した品質のデザインをユーザーに提供できるようにすることである」と捉えている。


デザインシステムを構築する上において様々な課題や障壁があるが、この記事では コンポーネントの配信 に焦点をあてて考えたい。


https://storybook.js.org/tutorials/design-systems-for-developers/react/ja/introduction/

コンポーネントの配信


https://storybook.js.org/tutorials/design-systems-for-developers/react/ja/distribute/

例えば、デザインシステムを構築し、コンポーネントを複数のサービスのフロントエンドに配信するケースを考えよう。
よく取られる方法は、npmライブラリとしてUIライブラリを配信する方法である。

https://storybook.js.org/tutorials/design-systems-for-developers/react/ja/distribute/

コンポーネントをビルド・バンドルし、バージョニングしてnpmライブラリとして公開し、コンポーネントを利用するアプリケーション側でそのライブラリをインポートして使用する。

これは最も一般的な方法であるが、実際に運用を開始するといくつかの課題が発生する。

課題① 反映までの速度とコスト

配信中のコンポーネントに何らかの変更を加える場合、ビルド・バンドルを行い、ルールに従ってバージョンを進めて再度公開する。
利用側では変更内容を確認して、ライブラリをアップグレードして反映する。その後テストと再デプロイを行ってプロダクションに反映する。

これらの作業を確実に挟む必要があるため、デザインシステム側で変更を加えても、即座に利用側に反映されるわけではなく、組織によってそのスピード・タイミングは異なる。
何らかの理由によって、「変更の反映のタイミングを揃えなければならない」というケースが発生すると非常に取り扱いが難しくなる。

また、変更自体が実際のユースケースではうまく動かないこと(デグレード)も起こり得るが、それを修正・反映するにも、再度同じステップを踏まなければならないため、かなりの時間とコストがかかる。
一つのバージョンの中に複数の変更が入っていることも多いので、「コンポーネントAのこの変更を利用したいだけなのに、コンポーネントBの変更も同時に対応しなければならない」ということもある。

課題② 技術スタックギャップとバンドルサイズ

例えばデザインシステムからコンポーネントをReactで記述して配信する場合、基本的には利用側もReactでシステムが構築していることが前提となる。
組織の中で統率・統一が行われていれば何の問題もない。しかし、アプリケーションによってはレガシーな技術を利用していて簡単には置き換えられなかったり、リプレースの過渡期など様々な理由によって、コンポーネントを配信している側が採用している(したい)技術と、利用側の技術スタックが乖離してしまうケースもある。

例えばReactであれば、React自体をバンドルするか、CDNからモジュールインポートすれば問題は解消できるが、その分スクリプトサイズは大きくなってしまい、更にはモジュールフェデレーションをどうやって実現するのかという課題が伴ってくる。


異なる技術スタックを持つプロジェクトや、リアルタイムでの変更反映を容易にする仕組みなどが、デザインシステムの普及と共に今後ますます重要になってくることが予想されるが、実際にはどうかは筆者も確信を持って言えない。

最適な解決策は組織やプロジェクトによって異なるが、筆者の考えとしては、現代のフロントエンド技術を活用して、従来の課題を克服しつつ、デザインシステムの有効活用を促進することが大切だと感じている。

Fragment Piercingの紹介

これらの問題を解決するために、Cloudflareが提唱するFragment Piercing (フラグメント・ピアシング)を紹介する。

Fragment Piercingが登場するのは、「Cloudflare Workersによるマイクロフロントエンドの段階的な採用」という記事だ。

https://blog.cloudflare.com/ja-jp/fragment-piercing-ja-jp/

この記事では、レガシーでモノリシックなWebアプリケーションに対して、段階的にマイクロフロントエンドを採用していくための手法を紹介している。
ただし、この手法(Fragment Piercing)から着想を得ることが目的で、「マイクロフロントエンド」というキーワードはあまり関係がないので一度忘れてほしい。

ここでいう「フラグメント」とは、機能単位で切り出されたUIパーツであり、フィーチャーコンポーネントとほぼ同意義である。



レガシーアプリケーションにピアシングされたフラグメントの図

まずこの図の上部のようなページ(Piercing Outletと書かれたページ)に、フラグメントのコンポーネントをピアシング(インポート)することを考える。

実際にCloudflareが作ったデモアプリケーションがあるので、一度触ってみてほしい。

https://productivity-suite.web-experiments.workers.dev/login

(ユーザ名は任意、パスワードは不要)

画面上部のLegacy app bootstrap delayで、ベースのページのアプリケーションの応答速度を調整でき、Piercing Enabledでフラグメントピアシングを利用するかしないかを選択できる。

Legacy app bootstrap delayを3sくらいにし、Piercing Enabledのチェックボックスを外すと、擬似的にサイト全体がもっさりしている状態が再現できる。
続いて、Legacy app bootstrap delayをそのままにし、Piercing Enabledをオンにしてリロードすると、サイトのもっさり具合は変わらないが、フラグメント部分だけは先にレンダリングが行われることがわかる。

このデモから、Piercing Enabledがオンの(フラグメントピアシングを利用している)状態では、「ベースのページとフラグメント部分のレンダリングが分離されている」というイメージがなんとなく湧くだろう。

Fragment Piercingの解説


連携するWorkerとレガシーアプリケーションホストの概要

この図が、先程のデモアプリケーションをどのようにホスティングしているかを表している。

右上のLegacy application hostがベースのページで、各フラグメントも同じようにホスティングされている。
ベースのアプリケーションでフラグメントのモジュールをimport fromで利用しているわけではなく、単体でホスティングしてサーバサイドレンダリングされているという点がこのFragment Piercingの特徴である。

実際に各フラグメントエンドポイントにアクセスが可能なので、気になる人は見てほしい

これらの独立してホスティングされているフラグメントを、中央のPiercing Gatewayで結合している状態が、先程のデモアプリケーションでPiercing Enabledをオンにしている状態である。
(Piercing Enabledがオフのときに関しては後述する)

実際にDev ToolsのNetworkを見てみると、Piercing Enabledをオフにしている時は、クライアント側でフラグメントのエンドポイントをフェッチしており、Piercing Enabledをオンにしている時は、その通信が発生していないことがわかる。


Piercing Enabledをオフにしている時はクライアントでフラグメントがフェッチされる


Piercing Enabledをオンにしている時はPiercing Gatewayで結合するのでフラグメントのフェッチが発生しない

ペースのアプリケーションには、フラグメントを差し込む箇所に、次のようなpiercing-fragment-outletというウェブコンポーネントが記述されている。

export function Login() {return (
    <div className="login-page" ref={ref}>
      <piercing-fragment-outlet fragment-id="login" />
    </div>
  );
}

Piercing Enabledをオフにしている状態では、このウェブコンポーネントがクライアント側でフラグメントのエンドポイントをフェッチし、SSRされたフラグメントのDOMを差し込むようになっている。
iframeを使用して、別でホスティングしているパーツ(フラグメント)をページ内に埋め込むような手法の、「iframeではなくウェブコンポーネントを利用する版」と捉えるとイメージがしやすいかもしれない。

https://github.com/cloudflare/workers-web-experiments/blob/main/productivity-suite/piercing-library/src/piercing-fragment-outlet.ts#L88-L120

Piercing Enabledがオンの時は、同じような処理をエッジランタイム上で行なわれ、ベースのアプリケーションにフラグメントのDOMが差し込まれた状態で最初のHTMLが返却される。
クライアント側から見れば、まるで最初からフラグメントがベースのアプリケーションから提供されていたかのように振る舞う。

https://github.com/cloudflare/workers-web-experiments/blob/main/productivity-suite/piercing-library/src/piercing-gateway.ts

最後にPiercing Enabledがオンの時のフロー図を載せておく。


ログインページ閲覧時のリクエストのフロー

これが、Fragment PiercingとPiercing Gatewayの仕組みである。

コンポーネントの配信について再考する

Fragment Piercingがどういったものなのか、どのような仕組みで動いているのかを理解したところで、先のコンポーネントの配信における課題に再フォーカスする。

  1. 反映までの速度とコスト
    • npmライブラリを経由するために、利用側はライブラリのアップグレードとそれに伴う作業(再デプロイ)が必要であり、配信側の変更をシームレスに反映することが困難
  2. 技術スタックギャップとバンドルサイズ
    • 配信側と利用側とで技術スタックのギャップを考慮する必要があり、場合によってはバンドルサイズやモジュールフェデレーションも考慮しなければならない

これらが、前述した課題であった。

Fragment Piercingで反映の速度とコストを最小にする

Fragment Piercingがどういった仕組みであるかが理解できていれば、1. 反映までの速度とコストに関しては、解決方法もなんとなく察しが付くであろう。

コンポーネントごとにホスティングしてサーバサイドレンダリングし、Piercing Gateway(エッジランタイム)でコンポーネントのDOMを結合するという手法が実現できれば、コンポーネントを利用する側は変更の反映にかかるコストがかからなくなる。

Qwikでラップして、クライアントのフットプリントを最小にする

残る課題は、2. 技術スタックギャップとバンドルサイズについてである。

例として、コンポーネントをReactで実装することを考える。
Reactはたとえサーバサイドレンダリングしたとしても、クライアントではハイドレーションする必要があるために、クライアント用のバンドルが必要になる。
完全に静的なコンポーネントであれば、ハイドレーションを無効化できるが、フォームやモーダルを持つようなインタラクティブなコンポーネントはハイドレーションがないと操作できない。

また、パーシャルハイドレーションといった、「このコンポーネントだけハイドレーションする」といった手法は現在のReactでは選択できず、クライアント用のスクリプトを何らかの方法で配布する必要がでてくる。


https://blog.saeloun.com/2021/12/16/hydration/

クライアントスクリプトが小さめのPreactや、ビルド時にコンパイルされるようなSvelteを採用する事が考えられる。
しかし、実際にはReactのエコシステムやトレンドを考慮すると、この課題を解決するためにわざわざマイナーなライブラリを採用し、一から書き換えるのは非常に効率が悪い。

そこで利用するのがQwikである。実際にFragment Piercingの例でも、フラグメントの一部にQwikが採用されている。
Qwikはクライアントスクリプトを効率よくチャンクし、更にスクリプトをロードするタイミングが調整可能であるため、フットプリントを最小に抑えることができる。
必要最低限のスクリプトが、必要になったタイミングで初めてロードされるので、モジュールフェデレーションを考慮する必要がないというのが、Fragment Piercingの例でQwikが採用されている主な理由である。
(Qwikについて知りたい人は下の記事を参照してほしい)

https://zenn.dev/aiji42/articles/fafa354f79660d

そして、Qwikにはqwikify$というReactで記述されたコンポーネントを、Qwik用のスクリプトに変換するラッパーが存在している。
Qwikの特徴である、スクリプトをロードさせるタイミングの制御も可能となり、「このコンポーネントは完全に静的であるためハイドレーションを必要としない」「このコンポーネントはカーソルがホバーするまではハイドレーションしなくていい」という選択的なスクリプトのロードとハイドレーションができる。

https://qwik.builder.io/docs/integrations/react/

/** @jsxImportSource react */
import { qwikify$ } from '@builder.io/qwik-react';
import { useState } from 'react';
 
// Create React component standard way
function Counter() {
  // Print to console to show when the component is rendered.
  console.log('React <Counter/> Render');
  const [count, setCount] = useState(0);
  return (
    <button className="react" onClick={() => setCount(count + 1)}>
      Count: {count}
    </button>
  );
}

// Specify eagerness to hydrate component on hover event.
export const QCounter = qwikify$(Counter, { eagerness: 'hover' });

コンポーネント自体は書き慣れたReactと、Reactのエコシステムライブラリを使って作成し、Qwikとqwikify$を利用してホスティングすることで、コンポーネント利用側の技術スタックに左右されず、またフットプリントも最小に抑えることができる。
ホスティング部分やビルド部分をプラグイン化して隠蔽できれば、開発者はReact以外の技術(Qwik)が使用されているということを、全く意識しなくて済むだろう。

また、Qwikでホスティングする利点は他にもある。
一例としてstyleをHTML上に自動的に展開するという特徴が上げられる。
tailwindやcssモジュールで記述したスタイルが<style>タグに変換され、直接HTML上に展開された状態でフラグメントのレスポンスを得る事ができる。

実際にPiercing Fragmentのログインフラグメントを開いてみて、Networkからレスポンスを確認するとわかりやすい。


QwikはスタイルをHTMLに展開した状態で返却する

ウェブコンポーネントのShadowDOMを組み合わせると、スタイルが完全にアイソレートされた状態になり、ベースのページのスタイルと競合しないコンポーネントを配信が可能になる。

システムの概要

これまでの話を図にまとめるとこのようになる。

ベースとなるアプリケーションは何のライブラリやフレームワーク、CMSで構築されていても良い。
コンポーネントをインポートしたい箇所には、<recat-portable src="https://entry.example.com/component-a" />このようなウェブコンポーネントのエレメントを差し込む。

コンポーネントの配信システムはQwikで構築されており、コンポーネント自体はReactで記述する。
https://entry.example.com/component-a のようなURLで、それぞれのコンポーネントのリクエストとプレビューが可能な状態にしておく。

Piercing GatewayはCloudflare Workersで構築し、ベースのアプリケーションのリクエストに対してのプロキシワーカーとして動くようにしておく。
ベースのアプリケーションのHTMLを解析してウェブコンポーネントのsrcに記述されているエンドポイントにアクセスする。得られたコンポーネントのDOMをベースのHTMLに差し込んでクライアントに返却する。

クライアントではハイドレーションが必要な場合に、スクリプトをコンポーネント配信システムにリクエストしてインタラクティブな操作を受けられるようにする。
(※この制御はqwikify$に任せる)

また、Piercing Gateway(エッジ)の実装は次のような工夫をしておくことで、可能な限り高速に結合ができるようにしておく。

  • 各コンポーネントのDOMをKVにキャッシュしておく
  • レスポンスを返した後に、untilWaitでページ内のコンポーネントのリストをKVに保持しておく
    • エッジHTMLの解析をしてから各コンポーネントを逐一リクエストするのではなく、KVに保持されたリストからコンポーネントデータを作る
    • リストにないコンポーネントがあった場合
      • エッジでは結合させずクライアント側でのフェッチにフォールバックする
      • エッジではKVのリストを更新し、次回以降のリクエストに備える

まとめ

Fragment Piercingから得た着想を元にした、新しいコンポーネント配信システムについて考察した。

なお、「システムの概要」で示したものに関しては、実際にサンプルとなるシステムを構築済みであり、実験自体は完了している状態である。
しかし、実際のアプリケーション開発や運用にこのシステムを導入するには、ハードルが多くあると認識している。
例えば、テストの課題やパフォーマンスの問題(フットプリントを最小にするように工夫しているが)、またこのシステム自体の運用・保守に関する課題が山積みの状態である。

冒頭に「デザインシステムにおけるコンポーネントの配信」を引き合いに出しているが、このシステムが配信するのは、最小のUIコンポーネントではなくフィーチャーコンポーネントであるため、若干スコープがずれていることも否めない。
ただ、例えば、APIからプロダクトデータを取得してショーケースコンポーネントを作り、記事データに埋め込む場合など(Wordpressのショートコードのような)には、フィットするのではないかと個人的に考えている。

ということで、実際の運用に向けた追加の動きがあれば、追加で情報発信していきたい。

GitHubで編集を提案

Discussion