先日行われたフロントエンドカンファレンス名古屋で筆者が特に興味を持ったのが、こちらの発表です。
全体的に面白い発表ですが、その中でも筆者が興味を持ったのがGenerative UIです。
これは、AIがユーザーへの返答の一部として、UI(を表すコード)をその場で生成することを指します。AIが実際に出力するのはJSONとかでしょうが、それをクライアントが適切に描画することで、UIが動的に生成されたことになります。最近のLLMとチャットで会話している場合はこのGenerative UIをすでに見たことがある可能性があります。例えば筆者はClaudeのチャットでGenerative UIをよく目にしています。
前述の発表でもLLMの特性としてストリーミングが挙げられていました。つまり、LLMの出力は全部一気に出力されるのではなく1、時間をかけて徐々に出力されます。それをユーザーになるべく早く届けるために、出力がまだ完了していなくてもできた部分がクライアントにストリーミングされます。これによって、クライアントから見たら「テキストが徐々に表示される」という挙動になります。
この特徴から、Generative UIではUIが徐々に表示されることが分かりますね。これがこの記事の主題です。
徐々に完成するUIの表示方法
UIが徐々に表示されること自体は、特段珍しいものではありません。昔から「ローディング」として、UIの実装・設計時に考えなければならないことの一部でした。
今回新しいのは、何がローディングされるかです。典型的なローディングは、UIの定義(Reactのコードとか)自体はすでにクライアントに揃っている中で、UIを表示するのに必要なデータを取得するのに時間がかかるためにローディングが発生していました。一方、Generative UIでは、UIの定義そのもの(Reactで例えるなら、Reactのコンポーネントツリーそのもの)がストリーミングされます。
ここで、ローディングに関して筆者が最近読んで良かった記事を紹介します。
いくつかのローディング手法が紹介されていますが、Generative UIと特に相性が良いのはスケルトンです。これは、実際のUIがまだ表示できない状態で、本来のUIのおおざっぱな形だけ先に表示しておいてローディング中の感じを出す手法です。
以下の画像が、UIの一部がスケルトン表示されている例です。
ストリーミングされるUIのプロトコル
ストリーミングに対応したUI(のコード)の表現方法は、通常我々が実装するようなUIとは異なります。例えばAIにReactのコードを直接出力してもらったとしてもうまくいきません。AIが出力途中の中途半端なコードをパースして上の画像のような表現に変換することは困難だからです。
ここでは、VercelがGenerative UIのフレームワークとして打ち出しているjson-renderにおけるプロトコルを例として取り上げます。
余談ですが、VercelはAIに関連するエコシステムを取ろうと頑張っていますね。skillsなどもそうです。
今回のプロトコルは、実はNDJSONです。NDJSONとは、1行に1つのJSON(オブジェクト)を書いて改行区切りで複数のオブジェクトを送ることを指します。各オブジェクトは、JSON Patch (RFC 6902) です。
要するに、UIを表す1つのオブジェクトがあり、そのオブジェクトに対するパッチがストリーミングされます。1回のパッチで少しずつUIの中身が増えていくことでUIのストリーミングを表現しているということです。
具体例は以下のとおりです。まず、UIの初期状態はこんな感じのオブジェクトです。
{
"root": "card",
"elements": {}
}
これは、「cardというIDのelementを表示するというUI」を表しています。ただし、肝心のcardの定義はまだ届いていません。
最初のパッチで、cardの定義が提供されます(elementsの中に入ります)。
{
"root": "card",
"elements": {
"card": {
"type": "Card",
"props": {
"title": "Miso Carbonara",
"accent": "amber"
},
"children": [
"intro",
"stack"
]
}
}
}
これは、"type": "Card"というのは「cardというIDの要素はCardというコンポーネントのインスタンスである」という意味です。propsもあり、子要素としてintroとstackが指定されていますが、これらの定義はまだ届いていません。
このオブジェクトの意味をReact風の疑似コードで表現するとしたらこういう感じでしょう。
const card = <Card title="Miso Carbonara" accent="amber">
{intro}
{stack}
/>;
renderRoot(card);
次のパッチではintroの定義が届きます。
{
"root": "card",
"elements": {
"card": { 省略 },
"intro": {
"type": "Text",
"props": {
"content": "A weeknight pasta that swaps pancetta and pecorino for miso paste and parmesan.",
"muted": true
},
"children": []
}
}
}
このような感じでelementsの中身(IDがついたUIの一部)が徐々に増えていきます。
典型的には、新しく届いたelementはまた新たな未知のelementを参照します(最初のパッチで届いたcardが、新たにintroとstackを参照していましたね)。
このように、未知の部分を残しつつUIオブジェクトの詳細を詰めていきます。未知の部分が無くなったらUI完成ということになります。
ちなみに完成形はこんな感じ
{
"root": "card",
"elements": {
"card": {
"type": "Card",
"props": {
"title": "Miso Carbonara",
"accent": "amber"
},
"children": [
"intro",
"stack"
]
},
"intro": {
"type": "Text",
"props": {
"content": "A weeknight pasta that swaps pancetta and pecorino for miso paste and parmesan.",
"muted": true
},
"children": []
},
"stack": {
"type": "Stack",
"props": {
"gap": "md",
"direction": "column"
},
"children": [
"ingredients-h",
"ingredients",
"steps-h",
"steps"
]
},
"ingredients-h": {
"type": "Heading",
"props": {
"text": "Ingredients",
"level": "h3"
},
"children": []
},
"ingredients": {
"type": "List",
"props": {
"ordered": false,
"items": [
"200 g spaghetti",
"2 large eggs + 1 yolk",
"30 g grated parmesan",
"1 tbsp white miso",
"Black pepper, lots"
]
},
"children": []
},
"steps-h": {
"type": "Heading",
"props": {
"text": "Method",
"level": "h3"
},
"children": []
},
"steps": {
"type": "List",
"props": {
"ordered": true,
"items": [
"Boil pasta in well-salted water until just shy of al dente.",
"Whisk eggs, miso, parmesan, and pepper in a heatproof bowl.",
"Toss hot pasta into the bowl off-heat, splash in pasta water, stir until silky.",
"Plate, top with more parmesan and a crack of pepper."
]
},
"children": []
}
}
}
スケルトンの実装
ここからが本題です。
このオブジェクトを実際のUIにすることを考えましょう。これは極端に難しいわけではなく、適当にオブジェクトをトラバースして、Reactのコンポーネントツリーに変換すればいいわけです。
クライアント側では、コンポーネント(例えばCard)の定義があらかじめ用意されています。
Card: ({ props, children }) => {
const [intro, stack] = children;
const isLoading = intro === null || stack === null;
return (
<section className={`card card-accent-${props.accent ?? "blue"}`}>
{props.title ? (
<header className="card-title">{props.title}</header>
) : null}
<div className="card-body">
{isLoading ? <SkeletonBlock /> : children}
</div>
</section>
);
},
実際、json-renderには各UIライブラリ向けのレンダラーが同梱されており、このレンダラーというのがまさに上記の作業をやってくれる部分です。
スケルトンを出したい場合、「childrenとして与えられているけどまだ定義が来ていないelement」のハンドリングをしなければいけません。
上の例では、const isLoading = intro === null || stack === nullとしていますね。実は、デフォルト(@json-render/react)では、このように「まだ来ていないelement」はnullで表現されます。スケルトンを表示するためには、nullを検知して条件分岐し、スケルトンを出し分けることが必要になります。
しかし、思いますよね。
いや、Suspenseの出番では?
そうです。現代的なReactでは、「ローディング中」を表すためにSuspenseが使えます。UIの中身がローディング中であれば、それをサスペンドで表すのがReact wayなはずです。
つまり、Cardは理想的にはこんな感じで定義できるべきです。
Card: ({ props, children }) => {
return (
<section className={`card card-accent-${props.accent ?? "blue"}`}>
{props.title ? (
<header className="card-title">{props.title}</header>
) : null}
<div className={"card-body"}>
<Suspense fallback={<SkeletonBlock />}>
{children}
</Suspense>
</div>
</section>
);
},
つまり、childrenの中身がまだ全部揃っていなければchildrenはサスペンドし、代わりにSuspenseフォールバックとしてスケルトンが表示されます。
childrenの中身が十分得られたら、スケルトンではなく中身に置き換わります。Suspenseがネストされていれば、このSuspenseは解除されるけどchildrenの中のSuspense(内側のSuspense)がフォールバックになり、といったことも考えられます。
筆者は、あのVercelが作ったのだから@json-render/reactはSuspenseベースになっているんじゃないかと思いわくわくしながら調べたのですが、そうではないようでした。すこし残念です。
Suspense対応のカスタムレンダラー
しかし、ご安心ください。上記のようなSuspenseを用いたスケルトンUIを作れるカスタムレンダラーを用意しました。
カスタムレンダラーというのは、@json-render/reactから提供されるRendererを使わずに自前でオブジェクトからコンポーネントツリーへの変換を行うということです。
肝となるのはカスタムレンダラーの一部の実装である以下の部分です。これはelementKeyを受け取り、1つの要素(cardとかintroとか)のレンダリングを担当する部分です。まだelementsに該当の要素がなければthrow NEVER_RESOLVES;としています。字面から想像できるとおり、永遠に解決しないPromiseを投げることでサスペンドを発生させています。
export function RenderElement({
spec,
elementKey,
registry,
}: RenderElementProps): ReactNode {
// spec.elements itself may not exist yet during the very first patches
// (e.g. only `/root` has streamed in). Optional chain so we suspend in
// that case instead of crashing.
const element = spec.elements?.[elementKey];
if (!element) {
throw NEVER_RESOLVES;
}
// ...(省略)
}
他は普通に何の変哲もない実装です。
実際に筆者のコードを動かしてみるとスケルトンと言いつつ結構ガタガタで良くないローディング体験なのですが、そこをパッと完璧にするのはAIだと難しかったのでご愛嬌です。
まとめ
この記事では、Generative UIにおいてUIそのものがストリーミングされるという概念について紹介するとともに、その際、スケルトンのようなローディング中の表現が重要であることを紹介しました。
また、UIのストリーミングはSuspenseを用いて表現できるという筆者の考えを示し、既存の@json-render/reactでは実現できなかったため、独自の実装例を紹介しました。
次回予告
そもそも、React本体にもストリーミングの概念がありますよね。
だって、ストリーミングSSRとかやっていますし。
今は、ストリーミングの取り扱い自体は完全にアプリケーション側でやっていますが、もっといい感じにReactに任せる方法もあるのではないでしょうか。
ここについて引き続き研究し、いい感じの結果がでたら続編としてご紹介したいと思います。
-
拡散モデルとかもありますが、この記事の本筋ではないので触れません。 ↩
