Stream dan koleksi

List besar tanpa penyalinan DOM.

Daftar isi

Koleksi besar seperti daftar kunjungan, log, atau papan pesan membuat pola assigns biasa menjadi mahal, karena satu perubahan kecil memaksa seluruh daftar dirender ulang lalu dibandingkan ulang di browser. Phoenix LiveView menjawabnya dengan stream, yaitu koleksi yang itemnya dilacak satu per satu, sehingga yang dikirim ke browser hanya tambahan dan penghapusannya. Topik ini membahas biaya list biasa, mekanisme stream beserta dom id-nya, satu skrip utuh yang menampilkan delta state internal, dan kesalahan pemakaian yang paling sering.

Masalah list besar dalam assigns#

Cara paling langsung menampilkan daftar adalah menaruhnya di assigns lalu mengulangnya dengan :for. Cara itu bekerja dengan benar untuk daftar pendek, tetapi pembaruan satu item berarti mengganti seluruh assigns daftar. Pelacakan perubahan HEEx tidak dapat mengetahui item mana yang berubah, sehingga seluruh cabang :for dirender ulang, dan mekanisme patch membandingkan ulang seluruh node daftarnya sebelum mengirim hasilnya.

Biayanya muncul pada dua sisi. Di server, setiap render menyusun ulang seluruh node daftar meskipun yang berubah hanya satu baris, dan state daftar tersimpan sebagai satu nilai besar dalam assigns process halaman. Di jaringan, patch yang dihasilkan berpotensi membawa banyak node yang sebenarnya tidak berubah. Untuk daftar yang tumbuh terus, misalnya catatan kunjungan ruang baca yang mencapai ratusan baris, biaya itu dibayar pada setiap pembaruan, dan stream memangkasnya menjadi sebesar perubahannya saja.

Mekanisme stream#

Fungsi stream(socket, :kunjungan, items) menyimpan koleksi ke dalam assigns khusus bernama streams, dan tiap item diberi dom id otomatis, misalnya kunjungan-1, dari nama stream dan nilai field id item. Template mengulang stream dengan bentuk tuple {{dom_id, item} <- @streams.kunjungan}, dan wadahnya ditandai phx-update="stream" serta diberi id, sehingga kerangka di sisi browser menambah dan menghapus node tanpa merender ulang item-item lain.

Yang dikirim saat event berlangsung bukan daftar utuh, melainkan delta. stream_insert/3 mencatat satu tambahan, stream_delete/3 mencatat satu penghapusan, dan hanya catatan itu yang tiba di browser. Sifat murni render tetap terjaga, karena stream tetap dikonsumsi oleh :for pada template, hanya sumber datanya yang bukan lagi list biasa.

Skrip utuh: daftar kunjungan ruang baca#

Skrip berikut memuat LiveView dengan stream tiga kunjungan, merender HTML-nya, lalu mencatat kunjungan baru dengan stream_insert/3. State internal stream ditampilkan untuk memperlihatkan delta yang tercatat, dan HTML dirender ulang untuk memperlihatkan hasilnya:

elixir stream.exs
# stream.exs — stream LiveView: delta state internal dan render item dengan dom id
Mix.install([{:phoenix_live_view, "~> 1.1"}, {:jason, "~> 1.4"}])
Logger.configure(level: :warning)

defmodule KunjunganLive do
  use Phoenix.LiveView

  def mount(_params, _session, socket) do
    kunjungan = [
      %{id: 1, nama: "Ali"},
      %{id: 2, nama: "Budi"},
      %{id: 3, nama: "Citra"}
    ]

    {:ok, stream(socket, :kunjungan, kunjungan)}
  end

  def handle_event("catat", %{"nama" => nama}, socket) do
    {:noreply, stream_insert(socket, :kunjungan, %{id: 4, nama: nama})}
  end

  def render(assigns) do
    ~H"""
    <ul id="ruangan" phx-update="stream">
      <li :for={{dom_id, k} <- @streams.kunjungan} id={dom_id}>{k.nama}</li>
    </ul>
    """
  end
end

Code.ensure_loaded(Phoenix.LiveViewTest)

# socket tiruan perlu satu field privat agar hook render stream dapat dipasang
socket0 =
  struct(Phoenix.LiveView.Socket)
  |> struct(private: %{lifecycle: struct(Phoenix.LiveView.Lifecycle)})

{:ok, socket} = KunjunganLive.mount(%{}, %{}, socket0)
IO.puts(Phoenix.LiveViewTest.rendered_to_string(KunjunganLive.render(socket.assigns)))

{:noreply, socket2} = KunjunganLive.handle_event("catat", %{"nama" => "Dewi"}, socket)

# state internal hanya mencatat satu tambahan baru, bukan salinan daftar utuh
IO.inspect(socket2.assigns.streams.kunjungan.inserts, label: "inserts tercatat")

IO.puts(Phoenix.LiveViewTest.rendered_to_string(KunjunganLive.render(socket2.assigns)))
text Keluaran
<ul id="ruangan" phx-update="stream">
  <li id="kunjungan-1">Ali</li><li id="kunjungan-2">Budi</li><li id="kunjungan-3">Citra</li>
</ul>
inserts tercatat: [
  {"kunjungan-4", -1, %{id: 4, nama: "Dewi"}, nil, false},
  {"kunjungan-3", -1, %{id: 3, nama: "Citra"}, nil, nil},
  {"kunjungan-2", -1, %{id: 2, nama: "Budi"}, nil, nil},
  {"kunjungan-1", -1, %{id: 1, nama: "Ali"}, nil, nil}
]
<ul id="ruangan" phx-update="stream">
  <li id="kunjungan-1">Ali</li><li id="kunjungan-2">Budi</li><li id="kunjungan-3">Citra</li><li id="kunjungan-4">Dewi</li>
</ul>

Bagian inserts tercatat memperlihatkan inti mekanisme stream. Setiap baris berupa tuple {dom_id, posisi, item, limit, update_only}, dan stream hasil mount/3 sudah mencatat satu tuple per item awal, sedangkan event catat hanya menambah satu tuple baru untuk kunjungan-4. Pada aplikasi nyata, tuple terbaru itulah satu-satunya yang dikirim ke browser, sehingga daftar seratus baris tetap murah saat diperbarui. HTML yang dirender ulang pun menempatkan Dewi di akhir dengan dom id yang stabil. Skripnya dapat dijalankan ulang dari terminal:

bash
# menjalankan skrip stream dari folder topik
elixir stream.exs
text Keluaran
<ul id="ruangan" phx-update="stream">
  <li id="kunjungan-1">Ali</li><li id="kunjungan-2">Budi</li><li id="kunjungan-3">Citra</li>
</ul>
inserts tercatat: [
  {"kunjungan-4", -1, %{id: 4, nama: "Dewi"}, nil, false},
  {"kunjungan-3", -1, %{id: 3, nama: "Citra"}, nil, nil},
  {"kunjungan-2", -1, %{id: 2, nama: "Budi"}, nil, nil},
  {"kunjungan-1", -1, %{id: 1, nama: "Ali"}, nil, nil}
]
<ul id="ruangan" phx-update="stream">
  <li id="kunjungan-1">Ali</li><li id="kunjungan-2">Budi</li><li id="kunjungan-3">Citra</li><li id="kunjungan-4">Dewi</li>
</ul>

stream_delete dan batas ukuran#

Penghapusan mengikuti mekanisme yang sama dengan tambahan. Fungsi stream_delete/3 menerima item yang akan dihapus dan mencatat dom id-nya, sedangkan stream_delete_by_dom_id/3 menerima dom id itu secara langsung. Satu event pun dapat mencatat beberapa operasi sekaligus, dan semuanya diterapkan oleh browser pada satu patch.

Ukuran stream juga dapat dipagari. Opsi limit: n pada stream/3 memangkas jumlah item yang ditahan di browser, sehingga daftar yang terus bertambah tidak tumbuh tanpa batas. Contohnya stream(socket, :kunjungan, kunjungan, limit: 100) yang menyisakan seratus baris terbaru di sisi klien. Panggilan stream_insert/4 berikutnya perlu meneruskan opsi :limit yang sama apabila pagar itu ingin dipertahankan, dan opsi at: 0 menyisipkan item baru di awal wadah alih-alih menambahkannya di akhir. Kotak pesan untuk keadaan kosong sebaiknya ditaruh sebagai elemen lain dengan id sendiri di dalam wadah stream, karena kekosongan stream tidak dapat diuji dengan Enum.empty?/1 dari sisi server.

html Contoh: penghapusan dan pembaruan item stream
{:noreply,
 socket
 |> stream_insert(:kunjungan, %{id: 4, nama: "Dewi"})
 |> stream_delete(:kunjungan, %{id: 1, nama: "Ali"})}

Kesalahan umum#

Kesalahan pertama adalah mengolah stream dengan fungsi Enum alih-alih mengulangnya langsung dengan for comprehension. Stream hanya boleh dikonsumsi langsung oleh :for, dan pemanggilan fungsi lain memicu error berikut:

elixir
# mengolah stream dengan Enum langsung di dalam template
Mix.install([{:phoenix_live_view, "~> 1.1"}, {:jason, "~> 1.4"}])
Logger.configure(level: :warning)

defmodule RusakLive do
  use Phoenix.LiveView

  def mount(_params, _session, socket) do
    {:ok, stream(socket, :buku, [%{id: 1, judul: "Kamus Bahasa"}])}
  end

  def render(assigns) do
    ~H"""
    <p>{Enum.map(@streams.buku, & &1.judul)}</p>
    """
  end
end

# socket tiruan perlu satu field privat agar hook render stream dapat dipasang
socket0 =
  struct(Phoenix.LiveView.Socket)
  |> struct(private: %{lifecycle: struct(Phoenix.LiveView.Lifecycle)})

{:ok, socket} = RusakLive.mount(%{}, %{}, socket0)

try do
  Phoenix.LiveViewTest.rendered_to_string(RusakLive.render(socket.assigns))
rescue
  e in ArgumentError -> IO.puts("ArgumentError: #{Exception.message(e)}")
end
text Keluaran
ArgumentError: streams can only be consumed directly by a for comprehension.
If you are attempting to consume the stream ahead of time, such as with
`Enum.with_index(@streams.buku)`, you need to place the relevant information
within the stream items instead.

Pesan errornya menyarankan solusinya, yaitu meletakkan data yang dibutuhkan di dalam item stream itu sendiri, misalnya menyertakan nomor urut sebagai field, alih-alih menghitungnya lewat Enum.with_index/2 di template.

Kesalahan kedua adalah wadah stream tanpa atribut id. Wadah dengan phx-update="stream" membutuhkan id untuk dikenali oleh kerangka di sisi browser, dan wadah tanpa id membuat patch stream gagal diterapkan saat runtime. Pola <ul id="ruangan" phx-update="stream"> pada skrip utuh adalah bentuk minimum yang harus dipertahankan.

Kesalahan ketiga adalah menyimpan item tanpa field id. Dom id bawaan dibangun dari field itu, sehingga item tanpa id membutuhkan konfigurasi stream_configure/3 dengan fungsi :dom_id sendiri. Tanpa keduanya, penambahan item tidak dapat dilacak dengan stabil dari satu render ke render berikutnya, dan patch yang dihasilkan menjadi tidak dapat dipercaya.

Ringkasan#

  • stream/3 menyimpan koleksi di assigns streams, memberi tiap item dom id stabil dari field id, dan dikonsumsi template lewat :for yang mengulang tuple {dom_id, item}.
  • Pembaruan stream berupa delta, yaitu catatan stream_insert/3 dan stream_delete/3, sehingga satu perubahan mengirim satu node, bukan seluruh daftar.
  • Opsi limit: n memagari ukuran daftar di browser, sedangkan wadah stream wajib memiliki id dan ditandai phx-update="stream".
  • Stream hanya boleh dikonsumsi langsung oleh :for, dan fungsi Enum yang diterapkan padanya memicu ArgumentError yang menyebut perbaikannya.

Referensi#