LiveView 
เราต้องการตัวอย่างจริง ฟรี และ โอเพ่นซอร์ส ด้วยรหัส เต็ม การทดสอบและการรับรองความถูกต้อง
เราเขียนสิ่งนี้เพื่อให้เราสามารถชี้ผู้คนในทีม/ชุมชนการเรียนรู้ Phoenix LiveView
ตัวอย่าง/บทช่วยสอน LiveView นี้จะนำคุณจากศูนย์ไปยัง แอพที่ทำงานได้ อย่างสมบูรณ์ ใน 20 นาที
นี่คือสารบัญของสิ่งที่คุณคาดหวังที่จะครอบคลุมในตัวอย่าง/การสอนนี้:
LiveViewPhoenixlive คอนโทรลเลอร์และเทมเพลต LiveViewrouter.exmount/3 อัปเดตmount/3handle_event/3handle_info/2AUTH_API_KEYauth_plugrouter.exAuthControlleron_mount/4 ใครก็ตามที่เรียนรู้ Phoenix LiveView ต้องการบทช่วยสอนในตัวเอง ได้แก่ : Setup Testing Authentication Presence
ขอ แนะนำ แม้ว่าคุณ จะไม่ได้ ทำตาม การสอนเคาน์เตอร์ LiveView เนื่องจากอันนี้ก้าวหน้ากว่า อย่างน้อยเช็คเอาต์รายการสิ่งที่จำเป็นต้องมีเพื่อให้คุณรู้ว่าคุณต้องติดตั้งอะไรในคอมพิวเตอร์ของคุณก่อนที่จะเริ่มการผจญภัยนี้!
หากคุณติดตั้ง Elixir , Phoenix และ Postgres คุณก็พร้อมที่จะไป!
Phoenix เริ่มต้นด้วยการสร้างแอปพลิเคชันใหม่ liveview_chat Phoenix :
mix phx.new liveview_chat --no-mailer --no-dashboard เราไม่ต้องการคุณสมบัติ email หรือ dashboard ดังนั้นเราจึงไม่รวมพวกเขาออกจากแอพของเรา คุณสามารถเรียนรู้เพิ่มเติมเกี่ยวกับการสร้างแอพฟีนิกซ์ใหม่โดยการรัน: mix help phx.new
รัน mix deps.get เพื่อดึงการพึ่งพา จากนั้นสร้าง ฐานข้อมูล liveview_chat_dev postgres โดยเรียกใช้คำสั่ง:
mix ecto.setupคุณควรเห็นผลลัพธ์คล้ายกับสิ่งต่อไปนี้:
The database for LiveviewChat.Repo has been created
14:20:19.71 [info] Migrations already upเมื่อคำสั่งนั้นสำเร็จตอนนี้คุณควรจะเริ่มแอปพลิเคชันโดยเรียกใช้คำสั่ง:
mix phx.serverคุณจะเห็นเอาต์พุตเทอร์มินัลคล้ายกับต่อไปนี้:
[info] Running LiveviewChatWeb.Endpoint with cowboy 2.9.0 at 127.0.0.1:4000 (http)
[debug] Downloading esbuild from https://registry.npmjs.org/esbuild-darwin-64/-/esbuild-darwin-64-0.14.29.tgz
[info] Access LiveviewChatWeb.Endpoint at http://localhost:4000
[watch] build finished, watching for changes... เมื่อคุณเปิด URL: http://localhost:4000 ในเว็บเบราว์เซอร์ของคุณคุณควรเห็นสิ่งที่คล้ายกับ:

live คอนโทรลเลอร์และเทมเพลต LiveView สร้างโฟลเดอร์ lib/liveview_chat_web/live และคอนโทรลเลอร์ที่ lib/liveview_chat_web/live/message_live.ex :
defmodule LiveviewChatWeb.MessageLive do
use LiveviewChatWeb , :live_view
def mount ( _params , _session , socket ) do
{ :ok , socket }
end
def render ( assigns ) do
LiveviewChatWeb.MessageView . render ( "messages.html" , assigns )
end
endหมายเหตุ : ชื่อไฟล์หรือรหัสไม่มีคำว่า " คอนโทรลเลอร์ " ทุกที่ หวังว่ามันจะไม่สับสน มันเป็น "คอนโทรลเลอร์" ในแง่ที่ว่ามันควบคุมสิ่งที่เกิดขึ้นในแอพ
คอนโทรลเลอร์ LiveView ต้องใช้ฟังก์ชั่น mount/3 และ render/1 ที่กำหนดไว้
เพื่อให้คอนโทรลเลอร์ง่ายขึ้น mount/3 เพียงแค่ส่งคืน {:ok, socket} tuple โดยไม่มีการเปลี่ยนแปลงใด ๆ render/1 เรียกใช้ LiveviewChatWeb.MessageView.render/2 (รวมอยู่ใน Phoenix ) ซึ่งแสดงผล messages.html.heex template ซึ่งเราจะกำหนดด้านล่าง
สร้าง lib/liveview_chat_web/views/message_view.ex ไฟล์:
defmodule LiveviewChatWeb.MessageView do
use LiveviewChatWeb , :view
end สิ่งนี้คล้ายกับ view Phoenix ปกติ ไม่มีอะไรพิเศษ/น่าสนใจที่นี่
ถัดไปสร้าง lib/liveview_chat_web/templates/message จากนั้นสร้าง
lib/liveview_chat_web/templates/message/messages.html.heex และเพิ่มบรรทัด HTML :
< h1 > LiveView Message Page </ h1 > ในที่สุดเพื่อให้ เค้าโครงรูท ง่ายขึ้นให้เปิดไฟล์ lib/liveview_chat_web/templates/layout/root.html.heex และอัปเดตเนื้อหาของ <body> ถึง:
< body >
< header >
< section class =" container " >
< h1 > LiveView Chat Example </ h1 >
</ section >
</ header >
< %= @inner_content % >
</ body > router.ex ตอนนี้คุณได้สร้างไฟล์ที่จำเป็นแล้วให้เปิดเราเตอร์ lib/liveview_chat_web/router.ex แทนที่คอนโทรลเลอร์เส้นทาง PageController เริ่มต้น:
get "/" , PageController , :index ด้วยตัวควบคุม MessageLive :
scope "/" , LiveviewChatWeb do
pipe_through :browser
live "/" , MessageLive
endตอนนี้ถ้าคุณรีเฟรชหน้าคุณควรเห็นสิ่งต่อไปนี้:

ณ จุดนี้เราได้ทำการเปลี่ยนแปลงเล็กน้อยซึ่งหมายความว่าชุดทดสอบอัตโนมัติของเราจะไม่ผ่านอีกต่อไป ... เรียกใช้การทดสอบในบรรทัดคำสั่งของคุณด้วยคำสั่งต่อไปนี้:
mix testคุณจะเห็นผลลัพธ์คล้ายกับสิ่งต่อไปนี้:
Generated liveview_chat app
..
1) test GET / (LiveviewChatWeb.PageControllerTest)
test/liveview_chat_web/controllers/page_controller_test.exs:4
Assertion with = ~ failed
code: assert html_response(conn, 200) = ~ " Welcome to Phoenix! "
left: " <!DOCTYPE html><html lang= " en " > <head> <meta charset= " utf-8 " > <meta http-equiv= " X-UA-Compatible " content= " IE=edge " >
<title data-suffix= " · Phoenix Framework " >LiveviewChat · Phoenix Framework</title> <link phx-track-static rel= " stylesheet " href= " /assets/app.css " > <script defer phx-track-static type= " text/javascript " src= " /assets/app.js " ></script> </head>
<body> <header> <section class= " container " >
<h1>LiveView Chat Example</h1></section> </header>
<h1>LiveView Message Page</h1></main></div> </body></html> "
right: " Welcome to Phoenix! "
stacktrace:
test/liveview_chat_web/controllers/page_controller_test.exs:6: (test)
Finished in 0.03 seconds (0.02s async, 0.01s sync)
3 tests, 1 failure นี่เป็นเพราะ page_controller_test.exs ยังคงคาดหวังว่าหน้าแรกจะมี "Welcome to Phoenix!" ข้อความ.
มาอัปเดตการทดสอบกันเถอะ! สร้างโฟลเดอร์ test/liveview_chat_web/live และไฟล์ message_live_test.exs ภายใน: test/liveview_chat_web/live/message_live_test.exs
เพิ่มรหัสทดสอบต่อไปนี้ลงไป:
defmodule LiveviewChatWeb.MessageLiveTest do
use LiveviewChatWeb.ConnCase
import Phoenix.LiveViewTest
test "disconnected and connected mount" , % { conn: conn } do
conn = get ( conn , "/" )
assert html_response ( conn , 200 ) =~ "LiveView Message Page"
{ :ok , _view , _html } = live ( conn )
end
end เรากำลังทดสอบว่า / จุดสิ้นสุดสามารถเข้าถึงได้และมีข้อความ "LiveView Message Page" บนหน้า
ดูเพิ่มเติมที่โมดูล LiveViewTest สำหรับข้อมูลเพิ่มเติมเกี่ยวกับการทดสอบและ LiveView
ในที่สุดคุณสามารถลบรหัสที่สร้างเริ่มต้นทั้งหมดที่เชื่อมโยงกับ PageController :
rm test/liveview_chat_web/controllers/page_controller_test.exsrm lib/liveview_chat_web/controllers/page_controller.exrm test/liveview_chat_web/views/page_view_test.exsrm lib/liveview_chat_web/views/page_view.exrm -r lib/liveview_chat_web/templates/page ตอนนี้คุณสามารถเรียกใช้การทดสอบอีกครั้งด้วยคำสั่ง mix test คุณควรเห็นสิ่งต่อไปนี้ (การทดสอบผ่าน):
Generated liveview_chat app
...
Finished in 0.1 seconds (0.06s async, 0.1s sync)
3 tests, 0 failures
Randomized with seed 841084 ด้วยโครงสร้าง LiveView ที่กำหนดไว้เราสามารถมุ่งเน้นไปที่การสร้างข้อความ ฐานข้อมูลจะบันทึกข้อความและชื่อของผู้ส่ง มาสร้างสคีมาและการย้ายถิ่นใหม่:
mix phx.gen.schema Message messages name:string message:stringหมายเหตุ : อย่าลืมเรียกใช้
mix ecto.migrateเพื่อสร้างตารางmessagesใหม่ในฐานข้อมูล
ตอนนี้เราสามารถอัปเดตสคี Message เพื่อเพิ่มฟังก์ชั่นสำหรับการสร้างข้อความใหม่และแสดงรายการข้อความที่มีอยู่ นอกจากนี้เราจะอัปเดตชุดการเปลี่ยนแปลงเพื่อเพิ่มข้อกำหนดและการตรวจสอบความถูกต้องในข้อความ เปิดไฟล์ lib/liveview_chat/message.ex และอัปเดตรหัสด้วยข้อมูลต่อไปนี้:
defmodule LiveviewChat.Message do
use Ecto.Schema
import Ecto.Changeset
import Ecto.Query
alias LiveviewChat.Repo
alias __MODULE__
schema "messages" do
field :message , :string
field :name , :string
timestamps ( )
end
@ doc false
def changeset ( message , attrs ) do
message
|> cast ( attrs , [ :name , :message ] )
|> validate_required ( [ :name , :message ] )
|> validate_length ( :message , min: 2 )
end
def create_message ( attrs ) do
% Message { }
|> changeset ( attrs )
|> Repo . insert ( )
end
def list_messages do
Message
|> limit ( 20 )
|> order_by ( desc: :inserted_at )
|> Repo . all ( )
end
end เราได้เพิ่มฟังก์ชั่น validate_length บนอินพุตข้อความเพื่อให้แน่ใจว่าข้อความมีอักขระอย่าง น้อย 2 ตัว นี่เป็นเพียงตัวอย่างที่แสดงให้เห็นว่าการตรวจสอบความถูก changeset ทำงานอย่างไรกับแบบฟอร์มในหน้า LiveView
จากนั้นเราก็สร้างฟังก์ชั่น create_message/1 และ list_messages/0 เช่นเดียวกับตัวอย่างฟีนิกซ์-แชทเรา limit จำนวนข้อความที่ส่งคืนไปยัง 20 ล่าสุด
mount/3 อัปเดต เปิดไฟล์ lib/liveview_chat_web/live/message_live.ex และเพิ่มบรรทัดต่อไปนี้ที่บรรทัดที่ 3:
alias LiveviewChat.Message ต่อไปอัปเดตฟังก์ชั่น mount/3 ในไฟล์ lib/liveview_chat_web/live/message_live.ex เพื่อใช้ฟังก์ชัน list_messages :
def mount ( _params , _session , socket ) do
messages = Message . list_messages ( ) |> Enum . reverse ( )
changeset = Message . changeset ( % Message { } , % { } )
{ :ok , assign ( socket , changeset: changeset , messages: messages ) }
end mount/3 จะได้รับรายการ messages และสร้าง changeset ที่จะใช้สำหรับแบบฟอร์มข้อความ จากนั้นเรากำหนด changeset และ messages ไปยังซ็อกเก็ตซึ่งจะแสดงในหน้า LiveView
อัปเดตเทมเพลต messages.html.heex เป็นรหัสต่อไปนี้:
< ul id =' msg-list ' phx-update =" append " >
< %= for message < - @messages do % >
< li id = { "msg-#{message.id}"} >
< b > < %= message.name % > : </ b >
< %= message.message % >
</ li >
< % end % >
</ ul >
< .form let={f} for={@changeset} id="form" phx-submit="new_message" phx-hook="Form" >
< %= text_input f, :name, id: "name", placeholder: "Your name", autofocus: "true" % >
< %= error_tag f, :name % >
< %= text_input f, :message, id: "msg", placeholder: "Your message" % >
< %= error_tag f, :message % >
< %= submit "Send"% >
</ .form > ก่อนอื่นจะแสดงข้อความใหม่จากนั้นให้แบบฟอร์มสำหรับผู้คนใน create ข้อความใหม่
หากคุณรีเฟรชหน้าคุณควรดูสิ่งต่อไปนี้:

<.form></.form> ไวยากรณ์คือวิธีการใช้ส่วนประกอบฟังก์ชันฟอร์ม
ส่วนประกอบของฟังก์ชั่นคือฟังก์ชั่นใด ๆ ที่ได้รับ
assignsแผนที่เป็นอาร์กิวเมนต์และส่งคืนstructที่แสดงผลที่สร้างขึ้นด้วย~Hsigil
ในที่สุดเรามาตรวจสอบให้แน่ใจว่าการทดสอบยังคงผ่านไปโดยการอัปเดต assert ใน test/liveview_chat_web/live/message_live_test.exs ไฟล์เป็น:
assert html_response ( conn , 200 ) =~ "LiveView Chat" เนื่องจากเราได้ลบชื่อ LiveView Message Page H1 เราสามารถทดสอบชื่อในเค้าโครงรูทแทนและตรวจสอบให้แน่ใจว่าหน้ายังคงปรากฏขึ้นอย่างถูกต้อง
ในขณะนี้ถ้าเราเรียกใช้แอพ Phoenix mix phx.server และส่งแบบฟอร์มในเบราว์เซอร์จะไม่มีอะไรเกิดขึ้น หากเราดูบันทึกเซิร์ฟเวอร์เราจะเห็นสิ่งต่อไปนี้:
** (UndefinedFunctionError) function LiveviewChatWeb.MessageLive.handle_event/3
is undefined or private
(liveview_chat 0.1.0) LiveviewChatWeb.MessageLive.handle_event("new_message",
%{"_csrf_token" => "fyVPIls_XRBuGwlkMhxsFAciRRkpAVUOLW5k4UoR7JF1uZ5z2Dundigv",
"message" => %{"message" => "", "name" => ""}}, #Phoenix.LiveView.Socket
ในการส่งแบบฟอร์มกำลังสร้างเหตุการณ์ใหม่ที่กำหนดด้วย phx-submit :
< . form let = { f } for = { @ changeset } id = "form" phx - submit = "new_message" >
อย่างไรก็ตามเหตุการณ์นี้ยังไม่ได้รับการจัดการบนเซิร์ฟเวอร์เราสามารถแก้ไขได้โดยการเพิ่มฟังก์ชั่น handle_event/3 ใน lib/liveview_chat_web/live/message_live.ex :
def handle_event ( "new_message" , % { "message" => params } , socket ) do
case Message . create_message ( params ) do
{ :error , changeset } ->
{ :noreply , assign ( socket , changeset: changeset ) }
{ :ok , _message } ->
changeset = Message . changeset ( % Message { } , % { "name" => params [ "name" ] } )
{ :noreply , assign ( socket , changeset: changeset ) }
end
end ฟังก์ชั่น create_message ถูกเรียกด้วยค่าจากแบบฟอร์ม หากเกิด error ในขณะที่พยายามบันทึกข้อมูลในฐานข้อมูลตัวอย่างเช่น changeset สามารถส่งคืนข้อผิดพลาดได้หากชื่อหรือ message ว่างเปล่าหรือหาก message สั้นเกินไป changeset จะถูกกำหนดอีกครั้งให้กับซ็อกเก็ต สิ่งนี้จะช่วยให้แบบฟอร์มแสดงข้อมูล error :

หากข้อความถูกบันทึกโดยไม่มีข้อผิดพลาดใด ๆ เรากำลังสร้างชุดการเปลี่ยนแปลงใหม่ซึ่งมีชื่อจากแบบฟอร์มเพื่อหลีกเลี่ยงคนที่ต้องป้อนชื่อของพวกเขาอีกครั้งในแบบฟอร์มและเรากำหนดชุดการเปลี่ยนแปลงใหม่ให้กับซ็อกเก็ต

ตอนนี้แบบฟอร์มจะปรากฏขึ้นเราสามารถเพิ่มการทดสอบต่อไปนี้เพื่อ test/liveview_chat_web/live/message_live_test.exs :
import Plug.HTML , only: [ html_escape: 1 ]
test "name can't be blank" , % { conn: conn } do
{ :ok , view , _html } = live ( conn , "/" )
assert view
|> form ( "#form" , message: % { name: "" , message: "hello" } )
|> render_submit ( ) =~ html_escape ( "can't be blank" )
end
test "message" , % { conn: conn } do
{ :ok , view , _html } = live ( conn , "/" )
assert view
|> form ( "#form" , message: % { name: "Simon" , message: "" } )
|> render_submit ( ) =~ html_escape ( "can't be blank" )
end
test "minimum message length" , % { conn: conn } do
{ :ok , view , _html } = live ( conn , "/" )
assert view
|> form ( "#form" , message: % { name: "Simon" , message: "h" } )
|> render_submit ( ) =~ "should be at least 2 character(s)"
end เราใช้ฟังก์ชัน form/3 เพื่อเลือกแบบฟอร์มและทริกเกอร์เหตุการณ์ส่งด้วยค่าที่แตกต่างกันสำหรับชื่อและข้อความ เรากำลังทดสอบว่าข้อผิดพลาดจะปรากฏขึ้นอย่างถูกต้อง
แทนที่จะต้องโหลดหน้าใหม่เพื่อดูข้อความที่สร้างขึ้นใหม่เราสามารถใช้ PubSub ( Pub Lish Sub Scribe) เพื่อแจ้งลูกค้าที่เชื่อมต่อทั้งหมดว่ามีการสร้างข้อความใหม่และอัปเดต UI เพื่อแสดงข้อความใหม่
เปิดไฟล์ lib/liveview_chat/message.ex และเพิ่มบรรทัดต่อไปนี้ใกล้ด้านบน:
alias Phoenix.PubSubถัดไปเพิ่ม 3 ฟังก์ชั่นต่อไปนี้:
def subscribe ( ) do
PubSub . subscribe ( LiveviewChat.PubSub , "liveview_chat" )
end
def notify ( { :ok , message } , event ) do
PubSub . broadcast ( LiveviewChat.PubSub , "liveview_chat" , { event , message } )
end
def notify ( { :error , reason } , _event ) , do: { :error , reason } subscribe/0 จะถูกเรียกเมื่อไคลเอนต์ได้แสดงหน้า LiveView อย่างถูกต้องและฟังข้อความใหม่ มันเป็นเพียงฟังก์ชั่น wrapper สำหรับ phoenix.pubsub.subscribe
notify/2 จะถูกเรียกใช้ในแต่ละครั้งที่มีการสร้างข้อความใหม่เพื่อออกอากาศข้อความไปยังไคลเอนต์ที่เชื่อมต่อ Repo.insert สามารถส่งคืน {:ok, message} หรือ {:error, reason} ดังนั้นเราต้องกำหนด notify/2 จัดการทั้งสองกรณี
อัปเดตฟังก์ชั่น create_message/1 ใน message.ex เพื่อเรียกใช้ฟังก์ชั่น notify/2 ที่สร้างขึ้นใหม่ของเรา:
def create_message ( attrs ) do
% Message { }
|> changeset ( attrs )
|> Repo . insert ( )
|> notify ( :message_created )
endmount/3 ตอนนี้เราสามารถเชื่อมต่อไคลเอนต์เมื่อแสดงหน้า LiveView ที่ด้านบนของไฟล์ lib/liveview_chat_web/live/message_live.ex เพิ่มบรรทัดต่อไปนี้:
alias LiveviewChat.PubSub จากนั้นอัปเดตฟังก์ชั่น mount/3 ด้วย:
def mount ( _params , _session , socket ) do
if connected? ( socket ) , do: Message . subscribe ( )
messages = Message . list_messages ( ) |> Enum . reverse ( )
changeset = Message . changeset ( % Message { } , % { } )
{ :ok , assign ( socket , messages: messages , changeset: changeset ) }
end mount/3 ตอนนี้ตรวจสอบซ็อกเก็ตเชื่อมต่อจากนั้นเรียก Message.subscribe/0 ฟังก์ชั่น
handle_event/3 เนื่องจากค่าส่งคืนของ create_message/1 มีการเปลี่ยนแปลงเราจำเป็นต้องอัปเดต handle_event/3 เป็นต่อไปนี้:
def handle_event ( "new_message" , % { "message" => params } , socket ) do
case Message . create_message ( params ) do
{ :error , changeset } ->
{ :noreply , assign ( socket , changeset: changeset ) }
:ok -> # broadcast returns :ok (just the atom!) if there are no errors
changeset = Message . changeset ( % Message { } , % { "name" => params [ "name" ] } )
{ :noreply , assign ( socket , changeset: changeset ) }
end
endhandle_info/2 ขั้นตอนสุดท้ายคือการจัดการเหตุการณ์ :message_created โดยกำหนดฟังก์ชั่น handle_info/2 ใน lib/liveview_chat_web/live/message_live.ex :
def handle_info ( { :message_created , message } , socket ) do
messages = socket . assigns . messages ++ [ message ]
{ :noreply , assign ( socket , messages: messages ) }
endเมื่อได้รับเหตุการณ์ข้อความใหม่จะถูกเพิ่มลงในรายการข้อความที่มีอยู่ รายการใหม่จะถูกกำหนดให้กับซ็อกเก็ตซึ่งจะอัปเดต UI เพื่อแสดงข้อความใหม่
เพิ่มการทดสอบต่อไปนี้เพื่อ test/liveview_chat_web/live/message_live_test.exs เพื่อให้แน่ใจว่าข้อความจะปรากฏขึ้นอย่างถูกต้องบนหน้า:
test "message form submitted correctly" , % { conn: conn } do
{ :ok , view , _html } = live ( conn , "/" )
assert view
|> form ( "#form" , message: % { name: "Simon" , message: "hi" } )
|> render_submit ( )
assert render ( view ) =~ "<b>Simon:</b>"
assert render ( view ) =~ "hi"
end
test "handle_info/2" , % { conn: conn } do
{ :ok , view , _html } = live ( conn , "/" )
assert render ( view )
# send :created_message event when the message is created
Message . create_message ( % { "name" => "Simon" , "message" => "hello" } )
# test that the name and the message is displayed
assert render ( view ) =~ "<b>Simon:</b>"
assert render ( view ) =~ "hello"
end ตอนนี้คุณควรมีแอปพลิเคชันแชทที่ใช้งานได้โดยใช้ LiveView! เรียกใช้แอ Phoenix ด้วย:
mix phx.server เยี่ยมชมแอพ localhost:4000 ใน 2 เบราว์เซอร์ขึ้นไปและส่งข้อความด้วยตัวเอง!

ปัญหาหนึ่งที่เราสามารถสังเกตได้คือการป้อนข้อความไม่ได้รีเซ็ตค่าว่างเสมอหลังจากส่งข้อความโดยใช้ปุ่ม Enter บนฟิลด์อินพุต สิ่งนี้บังคับให้เราลบข้อความก่อนหน้าด้วยตนเองก่อนที่จะเขียนและส่งข้อความใหม่
เหตุผลคือ:
ไคลเอนต์ JavaScript เป็นแหล่งที่มาของความจริงสำหรับค่าอินพุตปัจจุบันเสมอ สำหรับ อินพุตใด ๆ ที่มีโฟกัส
LiveViewจะไม่เขียนทับค่าปัจจุบันของอินพุตแม้ว่าจะเบี่ยงเบนจากการอัปเดตที่แสดงผลของเซิร์ฟเวอร์ ดู: https://hexdocs.pm/phoenix_live_view/form-bindings.html#javascript-client-specifics
ทางออกของเราคือการใช้ phx-hook เพื่อเรียกใช้ JavaScript บนไคลเอนต์หลังจากหนึ่งในการเรียกกลับวัฏจักรชีวิต LiveView (ติดตั้งก่อนอัปเดตอัปเดตทำลายทำลายการเชื่อมต่อเชื่อมต่อใหม่)
มาเพิ่มตะขอเพื่อตรวจสอบเมื่อมี updated แบบฟอร์มข้อความ ในไฟล์ message.html.heex เพิ่มแอตทริบิวต์ phx-hook ลงในองค์ประกอบ <.form> :
< .form let={f} for={@changeset} id="form" phx-submit="new_message" phx-hook="Form" > จากนั้นในไฟล์ assets/js/app.js ให้เพิ่มตรรกะ JavaScript ต่อไปนี้:
// get message input element
let msg = document . getElementById ( 'msg' ) ;
// define "Form" hook, the name must match the one
// defined with phx-hoo="Form"
let Hooks = { }
Hooks . Form = {
// Each time the form is updated run the code in the callback
updated ( ) {
// If no error displayed reset the message value
if ( document . getElementsByClassName ( 'invalid-feedback' ) . length == 0 ) {
msg . value = '' ;
}
}
}
let csrfToken = document . querySelector ( "meta[name='csrf-token']" ) . getAttribute ( "content" )
let liveSocket = new LiveSocket ( "/live" , Socket , { params : { _csrf_token : csrfToken } , hooks : Hooks } ) // Add hooks: Hooks ตรรกะหลักในการรีเซ็ตค่าข้อความที่อยู่ในฟังก์ชันการโทรกลับ updated() :
if ( document . getElementsByClassName ( 'invalid-feedback' ) . length == 0 ) {
msg . value = '' ;
} ก่อนที่จะตั้งค่าค่าเป็นสตริงที่ว่างเปล่าเราตรวจสอบก่อนว่าไม่มีข้อผิดพลาดใด ๆ ที่แสดงในแบบฟอร์มโดยการตรวจสอบคลาส CSS invalid-feedback (อ่านเพิ่มเติมเกี่ยวกับข้อเสนอแนะ: https://hexdocs.pm/phoenix_live_view/form-bindings.html#phx-feedback-for)
ขั้นตอนสุดท้ายคือการตั้ง hooks บน liveSocket ด้วย hooks: Hooks ตอนนี้อินพุตข้อความควรรีเซ็ตเมื่อมีการเพิ่มข้อความใหม่!
ในขณะนี้ฟังก์ชั่น mount/3 เป็นครั้งแรกเริ่มต้นรายการข้อความโดยการโหลดข้อความ 20 ข้อความล่าสุดจากฐานข้อมูล:
def mount ( _params , _session , socket ) do
if connected? ( socket ) , do: Message . subscribe ( )
messages = Message . list_messages ( ) |> Enum . reverse ( ) # get the list of messages
changeset = Message . changeset ( % Message { } , % { } )
{ :ok , assign ( socket , messages: messages , changeset: changeset ) } ## assigns messages to socket
end จากนั้นทุกครั้งที่มีการสร้างข้อความใหม่ฟังก์ชั่น handle_info ผนวกข้อความเข้ากับรายการข้อความ:
def handle_info ( { :message_created , message } , socket ) do
messages = socket . assigns . messages ++ [ message ] # append new message to the existing list
{ :noreply , assign ( socket , messages: messages ) }
endสิ่งนี้อาจทำให้เกิดปัญหาได้หากรายการข้อความนานเกินไปเนื่องจากข้อความทั้งหมดถูกเก็บไว้ในหน่วยความจำบนเซิร์ฟเวอร์
เพื่อลดการใช้หน่วยความจำให้น้อยที่สุดเราสามารถกำหนดข้อความเป็นการ assign ชั่วคราว:
def mount ( _params , _session , socket ) do
if connected? ( socket ) , do: Message . subscribe ( )
messages = Message . list_messages ( ) |> Enum . reverse ( )
changeset = Message . changeset ( % Message { } , % { } )
{ :ok , assign ( socket , messages: messages , changeset: changeset ) ,
temporary_assigns: [ messages: [ ] ] }
endรายการข้อความจะถูกเรียกคืนหนึ่งครั้งจากนั้นจะรีเซ็ตเป็นรายการว่าง
ตอนนี้ handle_info/2 ต้องการกำหนดข้อความใหม่ให้กับซ็อกเก็ตเท่านั้น:
def handle_info ( { :message_created , message } , socket ) do
{ :noreply , assign ( socket , messages: [ message ] ) }
end ในที่สุดเทมเพลตข้อความ heex จะรับฟังการเปลี่ยนแปลงใด ๆ ในรายการข้อความที่มี phx-update และผนวกข้อความใหม่เข้ากับรายการที่แสดงที่มีอยู่
< ul id =' msg-list ' phx-update =" append " >
< %= for message < - @messages do % >
< li id = {message.id} >
< b > < %= message.name % > : </ b >
< %= message.message % >
</ li >
< % end % >
</ ul > ดูเพิ่มเติมที่หน้าเอกสาร temporary-assigns ของ Phoenix: https://hexdocs.pm/phoenix_live_view/dom-patching.html#temporary-assigns
ขณะนี้ฟิลด์ name ถูกทิ้งให้กับบุคคลที่จะกำหนด ด้วยตนเอง ก่อนที่พวกเขาจะส่งข้อความ นี่เป็นสิ่งที่ดีในแอพสาธิตขั้นพื้นฐาน แต่เรารู้ว่าเราทำได้ดีกว่า ในส่วนนี้เราจะเพิ่มการรับรองความถูกต้องโดยใช้ auth_plug ที่จะช่วยให้ผู้คนที่ใช้แอพสามารถตรวจสอบสิทธิ์ด้วยบัญชี GitHub หรือ Google ของพวกเขาจากนั้นเติม name ในแบบฟอร์มข้อความล่วงหน้า
AUTH_API_KEYตามคำแนะนำแรกสร้าง คีย์ API ใหม่ที่ https://authdemo.fly.dev/ เช่น:

จากนั้นสร้างไฟล์ .env และเพิ่มคีย์ API ที่สร้างขึ้นใหม่ของคุณ:
export AUTH_API_KEY = 88SwQGzaZoJYXs6ihvwMy2dRVtm6KVeg4tSCjRKtwDvMUYUbi/88SwQDatWtSTMd2rKPnaZsAWFNpbf4vv2ZK7JW2nwuSypMeg/authdemo.fly.devหมายเหตุ : ด้วยเหตุผลด้านความปลอดภัยนี่ไม่ใช่คีย์ API ที่ถูกต้อง โปรดสร้างของคุณเองฟรีและใช้เวลาน้อยกว่าหนึ่งนาที
auth_plug เพิ่มแพ็คเกจ auth_plug ลงในการพึ่งพาของคุณ ในไฟล์ mix.exs อัปเดตฟังก์ชัน deps ของคุณและเพิ่ม:
{ :auth_plug , "~> 1.4.10" } การพึ่งพานี้จะสร้างเซสชันใหม่สำหรับคุณและสื่อสารกับแอปพลิเคชัน DWYL auth
อย่าลืม:
source .envmix deps.get ตรวจสอบให้แน่ใจว่า AUTH_API_KEY สามารถเข้าถึงได้ก่อนที่จะรวบรวมการพึ่งพาใหม่
คุณสามารถคอมไพล์การพึ่งพาได้อีกครั้งด้วย mix deps.compile --force
ตอนนี้เราสามารถเริ่มเพิ่มคุณสมบัติการรับรองความถูกต้อง
router.ex เพื่ออนุญาตให้ผู้ใช้ "ไม่ผ่านการตรวจสอบ" ผู้ใช้ "เข้าถึงแชทเราใช้ปลั๊ก AuthPlugOptional อ่านเพิ่มเติมได้ที่ Auth เสริม
ในไฟล์ router.ex เราสร้าง Plug Pipeline ใหม่:
# define the new pipeline using auth_plug
pipeline :authOptional , do: plug ( AuthPlugOptional ) ต่อไปอัปเดต scope "/", LiveviewChatWeb do บล็อกเป็นสิ่งต่อไปนี้:
scope "/" , LiveviewChatWeb do
pipe_through [ :browser , :authOptional ]
live "/" , MessageLive
get "/login" , AuthController , :login
get "/logout" , AuthController , :logout
endตอนนี้เราอนุญาตให้การรับรองความถูกต้องเป็น ตัวเลือก สำหรับเส้นทางทั้งหมดในเราเตอร์ ง่ายเฮ้?
AuthController สร้าง AuthController ด้วยฟังก์ชั่นทั้ง login/2 และ logout/2
สร้างไฟล์ใหม่: lib/liveview_chat_web/controllers/auth_controller.ex และเพิ่มรหัสต่อไปนี้:
defmodule LiveviewChatWeb.AuthController do
use LiveviewChatWeb , :controller
def login ( conn , _params ) do
redirect ( conn , external: AuthPlug . get_auth_url ( conn , "/" ) )
end
def logout ( conn , _params ) do
conn
|> AuthPlug . logout ( )
|> put_status ( 302 )
|> redirect ( to: "/" )
end
end ฟังก์ชั่น login/2 เปลี่ยนเส้นทางไปยังแอพ Dwyl Auth อ่านเพิ่มเติมเกี่ยวกับวิธีการใช้ฟังก์ชั่น AuthPlug.get_auth_url/2 เมื่อตรวจสอบสิทธิ์ผู้ใช้จะถูกเปลี่ยนเส้นทางไปยัง / จุดสิ้นสุดและเซสชัน jwt จะถูกสร้างขึ้นบนไคลเอนต์
ฟังก์ชั่น logout/2 เรียกใช้ AuthPlug.logout/1 ซึ่งลบเซสชัน (JWT) และเปลี่ยนเส้นทางกลับไปที่หน้าแรก
on_mount/4 LiveView จัดเตรียมการโทรกลับ on_mount ที่ให้เราเรียกใช้รหัส ก่อน mount เราจะใช้การโทรกลับนี้เพื่อตรวจสอบเซสชัน jwt และกำหนดค่า person ( Map ) และค่า loggedin ( boolean ) ให้กับ socket
ในไฟล์ lib/liveview_chat_web/controllers/auth_controller.ex เพิ่มรหัสต่อไปนี้เพื่อกำหนดสองรุ่นของ mount/4 :
# import the assign_new function from LiveView
import Phoenix.LiveView , only: [ assign_new: 3 ]
# pattern match on :default auth and check session has jwt
def on_mount ( :default , _params , % { "jwt" => jwt } = _session , socket ) do
# verify and retrieve jwt stored data
claims = AuthPlug.Token . verify_jwt! ( jwt )
# assigns the person and the loggedin values
socket =
socket
|> assign_new ( :person , fn ->
AuthPlug.Helpers . strip_struct_metadata ( claims )
end )
|> assign_new ( :loggedin , fn -> true end )
{ :cont , socket }
end
# when jwt is not defined just returns the current socket
def on_mount ( :default , _params , _session , socket ) do
socket = assign_new ( socket , :loggedin , fn -> false end )
{ :cont , socket }
endAssign_New/3 กำหนดค่าให้กับซ็อกเก็ตหากไม่มีอยู่
เมื่อมีการกำหนดค่าโทรกลับ on_mount/2 แล้วเราสามารถเรียกได้ใน lib/liveview_chat_web/live/message_live.ex ของเรา: ไฟล์:
defmodule LiveviewChatWeb.MessageLive do
use LiveviewChatWeb , :live_view
alias LiveviewChat.Message
# run authentication on mount
on_mount LiveviewChatWeb.AuthController
ตอนนี้เรามีตรรกะทั้งหมดเพื่อให้ผู้คนรับรองความถูกต้องเราเพียงแค่ต้องอัปเดตไฟล์เค้าโครงรูทของเรา lib/liveview_chat_web/templates/layout/root.html.heex เพื่อแสดงลิงค์ login (หรือ logout ):
< body >
< header >
< section class =" container " >
< nav >
< ul >
< %= if @loggedin do % >
< li >
< img width =" 40px " src = {@person.picture}/ >
</ li >
< li > < %= link "logout", to: "/logout" % > </ li >
< % else % >
< li > < %= link "Login", to: "/login" % > </ li >
< % end % >
</ ul >
</ nav >
< h1 > LiveView Chat Example </ h1 >
</ section >
</ header >
< %= @inner_content % >
</ body > หากบุคคลนั้นยัง ไม่ได้ loggedin เราจะแสดงลิงค์ login มิฉะนั้นลิงค์ออก logout จะปรากฏขึ้น
ขั้นตอนสุดท้ายคือการแสดงชื่อของบุคคลที่เข้าสู่ระบบในฟิลด์ชื่อของแบบฟอร์มข้อความ เพื่อที่เราสามารถอัปเดตฟอร์มการเปลี่ยนแปลงในฟังก์ชั่น mount เพื่อตั้งค่าพารามิเตอร์ชื่อ:
def mount ( _params , _session , socket ) do
if connected? ( socket ) , do: Message . subscribe ( )
# add name parameter if loggedin
changeset =
if socket . assigns . loggedin do
Message . changeset ( % Message { } , % { "name" => socket . assigns . person [ "givenName" ] } )
else
Message . changeset ( % Message { } , % { } )
end
messages = Message . list_messages ( ) |> Enum . reverse ( )
{ :ok , assign ( socket , messages: messages , changeset: changeset ) ,
temporary_assigns: [ messages: [ ] ] }
endตอนนี้คุณสามารถเรียกใช้แอปพลิเคชันและสามารถเข้าสู่ระบบ/ออกจากระบบ!

ในส่วนนี้เราจะใช้ สถานะของฟีนิกซ์ เพื่อแสดงรายการของผู้ที่ใช้แอปพลิเคชัน
ขั้นตอนแรกคือการสร้างไฟล์ lib/liveview_chat/presence.ex :
defmodule LiveviewChat.Presence do
use Phoenix.Presence ,
otp_app: :liveview_chat ,
pubsub_server: LiveviewChat.PubSub
end จากนั้นใน lib/liveview_chat/application.ex เราเพิ่มโมดูล Presence ที่สร้างขึ้นใหม่ลงในรายการแอปพลิเคชันเพื่อให้หัวหน้างานเริ่มต้น:
def start ( _type , _args ) do
children = [
# Start the Ecto repository
LiveviewChat.Repo ,
# Start the Telemetry supervisor
LiveviewChatWeb.Telemetry ,
# Start the PubSub system
{ Phoenix.PubSub , name: LiveviewChat.PubSub } ,
# Presence
LiveviewChat.Presence ,
# Start the Endpoint (http/https)
LiveviewChatWeb.Endpoint
# Start a worker by calling: LiveviewChat.Worker.start_link(arg)
# {LiveviewChat.Worker, arg}
]
...
ตอนนี้เราพร้อมที่จะใช้คุณสมบัติการแสดงตนในจุดสิ้นสุดของ LiveView
ในไฟล์ lib/liveview_chat_web/live/message_live.ex อัปเดตฟังก์ชั่น mount ด้วยสิ่งต่อไปนี้:
@ presence_topic "liveview_chat_presence"
def mount ( _params , _session , socket ) do
if connected? ( socket ) do
Message . subscribe ( )
{ id , name } =
if socket . assigns . loggedin do
{ socket . assigns . person [ "id" ] , socket . assigns . person [ "givenName" ] }
else
{ socket . id , "guest" }
end
{ :ok , _ } = Presence . track ( self ( ) , @ presence_topic , id , % { name: name } )
Phoenix.PubSub . subscribe ( PubSub , @ presence_topic )
end
changeset =
if socket . assigns . loggedin do
Message . changeset ( % Message { } , % { "name" => socket . assigns . person [ "givenName" ] } )
else
Message . changeset ( % Message { } , % { } )
end
messages = Message . list_messages ( ) |> Enum . reverse ( )
{ :ok ,
assign ( socket ,
messages: messages ,
changeset: changeset ,
presence: get_presence_names ( )
) , temporary_assigns: [ messages: [ ] ] }
end มาสรุปการเปลี่ยนแปลงหลักของฟังก์ชั่น mount/3 :
ก่อนอื่นเราสร้างแอตทริบิวต์โมดูล @presence_topic เพื่อกำหนด topic ที่เราจะใช้กับฟังก์ชั่นการแสดงตน
ส่วนต่อไปนี้ของรหัสกำหนด tuple ที่มี id ของบุคคลและชื่อของพวกเขา ชื่อจะเริ่มต้นเป็น "แขก" หากบุคคลนั้น ไม่ได้ เข้าสู่ระบบ
{ id , name } =
if socket . assigns . loggedin do
{ socket . assigns . person [ "id" ] , socket . assigns . person [ "givenName" ] }
else
{ socket . id , "guest" }
endประการที่สองเราใช้ฟังก์ชั่นแทร็ก/4 เพื่อให้สถานะรู้ว่าไคลเอนต์ใหม่กำลังดูแอปพลิเคชัน:
{ :ok , _ } = Presence . track ( self ( ) , @ presence_topic , id , % { name: name } )ประการที่สามเราใช้ PubSub เพื่อฟังการเปลี่ยนแปลงสถานะ (บุคคลที่เข้าร่วมหรือออกจากแอปพลิเคชัน):
Phoenix.PubSub . subscribe ( PubSub , @ presence_topic ) ในที่สุดเราก็สร้าง presence ใหม่ที่กำหนดในซ็อกเก็ต:
presence : get_presence_names ( ) ฟังก์ชั่น get_presence_names จะส่งคืนรายชื่อผู้ใช้ Loggedin และหากผู้ใช้ "แขก" จำนวนใด ๆ
เพิ่มรหัสต่อไปนี้ในตอนท้ายของโมดูล MessageLive :
defp get_presence_names ( ) do
Presence . list ( @ presence_topic )
|> Enum . map ( fn { _k , v } -> List . first ( v . metas ) . name end )
|> group_names ( )
end
# return list of names and number of guests
defp group_names ( names ) do
loggedin_names = Enum . filter ( names , fn name -> name != "guest" end )
guest_names =
Enum . count ( names , fn name -> name == "guest" end )
|> guest_names ( )
if guest_names do
[ guest_names | loggedin_names ]
else
loggedin_names
end
end
defp guest_names ( 0 ) , do: nil
defp guest_names ( 1 ) , do: "1 guest"
defp guest_names ( n ) , do: " #{ n } guests" การเรียกใช้ฟังก์ชั่นที่สำคัญในรหัสด้านบนคือ Presence.list(@presence_topic) ฟังก์ชั่นรายการ/1 ส่งคืนรายชื่อผู้ใช้โดยใช้แอปพลิเคชัน ฟังก์ชั่น group_names และ guest_names อยู่ที่นี่เพื่อจัดการข้อมูลการแสดงตนที่ส่งคืนโดย list ดู https://hexdocs.pm/phoenix/phoenix.presence.html#c:list/1-presence-data-structure
จนถึงตอนนี้เราได้ติดตามผู้คนใหม่ ๆ โดยใช้หน้าแชทในฟังก์ชั่น mount และเราได้ใช้ PubSub เพื่อฟังการเปลี่ยนแปลงสถานะ ขั้นตอนสุดท้ายคือการจัดการการเปลี่ยนแปลงเหล่านี้โดยการเพิ่มฟังก์ชั่น handle_info :
def handle_info ( % { event: "presence_diff" , payload: _diff } , socket ) do
{ :noreply , assign ( socket , presence: get_presence_names ( ) ) }
endในที่สุดความแตกต่างของการเข้าร่วมและออกจากกิจกรรมจะถูกส่งไปยังลูกค้าเมื่อพวกเขาเกิดขึ้นแบบเรียลไทม์กับเหตุการณ์ "present_diff"
ฟังก์ชั่น handle_info จับเหตุการณ์ presence_diff และกำหนดใหม่ให้กับซ็อกเก็ตค่า presence ด้วยผลลัพธ์ของการเรียกฟังก์ชัน get_presence_names
ในการแสดงชื่อที่เราเพิ่มต่อไปนี้ใน lib/liveview_chat_web/templates/message/messages.html.heex ไฟล์เทมเพลต:
< b > People currently using the app: </ b >
< ul >
< %= for name < - @presence do % >
< li >
< %= name % >
</ li >
< % end % >
</ ul >ตอนนี้คุณควรจะสามารถเรียกใช้แอปพลิเคชันและดูผู้ใช้ Loggedin และจำนวนผู้ใช้แขก
เราสามารถทดสอบได้ว่าเทมเพลตได้รับการอัปเดตอย่างถูกต้องโดยการเพิ่มการทดสอบทั้งสองนี้ใน test/liveview_chat_web/live/message_live_test.exs :
test "1 guest online" , % { conn: conn } do
{ :ok , view , _html } = live ( conn , "/" )
assert render ( view ) =~ "1 guest"
end
test "2 guests online" , % { conn: conn } do
{ :ok , _view , _html } = live ( conn , "/" )
{ :ok , view2 , _html } = live ( conn , "/" )
assert render ( view2 ) =~ "2 guests"
end หากคุณยังใหม่กับ Tailwind โปรดดู: https://github.com/dwyl/learn-tailwind
แทนที่เนื้อหาของ lib/liveview_chat_web/templates/layout/root.html.heex ด้วย:
<!DOCTYPE html >
< html lang =" en " >
< head >
< meta charset =" utf-8 " />
< meta http-equiv =" X-UA-Compatible " content =" IE=edge " />
< meta name =" viewport " content =" width=device-width, initial-scale=1.0 " />
< meta name =" csrf-token " content = {csrf_token_value()} >
< %= live_title_tag assigns[:page_title] || "LiveviewChat", suffix: " · Phoenix Framework" % >
< script defer phx-track-static type =" text/javascript " src = {Routes.static_path(@conn, "/assets/app.js")} > </ script >
< script src =" https://cdn.tailwindcss.com " > </ script >
</ head >
< body >
< header class =" bg-slate-800 w-full min-h-[15%] pt-5 pb-1 mb-2 " >
< section >
< nav >
< div class =" text-white width-[10%] float-left ml-3 -mt-5 align-middle " >
< b > People in Chat: </ b >
< ul >
< %= for name < - @presence do % >
< li >
< %= name % >
</ li >
< % end % >
</ ul >
</ div >
< ul class =" float-right mr-3 " >
< %= if @loggedin do % >
< li >
< img width =" 42px " src = {@person.picture} class =" -mt-3 " />
</ li >
< li class =" text-white " >
< %= link "logout", to: "/logout" % >
</ li >
< % else % >
< li class =" bg-green-600 text-white rounded-xl px-4 py-2 w-full mb-2 font-bold " >
< %= link "Login", to: "/login" % >
</ li >
< % end % >
</ ul >
</ nav >
< h1 class =" text-3xl mb-4 text-center font-mono text-white " > LiveView Chat Example </ h1 >
</ section >
</ header >
< %= @inner_content % >
</ body >
</ html > จากนั้นแทนที่เนื้อหาของ lib/liveview_chat_web/templates/message/messages.html.heex ด้วย:
< ul id =' msg-list ' phx-update =" append " >
< %= for message < - @messages do % >
< li id = { "msg-#{message.id}"} class="px-5" >
< small class =" float-right text-xs align-middle " >
< %= message.inserted_at % >
</ small >
< b > < %= message.name % > : </ b >
< %= message.message % >
</ li >
< % end % >
</ ul >
< footer class =" fixed bottom-0 w-full bg-slate-300 pb-2 px-5 pt-2 " >
< .form let={f} for={@changeset} id="form" phx-submit="new_message" phx-hook="Form" >
< %= if @loggedin do % >
< %= text_input f, :name, id: "name", value: @person.givenName,
class: "hidden" % >
< % else % >
< %= text_input f, :name, id: "name", placeholder: "Name", autofocus: "true",
class: "border p-2 w-9/12 mb-2 mt-2 mr2" % >
< span class =" italic text-2xl ml-4 " > or </ span >
< span class =" bg-green-600 text-white rounded-xl px-4 py-2 mb-2 mt-3 float-right " >
< %= link "Login", to: "/login" % >
</ span >
< %= error_tag f, :name % >
< % end % >
< %= text_input f, :message, id: "msg", placeholder: "Message",
class: "border p-2 w-10/12 mb-2 mt-2 float-left" % >
< p class =" text-amber-600 " >
< %= error_tag f, :message % >
</ p >
< %= submit "Send", class: "bg-sky-600 text-white rounded-xl px-4 py-2 mt-2 float-right" % >
</ .form >
</ footer >ตอนนี้คุณควรมี UI/เลย์เอาต์ที่มีลักษณะเช่นนี้:

หากคุณมีคำถามเกี่ยวกับคลาส Tailwind ใด ๆ ที่ใช้โปรดใช้เวลา 2 นาที googling และถ้าคุณยังคงติดอยู่ให้เปิดปัญหา
หากคุณพบว่าตัวอย่างนี้มีประโยชน์โปรด rep ที่เก็บ GitHub เพื่อให้เรา ( และอื่น ๆ ) รู้ว่าคุณชอบมัน!
นี่คือที่เก็บอื่น ๆ ที่คุณอาจต้องการอ่าน:
มีคำถามหรือข้อเสนอแนะ? อย่าลังเลที่จะเปิดปัญหาใหม่!
ขอบคุณ!