フューチャー技術ブログ

「リアクティブコントローラ」導入がもたらすかもしれないウェブフロントエンド設計の変化

フロントエンド連載2日目のエントリーです。

あまり話題になっていないような気がしますが、Web Componentsを実装するためのフレームワークのLit-Element v3がバージョンアップして、ついでにリブランディングしてLit v2.0となりました。ロゴも変わり、ウェブサイトも新しくなりました。

Lit-Elementトップページ

本当はこのLitの紹介をこの連載でしようとしたのですが、上記のウェブサイトがすごく詳しいので、単に紹介するだけの記事だとあまり価値がないので、この中のコントローラ機能のみをとりあげようと思いますが、まずはWeb Componentsとは、というところを説明します。

n回目のWeb Components元年

以前次のような記事を書きました。最初のPolymerというフレームワークが推進していたころよりも、ブラウザ対応も進み、ReactやVueといった人気のフレームワークとも気軽に組み合わせられるようになり、使えるところがぐん、と増えました。

jxckさんの発表で、3回目の元年な話もあります。主にWebPackingなどのAPIの進化がこれからWeb Componentsの普及に一役買うのでは、という内容のようです。

Headless UIがもしWeb Components版を出してくれたら、世のWeb/CSSデザイナーの人たちのポートフォリオに載せやすいものとなって作ろう、という人は増えるのでは?という気はするのですが、それがなかったとしても、2021年は本当の元年になってくれそうな気がすごいしています。

Web Componentsが普及したあかつきには、多種多様なコンポーネント集が手に入り、ReactでもVueでもSvelteでもなんでもフロントエンド開発のときに各フロントエンド専用のUI部品集だけでなく「Web Components製の部品も利用の選択肢に入る」状態になると思います。そのためにはWeb Componentsで作られた部品集がたくさん世の中に作られることが大事だと思っていましたが、リニューアルされたLitのウェブサイトのフッター付近にたくさん部品集のリンクがあります。

注目はアラスカエアラインのフライト情報コンポーネントですね。こういうのがWeb Componentsっぽい。このサイトの、コンポーネントの背景を斜めストライプにすることでコンポーネントのビジュアルと背景の区別がつきやすくなっているの、めちゃ良いですね。Storybookとかでコンポーネント集作る人はみんなやっても良さそう。

アラスカエアラインのコンポーネント一覧

それらのコンポーネント集以上の起爆剤となりそうと思っているのが、Google I/O 2021で発表されたAndroidの新UIコンセプトのMaterial Youです。

Material Designが発表されたときも、モバイルからウェブまで全部をカバーするUIコンセプトということで、CSSも提供され、material-design-liteというライブラリが出たり、それらを元にしたBootstrapのテーマが出たりしつつ、React向けにはMaterial UI、Vue.jsにはVuetify、AngularではAngular Materialがそれぞれ開発されています。ですが、「とりあえずこれ使っておこう」となるまでには年単位の時間がかかっていました。

一方で、細々とMaterial DesignのUI部品のLit Element実装も行われているな、と思っていたら、Material Design本家のチュートリアルもいつのまにかWeb Components前提となっていますし、なにより、Material YouはLitで実装する==Web Componentsになるよ、と中の人からレスポンスが!!

Material UIやVuetifyやAngular MaterialがMaterial Youに対応するか、別のそういうバージョンが実装されるよりも、Web Components版を使った方が圧倒的に早い、ということになりそうです。やったね!!

Litの新機能のリアクティブコントローラとは何者か

Lit Elementからの変化はこちらをみていただくのが確実ですが軽くリストアップします。

  • ビルド結果のサイズはより小さく(minify時で30%)、より高速に(初期表示が5-20%、更新が7-15%)
  • リアクティブコントローラというコンセプトが入った
  • experimental: Reactコンポーネントを作るアダプターが導入
  • experimental: サーバーサイドレンダリングができるようになる

ここでとりあげるのはコントローラです。ライフサイクルイベントのハンドラーをどこにどう実装するのか、というのはいろいろ試行錯誤があり、クラスのメソッドだったり、Hooksが導入されたり、といろいろ変わってきました。Hooksもライフサイクルイベントのハンドラーの再利用がしやすくなる仕組みでしたが、コントローラはこのハンドラーのセットをオブジェクトして切り出しておける機能となります。Javaとかのオブジェクト指向をやっていた人にはわかりやすいものかと思います。

コントローラという概念自体はモバイルな開発では以前から使われているアイディアで、iOSの開発でも見かけるし、Flutterにもありますね。Jetpack Composeはユーザーがロジックを定義する対象ではないのかな?ナビゲーション周りぐらいでしかコントローラという言葉が出てこなくて、UIを持たない提供ロジックの置き場っぽい使われ方っぽいですね。一般名詞なのでフレームワークごとに用法が違ってきがちですが、iOS的なコントローラに近そうです。

Litのドキュメント中はたんにコントローラと書かれているところもありますが、ページや段落の初出時には「Reactive Controller」と書かれているので正式名称はこちらっぽいです。APIのインタフェース名もこれになっています。一般名詞を特定用法で振り回すのはあんまり好きじゃない(用語を大切にするはずのDDDがリポジトリとかサービスとかといった名前でよく混乱を引き起こしている)ので、この記事でもリアクティブコントローラと書くようにします。

Litのリアクティブコントローラの設計は単にLitに閉じるものではなく、他のフレームワークからも使えるようなアダプターが提供されようとしています。

https://github.com/lit/lit/issues/1682

litのIssue1682

リアクティブコントローラのAPI

リアクティブコントローラを実装するにはReactiveController interfaceを実装したクラスを作ります。コンストラクタの中で呼び出しているaddConttoller()で、ホストとなるコンポーネントと接続されます。リアクティブコントローラからはホストのrequestUpdate()メソッドを呼び出すことで変更を通知します。

次のサンプルはLit.devにあったタイマーのコントローラです。

import {ReactiveController, ReactiveControllerHost} from 'lit';

export class ClockController implements ReactiveController {
host: ReactiveControllerHost;

value = new Date();
timeout: number;
private _timerID?: number;

constructor(host: ReactiveControllerHost, timeout = 1000) {
(this.host = host).addController(this);
this.timeout = timeout;
}
hostConnected() {
// Start a timer when the host is connected
this._timerID = setInterval(() => {
this.value = new Date();
// Update the host with new value
this.host.requestUpdate();
});
}
hostDisconnected() {
// Clear the timer when the host is disconnected
clearInterval(this._timerID);
this._timerID = undefined;
}
}

この手のやつが欲しくなるのって、Cheetah-GridとかagGridとかのテーブルコンポーネント系や、ツリービューとかですよね。プロパティに値を入れたらその通りにステートレスで仮想DOMやらなんやらで表示される、というのが今時のウェブ開発の目指すところですが、テーブル部品が入ると途端にその構造が大きく変更されがちです。

そもそも、外から与えるのは初期データで、更新後のデータは内部だけ、とかの設計になっている部品なら良いのですが(ReactのuseState的な)、全部のデータを上流に戻す前提とか、更新のトリガーは中と外のどちらが担当するか、など、まあたいてい再利用できないし、そこだけ大きく重力場が歪む感じがあります。ライフサイクルメソッドを駆使する形になるし、 ReduxとかVuex的なストアとの共存もまた厳しいものがあります。

この手のデータは、データを取得してくるキーは少なく、実態のデータが多くなりがちです。例えば、キーとなる入力はページ番号とソートキーの2つだけど、持っているデータは1ページ分の100行のデータ、といった具合です。ReactとかVueのプログラミングモデルとちょっと相性が悪いのはこの部分かな、と思います。

テーブルコンポーネントの場合、固定のデータをとってくるのか、無限スクロール的にデータを取ってくるのか、ページングなのか、いくつか選択肢があります。そのあたり、ユーザーが自分の使いたいユースケースに合わせて選択できると夢が広がります。

コントローラを外から設定する

ドキュメントを隅から隅まで読んでも、コントローラをコンポーネントの外から渡すというのはサンプルはありませんでしたが、イベントとして処理すれば良さそうです。こんな感じで書けばconnectedと表示されました。これでテーブルデータの更新をお任せすることが可能な気がします。

import { LitElement, html, css } from 'lit';
import { customElement, property } from 'lit/decorators.js';

@customElement('my-component')
// eslint-disable-next-line @typescript-eslint/no-unused-vars
class MyComponent extends LitElement {
private controller?: ReactiveController;

render() {
return html`<div`;
}

firstUpdated() {
const myEvent = new CustomEvent('oninit', {
detail: { message: 'onInit happened.' },
bubbles: true,
composed: true,
});
this.dispatchEvent(myEvent);
}

setController(controller: ReactiveController) {
this.controller = controller;
}
}
<!DOCTYPE html>
<html>
<body>
<my-component oninit="console.log" id="host"></my-component>
</body>
<script>
class MyController {
constructor(host) {
this.host = host;
host.addController(this);
}
hostConnected() {
console.log('connected');
}
}
document.getElementById('host').addEventListener('oninit', (e) => {
e.target.setController('test');
}, { once: true});
</script>
</html>

これは生DOM APIを読んでいるので冗長な感じですが、Vueとかならイベントハンドラはタグ宣言の中で行えますし、そこまで大変ではないかなと思います。まあ、将来的にReactラッパーみたいな感じで、各コンポーネント向けのラッパーが簡単に作れるようになれば・・・

テーブルのデータ取得用のコントローラを作ってみる

みんな大好きCheetah-Gridのデータを取ってくるコントローラを作ってみます。ただ、サンプルとして作っているので、そこまで実用的ではないです。たいていのこの手のコンポーネントは、データ元を表すオブジェクトが提供されており、それをコンポーネントに渡せばだいたいやりたいことはできてしまいます。Cheetah-Gridも例に漏れず、DataSourceで無限スクロールな用途での内部のキャッシュやら描画やらはいい感じにやってくれます。

ですが、ちょっとダサいページングなUIを要望された、という想定で、ページングのデータ取得のリアクティブコントローラを実装してみます。

本当はGrapeCityのWijmoのサンプルみたいにカラム定義もタグで書きたかったけど、まあ今回はリアクティブコントローラのサンプルなのでそこは省略しておきます。

データソースはどこかのウェブサイトかもしれないし、固定テーブルかもしれないし、計算で作るかもしれません。テーブルのユーザーは次のインタフェースを実装し、その中でデータ取得を行うものとします。

DataSourceController.ts
import { ReactiveController } from 'lit';

interface DataSourceController extends ReactiveController {
setPage(page: number);
}
  • setPage()が呼ばれたら中でデータを取得。中でホストのsetData()を呼ぶ。
  • サーバー側で更新がかかったらそれを検知できるように、変更検知時にはホストのsetData()を呼んでもいいものとする

データをどこから取ってくるかを利用者が定義できるようになりますね。ReactiveControllerインタフェースをextendsしていますが、これにも対応することで、コンポーネントが表示されているかどうかが検知できるのでサーバー接続を開始したり終了したりといったこともコントローラ実装者が制御できるようになりますね。

とりあえず、何も考えず、数値と、それを7で割った余りの数値をリストアップするという実用的じゃないサンプルを作ってみます。本当はかっこいいCheetah-Gridをとりあえず固定長テーブル表示で使うWeb Componentsはこんな感じでできます。

import { LitElement, html, css, ReactiveController } from 'lit';
import { customElement } from 'lit/decorators.js';
import { ref, Ref, createRef } from 'lit/directives/ref.js';
import { ListGrid } from 'cheetah-grid';

interface DataSourceController extends ReactiveController {
setPage(page: number): void;
}

@customElement('page-grid')
// eslint-disable-next-line @typescript-eslint/no-unused-vars
class PageGrid extends LitElement {
tagRef: Ref<HTMLDivElement> = createRef();

private controller?: DataSourceController<{ num: number; rem: number }>;

listGrid?: ListGrid<{ num: number; rem: number }>;

static styles = css`
div.grid {
height: 600px;
border: solid 1px #ddd;
}
`;

render() {
return html`<div class="grid" ${ref(this.tagRef)} />`;
}

firstUpdated() {
console.log('firstUpdated');
const tag = this.tagRef.value;
this.listGrid = new ListGrid({
parentElement: tag,
header: [
{ field: 'num', caption: 'num' },
{ field: 'rem', caption: 'rem 7' },
],
});
const myEvent = new CustomEvent('oninit', {
detail: { message: 'onInit happened.' },
bubbles: true,
composed: true,
});
this.dispatchEvent(myEvent);
}

setController(
controller: DataSourceController<{ num: number; rem: number }>
) {
this.controller = controller;
this.addController(controller);
}

setData(data: { num: number; rem: number }[]) {
if (this.listGrid) {
this.listGrid.records = data;
}
}
}

利用側はこんな感じで、コントローラを作成し、初期化のコールバックでコントローラを設定します。ページング機構を作るのがめんどくさかったので、一秒間に1ページ進むようにしています。

<!DOCTYPE html>
<html>
<body>
<page-grid oninit="console.log" id="grid"></page-grid>
</body>
<script>
class MyController {
constructor(host) {
this.host = host;
}
setPage(page) {
const data = [];
for (let i = 0; i < 20; i++) {
data.push({ num: i + page * 10, rem: (i + page * 10) % 7 });
}
this.host.setData(data);
}
}
document.getElementById('grid').addEventListener('oninit', (e) => {
const controller = new MyController(e.target);
e.target.setController(controller);
let page = 1;
setInterval(() => {
controller.setPage(++page);
}, 1000);
});
</script>
</html>

ユーザーがデータ取得をリアクティブコントローラでカスタマイズ可能なWebComponentsのテーブルコンポーネントが簡単にできました。

テーブルコンポーネント

まとめ

データソースをリアクティブコントローラとして分離する実装について解説しました。まあコントローラを外部から与えるという方法は公式ドキュメントにないので、本当にこれでいいのかは不明ですが、今まで悩みの種だった、ちょっと大きめなデータを扱うコンポーネント、テーブルとかツリービューを実装するときの光明になりそうです。仮想DOMではなく、Cheetah-GridのようにCanvas描画で超高速みたいにうたう高性能な部品に、大きめのデータをフレームワークの中でどう渡すかというところで、フレームワークとステート管理機構で、データ取得のキーだけ管理して、実体の生データはリアクティブコントローラが保持して、高性能な部品に渡す、ということになれば、ライブラリとの接合部分がスパゲティになりがちなところが簡潔になるんじゃないかと思います。

この機構はLitだけではなく、他のコンポーネントでも活用できそうなところもいいですね。うちの会社だと技術選定はお客さんの要望次第なところもあってここ三年でReactもVueもAngularも全部さわりました。Web ComponentsでUI部品も共通化されるといいな、といつも思っていましたが、ちょっとしたロジック、たとえばページを開いたタイミングでサーバーからデータ取得とかそういうのも再利用したり、結合が疎になってテストしやすくなるとさらに良いですね。

あとはMaterial YouのリリースでLit製コンポーネントをみんなが使うようになるのが楽しみですね。2021年がWeb Componentsの3回目の元年だとして、4回目と言わなくても済むぐらい広まって欲しいですね。