エンジニアのソフトウェア的愛情

または私は如何にして心配するのを止めてプログラムを・愛する・ようになったか

Phoenix LiveView で無限スクロール

背景という名の釈明

先日から仕事で無限スクロールの処理の修正に関わっていて、「これこそ Phoenix LiveView を使えば簡単なのにな」などと感じたので、LiveView の無限スクロールの記事を書くことにしました。

記事にするコードを書き終え、あとは文章を書くだけ、というところで Elixir Forum を開いたら、Phoenix LiveView で書く無限スクロールの話題が Fly.io の「The Phoenix Files」に掲載されているのを見つけました。

fly.io

正しくは。 無限スクロールを含むチャットアプリケーションの話題なので、わたしが書こうとしていたものよりずっと充実した内容になっています。

さてどうしたものか、と三日三晩ほど思案しましたが、自分のための備忘録ぐらいにはなるだろうと観念して記事にすることにしました。 そもそもこのブログ記事は、大体は自分のための備忘録ですし。

そんなわけで無限スクロールです。

アイテムを一覧表示する、まずは手動で

まずは前段階として、アイテムを一覧表示し、続きは手動で読み込む LiveView を準備します。

lib/my_app_web/live/item_live.ex

MyApp.Item.list_items/2 でアイテムの一覧を取得し表示する単純な LiveView です。 ただし一覧の取得に時間がかかることを前提として、一覧取得の処理を Task.async/1 で実行して、実行中は「Loading...」のメッセージを表示するようにしました。

defmodule MyAppWeb.ItemLive do
  use MyAppWeb, :live_view

  alias MyApp.Item

  @impl true
  def render(assigns) do
    ~H"""
    <.header>Items</.header>

    <div id="items" class="border-t border-b divide-y" phx-update="stream">
      <div :for={{dom_id, item} <- @streams.items} id={dom_id} class="py-4">
        <%= item.content %>
      </div>
    </div>

    <div class="flex justify-center mt-4">
      <.button :if={!@loading} type="button" phx-click="load">load</.button>
      <span :if={@loading}>Loading...</span>
    </div>
    """
  end

  @impl true
  def mount(_params, _session, socket) do
    task =
      if connected?(socket) do
        start_loading_task(0)
      else
        nil
      end

    {
      :ok,
      socket
      |> stream(:items, [])
      |> assign(
        next_page: 1,
        loading: true, # ページを開いたときに読み込みを開始するので最初を true にしています
        task: task
      )
    }
  end

  @impl true
  def handle_event("load", _params, socket) do
    if socket.assigns.loading do
      # 読み込み中に load イベントを受け取ったばあいは何もしないようにします
      {:noreply, socket}
    else
      page = socket.assigns.next_page

      task = start_loading_task(page)

      {:noreply, assign(socket, next_page: page + 1, loading: true, task: task)}
    end
  end

  # タスクの結果を受け取りストリームに追加します
  @impl true
  def handle_info({ref, items}, socket) when socket.assigns.task.ref == ref do
    socket =
      items
      |> Enum.reduce(socket, &stream_insert(&2, :items, &1))
      |> assign(loading: false, task: nil)

    {:noreply, socket}
  end

  # `DOWN` などの、タスクの結果以外のメッセージをハンドルします
  def handle_info(_, socket) do
    {:noreply, socket}
  end

  defp start_loading_task(page) do
    Task.async(fn ->
      Item.list_items(page * 10, 10)
    end)
  end
end

lib/my_app/item.ex

データを取得するモジュールと関数です。 MyApp.Item.list_items/2 は、ちゃんとデータベースのインタフェースとして実装するのもよいのですが、簡単のため今回も擬似的なふるまいをするコードにしています。

defmodule MyApp.Item do
  defstruct [:id, :content]

  def new(id, content) do
    %__MODULE__{id: id, content: content}
  end

  def list_items(offset, size) do
    offset..(offset + size - 1)
    |> Enum.map(fn i ->
      Process.sleep(100) # 1 アイテムあたり取得に 100ms かかる状況を擬似的に表現
      new(i, "Item-#{i}")
    end)
  end
end

lib/my_app_web/router.ex

最後にルーティングを追加します。

     pipe_through :browser
 
     get "/", PageController, :home
+    live "/items", ItemLive
   end
 
   # Other scopes may use custom stacks.

実行

サーバを起動して、 http://localhost:4000/items にアクセスします。 次のような表示になると思います。

mix phx.server

「Load」ボタンを押すと表示が「Loading...」というメッセージに替わり、およそ1秒後にアイテムが10件追加で表示されます。

ここで、ボタンを押す代わりに、ボタンの位置がページ上に表示されるころのタイミングで自動的に読み込むようにすれば、無限スクロールを実現できるはずです。

アイテムを一覧表示する、今度は自動で

assets/js/app.js

ここで非常に残念で悲しいお知らせです。 「ボタンの位置がページ上に表示されるころのタイミングで自動的に読み込むように」するためには、今はまだ JavaScript でフックを記述しなければなりません。

残念でなりませんが、LiveView のおかげで記述しないければならない JavaScript の量を最小限にできるので、それで気を取り直して先に進ことにします。

今回、ある特定の要素がページ上に表示されたら読み込みを開始することにし、そのためのイベントの生成には「交差オブザーバー API」を利用します。

developer.mozilla.org

まず assets/js/app.js に次のコードを追加します。

const Hooks = {}
Hooks.AutoLoad = {
  mounted() {
    this.intersectionObserver = new IntersectionObserver((entries) => {
      if (entries[0].isIntersecting) {
        this.pushEvent('load')
      }
    })
    this.intersectionObserver.observe(this.el)
  },

  disconnected() {
    this.intersectionObserver.unobserve(this.el)
  }
}

次に既存のコードを修正して LiveSocket ソケットのオブジェクトを生成するときのオプションに、上で書いたフックを設定します。

 import topbar from "../vendor/topbar"
 
 let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
-let liveSocket = new LiveSocket("/live", Socket, {params: {_csrf_token: csrfToken}})
+let liveSocket = new LiveSocket("/live", Socket, {params: {_csrf_token: csrfToken}, hooks: Hooks})
 
 // Show progress bar on live navigation and form submits
 topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"})

これにより LiveView で AutoLoad のフックを設定した要素が画面上に現れると load イベントが発生するようになりました。

lib/my_app_web/live/item_live.ex

最後に LiveView を修正します。

まず、ボタンとメッセージを格納している divphx-hook="AutoLoad" を追加します。 フックを設定する要素は ID も設定されている必要があります。 今回はフックのために設定する以外では ID を利用していませんので、ここでは適当に "auto-load" という値を設定しています。

そして、イベントを発生させるボタンは不要になるので、単純なメッセージ表示に変更します。

       </div>
     </div>
 
-    <div class="flex justify-center mt-4">
+    <div id="auto-load" class="flex justify-center mt-4" phx-hook="AutoLoad">
-      <.button :if={!@loading} type="button" phx-click="load">load</.button>
+      <span :if={!@loading}>Load</span>
       <span :if={@loading}>Loading...</span>
     </div>
     """

これで無限スクロールへの書き換えは完了です。 ボタンをクリックしたときと同じイベントを利用しているので、読み込みの処理は変更する必要がありません。

実行

Phoenix は、開発環境であればコードの変更時のリロードが機能しているので、サーバを起動したまま上の2つのコードを変更したのでしたら、すでに表示が切り替わっていると思います。

一覧表示をスクロールして「Load」のメッセージが表示されると、すぐに「Loading...」に切り替わり、読み込みが始まることが確認できます。

実は弱点(欠点)が一つ

すでに気がつかれている方もいらっしゃると思いますが。

この実装では、一覧表示の下にあるメッセージがスクロールして現れるのではなく、最初からページ中に表示されているばあいは読み込みが発生しません。 最初に一覧するアイテムの数をページサイズに合わせて調節できるとよいのですが、書かなければならない JavaScript のコードが増えそうなので、今回は考えないことにしました。

JavaScript のコードを駆逐しつつページサイズに合わせた読み込みをする方法は、宿題とさせてください。

いつか読むはずっと読まない:単弓類、雌伏のとき

「かはく」こと国立科学博物館恐竜博2023 が始まりました。 かはくにはもう3年くらい足を運べていないので、この機に休暇をとって丸一日堪能してこようかと思います。