LiveViewはSvelteと最も相性がよい
(blog.sequin.io)- Sequinチームは、React SPAと純粋な Phoenix LiveView の両方を試した結果、LiveSvelteでLiveViewと Svelte を組み合わせる方法が最も生産的だと判断した
- LiveViewはサーバー状態とDOMの差分更新により、SPAなしでリッチなUIを作れるが、クライアント状態が必要になった瞬間にJSモジュール、フック、LiveViewの状態が混在し、境界が曖昧になる
- 純粋なLiveViewの LiveView、LiveComponent、Component は、ライフサイクル、propsの受け渡し、通信方式が異なり、リファクタリングや状態の配置がReactより面倒になる
- LiveSvelteは、Elixirがpropsを下ろし、Svelteが
live.pushEventでイベントを上げるという、明確な双方向の境界を提供する - この組み合わせでは、ルーティング、ビジネスロジック、データ準備をサーバー側に置き、UI状態、アニメーション、条件付きレンダリングをSvelteが担うことで、SPAの複雑さを減らせる
LiveViewが提供する開発モデル
- 従来のサーバーレンダリングアプリでは、サーバーは状態を持たず、クライアントからのリクエストごとにページをレンダリングする
- SPAでは、クライアントがページ構成を担い、バックエンドAPIでデータを読み書きし、Reactの
useStateのような方法でクライアント状態を管理する - LiveViewは、サーバーがページレンダリングを引き続き担当しながらも状態を保持し、フロントエンドのアクションを処理した後、DOMをSPAのように差分更新する
- SPAの複雑さは、クライアントJSアプリが事実上1つのフロントエンド・マイクロサービスのように動作することから増大する
- フロントエンド専用の要件により、バックエンドのルートとコントローラーが増える
- 1つのリクエストが
onMount、API呼び出し、レスポンス解析、ルーター、認証プラグ、コントローラー、ドメイン関数、レスポンス準備層を通ることがある
- LiveViewの利点は、フロントエンド・マイクロサービスなしでもリッチなクライアント体験を作れる点にある
- テーブルのレンダリング関数の近くでデータベースを問い合わせられる
- 新しい行が入ってきたらテーブルにpushし、LiveViewがクライアントを更新する
- リクエストごとに全状態を作り直す従来のサーバーレンダリング方式よりも、ステートフルなフロントエンドフレームワークに近いインタラクションを作れる
純粋なLiveViewで直面した摩擦
-
クライアント側の状態は避けられない
- LiveViewでは、サーバーとユーザーの間の往復遅延を避けられない
- アニメーション、ツールチップ、DOMの表示・非表示、フォームフィールドの無効化といった作業は、クライアント側で処理しなければならない場合がある
- 2つの依存関係のあるドロップダウンを持つフォームでは、1つ目のドロップダウン選択直後に2つ目のドロップダウンを即座に無効化し、サーバーが新しいリストを埋めた後で再び有効化するとよいUXになる
- この動作をLiveViewだけで実装するには、複数の概念を組み合わせる必要がある
- JS、フック、LiveViewの状態を併用すると、DOM状態の一部がLiveViewの外側に存在することになり、一貫したパターンを作りにくい
-
Reactとは異なる状態の境界
- Reactは、状態とアクションという中核概念でほとんどの遷移を処理し、DOM状態とコンポーネント状態の境界が比較的明確である
- クライアントアクションとクライアント状態の間に遅延がないため、Reactは状態パラダイムでアクションと遷移を扱える
- LiveViewの状態はサーバー側にあるため、クライアントアクションとサーバー状態の間の遅延を考慮する必要がある
- 入力フィールドの例では、Reactは文字が入力される前に状態経路を通り、コンポーネントがキー入力ごとに再レンダリングされる
- LiveViewは、ユーザーが先に入力フィールドを変更し、少し時間が経ってからサーバー状態がそれを知って反応するモデルに近い
- 遅延がほとんどなければReactのように見えるが、遅延が大きくなるほど2つのモデルの違いは明確になる
3種類のコンポーネントが生む複雑さ
- LiveViewには LiveViews、LiveComponents、Components という3種類のコンポーネント型がある
- LiveViewsとLiveComponentsはReactのステートフルコンポーネントに近く、Componentsは関数コンポーネントに近い
- LiveViewは常に最上位の親コンポーネントになり、その下にLiveComponentsとComponentsがレンダリングされる
- Reactでは、
useStateフックを追加または削除する方法で、ステートフルコンポーネントと関数コンポーネントの間を簡単に行き来できる- どちらも同じ方法でpropsを受け取る
- 状態を除けば機能セットは同じである
- DOMイベントの登録と応答方式も同じである
- LiveViewでは3つのコンポーネント型の差が大きく、LiveViewをLiveComponentへリファクタリングするのが面倒になる
- LiveViewsとLiveComponentsでは、レンダリングおよびprops受け渡しの構文が異なる
- ライフサイクルが異なる
- 通信オプション が異なり、LiveViewsには
send、LiveComponentsにはsend_updateを使う - LiveComponentsはプロセスではないため、LiveViewsのようにシステムの他の部分と相互作用できない
LiveComponentの制約
- LiveViewは1つの Elixirプロセス なので、
pid、状態、inboxを持ち、Elixir/OTPシステムの他のプロセスのように動作できる - LiveViewではpub/subを使って、システム全体の変更を購読できる
- LiveComponentは独自のプロセスではなく、LiveViewが呼び出すモジュールである
- 親LiveViewプロセスが子コンポーネントの状態を保持するため、LiveViewが子LiveComponentsへのメッセージルーティングまで処理しなければならない
- LiveComponentsが独立した状態管理とアクション処理能力を持つには、それぞれがプロセスである必要があるが、現在のモデルはそうではない
- 実際の開発では、LiveComponentにイベントやアクションを送りたくてもよい方法がなく、
send_updateをアクション伝達に使うのか状態パッチに使うのかが曖昧になる- 状態パッチに使う場合、
update節でマウントと更新をどう区別するかも考える必要がある
- 状態パッチに使う場合、
LiveSvelteが作る境界
- LiveSvelte は、LiveViewで Svelteコンポーネント をレンダリングできるようにする
- LiveViewの
render内でElixirのデータ構造をフロントエンド用のplain mapにエンコードし、Svelteコンポーネントのpropsとして渡せる - Svelteコンポーネントは
export letでpropsを受け取り、LiveSvelteが提供するlivepropを通じてLiveViewと通信する - Svelte側では
live.pushEventでサーバーにイベントを送れる- 例では、Svelteのreactive blockが
formの変更時に"form_updated"イベントをサーバーへ送る - LiveViewは
handle_event("form_updated", ...)でフロントエンドのパラメーターをデコードし、changesetを更新する - changesetの検証エラーは、再び
errorspropを通じてフロントエンドへ渡される
- 例では、Svelteのreactive blockが
- データはElixirからpropsとしてSvelteへ下り、SvelteからはWebSocketイベントとしてLiveViewへ上がる
- LiveViewプロセスはいつでもpropsを更新し、Svelteコンポーネントを再レンダリングさせられる
LiveViewとSvelteの組み合わせの中核的性質
- この組み合わせの中核は、ステートフルなバックエンドプロセスとステートフルなフロントエンドコンポーネントが一緒に動作する点にある
- 3つの性質がこの組み合わせを支えている
- バックエンドがフロントエンドコンポーネントのpropsを制御する
- フロントエンドとバックエンドの両方が状態を持つ
- 双方が互いにメッセージを開始できる、非公開の双方向通信チャネルを持つ
- 1つ目の性質は、LiveViewのレンダリング方式によって可能になる
- サーバーの再レンダリング結果がクライアントへ自動的にpushされ、適用される
- サーバーがJSの親コンポーネントのようにコンポーネントpropsを更新できる
- 2つ目の性質は、LiveViewがElixirプロセスであることから生まれる
- Elixirプロセスは状態をカプセル化する方法である
- 3つ目の性質は、LiveViewが提供する継続的な WebSocket 接続によって可能になる
SPAとの違い
- ブラウザルーティングがすべてバックエンドで行われるため、構造が単純になる
- 一般的なSPAでは、ブラウザルートとAPIルートという2種類のルートを維持する必要がある
- バックエンドは状態を持ち、現在のルートと作業中のリソースを把握している
- 各アクションは状態を最初から作り直す代わりに、バックエンド自身の状態変更として処理できる
- フロントエンドとバックエンドの通信は非公開で、結合されている
- 単一コンポーネントを支えるためのRPC呼び出しで、サーバーの公開ルートを増やさない
- クライアントで
pushEventを見れば、対応するハンドラーが協調するElixirモジュール内にあることが分かる
- 機能は主に2つのファイルに分かれる
- バックエンドモジュールは、データベース問い合わせなどのバックエンド関数を呼び出す
- フロントエンドはコンポーネントとスタイルを取り込む
- フロントエンドとバックエンドの往復が、APIモジュール、ルーター、コントローラーのスタックを通らない
- 通信手順も減る
- バックエンドはprops更新だけでフロントエンドの変更を通知できる
- フロントエンドは、期限切れトークン、タイムアウト、障害処理用ハンドラーなしに
pushEventを送れる - WebSocketが開いていればサーバーは動作中であり、閉じていればLiveViewがグローバルな「disconnected」バナーを表示する
責務分離とコード構造
- この構造では、フロントエンド・マイクロサービスが取り除かれたかのように動作する
- ビジネスロジックはバックエンドに置く
- データのロード方法
- どのデータを読み込むか
- ソートとフィルタリングの方法
- バリデーター
- Svelteのフロントエンドコードは単純になる
- 条件付きレンダリングのための
if/endブロック - アニメーション
- サーバーに送る簡単な
pushEvent関数
- 条件付きレンダリングのための
- 一般的なSPAフロントエンドでは、サーバーデータ処理、表示用データ準備、サーバー送信用データ準備のための
map、reduce、filterロジックが多くなり得る - LiveSvelteアプリでは、こうした処理をサーバー側で実行できる
- LiveViewが、Svelteコンポーネントに必要な形へデータを準備する
- 複雑さはサーバー言語、サーバーデータ構造、サーバーテストスイートの中にとどまる
- LiveViewとSvelteコンポーネントは、疎結合の2つの要素というより、1つの機能の2つの半分に近い
- LiveViewはそのSvelteコンポーネントだけをレンダリングする
- そのSvelteコンポーネントは、そのLiveViewによってのみレンダリングされる
純粋なLiveViewと比べて変わる点
- LiveSvelte方式は、フロントエンドの状態と状態遷移を受け入れる
- フロントエンドとバックエンドの間の境界層が明確になる
- Svelteの成熟したコンポーネントパラダイムを活用できる
- 純粋なLiveViewだけではアクセスしにくいJavaScriptフレームワークのエコシステムも利用できる
- Svelteはアニメーションprimitivesを提供する
- より多くの責務をフロントエンドへ移すことで、不自然な中間地帯にまたがっている感覚が減る
Svelteを選んだ理由とLiveViewの位置づけ
- LiveSvelteを選んだ理由は、Reactに同程度に完成度の高いLiveViewライブラリがなかったためである
- Svelteは基本的な状態とリアクティビティにおいて、軽量で高速なフロントエンドフレームワークだと評価されている
- LiveViewが状態管理の大きな部分を担うため、Svelte側の状態管理はシンプルに保たれる
- Svelteのテンプレート機能もReactより好まれている
- 三項演算子の代わりに
if/elseを使える - 条件付き属性設定を使える
- 三項演算子の代わりに
- Svelte 5の runes は、Svelteをより学びやすく推論しやすいものにすると期待されている
- LiveViewは、フロントエンドコンポーネントをレンダリングし、差分更新を行い、ステートフルなバックエンドプロセスを維持し、WebSocket APIを提供する backend-for-frontend として最も輝く
1件のコメント
Hacker Newsの意見
マルチプレイヤーゲームでよく使われるパターンは、基本的にクライアントとサーバーの両方で同じコードを実行することです
クライアント側のコードはサーバー状態を予測して先に実行し、サーバー状態が届いたらクライアント状態を上書きします
ゲームでは、クライアントは自分の入力結果についてはかなりうまく推測できますが、他のプレイヤーの入力は分からないので、「予測」という言葉がよく合います
ただしこの方式は、ドロップダウンの有効化/無効化やローディングスピナーのように、公式なサーバー状態を待つ間に即時反応する用途にも使えます
パーティクルシステムやラグドールのように、サーバーでそもそも動かす必要のないクライアント状態も多く、継続的なサーバー接続があるなら、リアクティブなパラダイムでもできない理由はなさそうです
https://generals.io/のクローンで、LiveSvelteで作りました
実質的にはボードゲームで、毎秒2ティックなので予測機能は不要ですが、サーバー更新前にゲーム状態を先に見せる楽観的更新は使っています
サーバーと同期がずれた場合は、サーバーがゲーム状態を上書きします
ゲームロジックはすべてElixir内にあり、予測をきちんと行うにはサーバーとクライアントでロジックを共有する必要があります
そうでなければロジックを二重に書くことになりますが、それは破滅への道です
調べたわけではありませんが、考えられる解決策はゲームロジックをすべてGleam(https://gleam.run/)で書くことです
GleamはElixirと互換性があり、JavaScriptにもコンパイルできるので、理論上は同じコードをサーバーとクライアントで実行できます
もっとも、「それなら全部JSで書けばいいじゃないか」というのもかなりもっともな話で、私もたぶん同意すると思います
それでも得られる利点は、BEAMと、リアルタイム分散システムに向いた機能を使えることです。フロントエンド部分を除けば、マルチプレイヤーゲームにはぴったりです
誰かが死んで死体が吹っ飛んだり、面白い姿勢で挟まったりしても、他の人は誰も見られず、リプレイでも各クライアントが違ってレンダリングするため見返せません
リアルタイム録画で捉えられなければ永遠に消えてしまいます
Overwatchのように時々ばかばかしいことが起きるゲームでは、誰も一緒に見られないのが残念です
https://arstechnica.com/gaming/2019/10/explaining-how-fighti...が優れた解説で、関連する議論はhttps://news.ycombinator.com/item?id=34399790とhttps://news.ycombinator.com/item?id=26289933にあります
MMOのようにサーバー永続性が入ると、入出力のボトルネックもあわせて考慮する必要があります
https://prdeving.wordpress.com/2023/09/29/mmo-architecture-s...はその観点から興味深い記事で、議論はhttps://news.ycombinator.com/item?id=37702632にあります
たとえば、ライブ/インタラクティブなフォームがページ上の派生データを更新して、一見うまく動いているように見えたのに、後でサーバーエラーやネットワーク問題ですべて巻き戻されると困ります
さらに悪いことに、ユーザーがそれに気づかないままページを閉じたり移動したりする可能性もあります
LiveView + Svelteの組み合わせが好きです
ElixirConf 2022でこの2つを組み合わせる方法を発表しましたが、live_svelteのコントリビューターたちが実際に使えるものにしてくれました
リッチなユーザー体験を持つアプリには、常にクライアント側の状態が必要だと思います
特にニューヨークに住んでいると、移動中のネットワーク接続は当たり前ではありません
記事で触れられていない強力な機能の1つは、Phoenixのpubsubを使って、別サーバーで発生したサーバー側の状態変更もすべてのクライアントへリアクティブにプッシュできる点です
中程度以上のトラフィックをさばくには、Webサーバーを複数台置くことはかなり一般的です
LiveViewのようなユーザー体験を望みつつ、不安定なネットワークにも耐えたいなら、Triplit[1]にSvelte 5バインディングを追加しました
クライアント側でクエリを書き、WebSocketでサーバーと同期できます
Elixir開発者にはそれほど魅力的ではないかもしれませんが、Svelteが中心ならTriplitは良い選択肢です
[1] https://www.triplit.dev/docs/frameworks/svelte
新しい行が入ってきたときに、そのままテーブルへプッシュして LiveView にクライアントを更新させる方式は、その行が操作可能な業務用アプリでは避けたほうがよい
認知上の遅延によって、ユーザーが誤った項目をクリックしたり、誤った顧客にメールを送ったり、誤った取引を返金したりしやすくなる
望ましいユーザー体験は「データが変更されました。ここを押して更新してください」のような固定バナー
急ぐなら、新しい行を追加専用としてレンダリングし、スクロール位置が変わらないようにすべき
BeaconCMS では Svelte と LiveView を一緒に使っている
クライアント側で UI をより細かく制御する必要がある良いユースケースは確かにあるが、チームがあらゆるものに Svelte + LiveView を全面採用するのは慎重になるべき
Phoenix でも LiveView が常に答えというわけではなく、時には通常のレンダリングページで十分
すべてを全か無かで選んではいけない
記事で述べられているように、「LiveView 方式」から外れるだけの良いユースケースがある
往復時間が 1,000ms なら別のものも検討すべきだろうが、コストなどの理由で地理的に近いサーバーを置けないチームもあるため、クライアント側の状態管理が解決策になり得る
クライアント状態を管理する代わりに、クライアントとサーバー両方の状態を管理しようということか?
追加の API を作らなくて済むという利点はあっても、改善のようには見えない
常にクライアントが追跡すべき状態とサーバーが追跡すべき状態があり、そこにメモリ上だけでよい一時的な状態と、セッション間で維持されるべき状態が重なる
これを管理する方法はほぼ無限にあり、JavaScript には「何をしても正解がない」ので、フレームワークが定めたやり方を使うか、場当たり的な対処、つまり混沌で処理することになる
最後に書いた複雑なシングルページアプリでは、クライアントがページを読み込むたびに同期を実行するようにしていた
すべてのローカル状態やアセットには日付ベースのハッシュが付いていて、サーバー側の値と異なればサーバーが更新版を返していた
単純な変数でも巨大な JSON オブジェクトでも、画像/音声 BLOB 全体でも同じだった
クライアントがサーバー状態の変更を必要とする処理を行うと、その状態更新を WebSocket で送り、アプリの 99% は WebSocket ベースだった
特別なフレームワークやパターンは使わず、コードを書いていて「これはサーバーが追跡すべきだ」と思ったら新しい状態をサーバーへメッセージとして送り、ページ読み込み時に同期するかどうかはサーバーに判断させていた
この種の状態管理では、この程度がほぼ最も単純なメカニズムだと思う
WebSocket は状態管理に本当に大きく役立つ
実際のアプリケーションにはクライアントとサーバーの両方に状態が必要
サーバーはクライアントを信頼できないので権威ある状態を持つ必要があるが、クライアントも往復なしでユーザー操作に反応して再レンダリングできなければならない
すべてをそうするのではなく、筋が通るときだけ使えばよい
もう一つ気に入っている点は、Heex の代わりに Svelte をテンプレート言語のように使えること
必要でなければやる理由はなく、必要になる理由は性能とユーザー体験
Web アプリケーションにユーザー操作が多くクライアント JavaScript が必要で、実験が多いためリクエストごとにバンドルが変わるなら、クライアントへ送る JavaScript の量を減らすために、ロジックをサーバーとクライアントに分けたくなる
そうでなければ、アプリケーションが遭遇し得るあらゆるケースを含んだ MB 単位の JavaScript バンドルを持つことになる
Photoshop の Web 版のように、ユーザーが初期ロード時間を理解してくれるアプリなら問題ないかもしれないが、Stripe や Gmail のように即座にロードされることが期待されるアプリでは、ページの初期遅延を減らしたい
すべてをサーバーへ移すこともできるが、GitHub が経験したように、即時に反応すべきユーザー操作がサーバー往復を必要とするようになり、ページのインタラクティブ性が悪化する
そうなると、光速とサーバー分散が問題になる
async import のようなものでバンドルを遅延ロードすることもできるが、遅延ロードされた JavaScript が自分のデータを取得しようとしてさらにリクエストを送り、ウォーターフォール問題が発生する
こうした問題にすべて直面すると、結局はクライアントとサーバー間でのロジック/状態共有という複雑な問題を扱いやすくする解決策を探すことになる
このアプローチには「サーバーがユーザーに近づける距離には限界がある」という、文字どおりの光速制限がある
次の段階は、サーバーを WebAssembly にコンパイルしてクライアントへ送ること
そうすれば、実際のサーバー応答を待つ間に楽観的に応答をレンダリングできる
少し狂った話に聞こえるが、実際のプロジェクトでやり遂げて、魔法のようだった
クライアントでサーバーを実行してもそれは解決せず、結局永続性のネットワーク遅延はそのまま残る
最終的に勝たなければならないサーバー状態も、その永続性
特に理由がなくても、多くの過剰設計は実際に実装されがち
LiveSvelte を作ったので、気になることがあれば知らせてほしい
全体として、ずっとこういうモデルでアプリを作りたいと思っていました。
イベント指向で、サーバーとの双方向リアルタイム更新、順序付けられたイベント、ローカル/リモート状態といった方式です。
LiveViewのことは知らず、Erlang系の言語も使ったことはありませんが、たしかに何かをつかんでいるように思います。
従来のリクエスト・レスポンスモデルは、一貫性や古いデータの問題を微妙に、そして頻繁に引き起こします。
この10年が関数型プログラミングの概念を主流言語に取り込む時期だったのだとすれば、次の10年は状態を持つメッセージ指向プログラミングを主流のフルスタックに取り込む方向であってほしいです。
ちなみに前の3つは、私が作ったか、作るのを手伝ったものです。
ガイドに沿って少し触ってみれば、およそ2時間以内にブラウザで動く基本的なPhoenixチャットアプリを立ち上げられるはずです。
良い解決策です。
私のアプリではLiveViewと併せて再利用可能なStimulusコントローラーを使っていて、これもスムーズに動作します。
全体としてLiveViewでの開発は楽しいですが、実際のシナリオで使えば使うほど、HotwireのようなステートレスなHTTPフレームワークの利点もより感じるようになります。
より高性能で再接続に強い印象があり、安定性のためにユーザーの近くへサーバーを多く配置する必要も減ります。
しかしStimulusは、クライアントに状態を保持するための洗練された方法を提供しません。
「Stimulusアプリケーションの状態はDOMの属性として存在する」という形なので、バニラJSやjQueryより優れているわけではありません。
実際のプロジェクトでStimulusを使ったことはないので、値と変更コールバックは最初に思ったより良い体験である可能性はあります。
この組み合わせは複雑さの度合いがちょうどよく、SvelteをLiveViewと一緒に使うと、そもそも問題ではないものまで多く扱っているように感じます。
必要なのはバニラJSか、その上の薄いレイヤーであって、フルフレームワークではありません。
どうせJavaScriptをたくさん書く必要はありません。
運用中のアプリのJSは200行を超えておらず、Stimulusコントローラーのいくつかは削除できそうです。
LiveViewはそれほど優れています。
Stimulus-LiveViewフックアダプターもかなりハッキーに作って、StimulusコントローラーがLiveViewプロセスへ直接イベントを送れるようにしました。
それにLiveViewは、fly.io流のKool-Aidを飲みすぎていない限り、地理的に分散したサーバーを必ず要求するわけではありません。
切断も見事に処理します。
ゼロダウンタイム更新をサポートするために、別途何かをしたこともありません。
クライアントがWebSocket接続を失い、新しいバージョンへ再接続して状態を復元する過程は、すべてデフォルトで自動処理されます。
これ以上何を望むのでしょうか?
私たちの会社はLiveViewとAlpineを併用して大きなメリットを得ており、特にクライアントで
evalを許可できないためCSPモードを使っています。初めてLiveSvelteを見たときは、非常に新しく、検証がまだ不十分に見え、厳格なCSP環境で未解決の影響もありました。
今ではかなり有用になったようでうれしいです。
私たちのパターンは、おおむねLiveAlpineと呼べるものです。
コンポーネントにHTMLが必要ならHTML Componentを使い、サーバー側の状態があればLive Componentを使います。
クライアント側の動作や状態が必要ならAlpineコンポーネントも定義します。
サーバーでクライアントイベントを受け取ったりHTTPリクエストを行ったりする必要があれば、Phoenix Hookも定義します。