Open45

Programming Phoenix LiveView 読書メモ

chapter1, chapter2でLiveViewの全体像の説明、コード生成などの話。chapter3以降はゲームのサンプルを作るなどより実践って感じかな。

Pento という架空の会社、システムを題材にシステム構築が進むみたい。

phoenixのv1.5ベースで書かれてある。p.10に mix phx.new pento --live とあるが、v1.6では --live はなく逆に --no-live になった。デフォルトがLiveViewベースで、いらない場合に --no-liveをつける。

「LiveViewでの状態管理は Phoenix.LiveView.Socket 構造体で管理されるからまずそれを見ていくで」という流れ

iex(1)> h Phoenix.LiveView.Socket

                            Phoenix.LiveView.Socket

The LiveView socket for Phoenix Endpoints.

This is typically mounted directly in your endpoint.

    socket "/live", Phoenix.LiveView.Socket

iex(2)> Phoenix.LiveView.Socket.__struct__
#Phoenix.LiveView.Socket<
  assigns: %{__changed__: %{}},
  endpoint: nil,
  id: nil,
  parent_pid: nil,
  root_pid: nil,
  router: nil,
  transport_pid: nil,
  view: nil,
  ...
>
  • assigns が何より重要
  • 画面の状態を保持するmap
  • Router -> Index.mount -> Index.render が基本の流れ
  • mountで初期化が行われる
  • routerで `live/3`関数を使って "live route" を定義できる
  • 通常の(?) getやpostと別で、LiveView用のルーティングを定義する

「以下のコードができる」みたいな説明があるけど、v1.6ではなくなっている。

scope "/", PentoWeb do pipe_through :browser
  live "/", PageLive, :index

フォーラムにもそのやりとりがあった

https://elixirforum.com/t/the-live-option-phoenix-1-6-0-rc-does-not-seem-to-work-properly/42043

v1.5ベースなので .leex で記述が進む。v1.6では .heex になったので読み替えていく必要あり

全体の流れ

  • 初回アクセス時にmount -> render
  • ページ全体がrenderingされる
    • いわゆるSSR(サーバーサイドレンダリング)といえばいいのかな
  • WebSocket接続が開始される
  • あとはひたすら以下ループ
    • フロントからのイベント受信(click, submit, scroll, file upload...)
    • stateの更新
    • 更新したstateをもとに再度render

この書き方もv1.5版。

def render(assigns) do 
  ~L"""
    <h1>Your score: <%= @score %></h1>
    <h2>
      <%= @message %>
    </h2>
    <h2>
      <%= for n <- 1..10 do %>
        <a href="#" phx-click="guess" phx-value-number="<%= n %>"><%= n %></a>
      <% end %>
  </h2>
"""
end

Phoenix v1.6では ~H を使う。これで動いた。タグの属性で変数展開する場合は <%= %> ではなくて { variable } の書き方をするっぽい

def render(assigns) do
  ~H"""
  <h1>Your score: <%= @score %></h1>
    <h2>
    <%= @message %>
    </h2>
    <h2>
    <%= for n <- 1..10 do %>
      <a href="#" phx-click="guess" phx-value-number={ n }><%= n %></a>
    <% end %>
    </h2>
  """
end

@messagesocket.assigns.message の省略記法。 @ がついてたらsocketから取ってきてると思えばヨシか

templateの現在時刻を表示する関数を生やして再度ページを見ても、変更が加わるのは一度だけ。LiveViewは変更が必要なDOMのみ再描画するため効率的。なるほど。

def render(assigns) do
  ~H"""
  <h1>Your score: <%= @score %></h1>
    <h2>
    <%= @message %>
    It's <%= time() %>
    </h2>
    <h2>
    <%= for n <- 1..10 do %>
      <a href="#" phx-click="guess" phx-value-number={n}><%= n %></a>
    <% end %>
    </h2>
  """
end

def time() do
  DateTime.utc_now |> to_string
end

Chapter2ではLiveViewではAuthenticationをどう扱うかやっていく

認証の本題に入る前に、実装パターンのお話。CRC(Constructors, Reducers, Converters)のパターンを覚えようとのこと。

  • Constructors: 適切な入力からコアの型を作成
  • Reducers: その型のまま変換
  • Converters: 別の型へ変換

実装例が載っている。

defmodule Number do
  def new(string), do: Integer.parse(string) |> elem(0)
  def add(number, addend), do: number + addend
  def to_string(number), do: Integer.to_string(number)
end

こういったパターンはデータの変換には頻出で、例えばReactだと state reducer パターンなるのがあると。

https://kentcdodds.com/blog/the-state-reducer-pattern-with-react-hooks

phoenixでいうとPlugもこのCRCパターンに該当すると。イメージのコード例が書かれてある。

connection
|> process_part_of_request(...)
|> process_part_of_request(...)
|> render()

1つ1つのplug関数はreducersであり、%Plug.Conn{} に対して Plug.Conn の構造体のままいろんなデータを付加していくイメージ。

Phoenixのデータの流れ。custom_applicationにcontrollerやLiveViewが該当する。

connection_from_request
|> endpoint
|> router
|> custom_application

1つ1つの小さなplugによってPhoenixという1つの大きな関数が出来ているとイメージすると良さげ

In order to understand how Phoenix handles web requests, and therefore how LiveView handles web requests, you can think of Phoenix requests as simply one big function broken down into smaller plugs. These plugs are stitched together, one after another, as if they were in one big pipeline.

plugの全体像の話が終わって、認証の実装へ。 phx.gen.auth を使って実装を進める。
この辺は今の最新版と同じ挙動

$ mix phx.gen.auth Accounts User users
$ mix deps.get
$ mix ecto.migrate
$ mix test

chapter2は丁寧めに phx.gen.auth の説明がある。

最終的に、ログインユーザーの情報をLiveViewで作った画面で表示するところまで進んでchapter2はおしまい。実際に動いた

Image from Gyazo

chapter3は phx.gen.live にはじまって、生成されたコードの説明が進む。Ecto.SchemaやEcto.Changesetの説明もあって丁寧

chapter3はliveviewから一旦離れてEctoの話、Contextの考え方など知識として入れておきたい話がまとまっていた

chapter4では生成されたファイルをさらに深掘り、routerからの処理の流れの解説が始まる

  • router.ex で使われているlive/4 macroはPhoenix.LiveView.Routerによって定義されている
  • phoenixプロジェクトを生成したタイミングから利用可能
  • use <App>Web, :router の記述 -> <app>_web.exrouter/0 に定義してあるマクロが展開される -> Phoenix.LiveView.Router がimportされる

この辺のマクロを活用した記述はLiveViewないときでも変わらずなところだな

https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.Router.html#live/4

live/4 の最後の引数は live action と呼ばれるらしい。controllerのactionと同じ考えで良さそう。

chapter4のコードの説明で .leex の説明が登場するが、ここも .heex に読み替える必要あり。内容自体は今とそこまで齟齬なさげ

live_patch/2 で状態遷移したタイミングでは mount/3 は呼ばれない

LiveComponent周りの話は最新だと違ってきてそうだ。この辺は書籍は参考に留めて、生成されたコードを読んだ方が良さげ

HEEXだと

  • <.modal ...> のようにViewの関数を実行できる
  • <.live_component ...> でコンポーネントを呼び出し
    • LiveViewの中に埋め込めるLiveView的なイメージ?
  • render_slot/2 を使って引数に応じてrendering結果を変える

https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.Helpers.html#render_slot/2

<.form ...> のように呼んでいるform関数はLiveView.Helpersで最初から定義されていて、 Phoenix.HTML.form_for/4 をwrapする形になっていると。

This function is built on top of Phoenix.HTML.Form.form_for/4. For more information about options and how to build inputs, see Phoenix.HTML.Form.

https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.Helpers.html#form/1

let って何?ってなったけどこの辺に書いてあるな。

render_slot/2 でレンダリングする際に渡した引数をcaller(親)側に戻すことができると。

https://hexdocs.pm/phoenix_live_view/Phoenix.Component.html#module-default-slots

実装追ってみると、 f にはHelperを介して %Phoenix.HTML.Form{} が入っていることがわかる

https://github.com/phoenixframework/phoenix_live_view/blob/v0.17.5/lib/phoenix_live_view/helpers.ex#L1047
  <.form
    let={f}
    for={@changeset}
    id="product-form"
    phx-target={@myself}
    phx-change="validate"
    phx-submit="save">
  
    <%= label f, :name %>
    <%= text_input f, :name %>
    <%= error_tag f, :name %>
  
    <%= label f, :description %>
    <%= text_input f, :description %>
    <%= error_tag f, :description %>
  
    <%= label f, :unit_price %>
    <%= number_input f, :unit_price, step: "any" %>
    <%= error_tag f, :unit_price %>
  
    <%= label f, :sku %>
    <%= number_input f, :sku %>
    <%= error_tag f, :sku %>
  
    <div>
      <%= submit "Save", phx_disable_with: "Saving..." %>
    </div>
  </.form>

ex側では push_redirect/2、 heex側では live_redirect を使うことで別LiveViewへリダイレクトできる。

patchと違い、redirectだと再度 mount/3 が呼ばれるのがポイント。保存した結果などを再度DBから取得する必要があるため、edit後やshowへの遷移はredirectで行うのが良い

生成したコマンドにそこまで手は加えず、細かい説明を加えたところでPart1は終了。Part1だけでかなり基礎は抑えられた感。

Part2 Chapter5。Ecto.Changesetを使ったリアルタイムバリデーションの実践から。応用に入っていく

File uploadの説明が入る

  • socketに対して allow_upload/3 を呼び出すと %Phoenix.LiveView.UploadConfig{}:uploads というkeyでsocketに追加される
  • 許可する拡張子や、自動でのuploadをする/しないなど、設定を持つ
  • allow_upload/3 の第一引数に指定した値を指定して、 live_file_input を実行、inputタグを生成する

file uploadまで組んでchapter5は終了。chapter6, chapter7とコンポーネントの話が続く模様

chapter6 stateless componentというお話。アンケートツールの開発を題材に進む

LiveViewに入る前にContext関数の実装を進める。Queryモジュールを別に作って各クエリの再利用性を上げていく実装。この辺はLiveView関係なくフツーに勉強になる。

defmodule Pento.Catalog.Product.Query do
  import Ecto.Query

  alias Pento.Survey.Rating
  alias Pento.Catalog.Product
  def base, do: Product

  def with_user_rating(query \\ base(), user) do
    ratings_query = Rating.Query.preload_user(user)

    query
    |> preload(ratings: ^ratings_query)
  end
end
  def list_products_with_user_ratings(user) do
    Product.Query.with_user_rating(user)
    |> Repo.all()
  end

これは重要なところだ

  • 初回アクセス時と、WebSocket接続確立時の2回 mount/3 が呼ばれる
  • mountでクエリを発行してしまうと無駄なDBアクセスに繋がるためNG
  • 初回アクセス時に Plug.Conn.assigns の値は socket.private.assign_new に格納される
  • websocket接続が確立して2回目のマウント後は socket.private.assign_newの値は利用できない
  • assign_new/3 を使うと、socketに値が無ければ第3引数に渡した関数の結果をsocketにアサインすることができる
  • ∴ ↓↓のように書けば、初回アクセス時にはPlug.Conn.assignsの値が入っているためクエリは実行されず、socket接続後に Plug.Conn.assigns の値が使えなくなる = socket.private.assign_new の値が空になっているため、クエリが実行される
    • :current_user の値がPlug.Conn.assignsに入っている前提
  def mount(_params, %{"user_token" => token} = _session, socket) do
    {:ok, socket |> assign_user(token)}
  end

  defp assign_user(socket, token) do
    assign_new(socket, :current_user, fn ->
      Accounts.get_user_by_session_token(token)
    end)
  end

componentを作成して描画するところまで進んでchapter6は完了。submitイベントのハンドリングからchapter7へ進む

作成者以外のコメントは許可されていません