Bagian ketiga dari Web FullStack menggunakan Phoenix LiveView akan membahas tentang Javascript Interoperability. Apa itu Javascript Interoperability atau lebih dikenal dengan JS Interop pada LiveView ? Seperti artinya JS Interop adalah suatu mekanisme untuk menggunakan kode Javascript dalam keseluruhan siklus LiveView. Seperti pada tujuan didesain-nya LiveView adalah bukan meniadakan fungsi Javascript pada kode yang dibangun, tetapi tujuan LiveView adalah meminimalkan penggunaan Javascript pada kode framework Phoenix. Pada akhirnya cepat atau lambat mekanisme JS Interop ini akan digunakan pada kode framework Phoenix, karena pada akhirnya kita membutuhkan solusi custom ataupun solusi yang lebih advance dari Javascript, jika kebutuhan dari aplikasi mengharuskan hal tersebut.
Untuk melihat bagaimana penggunaan JS Interop, mari kita coba implementasikan fungsi hapus data sebagai bagian dari CRUD dari aplikasi Todo yang telah diimplementasikan sebelumnya. Sebelum masuk ke JS Interop, akan dibandingkan juga implementasi hapus ini jika tanpa menggunakan JS Interop. Seperti biasa, untuk memudahkan dan mempercepat pemahaman, sebelumnya silahkan clone atau fork github repo berikut https://github.com/rizki96/liveview_todo.
Solusi penghapusan tanpa JS Interop
Solusi tanpa JS Interop merupakan solusi yang paling mudah untuk dilakukan. Pertama adalah tambahkan informasi pada file 'lib/liveview_todo_web/router.ex', sehingga terdapat link untuk melakukan penghapusan atau delete pada endpoint '/todo'. Berikut contoh penggalan kode dari router.ex
scope "/", LiveviewTodoWeb do | |
pipe_through :browser | |
live "/", PageLive, :index | |
live "/todo", TodoLive, [:index, :delete] | |
end |
Saat ini artinya module TodoLive telah memiliki endpoint :delete. Sehingga file 'lib/liveview_todo_web/live/components/todos/todo_list_item.ex' dapat ditambahkan salah satu fungsi link html untuk liveview yaitu live_redirect untuk melakukan operasi penghapusan. Berikut isi file 'todo_list_item.ex' yang terbaru
defmodule LiveviewTodoWeb.Todos.TodoListItemComponent do | |
use Phoenix.LiveComponent | |
use Phoenix.HTML | |
alias LiveviewTodoWeb.Router.Helpers, as: Routes | |
alias LiveviewTodoWeb.TodoLive | |
require Logger | |
def render(assigns) do | |
~L""" | |
<li id="list-item-<%= @todo.id %>" phx-update="replace"> | |
<label class='<%= if @todo.completed == true, do: "complete", else: "" %>'> | |
<%= checkbox(:todo, :completed, phx_click: "update_todo:#{@todo.id}", phx_value: Jason.encode!(@todo), id: "chkbox-#{@todo.id}", checked: @todo.completed) %> | |
<%= @todo.text %> | |
</label> | |
<%= live_redirect("X", to: Routes.live_path(@socket, TodoLive, [id: @todo.id, action: :delete]), replace: false) %> | |
</li> | |
""" | |
end | |
def mount(socket) do | |
{:ok, socket} | |
end | |
end |
Terlihat dari fungsi live_redirect, link tersebut akan membawa menuju ke Routes.live_path(@socket, TodoLive, [id: @todo.id, action: :delete]), replace: false), Routes.live_path ini merupakan representasi dari link '/todo' yang ada pada file 'router.ex', yang kemudian akan di render oleh browser sebagai '/todo?id={id}&action=delete'. Dan dari router live_path tersebut, client akan dibawa ke fungsi handle_params pada module TodoLive. Seterusnya module TodoLive akan berbentuk sebagai berikut
defmodule LiveviewTodoWeb.TodoLive do | |
use LiveviewTodoWeb, :live_view | |
alias LiveviewTodo.Tasks | |
alias LiveviewTodoWeb.Todos.AddTodoFormComponent | |
alias LiveviewTodoWeb.Todos.TodoListComponent | |
require Logger | |
@impl true | |
def mount(_params, _session, socket) do | |
{:ok, todos} = Tasks.list_todos() | |
todos = Enum.map(todos, fn {_, todo} -> todo end) | |
socket = assign(socket, todos: todos) | |
{:ok, assign(socket, temporary_assigns: [todos: []])} | |
end | |
@impl true | |
def handle_event("add_todo", %{"todo" => todo} = _params, socket) do | |
Logger.log(:debug, "#{inspect todo}") | |
new_todo = Tasks.create_todo(todo) | |
{:noreply, assign(socket, :todos, [new_todo])} | |
end | |
@impl true | |
def handle_event("update_todo:" <> todo_id, params, socket) do | |
Logger.log(:debug, "#{inspect params}") | |
old_todo = Tasks.get_todo!(String.to_integer(todo_id)) | |
socket = | |
if old_todo do | |
updated_todo = Tasks.update_todo(old_todo, | |
(if Map.has_key?(params, "value") and params["value"] == "true", | |
do: %{completed: true, id: old_todo.id, text: old_todo.text}, | |
else: %{completed: false, id: old_todo.id, text: old_todo.text})) | |
assign(socket, :todos, [updated_todo]) | |
else | |
socket | |
end | |
{:noreply, socket} | |
end | |
# versi tanpa JS Interop | |
@impl true | |
def handle_params(%{"id" => id, "action" => "delete"} = _params, _uri, socket) do | |
Logger.log(:debug, "#{inspect id}") | |
del_todo = Tasks.get_todo!(String.to_integer(id)) | |
socket = | |
if del_todo do | |
Tasks.delete_todo(del_todo) | |
{:ok, todos} = Tasks.list_todos() | |
todos = Enum.map(todos, fn {_, todo} -> todo end) | |
assign(socket, todos: todos) | |
else | |
socket | |
end | |
{:noreply, socket} | |
end | |
@impl true | |
def handle_params(_params, _uri, socket) do | |
# all unhandled params goes here | |
{:noreply, socket} | |
end | |
end |
Maka setelah semua kode tersebut terpasang, maka penghapusan sudah bisa dilakukan. Tetapi ada hal yang perlu diingat dari kode diatas. Yaitu jika dilihat pada bagian '# versi tanpa JS Interop' setelah handle_params melakukan penghapusan terhadap data todo, maka semua data kembali akan di fetch dari database dengan fungsi {:ok, todos} = Tasks.list_todos() dan state dari todos akan di assign ulang. Hal ini tentu akan menimbulkan bottle neck jika jumlah data todo yang di fetch cukup signifikan. Untuk menghindari hal tersebut, yang perlu dilakukan adalah mengganti operasi fetch data tadi dengan operasi pengiriman event ke client browser dalam hal ini ke Javascript, sehingga nanti Javascript yang akan melakukan penghapusan todo item, tanpa perlu melakukan fetch data dan mengirimkan data ke client browser. Hal tersebut akan coba dijelaskan pada bagian berikutnya yaitu solusi menggunakan JS Interop. Dan berikut adalah ilustrasi pengiriman data dari server ke client dengan solusi tanpa JS Interop.
Solusi penghapusan dengan JS Interop
Sebelumnya perlu diketahui bahwa JS Interop merupakan sebuah siklus berupa fungsi callback yang terjadi pada sebuah LiveView Component. Siklus tersebut terdiri dari fungsi mounted, beforeUpdate, updated, beforeDestroy, destroyed dan lainnya, untuk lebih jelas tentang siklusnya bisa dilihat pada link ini.
Disebabkan karena yang akan melakukan penghapusan ada pada elemen list, maka akan diimplementasikan JS Interop ini pada LiveView Component 'todo_list.ex' pada direktori 'lib/liveview_todo_web/live/components/todos'. Untuk memulai mari buat sebuah file javascript yang berfungsi sebagai hook, pada direktori 'assets/js' dengan nama file 'todo_list_hook.js'. Berikut isi dari file 'todo_list_hook.js'
const TodoListHook = { | |
mounted() { | |
console.log("mounted"); | |
this.handleEvent("delete_todo_event", (data) => { | |
// use data to update your component | |
console.log("delete_todo: list-item-" + data.todo.id); | |
var lis = document.querySelectorAll('#list-item-' + data.todo.id); | |
for(var i=0; i < lis.length; i++) { | |
this.el.removeChild(lis[i]); | |
} | |
}); | |
}, | |
updated() { | |
console.log("updated"); | |
} | |
} | |
export default TodoListHook; |
Kemudian lakukan modifikasi pada file 'app.js' pada direktori 'assets/js', dimana file Javascript ini yang akan dimuat (load) untuk pertama kali pada client browser. Berikut isi dari file 'app.js'
// We need to import the CSS so that webpack will load it. | |
// The MiniCssExtractPlugin is used to separate it out into | |
// its own CSS file. | |
import "../css/app.scss" | |
// webpack automatically bundles all modules in your | |
// entry points. Those entry points can be configured | |
// in "webpack.config.js". | |
// | |
// Import deps with the dep name or local files with a relative path, for example: | |
// | |
// import {Socket} from "phoenix" | |
// import socket from "./socket" | |
// | |
import "phoenix_html" | |
import {Socket} from "phoenix" | |
import NProgress from "nprogress" | |
import {LiveSocket} from "phoenix_live_view" | |
import TodoListHook from "./todo_list_hook" | |
let hooks = { TodoListHook } | |
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") | |
let liveSocket = new LiveSocket("/live", Socket, {hooks: hooks, params: {_csrf_token: csrfToken}}) | |
// Show progress bar on live navigation and form submits | |
window.addEventListener("phx:page-loading-start", info => NProgress.start()) | |
window.addEventListener("phx:page-loading-stop", info => NProgress.done()) | |
// connect if there are any LiveViews on the page | |
liveSocket.connect() | |
// expose liveSocket on window for web console debug logs and latency simulation: | |
// >> liveSocket.enableDebug() | |
// >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session | |
// >> liveSocket.disableLatencySim() | |
window.liveSocket = liveSocket | |
Dari kode diatas terlihat object hook TodoListHook di-import dan kemudian diteruskan ke dalam object LiveSocket. Setelah ini dibutuhkan melakukan modifikasi file berturut-turut yaitu 'todo_list.ex', 'todo_list_item.ex' dan 'todo_live.ex'. Pada file 'todo_list.ex' yang akan ditambahkan adalah tag phx-hook dengan nilai 'TodoListHook' (phx-hook='TodoListHook'). Sehingga isi file 'todo_list.ex' akan berbentuk
defmodule LiveviewTodoWeb.Todos.TodoListComponent do | |
use Phoenix.LiveComponent | |
alias LiveviewTodoWeb.Todos.TodoListItemComponent | |
require Logger | |
def render(assigns) do | |
~L""" | |
<ul phx-update="append" phx-hook="TodoListHook"> | |
<%= for todo <- @todos do %> | |
<%= live_component @socket, TodoListItemComponent, id: todo.id, todo: todo %> | |
<% end %> | |
</ul> | |
""" | |
end | |
def mount(socket) do | |
{:ok, socket} | |
end | |
end |
Pada file 'todo_list_item.ex' akan diubah fungsi 'live_redirect' menjadi 'live_patch'. Perbedaan 'live_redirect' dan 'live_patch' adalah pada 'live_redirect', phoenix yang akan menangani redirecting / pengarahan ke konten liveview yang baru dengan assign state yang baru. Pada 'live_patch' hal tersebut tidak dilakukan, semua operasi tersebut akan ditangani oleh Javascript. 'live_patch' hanya akan mengirimkan event ke handle_params. Berikut isi dari file 'todo_list_item.ex'
defmodule LiveviewTodoWeb.Todos.TodoListItemComponent do | |
use Phoenix.LiveComponent | |
use Phoenix.HTML | |
alias LiveviewTodoWeb.Router.Helpers, as: Routes | |
alias LiveviewTodoWeb.TodoLive | |
require Logger | |
def render(assigns) do | |
~L""" | |
<li id="list-item-<%= @todo.id %>" phx-update="replace"> | |
<label class='<%= if @todo.completed == true, do: "complete", else: "" %>'> | |
<%= checkbox(:todo, :completed, phx_click: "update_todo:#{@todo.id}", phx_value: Jason.encode!(@todo), id: "chkbox-#{@todo.id}", checked: @todo.completed) %> | |
<%= @todo.text %> | |
</label> | |
<%= live_patch("X", to: Routes.live_path(@socket, TodoLive, [id: @todo.id, action: :delete]), replace: false) %> | |
</li> | |
""" | |
end | |
def mount(socket) do | |
{:ok, socket} | |
end | |
end |
Bagaimana file 'todo_live.ex' ? Bagian yang dibutuhkan untuk dimodifikasi adalah bagian handle_params. Berikut isi dari file 'todo_live.ex' setelah dimodifikasi
defmodule LiveviewTodoWeb.TodoLive do | |
use LiveviewTodoWeb, :live_view | |
alias LiveviewTodo.Tasks | |
alias LiveviewTodoWeb.Todos.AddTodoFormComponent | |
alias LiveviewTodoWeb.Todos.TodoListComponent | |
require Logger | |
@impl true | |
def mount(_params, _session, socket) do | |
{:ok, todos} = Tasks.list_todos() | |
todos = Enum.map(todos, fn {_, todo} -> todo end) | |
socket = assign(socket, todos: todos) | |
{:ok, assign(socket, temporary_assigns: [todos: []])} | |
end | |
@impl true | |
def handle_event("add_todo", %{"todo" => todo} = _params, socket) do | |
Logger.log(:debug, "#{inspect todo}") | |
new_todo = Tasks.create_todo(todo) | |
{:noreply, assign(socket, :todos, [new_todo])} | |
end | |
@impl true | |
def handle_event("update_todo:" <> todo_id, params, socket) do | |
Logger.log(:debug, "#{inspect params}") | |
old_todo = Tasks.get_todo!(String.to_integer(todo_id)) | |
socket = | |
if old_todo do | |
updated_todo = Tasks.update_todo(old_todo, | |
(if Map.has_key?(params, "value") and params["value"] == "true", | |
do: %{completed: true, id: old_todo.id, text: old_todo.text}, | |
else: %{completed: false, id: old_todo.id, text: old_todo.text})) | |
assign(socket, :todos, [updated_todo]) | |
else | |
socket | |
end | |
{:noreply, socket} | |
end | |
# versi dengan JS Interop | |
@impl true | |
def handle_params(%{"id" => id, "action" => "delete"} = _params, _uri, socket) do | |
Logger.log(:debug, "#{inspect id}") | |
del_todo = Tasks.get_todo!(String.to_integer(id)) | |
socket = | |
if del_todo do | |
Tasks.delete_todo(del_todo) | |
push_event(socket, "delete_todo_event", %{todo: del_todo}) | |
else | |
socket | |
end | |
{:noreply, socket} | |
end | |
@impl true | |
def handle_params(_params, _uri, socket) do | |
# all unhandled params goes here | |
{:noreply, socket} | |
end | |
end |
Seperti bisa dilihat pada kode diatas, setelah penghapusan data operasi yang dilakukan adalah pengiriman event oleh fungsi 'push_event'. Event ini akan ditangkap oleh 'TodoListHook' pada file 'assets/js/todo_list_hook.js' di bagian 'this.handleEvent' yang kemudian akan menghapus element data todo yang bersangkutan pada LiveView Component 'todo_list.ex'. Sebagai bahan perbandingan, berikut informasi yang dikirim dari server ke client browser, jika menggunakan JS Interop. Data yang dikirim hanya informasi event dan todo data dari object yang telah dihapus di server. Dan terlihat dengan JS Interop ini akan mengefisienkan pengiriman data dari server ke client browser.
Dan terakhir, perlu diketahui bahwa dengan JS Interop ini dimungkinkan untuk LiveView berinteraksi dengan framework Javascript lain, seperti JQuery, React, Vue.js dan lain sebagainya. Demikian sekilas mengenai JS Interop pada Phoenix LiveView, mudah-mudahan bisa bermanfaat. Terima kasih.
Top comments (0)