Phoenix LiveViewでインタラクティブなアプリを構築する
Phoenix LiveViewで検索アプリを構築し、サーバー側の状態、HEExテンプレート、イベント、JavaScriptフックを実装。適した用途と限界も解説します。
Phoenix LiveViewは、HTMLをサーバー側でレンダリングし、各ページの状態を長時間稼働するElixirプロセス内に保持します。そして、永続的な接続を通じて、ページのうち変更された部分だけをブラウザに送信します。
ReactやVueでCRUD画面を構築した経験があれば、お決まりの手順をご存じでしょう。JSONエンドポイントを用意し、クライアント側のストアを作り、ローディング状態を管理し、バリデーションをサーバーとクライアントの双方に書きます。こうしたコードの大半は、同じ状態の2つのコピー間でデータを受け渡しているだけです。
本記事では、この種のアプリでLiveViewがどのようにAPIレイヤーを不要にするのかを解説します。LiveView 1.2の構文でライブ検索ボックスを構築し、クライアント側のJavaScriptを1つ追加したうえで、このモデルが適さなくなるケースも確認します。
重要なポイント
- LiveViewはUIの状態をサーバープロセス内に保持します。ブラウザのイベントはWebSocket経由で送られ、LiveViewは変更されたHTMLだけを返します。
- LiveViewの初回レンダリングは通常のHTTPレスポンスであり、WebSocketが接続されると
mount/3が再度実行されます。 - 基本的なLiveViewに必要なコールバックは3つです。
mount/3で初期状態を設定し、render/1でHEExテンプレートを返し、handle_event/3でphx-clickやphx-changeなどのバインディングに応じて状態を更新します。 - コロケーテッドフック(colocated hook)を使うと、コンポーネントのJavaScriptをマークアップのすぐ隣の
<script>タグに記述できます。Phoenix 1.8以降が必要です。 - サーバーで処理されるイベントは、すべてネットワークの往復を伴います。オフラインファーストのアプリや、クライアント側の操作が多いUIでは、依然としてReactやVueのほうが適しています。
Phoenix LiveViewは何をするのか?
Phoenix LiveViewは、状態管理、イベント処理、レンダリングをブラウザからサーバーへ移し、ブラウザ側は更新を受け取るためのライブ接続を開いたままにします。LiveViewのドキュメントでは、各LiveViewを1つのプロセスとして説明しています。プロセスはイベントを受け取り、状態を変更し、差分のある部分だけをページに送信します。
おなじみのSPAモデルに対応させると、次のようになります。
| SPAの概念 | LiveViewでの対応 |
|---|---|
コンポーネントの状態(useState、ref) | サーバー側ソケットのassigns |
イベントハンドラー(onClick) | handle_event/3コールバック |
| 仮想DOMのパッチ | WebSocket経由でプッシュされるHTMLの差分 |
| データ取得のためのAPI呼び出し | 同一プロセス内での関数呼び出し |
ライフサイクルは2つのフェーズで進みます。初回レンダリングは通常のHTTPレスポンスなので、JavaScriptが読み込まれる前からページが表示され、検索エンジンにもインデックスされます。その後ブラウザがWebSocketを開き、ページが開かれている間ずっと稼働し続けるプロセス内でmount/3が再度実行されます。つまり、mount/3内のコードはページの読み込みごとに2回実行されます。
htmxを使ったことがあれば、サーバーでレンダリングしたHTMLでUIを駆動するという考え方には馴染みがあるでしょう。違いは、LiveViewが接続中のページごとにサーバー上でステートフルなプロセスも保持する点です。
Phoenixプロジェクトを作成し、ライブルートを追加するには?
新規のPhoenix 1.8アプリにはLiveViewがあらかじめセットアップされているため、ジェネレーターコマンドを1つ実行するだけで、この例に必要なものがすべて揃います。ElixirとPhoenixインストーラーをインストールしたら、次を実行します。
mix phx.new my_app --no-ecto
cd my_app
mix phx.server
--no-ectoフラグを指定すると、デモには不要なデータベースレイヤーが省略されます。開発サーバーはhttp://localhost:4000で待ち受けます。--liveフラグは不要です。古いチュートリアルではこのフラグを指定していますが、現在ではLiveViewは生成されるすべてのアプリに含まれています。
lib/my_app_web/router.exを開きます。既存のscope "/", MyAppWeb doブロック内のpipe_through :browserの下に、次を追加します。
live "/search", SearchLive
liveマクロは、getがパスをコントローラーに対応付けるのと同じように、パスをLiveViewモジュールに対応付けます。MyAppとなっている箇所は、ご自身のアプリ名に置き換えてください。Phoenixはジェネレーター、認証のスキャフォールディング、LiveViewを標準で備えており、いわゆる「バッテリー同梱」型のフレームワークです。
Phoenix LiveViewでライブ検索ボックスを構築するには?
ライブ検索ボックスに必要なのは、入力に応じて反応するフォームバインディングと、状態をリセットするクリックバインディングの2つです。lib/my_app_web/live/search_live.exを作成します。
defmodule MyAppWeb.SearchLive do
use MyAppWeb, :live_view
def mount(_params, _session, socket) do
{:ok, assign(socket, query: "", results: frameworks())}
end
def render(assigns) do
~H"""
<form phx-change="search" phx-submit="search">
<input
id="search-input"
type="text"
name="q"
value={@query}
placeholder="Filter frameworks"
autocomplete="off"
phx-debounce="300"
/>
</form>
<button phx-click="clear">Clear</button>
<p>{length(@results)} matches</p>
<ul>
<li :for={name <- @results}>{name}</li>
</ul>
"""
end
def handle_event("search", %{"q" => q}, socket) do
{:noreply,
socket
|> assign(:query, q)
|> assign(:results, filter(q))}
end
def handle_event("clear", _params, socket) do
{:noreply, assign(socket, query: "", results: frameworks())}
end
defp frameworks do
["Phoenix", "Rails", "Laravel", "Django", "Next.js", "Nuxt", "SvelteKit", "Remix"]
end
defp filter(q) do
needle = String.downcase(q)
Enum.filter(frameworks(), &String.contains?(String.downcase(&1), needle))
end
end
mount/3はparams、session、socketをこの順で受け取り、初期assignsを設定したsocketを返します。render/1は~Hシジルで構築したHEExテンプレートを返します。phx-change、phx-submit、phx-clickはいずれも名前付きイベントをhandle_event/3に送信します。handle_event/3はイベント名でマッチングを行い、更新したassignsとともに{:noreply, socket}を返します。これを受けてLiveViewが、影響を受けたHTMLの差分をプッシュします。
phx-changeはユーザーの入力に合わせて発火するため、このままではキー入力のたびに往復通信が発生します。phx-debounceはこれをレート制限するもので、ミリ秒単位の数値または"blur"を指定できます。phx-submitを同じイベントに向けておくと、ブラウザがフォームをPOSTする代わりにLiveViewがEnterキーを処理します。フォームバインディングのガイドにあるとおり、フォームのparamsは%{"q" => "pho"}のように、input名をキーとする文字列キーのマップとして届きます。
テンプレートでは{@query}で値を埋め込みます。<%= @query %>を使っているチュートリアルは、現在の構文が導入される前のものです。:for属性はHEExにおける内包表記(comprehension)の省略記法です。
このファイルを理解するのに必要なElixirの知識は次のとおりです。
- アトム:
:ok、:noreply、:queryなどは名前付き定数で、文字列のenumに近いものです。 - タプル:
{:noreply, socket}のような、固定長の値のまとまりです。コールバックは処理結果を伝えるためにタプルを返します。 - パターンマッチングによる関数ヘッド:2つの
handle_event/3節はそれぞれ別の定義であり、Elixirは第1引数がイベント名にマッチする節を実行します。%{"q" => q}は、JavaScriptのconst { q } = paramsと同じようにparamsを分割代入します。 - パイプ:
|>は、左辺の値を次の関数呼び出しの第1引数として渡します。 - イミュータビリティ:
assignは新しいsocketを返します。既存の値がその場で変更されることはありません。
コロケーテッドフックでクライアント側のJavaScriptを追加するには?
コロケーテッドフックを使うと、コンポーネントのJavaScriptをマークアップのすぐ隣の<script>タグに記述できます。フック名はドットで始め、フックをアタッチする要素には一意のDOM idを付ける必要があります。フックAPIの詳細はJSインターオペラビリティガイドを参照してください。
以下の例では、キーボードショートカットを追加します。/で検索フィールドにフォーカスし、Escapeでクリアします。render/1内の<input>を次のように置き換えます。
<input id="search-input" type="text" name="q" value={@query}
placeholder="Filter frameworks" autocomplete="off"
phx-debounce="300"
phx-hook=".SearchShortcuts" />
<script :type={Phoenix.LiveView.ColocatedHook} name=".SearchShortcuts">
export default {
mounted() {
this.onKey = (e) => {
if (e.key === "/" && document.activeElement !== this.el) {
e.preventDefault()
this.el.focus()
}
if (e.key === "Escape" && document.activeElement === this.el) {
this.el.value = ""
this.el.blur()
this.pushEvent("clear", {})
}
}
window.addEventListener("keydown", this.onKey)
},
destroyed() {
window.removeEventListener("keydown", this.onKey)
}
}
</script>
mountedは要素がページに追加されたときに、destroyedは要素が削除されたときに実行されます。そのため、リスナーは一度だけ登録され、その後きちんとクリーンアップされます。pushEvent("clear", {})はボタンと同じhandle_event("clear", ...)節をトリガーするため、クライアントとサーバーで単一のコードパスを共有できます。イベントをプッシュする前にフィールドをクリアしてフォーカスを外すことで、表示上の値とサーバーの状態の整合性が保たれます。
ColocatedHookモジュールは、コンパイル時にスクリプトを抽出します。ドットで始まる名前はモジュール名で名前空間が付与されます。この機能にはPhoenix 1.8以降が必要です。mix phx.newで生成されるapp.jsでは、これらのフックがすでに登録されています。
import {hooks as colocatedHooks} from "phoenix-colocated/my_app"
// ...
hooks: {...colocatedHooks}
LiveViewが適するケース、適さないケースは?
handle_event/3が処理するイベントは、すべてサーバーとの往復通信を伴います。LiveViewは高速なネットワークでは即座に反応しますが、低速なネットワークでは動作がもたつきます。ドロップダウンを開くといった純粋に見た目だけの変更は、Phoenix.LiveView.JSコマンドを使えばサーバーに到達させずにブラウザ内で実行できます。
localhostではこのレイテンシーが見えません。mix phx.newで生成されるassets/js/app.jsでは、ソケットがすでにwindow.liveSocketとして公開されているので、低速な接続をシミュレートするには、ブラウザのコンソールで次を実行します。
liveSocket.enableLatencySim(500)
// later
liveSocket.disableLatencySim()
レイテンシーシミュレーターは、渡したミリ秒数をサーバーとの片道レイテンシーとして上乗せします。本番環境で低速な接続を使うLiveViewアプリのセッションリプレイを見ると、ユーザー側でも同じ問題が確認できます。入力が打鍵に追いつかなかったり、ページの切断・再接続時に画面がちらついたりします。
LiveViewにはさらに2つのコストがあります。イベントの処理にはサーバーとのライブ接続が必要なため、オフライン対応が困難です。また、本番環境でElixirを運用する必要があり、接続中のページごとに1つのプロセスが稼働します。
| 状況 | 適した選択肢 | 理由 |
|---|---|---|
| CRUD画面、ダッシュボード、フォーム、管理ツール | LiveView | 独立したAPIレイヤーが不要で、状態とレンダリングがサーバー側で完結する |
| オフラインファーストのアプリや接続環境が悪いアプリ | React / Vue | LiveViewはイベント処理にライブ接続を必要とする |
| クライアント側の操作が多いUI(エディター、Canvas、ドラッグ操作中心のUI) | React / Vue | 操作のたびに往復通信が発生する。フックで補えるが、クライアントフレームワークの代わりにはならない |
| Elixirの導入に消極的なチーム | React / Vue | サーバー上でElixirを運用・保守する必要がある |
サーバー駆動型UIとクライアント駆動型UIのトレードオフをより広い視点で知りたい場合は、こちらのhtmxとVue・Reactの比較をご覧ください。
まとめ
LiveViewは、本来ならJSON APIとクライアント側のストアが必要になるCRUD型のアプリに適しています。コールバックを1つのモジュールに記述し、状態はサーバーが保持し、ブラウザで実行すべき処理はフックやJSコマンドでカバーします。次のステップとしては、Phoenix 1.8アプリを生成し、現在のSPAのフォームを1つLiveViewで作り直してみるとよいでしょう。本格的に採用する前に、レイテンシーシミュレーターを有効にして、低速な接続でどのように動作するかを確認してください。
よくある質問
LiveViewのWebSocket接続が切れるとどうなりますか?
クライアントは指数バックオフで自動的に再接続し、mount/3が再度実行されます。そのため、mount/3で再構築されないassignsは失われます。id属性とphx-changeバインディングの両方を持つフォームは自動的に復元されます。再接続後、LiveViewが現在のフォームの値をphx-changeイベント経由で再送信するためです。別の復元用イベントをトリガーしたい場合はphx-auto-recoverを追加し、復元を無効にしたい場合は「ignore」を指定します。
mount/3内のコードが2回実行されるのを防ぐにはどうすればよいですか?
connected?(socket)のチェックで囲みます。Phoenix.LiveView.connected?/1は、静的なHTTPレンダリング中はfalseを、WebSocketが接続されるとtrueを返します。したがって、if connected?(socket)ブロック内に書いたサブスクリプション、タイマー、負荷の高いクエリはライブプロセスでのみ実行されます。静的レンダリングでも完全でインデックス可能なHTMLが生成されるよう、軽量なデフォルトのassignsはチェックの外に置いてください。
ブラウザを使わずにLiveViewをテストするにはどうすればよいですか?
LiveViewに同梱され、ExUnit上で動作するPhoenix.LiveViewTestを使います。live(conn, path)を呼び出してビューをマウントし、ビューのハンドルを取得したら、render_clickやrender_changeなどのヘルパーで操作し、返されたHTMLに対してアサーションを行います。これらのテストはLiveViewプロセスと直接やり取りするため、クライアント側のフックのJavaScriptは実行されません。
関数コンポーネントとLiveComponentの違いは何ですか?
関数コンポーネントはassignsを受け取ってHEExを返すステートレスな関数であり、そのマークアップから発生したイベントは親のLiveViewに送られます。一方、use Phoenix.LiveComponentで定義するLiveComponentは独自の状態を持ち、マークアップでphx-target={@myself}が指定されている場合は自身でイベントを処理します。どちらも親のLiveViewプロセス内で実行されます。基本的には関数コンポーネントを使い、状態とイベント処理をコンポーネント内に閉じ込める必要がある場合にのみLiveComponentを選択してください。
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.
Star on GitHub12k