Help us understand the problem. What is going on with this article?

作って学ぶPhoenix LiveView、チャットアプリの作成

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(ログインユーザー)を渡しています。

index.html.eex
<div>
  <%= live_render(@conn, TomboChatWeb.Rooms,
   session: %{"current_user" => @conn.assigns.current_user}) %>
</div>

チャットルームの表示(live_render)/選択(phx-click)

live_renderの第二引数で渡したTomboChatWeb.Roomsモジュールが以下です。

rooms.ex
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

チャットルームの表示

  1. LiveViewモジュールはまずmountを実行し、その後renderを実行します。
  2. render_roomでLiveViewのモジュール内からさらにLiveViewモジュールを呼び出し、
    キャプチャの右ペイン(Messages, Members)を表示しています。
rooms.ex
      <div class="column is-10">
        <%= render_room(@socket, @room) %>
      </div>

live_renderでレンダリング対象を切り替える場合の注意点は「live_renderはidを変更しないと再マウントしない」です。
一意となるidを付与する必要があります。

rooms.ex
  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で受け取ります。メッセージを投げて、受け取るように書くことができます。

クライアントでクリック

rooms.ex
        <%= for room <- @rooms do %>
          <li phx-click="room_id_<%= room.id %>">
            <%= room_name_tag(@room, room) %>
          </li>
        <% end %>

サーバーでイベントハンドリング

rooms.ex
  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を使わずシンプルにできました。

rooms.ex
          <li phx-click="room_id_<%= room.id %>">
            <%= room_name_tag(@room, room) %>
          </li>
rooms.ex
  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モジュールが以下です。

room.ex
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

これはごちゃごちゃしています:sweat_smile:
シンプルにするのが今後の課題です。

チャットメッセージの送受信(phx-submit、handle_event、broadcast、handle_info)

textareaに文字を入力し、submitボタンをクリックするとphx-submitイベントでチャットメッセージが送信されます。

room.ex
          <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で受けとることができます。

room.ex
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しているため、

room.ex
  def mount(
        _params,
        %{"room" => room, "current_user" => current_user} = _session,
        socket) do
    TomboChatWeb.Endpoint.subscribe(topic(room.id))
    # 略
  end

チャットルームに接続しているクライアントは、これをhandle_infoで受けとることができます。

room.ex
  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が全て再描画されてしまい非効率です。

room.ex
          <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も同時に指定すると、

room.ex
  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を使います。

room.ex
          <div id="messages" phx-update="append" phx-hook="RoomMessages" style="height: 360px; overflow: auto;">
          <!---->
          </div>

phx-hookはDOMが変化した際に呼び出されるフックです。
使用するためには予めフック時の動作をLiveSocketを生成する際に登録しておく必要があります。

app.js
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を呼び出すことで追跡がはじまり、

room.ex
  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を更新することができます。

room.ex
  # presenceに変化があると発行されるメッセージ("presence_diff")のコールバック
  def handle_info(
        %{event: "presence_diff"},
        %{assigns: %{room: room}} = socket) do
    {:noreply, assign(socket, users: list_presence(topic(room.id)))}
  end
room.ex
  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の使い方が参考になりました。

また、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の基本機能の一部を紹介しました。
この記事を読んで、これなら作れると思っていただけたら幸いです。

「いいね」よろしくお願いします。:wink:

fukuokaex
エンジニア/企業向けにElixirプロダクト開発・SI案件開発を支援する福岡のコミュニティ
https://fukuokaex.fun/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした