Model LiveView

Sebuah process berkondisi yang merender HTML.

Daftar isi

Phoenix LiveView membuat satu halaman interaktif berjalan sebagai process berkondisi (stateful process) yang merender HTML di server. Topik ini membahas model tersebut, kedudukannya di atas GenServer yang dipelajari pada modul Inti OTP, serta satu skrip utuh penghitung yang menampilkan HTML sebelum dan sesudah event diproses. Hasil akhirnya adalah pemahaman bahwa state sebuah halaman tersimpan di server dalam bentuk assigns, dan setiap interaksi pengguna hanyalah pesan yang memperbarui state itu lalu memicu render ulang.

LiveView sebagai process berkondisi#

Setiap halaman LiveView yang terbuka pada browser berarti satu process BEAM yang berdiri sendiri. Process itu menyimpan state halaman, menerima pesan dari browser melalui WebSocket, dan mengembalikan potongan HTML baru ketika state berubah. Bentuknya sama persis dengan pola process berkondisi pada modul Process, yaitu loop yang membawa state dari satu pesan ke pesan berikutnya, hanya saja loop itu tidak lagi ditulis secara manual.

Penulis modul hanya menulis kumpulan callback, dan kerangka LiveView menjalankan loopnya. use Phoenix.LiveView memasang behaviour dengan kontrak yang mirip GenServer, sehingga tabel berikut menjadi peta yang mudah dibaca:

PeranGenServerLiveView
Menyiapkan state awalinit/1mount/3
Menangani aksi penggunahandle_call/3 dan handle_cast/2handle_event/3
Menangani pesan process lainhandle_info/2handle_info/2
Tempat state disimpanargumen stateassigns pada socket
Hasil pekerjaannilai balasanHTML yang dikirim ke browser

Callback mount/3 menyiapkan state awal dan mengembalikan {:ok, socket}, sedangkan render/1 adalah fungsi murni yang mengubah assigns menjadi HTML melalui template HEEx. Callback handle_event/3 menangani aksi pengguna seperti klik tombol, dan handle_info/2 menangani pesan dari process lain. Tidak ada satu pun variabel yang dimutasi, karena seluruh perubahan state dinyatakan lewat nilai balikan callback, sesuai aturan imutabilitas bahasa ini.

Skrip utuh: penghitung yang dirender dua kali#

Skrip berikut memuat LiveView penghitung dengan tiga callback. mount/3 mengisi assigns awal hitung bernilai 0, handle_event/3 menaikkan nilainya satu per satu, dan render/1 menuliskannya ke HTML. Skripnya memanggil mount/3 sekali, merender hasilnya, lalu mengirim event tambah dua kali dan merender HTML setiap kali event selesai diproses:

elixir penghitung.exs
# penghitung.exs — LiveView penghitung: mount, handle_event, dan render dilihat langsung
Mix.install([{:phoenix_live_view, "~> 1.1"}, {:jason, "~> 1.4"}])
Logger.configure(level: :warning)

defmodule PenghitungLive do
  use Phoenix.LiveView

  def mount(_params, _session, socket) do
    {:ok, assign(socket, :hitung, 0)}
  end

  def handle_event("tambah", _params, socket) do
    {:noreply, update(socket, :hitung, &(&1 + 1))}
  end

  def render(assigns) do
    ~H"<p>Jumlah klik: {@hitung}</p>"
  end
end

Code.ensure_loaded(Phoenix.LiveViewTest)

# mount mengisi assigns awal pada socket tiruan
{:ok, socket} = PenghitungLive.mount(%{}, %{}, struct(Phoenix.LiveView.Socket))
IO.puts(Phoenix.LiveViewTest.rendered_to_string(PenghitungLive.render(socket.assigns)))
IO.inspect(Phoenix.LiveView.connected?(socket), label: "connected?")

# setiap event mengirim socket baru, seperti loop GenServer yang membawa state terbaru
{:noreply, socket2} = PenghitungLive.handle_event("tambah", %{}, socket)
IO.inspect(socket2.assigns.hitung, label: "hitung")
IO.puts(Phoenix.LiveViewTest.rendered_to_string(PenghitungLive.render(socket2.assigns)))

{:noreply, socket3} = PenghitungLive.handle_event("tambah", %{}, socket2)
IO.puts(Phoenix.LiveViewTest.rendered_to_string(PenghitungLive.render(socket3.assigns)))
text Keluaran
<p>Jumlah klik: 0</p>
connected?: false
hitung: 1
<p>Jumlah klik: 1</p>
<p>Jumlah klik: 2</p>

Keluaran itu memperlihatkan tiga fakta sekaligus. Render pertama menghasilkan <p>Jumlah klik: 0</p> murni dari assigns hasil mount/3, tanpa interaksi apa pun. Nilai connected?: false menandai bahwa socket tiruan belum tersambung ke browser, keadaan yang pada aplikasi nyata disebut dead render, yaitu render pertama yang dikirim sebagai isi halaman sebelum koneksi WebSocket terjalin. Setelah dua event tambah diproses, HTML berubah menjadi <p>Jumlah klik: 2</p>, dan perubahan itu hanya terjadi karena handle_event/3 mengembalikan socket dengan assigns baru. Skrip yang sama dapat dijalankan dari terminal setelah berkasnya disimpan:

bash
# menjalankan skrip penghitung dari folder topik
elixir penghitung.exs
text Keluaran
<p>Jumlah klik: 0</p>
connected?: false
hitung: 1
<p>Jumlah klik: 1</p>
<p>Jumlah klik: 2</p>

Pada aplikasi nyata, browser tidak merender ulang seluruh halaman hanya karena satu angka berubah. Kerangka LiveView membandingkan HTML sebelum dan sesudahnya, lalu hanya mengirim bagian yang berubah melalui WebSocket, sehingga browser menambal (patch) node yang berubah saja. Mekanisme patch ini bekerja tanpa JavaScript yang ditulis pengembang halaman, karena kerangkanya yang mengurusnya.

Assigns sebagai satu-satunya state#

Fungsi assign/3 dan update/3 adalah dua pintu masuk untuk mengubah state halaman. assign(socket, :hitung, 5) menulis atau mengganti satu kunci assigns, sedangkan update(socket, :hitung, &(&1 + 1)) menghitung nilai baru dari nilai lama. Keduanya mengembalikan socket baru, dan socket lama tetap utuh sebagai nilai immutable. Akibatnya, state halaman pada satu saat tertentu selalu dapat dibaca dari socket.assigns tanpa risiko berubah secara diam-diam.

Disiplin ini menjaga render/1 tetap murni. Fungsi render tidak pernah mengirim pesan dan tidak pernah mengubah state, sehingga render yang sama untuk assigns yang sama selalu menghasilkan HTML yang sama. Sifat itulah yang membuat patch DOM dapat dihitung dengan aman, dan juga yang membuat halaman mudah diuji: callback dipanggil dengan socket tiruan, lalu HTML-nya diperiksa, persis seperti pada skrip di atas.

Kesalahan umum#

Kesalahan pertama adalah mencoba mengubah assigns dari dalam render/1. Fungsi render menerima assigns dan mengembalikan template, tanpa akses ke socket, sehingga pemanggilan assign di dalamnya tidak mungkin terjadi. Perubahan state hanya sah di callback handle_*, dan render hanya membaca. Apabila sebuah nilai tampaknya perlu dihitung saat render, hitung nilainya di callback sebelumnya lalu simpan sebagai assigns.

Kesalahan kedua adalah menyimpan state halaman di atribut modul. Atribut modul dievaluasi pada waktu kompilasi dan nilainya dibagikan ke seluruh process, sehingga cocok untuk konstanta dan bukan untuk state per pengguna. Satu halaman LiveView berarti satu process dengan assigns miliknya sendiri, dan dua pengguna yang membuka halaman yang sama tidak pernah berbagi assigns, sebab setiap process memiliki memori terpisah.

Ringkasan#

  • Satu halaman LiveView adalah satu process BEAM berkondisi, yaitu GenServer dengan kontrak callback milik LiveView: mount/3 menyiapkan state, handle_event/3 menerima aksi pengguna, dan handle_info/2 menerima pesan process lain.
  • State halaman tersimpan di socket.assigns, hanya dapat diubah melalui nilai balikan callback, dan hanya dibaca oleh render/1.
  • Render pertama yang dikirim sebelum WebSocket tersambung disebut dead render, sedangkan patch selanjutnya hanya berisi bagian HTML yang berubah.
  • Callback LiveView adalah fungsi biasa yang mengembalikan tuple, sehingga dapat dipanggil langsung pada skrip untuk menguji siklusnya tanpa endpoint.

Referensi#