Phoenix LiveViewを学ぶためにチャットアプリを作ったので紹介します。
ソース https://gitlab.com/pojiro/tombo-chat/tree/qiita_liveview_article左側ペインのRoomsの部屋を選択すると、右側ペインのMessagesとMembersがその部屋のものになるというシンプルな作りです。
※画面レイアウトは「Build a chat app with Vue」のように作りました。
※CSSフレームワークはBulmaを使っています。
LiveView機能の使用/未使用一覧
このチャットアプリは以下の機能を使用しています。
各機能について使用箇所とその使い方を以降で説明していきます。
FEATURES/BINDING | ATTRIBUTES | 使用 |
---|---|---|
live macro | × | |
live_component, live_render | ○ | |
Params | phx-value-* | × |
Click Events | phx-click | ○ |
Focus/Blur Events | phx-blur, phx-focus, phx-target | × |
Form Events | phx-change, phx-submit, data-phx-error-for, phx-disable-with | ○ |
Key Events | phx-keydown, phx-keyup, phx-target | × |
Rate Limiting | phx-debounce, phx-throttle | × |
Custom DOM Patching | phx-update | ○ |
Temporary assigns | ○ | |
JS Interop | phx-hook | ○ |
Live navigation | × | |
Presence | ○ |
ページの表示(live_render)
live_renderを使って、LiveViewモジュールをレンダリングできます。
connの値等はsessionを介して、LiveViewのモジュールに渡すことができます。
以下ではcurrent_user(ログインユーザー)を渡しています。
<div>
<%= live_render(@conn, TomboChatWeb.Rooms,
session: %{"current_user" => @conn.assigns.current_user}) %>
</div>
チャットルームの表示(live_render)/選択(phx-click)
live_renderの第二引数で渡したTomboChatWeb.Roomsモジュールが以下です。
defmodule TomboChatWeb.Rooms do
use Phoenix.LiveView
use Phoenix.HTML
alias TomboChat.Spaces
def render(assigns) do
~L"""
<div class="columns">
<div class="column is-2">
<p class="title is-4">Rooms</p>
<hr>
<ul class="menu-list">
<%= for room <- @rooms do %>
<li phx-click="room_id_<%= room.id %>">
<%= room_name_tag(@room, room) %>
</li>
<% end %>
</ul>
</div>
<div class="column is-10">
<%= render_room(@socket, @room) %>
</div>
</div>
"""
end
defp room_name_tag(room, room), do: content_tag(:a, "# #{room.name}", class: "is-active")
defp room_name_tag(_, room), do: content_tag(:a, "# #{room.name}")
defp render_room(socket, room) do
live_render(socket, TomboChatWeb.Room,
id: room.id, # live_renderはidを変更しないと再マウントしない
session: %{"room" => room, "current_user" => socket.assigns.current_user})
end
def mount(_params, session, socket) do
{:ok,
assign(socket,
rooms: Spaces.list_rooms(),
room: Spaces.get_room_by(%{roomname: "lobby"}),
current_user: session["current_user"]
)
}
end
def handle_event("room_id_" <> id, _params, %{assigns: _assigns} = socket) do
{:noreply, assign(socket, room: Spaces.get_room(String.to_integer(id)))}
end
end
チャットルームの表示
- LiveViewモジュールはまずmountを実行し、その後renderを実行します。
- render_roomでLiveViewのモジュール内からさらにLiveViewモジュールを呼び出し、
キャプチャの右ペイン(Messages, Members)を表示しています。
<div class="column is-10">
<%= render_room(@socket, @room) %>
</div>
live_renderでレンダリング対象を切り替える場合の注意点は「live_renderはidを変更しないと再マウントしない」です。
一意となるidを付与する必要があります。
defp render_room(socket, room) do
live_render(socket, TomboChatWeb.Room,
id: room.id, # live_renderはidを変更しないと再マウントしない
session: %{"room" => room, "current_user" => socket.assigns.current_user})
end
チャットルームの選択
チャットルーム名にはphx-clickがバインドされており、クライアント(ブラウザ)でクリックするとイベントが発生します。
これをサーバーのhandle_eventで受け取ります。メッセージを投げて、受け取るように書くことができます。
クライアントでクリック
<%= for room <- @rooms do %>
<li phx-click="room_id_<%= room.id %>">
<%= room_name_tag(@room, room) %>
</li>
<% end %>
サーバーでイベントハンドリング
def handle_event("room_id_" <> id, _params, %{assigns: _assigns} = socket) do
{:noreply, assign(socket, room: Spaces.get_room(String.to_integer(id)))}
end
socketはステートの保管場所として使います。
assign(socket, room: Spaces.get_room(String.to_integer(id)))でクリックしたチャットルームに更新します。
classの動的な付与
アクティブになったチャットルーム名にはclassを付与して表示を変更しています。
タグを生成する関数をroom_name_tagとして切り出し、パターンマッチで条件分岐を行うことでifを使わずシンプルにできました。
<li phx-click="room_id_<%= room.id %>">
<%= room_name_tag(@room, room) %>
</li>
defp room_name_tag(room, room), do: content_tag(:a, "# #{room.name}", class: "is-active")
defp room_name_tag(_, room), do: content_tag(:a, "# #{room.name}")
チャットルームの実装
render_roomでレンダリングするチャットルームのLiveViewモジュールが以下です。
defmodule TomboChatWeb.Room do
use Phoenix.LiveView
use Phoenix.HTML
import TomboChatWeb.ErrorHelpers, only: [error_tag: 2]
alias TomboChat.Spaces
alias TomboChat.Messages
alias TomboChatWeb.Presence
alias TomboChatWeb.Endpoint
# render receive socket.assigns
def render(assigns) do
~L"""
<div class="columns">
<div class="column is-10">
<p class="title is-4">Messages@<%= @room.name %></p>
<div id="messages" phx-update="append" phx-hook="RoomMessages" style="height: 360px; overflow: auto;">
<%= for m <- @messages do %>
<div id="message-<%= m.id %>" class="message" style="margin: 0em 0em 0.5em 0em;">
<div class="message-body">
<p><span>@<%= m.user.name %>: <%= m.body %></span></p>
<p style="float: right;">
<time datetime="<%= shift_naive_datetime(m.inserted_at) %>"><%= shift_naive_datetime(m.inserted_at) %></time>
</p>
</div>
</div>
<% end %>
</div>
<div>
<%= form_for @message_changeset, "#", [phx_submit: :submit], fn f -> %>
<%= textarea f, :body, placeholder: "Enter Message", class: "textarea", rows: "1" %>
<%= error_tag f, :body %>
<%= submit "Submit", class: "button is-success", style: "float: right;" %>
<% end %>
</div>
</div>
<div class="column is-2">
<p class="title is-4">Members</p>
<hr>
<div class="list">
<%= for user <- @users do %>
<p class="list-item">
@<%= user.name %>(<%= user.count %>)
</p>
<% end %>
</div>
</div>
</div>
"""
end
defp topic(room_id), do: "room:#{room_id}"
defp list_presence(topic) do
Presence.list(topic)
|> Enum.map(
fn {_user_id, %{metas: list, user: user} = _data} ->
%{name: user.name, count: Enum.count(list)}
end
)
end
def shift_naive_datetime(%NaiveDateTime{} = datetime) do
datetime
|> NaiveDateTime.add(3600*9)
|> NaiveDateTime.to_string()
end
def mount(
_params,
%{"room" => room, "current_user" => current_user} = _session,
socket) do
TomboChatWeb.Endpoint.subscribe(topic(room.id))
{:ok, _ref} = Presence.track(
self(), # pid
topic(room.id), # topic
current_user.id, # key: tracked presences are grouped by key, cast as a string
%{} # meta
)
{:ok,
assign(socket,
room: room,
current_user: current_user,
message_changeset: Messages.change_message(),
messages: Messages.list_messages(room),
users: []
),
temporary_assigns: [messages: []]}
end
# presenceに変化があると発行されるメッセージ("presence_diff")のコールバック
def handle_info(
%{event: "presence_diff"},
%{assigns: %{room: room}} = socket) do
{:noreply, assign(socket, users: list_presence(topic(room.id)))}
end
def handle_info(
%{event: "new_message", payload: new_message,
topic: _}, socket) do
socket = assign(socket, messages: [new_message])
{:noreply, socket}
end
def handle_event(
"submit",
%{"message" => message} = _payload,
%{assigns: %{room: room, current_user: current_user}} = socket) do
case Messages.create_message(room.id, current_user.id, message) do
{:ok, message} ->
Endpoint.broadcast!(
topic(room.id), "new_message", Messages.preload(message, :user))
{:noreply, socket}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, message_changeset: changeset)}
end
end
end
これはごちゃごちゃしています
シンプルにするのが今後の課題です。
チャットメッセージの送受信(phx-submit、handle_event、broadcast、handle_info)
textareaに文字を入力し、submitボタンをクリックするとphx-submitイベントでチャットメッセージが送信されます。
<div>
<%= form_for @message_changeset, "#", [phx_submit: :submit], fn f -> %>
<%= textarea f, :body, placeholder: "Enter Message", class: "textarea", rows: "1" %>
<%= error_tag f, :body %>
<%= submit "Submit", class: "button is-success", style: "float: right;" %>
<% end %>
</div>
phx-clickと同じようにhandle_eventで受けとることができます。
def handle_event(
"submit",
%{"message" => message} = _payload,
%{assigns: %{room: room, current_user: current_user}} = socket) do
case Messages.create_message(room.id, current_user.id, message) do
{:ok, message} ->
Endpoint.broadcast!(
topic(room.id), "new_message", Messages.preload(message, :user))
{:noreply, socket}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, message_changeset: changeset)}
end
end
チャットメッセージはMessages.create_message(room.id, current_user.id, message)でDBに保存した後、
topicにEndpoint.broadcast!します。
チャットルームマウント時にtopicをsubscribeしているため、
def mount(
_params,
%{"room" => room, "current_user" => current_user} = _session,
socket) do
TomboChatWeb.Endpoint.subscribe(topic(room.id))
# 略
end
チャットルームに接続しているクライアントは、これをhandle_infoで受けとることができます。
def handle_info(
%{event: "new_message", payload: new_message,
topic: _}, socket) do
socket = assign(socket, messages: [new_message])
{:noreply, socket}
end
チャットメッセージの画面への反映(phx-update、temporary_assigns)
socketにアサインされたmessagesが変化するとforで回しているmessageが全て再描画されてしまい非効率です。
<div id="messages" phx-update="append" phx-hook="RoomMessages" style="height: 360px; overflow: auto;">
<%= for m <- @messages do %>
<div id="message-<%= m.id %>" class="message" style="margin: 0em 0em 0.5em 0em;">
<div class="message-body">
<p><span>@<%= m.user.name %>: <%= m.body %></span></p>
<p style="float: right;">
<time datetime="<%= shift_naive_datetime(m.inserted_at) %>"><%= shift_naive_datetime(m.inserted_at) %></time>
</p>
</div>
</div>
<% end %>
</div>
これを回避するためにphx-updateとtemprary_assignsを使うことができます。
マウント時にチャットルームに紐づくチャットメッセージをsocketにアサインしますが、temporary_assignsも同時に指定すると、
def mount(
_params,
%{"room" => room, "current_user" => current_user} = _session,
socket) do
# 略
{:ok,
assign(socket,
room: room,
current_user: current_user,
message_changeset: Messages.change_message(),
messages: Messages.list_messages(room),
users: []
),
temporary_assigns: [messages: []]} # ココ
end
前節のhandle_infoで受け取ったnew_messageのみがDOMとして追加(phx-update="append")されるようになります。
注意点はid="message-<%= m.id %>"を付与し、どこにappendすればよいか識別できるようにすることです。
※id付与を忘れてもそれっぽいエラーメッセージが出るの気付けると思います。
チャットメッセージを受信したらスクロール(phx-hook)
ここまでJavaScriptに触れませんでしたが、ここで触れます。
messageがphx-update="append"で追加されたら、そのmessageが表示されるように自動でスクロールさせたいです。
これを実現するためにphx-hookを使います。
<div id="messages" phx-update="append" phx-hook="RoomMessages" style="height: 360px; overflow: auto;">
<!--略-->
</div>
phx-hookはDOMが変化した際に呼び出されるフックです。
使用するためには予めフック時の動作をLiveSocketを生成する際に登録しておく必要があります。
import "phoenix_html"
let Hooks = {}
Hooks.RoomMessages = {
mounted() {
this.el.scrollTop = this.el.scrollHeight
},
updated() {
this.el.scrollTop = this.el.scrollHeight
}
}
// Enable connecting to a LiveView socket
import {Socket} from "phoenix"
import LiveSocket from "phoenix_live_view"
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content");
let liveSocket = new LiveSocket("/live", Socket, {params: {_csrf_token: csrfToken}, hooks: Hooks})
liveSocket.connect()
これにより新規メッセージがappendされた際にupdated()が動作します。
チャットルームにいるメンバー(Phoenix.Presence)
チャットルームにいるメンバーを表示するためにPresenceを使っています。
マウント時にPresence.tracを呼び出すことで追跡がはじまり、
def mount(
_params,
%{"room" => room, "current_user" => current_user} = _session,
socket) do
# 略
{:ok, _ref} = Presence.track(
self(), # pid
topic(room.id), # topic
current_user.id, # key: tracked presences are grouped by key, cast as a string
%{} # meta
)
# 略
end
変化があると%{event: "presence_diff"}が発行されます。
それをhandle_infoで受け、list_presenseでsocketのusersを更新することができます。
# presenceに変化があると発行されるメッセージ("presence_diff")のコールバック
def handle_info(
%{event: "presence_diff"},
%{assigns: %{room: room}} = socket) do
{:noreply, assign(socket, users: list_presence(topic(room.id)))}
end
defp list_presence(topic) do
Presence.list(topic)
|> Enum.map(
fn {_user_id, %{metas: list, user: user} = _data} ->
%{name: user.name, count: Enum.count(list)}
end
)
end
このチャットアプリではuser_idでpresenceをグループ化しているので、
同じユーザがセッションが別のブラウザや端末でアクセスすると名前の右側の数値が接続数として表示できます。
※これはProgramming Phoenixで学びました。
おわり
以上で、チャットアプリで使用したLiveView機能の紹介はおわりです。
チャットアプリはLiveViewサンプルアプリの定番
かも?しれません。
作った感触としてチャットアプリはLiveViewを理解するのに良い題材でした。
以下の実装例もありLiveViewの使い方が参考になりました。
- Tracking Users in a Chat App with LiveView, PubSub Presence
- LiveViewで認証付きのチャットアプリを構築する
- ElixirとPhoenixでWebSocketを使ったChatアプリケーションを作る
また、LiveViewアプリケーションのサンプルはLiveView demos, examples, and sample apps thread!に豊富にあり、参考にすることができます。
LiveViewが目指す所
Kill Your JavaScript
はWalk-Through of Phoenix LiveViewに書かれていることですが、
実際に触ってみるとユーザー開発者に極力JavaScriptを書かさせないという意思を感じました。
Phoenix LiveViewができるかぎりJavaScriptを隠蔽するため、
ユーザー開発者はHTMLとElixirを書くだけで済むようになっています。
そうはいっても、スクロールで見たようにJavaScriptゼロはむずかしいのかなぁというのが現在の感想です。
LiveViewは発展途上なのでこれからも要チェックです。
LiveViewアプリを作ろう!
おおざっぱですが、LiveViewの基本機能の一部を紹介しました。
この記事を読んで、これなら作れると思っていただけたら幸いです。
**「いいね」**よろしくお願いします。