การจัดการสถานะ Vue 3 สำหรับแผงผู้ดูแล: Pinia กับ local
การจัดการสถานะใน Vue 3 สำหรับแผงผู้ดูแล: เลือกระหว่าง Pinia, provide/inject และสถานะท้องถิ่น โดยใช้ตัวอย่างจริงเช่น ฟิลเตอร์ ร่างงาน และแท็บ

อะไรทำให้การจัดการสถานะยุ่งยากในแผงผู้ดูแล
แผงผู้ดูแลมักหนักไปด้วยสถานะเพราะมีชิ้นส่วนหลายอย่างอยู่บนหน้าจอเดียว ตารางไม่ได้เป็นแค่ข้อมูลอย่างเดียว มันยังมีการเรียง, ฟิลเตอร์, การแบ่งหน้า, แถวที่ถูกเลือก และบริบทว่า “อะไรเพิ่งเกิดขึ้น?” ที่ผู้ใช้พึ่งพาอยู่ เพิ่มฟอร์มยาว สิทธิ์ตามบทบาท และการกระทำที่จะเปลี่ยนพฤติกรรม UI แล้วการตัดสินใจเกี่ยวกับสถานะเล็ก ๆ ก็เริ่มมีความสำคัญ
ความท้าทายไม่ใช่การเก็บค่า แต่เป็นการทำให้พฤติกรรมคาดเดาได้เมื่อหลายคอมโพเนนต์ต้องการความจริงเดียวกัน ถ้าชิปฟิลเตอร์บอกว่า “Active” ตาราง, URL และการส่งออกข้อมูลควรเห็นตรงกัน ถ้าผู้ใช้แก้เรคคอร์ดแล้วออกจากหน้า แอปไม่ควรทำงานหายเงียบ ๆ ถ้าเปิดสองแท็บ แท็บหนึ่งไม่ควรเขียนทับอีกแท็บ
ใน Vue 3 คุณมักต้องเลือกจากสามที่ที่จะเก็บสถานะ:
- Local component state: เป็นของคอมโพเนนต์เดียวและปลอดภัยที่จะรีเซ็ตเมื่อมันถูกถอดออก
provide/inject: สถานะที่แชร์เฉพาะในหน้าเดียวหรือพื้นที่ฟีเจอร์ โดยไม่ต้องส่ง props ผ่านหลายชั้น- Pinia: สถานะที่ต้องอยู่รอดเมื่อเปลี่ยนหน้า ถูกนำกลับมาใช้ข้าม route และง่ายต่อการดีบัก
วิธีที่มีประโยชน์คือสำหรับแต่ละชิ้นของสถานะ ให้ตัดสินใจว่าควรอยู่ที่ไหนเพื่อให้ถูกต้อง ไม่ทำให้ผู้ใช้ประหลาดใจ และไม่กลายเป็นสปาเก็ตตี้
ตัวอย่างด้านล่างยึดตามปัญหาทั่วไปสามอย่างของแผงผู้ดูแล: ฟิลเตอร์และตาราง (อะไรควรคงอยู่หรือรีเซ็ต), ร่างและการแก้ไขที่ยังไม่บันทึก (ฟอร์มที่ผู้ใช้วางใจได้) และการแก้ไขหลายแท็บ (หลีกเลี่ยงการชนของสถานะ)
วิธีง่าย ๆ ในการจำแนกสถานะก่อนเลือกเครื่องมือ
การถกเถียงเรื่องสถานะจะง่ายขึ้นเมื่อคุณหยุดเถียงเรื่องเครื่องมือและเริ่มตั้งชื่อประเภทของสถานะที่มี สถานะแต่ละแบบมีพฤติกรรมต่างกัน และการผสมรวมกันเป็นสาเหตุของบั๊กแปลก ๆ
การแบ่งแยกเชิงปฏิบัติ:
- UI state: สวิตช์, ไดอะล็อกที่เปิด, แถวที่เลือก, แท็บที่ใช้งาน, ลำดับการเรียง
- Server state: คำตอบจาก API, ธงโหลด, ข้อผิดพลาด, เวลาที่รีเฟรชล่าสุด
- Form state: ค่าฟิลด์, ข้อผิดพลาดการตรวจสอบ, ธง dirty, ร่างที่ยังไม่ได้บันทึก
- Cross-screen state: สิ่งที่หลาย route ต้องอ่านหรือเปลี่ยน (workspace ปัจจุบัน, สิทธิ์ที่แชร์)
จากนั้นกำหนด ขอบเขต ถามว่าตอนนี้สถานะถูกใช้งานที่ไหน ไม่ใช่ว่าจะถูกใช้ที่ไหนในอนาคต ถ้ามันสำคัญแค่ในคอมโพเนนต์ตารางตัวเดียว สถานะท้องถิ่นมักจะพอ ถ้าสองคอมโพเนนต์พี่น้องบนหน้าเดียวต้องการมัน ปัญหาที่แท้จริงคือการแชร์ในระดับหน้า ถ้าหลาย route ต้องการ มันเข้าข่ายสถานะแอปที่แชร์
ถัดมาเป็น ช่วงชีวิต สถานะบางอย่างควรรีเซ็ตเมื่อปิดกล่องอื่น บางอย่างควรอยู่รอดเมื่อเปลี่ยนเส้นทาง (ฟิลเตอร์เมื่อกดดูเรคคอร์ดแล้วกลับมา) บางอย่างควรอยู่รอดแม้รีเฟรชหน้า (ร่างงานยาวที่ผู้ใช้กลับมาทีหลัง) การปฏิบัติเหมือนกันสำหรับทั้งสามแบบเป็นสาเหตุให้ฟิลเตอร์รีเซ็ตแบบลึกลับ หรือร่างงานหาย
สุดท้ายตรวจสอบ ความขนาน แผงผู้ดูแลมักเจอกรณีขอบ: ผู้ใช้เปิดเรคคอร์ดเดียวกันในสองแท็บ, รีเฟรชเบื้องหลังอัปเดตแถวในขณะที่ฟอร์มสกปรก, หรือบรรณาธิการสองคนแข่งกันบันทึก
ตัวอย่าง: หน้าจอ “Users” ที่มีฟิลเตอร์ ตาราง และลิ้นชักแก้ไข ฟิลเตอร์เป็น UI state ที่มีช่วงชีวิตแบบหน้า แถวเป็น server state ฟิลด์ในลิ้นชักเป็น form state ถ้าผู้ใช้คนเดียวถูกแก้ไขในสองแท็บ คุณต้องตัดสินใจเรื่องความขนานอย่างชัดเจน: บล็อก, รวม, หรือเตือน
เมื่อคุณติดป้ายสถานะตามประเภท ขอบเขต ช่วงชีวิต และความขนาน ตัวเลือกเครื่องมือ (local, provide/inject, หรือ Pinia) จะกระจ่างขึ้นตามมา
วิธีเลือก: กระบวนการตัดสินใจที่ใช้ได้จริง
การเลือกสถานะที่ดีเริ่มจากนิสัยหนึ่งอย่าง: อธิบายสถานะเป็นคำธรรมดาก่อนเลือกเครื่องมือ แผงผู้ดูแลรวมตาราง ฟิลเตอร์ ฟอร์มใหญ่ และการนำทางระหว่างเรคคอร์ด ดังนั้นแม้สถานะ “เล็ก” ก็อาจกลายเป็นแหล่งบั๊กได้
กระบวนการตัดสินใจ 5 ขั้นตอน
-
ใครต้องการสถานะนี้?
- คอมโพเนนต์เดียว: เก็บไว้เป็น local
- หลายคอมโพเนนต์ใต้หน้าหนึ่ง: พิจารณา
provide/inject - หลาย route: พิจารณา Pinia
ฟิลเตอร์เป็นตัวอย่างที่ดี ถ้าฟิลเตอร์มีผลแค่ตารางเดียวที่ถือมันไว้ สถานะท้องถิ่นก็เพียงพอ ถ้าฟิลเตอร์อยู่ในคอมโพเนนต์ header แต่ขับตารางด้านล่าง การแชร์ในระดับหน้า (โดยมักใช้
provide/inject) จะทำให้โค้ดสะอาด -
มันต้องมีชีวิตอยู่นานเท่าไหร่?
- ถ้ามันหายไปเมื่อคอมโพเนนต์ถูกถอด สถานะท้องถิ่นเหมาะ
- ถ้ามันต้องอยู่รอดเมื่อเปลี่ยน route Pinia มักเหมาะกว่า
- ถ้ามันต้องอยู่รอดหลังรีโหลด คุณต้องมีการเก็บถาวร (storage) ไม่ว่าจะเก็บที่ไหน
ข้อนี้สำคัญสำหรับร่างงาน ร่างที่ยังไม่บันทึกเป็นเรื่องที่ผู้ใช้คาดหวัง: พวกเขาคาดว่าร่างจะยังอยู่ถ้ากดออกแล้วกลับมา
-
มันควรถูกแชร์ข้ามแท็บเบราว์เซอร์หรือแยกต่อแท็บ?
การแก้ไขหลายแท็บเป็นที่ที่บั๊กซ่อนอยู่ ถ้าทุกแท็บควรมีร่างของตัวเอง หลีกเลี่ยง singleton โกลบอล ให้ใช้สถานะที่มีคีย์ตาม ID ของเรคคอร์ด หรือตรวจสอบให้มันอยู่ในขอบเขตหน้าเพื่อไม่ให้แท็บหนึ่งเขียนทับอีกแท็บ
-
เลือกตัวเลือกที่เรียบง่ายที่สุดที่พอเพียง
เริ่มจาก local แล้วเลื่อนไปเมื่อมีความเจ็บปวดจริง ๆ: prop drilling, โค้ดซ้ำ, หรือการรีเซ็ตที่ยากจะทำซ้ำ
-
ยืนยันความต้องการด้านดีบัก
ถ้าต้องการมุมมองที่ชัดเจนและตรวจสอบการเปลี่ยนแปลงข้ามหน้าจอ Pinia ด้วย actions และ state ที่รวมศูนย์จะช่วยได้มาก ถ้าสถานะสั้นและชัดเจน local state อ่านง่ายกว่า
Local component state: เมื่อมันพอ
สถานะท้องถิ่นเป็นค่าเริ่มต้นเมื่อข้อมูลสำคัญแค่คอมโพเนนต์เดียวบนหน้าหนึ่ง มันง่ายและไม่ควรข้ามไปสร้างสโตร์เกินจำเป็นที่คุณต้องดูแลนานหลายเดือน
การใช้งานชัดเจนคือตารางเดียวที่มีฟิลเตอร์ของตัวเอง ถ้าฟิลเตอร์มีผลแค่ตารางเดียว (เช่น รายการ Users) ให้เก็บเป็น ref ภายในคอมโพเนนต์ตาราง สิ่งเดียวกันใช้กับ UI เล็ก ๆ เช่น “modal เปิดหรือปิด?”, “แถวไหนถูกแก้ไข?”, และ “รายการไหนถูกเลือกตอนนี้?”
อย่าจัดเก็บสิ่งที่คำนวณได้ แบนเนอร์ “Active filters (3)” ควรคำนวณจากค่าฟิลเตอร์ปัจจุบัน ป้ายเรียงรูปแบบและธง “สามารถบันทึก” ก็ดีกว่าเมื่อเป็นค่า computed เพราะจะซิงค์โดยอัตโนมัติ
กฎการรีเซ็ตสำคัญกว่าตัวเครื่องมือที่คุณเลือก ตัดสินใจว่าคลียร์อะไรเมื่อเปลี่ยน route (มักเป็นทุกอย่าง) และอะไรควรอยู่เมื่อผู้ใช้สลับมุมมองภายในหน้าเดียวกัน (อาจเก็บฟิลเตอร์แต่ล้างการเลือกชั่วคราวเพื่อหลีกเลี่ยงการกระทำจำนวนมากที่ไม่คาดคิด)
สถานะท้องถิ่นมักพอเมื่อ:
- สถานะส่งผลต่อวิดเจ็ตหนึ่งตัว (ฟอร์มหนึ่ง ตารางหนึ่ง ไดอะล็อกหนึ่ง)
- ไม่มีหน้าจออื่นต้องอ่านหรือเปลี่ยนมัน
- คุณเก็บมันได้ภายใน 1–2 คอมโพเนนต์โดยไม่ต้องส่ง props ผ่านหลายชั้น
- คุณอธิบายพฤติกรรมการรีเซ็ตได้ในหนึ่งประโยค
ข้อจำกัดหลักคือความลึก เมื่อต้องส่งสถานะผ่านหลายคอมโพเนนต์ซ้อนกัน สถานะท้องถิ่นจะกลายเป็น prop drilling นั่นเป็นสัญญาณให้ย้ายไปใช้ provide/inject หรือสโตร์
provide/inject: แชร์สถานะภายในหน้าเดียวหรือพื้นที่ฟีเจอร์
provide/inject อยู่ตรงกลางระหว่างสถานะท้องถิ่นและสโตร์เต็มรูปแบบ พาเรนต์ “provide” ค่าให้ทุกอย่างข้างใต้ และคอมโพเนนต์ย่อย “inject” โดยไม่ต้อง prop drilling ในแอดมิน พฤติกรรมนี้เหมาะเมื่อสถานะเป็นของหน้าจอหรือฟีเจอร์เดียว ไม่ใช่ของทั้งแอป
รูปแบบที่พบบ่อยคือ page shell ที่ถือสถานะ ขณะที่คอมโพเนนต์เล็ก ๆ ดึงมาใช้: แถบฟิลเตอร์ ตาราง แถบการกระทำจำนวนมาก ลิ้นชักรายละเอียด และแบนเนอร์ “การเปลี่ยนแปลงยังไม่บันทึก” Shell สามารถ provide พื้นที่ reactive เล็ก ๆ เช่นวัตถุ filters, วัตถุ draftStatus (dirty, saving, error), และธงอ่านอย่างเดียวบางอย่าง (เช่น isReadOnly ตามสิทธิ์)
ควร provide อะไร (ให้มันเล็ก)
ถ้าคุณ provide ทุกอย่าง คุณแทบจะสร้างสโตร์ขึ้นมาใหม่โดยไม่มีโครงสร้าง Provide เฉพาะสิ่งที่ลูกหลายต้องการจริง ๆ ฟิลเตอร์เป็นตัวอย่างคลาสสิก: เมื่อตาราง ชิป การส่งออก และการแบ่งหน้าต้องซิงค์กัน การแชร์แหล่งเดียวของความจริงดีกว่าการต่อสู้กับ props และ events
ความชัดเจนและกับดัก
ความเสี่ยงใหญ่คือการมีพึ่งพาแบบลับๆ: ลูกทำงานได้เพราะบางอย่างด้านบน provide ข้อมูล และต่อมายากจะบอกว่าอัปเดตมาจากไหน
เพื่อให้โค้ดอ่านง่ายและทดสอบได้ ให้ใช้ชื่อ injection ชัดเจน (มักใช้ constant หรือ Symbol) และชอบให้ actions มากกว่าแค่วัตถุที่เปลี่ยนค่า API เล็ก ๆ เช่น setFilter, markDirty, resetDraft ทำให้ความเป็นเจ้าของและการเปลี่ยนแปลงที่อนุญาตชัดเจน
Pinia: สถานะที่แชร์และการอัปเดตที่คาดเดาได้ข้ามหน้าจอ
Pinia โดดเด่นเมื่อสถานะเดียวกันต้องสอดคล้องกันข้าม route และคอมโพเนนต์ ในแอดมิน นั่นมักหมายถึงผู้ใช้ปัจจุบัน สิทธิ์ของเขา องค์กร/เวิร์กสเปซที่เลือก และการตั้งค่าแอป ถ้าทุกหน้าทำซ้ำสิ่งเหล่านี้ มันจะเจ็บปวด
สโตร์มีประโยชน์เพราะให้ที่เดียวในการอ่านและอัปเดตสถานะที่แชร์ แทนที่จะส่ง props ผ่านชั้น คุณ import สโตร์ที่ต้องการ เมื่อย้ายจากรายการไปยังหน้ารายละเอียด UI ที่เหลือยังสามารถตอบสนองต่อ workspace ที่ถูกเลือก สิทธิ์ และการตั้งค่าเดียวกันได้
ทำไม Pinia ดูแลรักษาง่ายกว่า
Pinia บังคับโครงสร้างเรียบง่าย: state สำหรับค่าดิบ, getters สำหรับค่าอนุมาน, และ actions สำหรับการอัปเดต ใน UI แบบแอดมิน โครงสร้างนี้ป้องกัน “การแก้ปัญหาอย่างรวดเร็ว” กลายเป็นการแก้ไขที่กระจัดกระจาย
ถ้า canEditUsers ขึ้นกับบทบาทปัจจุบันบวก feature flag ให้ใส่กฎนั้นไว้ใน getter ถ้าการสลับ org ต้องเคลียร์การเลือกที่แคชไว้และโหลด navigation ใหม่ ให้ทำลำดับนั้นใน action คุณจะมี watchers ลึกลับน้อยลงและคำถามว่า “ทำไมสิ่งนี้เปลี่ยน?” น้อยลง
Pinia ยังทำงานได้ดีกับ Vue DevTools เมื่อเกิดบั๊ก จะง่ายกว่าในการตรวจสอบ state ของสโตร์และดูว่า action ใดถูกรัน แทนที่จะตามหาการเปลี่ยนแปลงผ่านวัตถุ reactive กระจัดกระจายที่สร้างในคอมโพเนนต์สุ่ม
หลีกเลี่ยงสโตร์ที่เป็นที่ทิ้งของทุกอย่าง
สโตร์โกลบอลให้ความรู้สึกเป็นระเบียบในตอนแรก แต่จะกลายเป็นลิ้นชักของจุกจิก ผู้สมัครที่ดีสำหรับ Pinia คือความกังวลที่แชร์จริง ๆ เช่น ตัวตนผู้ใช้และสิทธิ์ workspace ที่เลือก feature flags และข้อมูลอ้างอิงที่ใช้ทั่วหลายหน้า
ความกังวลเฉพาะหน้าควรอยู่ local เว้นแต่หลาย route จะต้องการจริง ๆ
ตัวอย่าง 1: ฟิลเตอร์และตารางโดยไม่เปลี่ยนทุกอย่างเป็นสโตร์
ลองนึกถึงหน้า Orders: ตาราง ฟิลเตอร์ (สถานะ ช่วงวันที่ ลูกค้า) การแบ่งหน้า และพาเนลข้างที่พรีวิวออร์เดอร์ที่เลือก สิ่งนี้ยุ่งได้เร็วเพราะเชื่อมโยงง่ายที่จะโยนทุกฟิลเตอร์และการตั้งค่าตารางเข้าไปในสโตร์โกลบอล
วิธีง่ายในการเลือกคือ ตัดสินใจว่าสิ่งใดควรถูกจำ และที่ไหน:
- Memory only (local หรือ provide/inject): รีเซ็ตเมื่อออกจากหน้า ดีสำหรับสถานะชั่วคราว
- Query params: แชร์ได้และรอดรีโหลด ดีสำหรับฟิลเตอร์และการแบ่งหน้าที่ผู้ใช้คัดลอก
- Pinia: อยู่รอดเมื่อนำทาง ดีสำหรับประสบการณ์ “กลับไปที่รายการเหมือนเดิม”
จากนั้นการ implement มักเป็นไปตามนี้:
ถ้าไม่มีใครคาดว่าการตั้งค่าจะอยู่รอดข้ามการนำทาง ให้เก็บ filters, sort, page, และ pageSize ภายในคอมโพเนนต์หน้า Orders และให้หน้านั้นเป็นตัวเรียก fetch ถ้า toolbar ตาราง และพรีวิวทั้งหมดต้องการโมเดลเดียวกันและการส่ง props เริ่มรก ให้ย้ายโมเดลรายการไปที่ page shell แล้วแชร์ผ่าน provide/inject ถ้าต้องการให้รายการรู้สึกคงอยู่ข้ามหน้า (เปิดคำสั่ง ดูแล้วกลับมาแล้วเจอฟิลเตอร์เดิมและการเลือกเดิม) Pinia จะเหมาะกว่า
กฎปฏิบัติ: เริ่ม local ย้ายไป provide/inject เมื่อหลายคอมโพเนนต์ลูกต้องการโมเดลเดียวกัน และใช้ Pinia ต่อเมื่อจำเป็นต้องเก็บข้าม route จริง ๆ
ตัวอย่าง 2: ร่างและการแก้ไขที่ยังไม่บันทึก (ฟอร์มที่ผู้ใช้ไว้ใจได้)
นึกถึงเจ้าหน้าที่ซัพพอร์ตแก้ไขเรคคอร์ดลูกค้า: รายละเอียดยืนยันการติดต่อ ข้อมูลบิล และบันทึกภายใน พวกเขาถูกขัดจังหวะ สลับหน้า แล้วกลับมา ถ้าฟอร์มลืมงานของพวกเขาหรือบันทึกข้อมูลครึ่งกลาง ๆ ความไว้วางใจจะหายไป
สำหรับร่าง แยกสามสิ่ง: เรคคอร์ดที่บันทึกล่าสุด การแก้ไขที่ผู้ใช้จัดเตรียม และสถานะ UI เท่านั้นอย่างข้อผิดพลาดการตรวจสอบ
สถานะท้องถิ่น: การแก้ไขชั่วคราวกับกฎ dirty ที่ชัดเจน
ถ้าหน้าจอแก้ไขเป็นทรงตัว สถานะท้องถิ่นมักปลอดภัยที่สุด เก็บสำเนา draft ของเรคคอร์ด ติดตาม isDirty (หรือแมป dirty ระดับฟิลด์) และเก็บข้อผิดพลาดขนาบแต่ละคอนโทรลของฟอร์ม
โฟลว์ง่าย ๆ: โหลดเรคคอร์ด ทำสำเนาเป็น draft แก้ไข draft แล้วส่งคำขอบันทึกเมื่อผู้ใช้กด Save เท่านั้น Cancel ทิ้ง draft และโหลดใหม่
provide/inject: ร่างเดียวที่แชร์ข้ามส่วนย่อย
ฟอร์มแอดมินมักแบ่งเป็นแท็บหรือพาเนล (Profile, Addresses, Permissions) ด้วย provide/inject คุณสามารถเก็บโมเดล draft หนึ่งตัวและเปิด API เล็ก ๆ เช่น updateField(), resetDraft(), validateSection() แต่ละส่วนอ่านและเขียน draft เดียวกันโดยไม่ต้องส่ง props ผ่านหลายชั้น
เมื่อ Pinia ช่วยเรื่องร่าง
Pinia มีประโยชน์เมื่อร่างต้องอยู่รอดเมื่อนำทางหรือมองเห็นได้จากนอกหน้าจอแก้ไข รูปแบบทั่วไปคือ draftsById[customerId] ดังนั้นแต่ละเรคคอร์ดมีร่างของตัวเอง ช่วยเมื่อผู้ใช้เปิดหน้าจอแก้ไขหลายหน้าพร้อมกัน
บั๊กที่มักเจอเกี่ยวกับร่างมาจากข้อผิดพลาดที่คาดเดาได้: สร้างร่างก่อนโหลดเรคคอร์ด, เขียนทับร่างที่สกปรกเมื่อ refetch, ลืมเคลียร์ข้อผิดพลาดเมื่อยกเลิก, หรือใช้คีย์เดียวที่ทำให้ร่างเขียนทับกัน ถ้ากำหนดกฎชัดว่าเมื่อใดจะสร้าง ทับ ทิ้ง ยึด persist และแทนที่หลังบันทึก ข้อผิดพลาดส่วนใหญ่จะหายไป
ถ้าคุณสร้างหน้าจอแอดมินด้วย AppMaster (appmaster.io) การแยกระหว่าง “ร่าง vs เรคคอร์ดที่บันทึก” ยังคงใช้ได้: เก็บร่างบนไคลเอนต์ และถือว่า backend เป็นแหล่งความจริงเมื่อบันทึกสำเร็จเท่านั้น
ตัวอย่าง 3: การแก้ไขหลายแท็บโดยไม่ให้สถานะชนกัน
การแก้ไขหลายแท็บเป็นที่ที่แผงผู้ดูแลมักพัง ผู้ใช้เปิด Customer A แล้ว Customer B สลับไปมา และคาดหวังว่าแต่ละแท็บจะจำการแก้ไขที่ยังไม่บันทึกของตัวเอง
การแก้คือออกแบบแต่ละแท็บเป็นชุดสถานะของตัวเอง ไม่ใช่ร่างแชร์หนึ่งชุด แต่ละแท็บต้องมีคีย์เฉพาะ (มักจาก ID เรคคอร์ด) ข้อมูลร่าง สถานะ (clean, dirty, saving) และข้อผิดพลาดของฟิลด์
ถ้าแท็บอยู่ในหน้าเดียว แนวทาง local ทำงานได้ดี เก็บรายการแท็บและร่างที่เป็นเจ้าของโดยคอมโพเนนต์หน้าที่เรนเดอร์แท็บ แต่ละพาเนลแก้ไขอ่านและเขียนเฉพาะบันเดิลของตัวเอง เมื่อปิดแท็บ ให้ลบบันเดิลนั้นและเรียบร้อย วิธีนี้แยกขอบเขตและง่ายต่อการเข้าใจ
รูปแบบข้อมูลมักเหมือนกัน:
- รายการอ็อบเจกต์แท็บ (แต่ละอันมี
customerId,draft,status, และerrors) activeTabKey- การกระทำเช่น
openTab(id),updateDraft(key, patch),saveTab(key), และcloseTab(key)
Pinia เหมาะเมื่อแท็บต้องอยู่รอดเมื่อนำทาง (ไป Orders แล้วกลับมา) หรือเมื่อหลายหน้าต้องเปิด/โฟกัสแท็บ ในกรณีนั้น store เล็ก ๆ ที่เป็น “ตัวจัดการแท็บ” ช่วยให้พฤติกรรมสอดคล้องกันทั่วแอป
การชนที่ต้องหลีกเลี่ยงคือการใช้ตัวแปรโกลบอลเดียวเช่น currentDraft มันใช้ได้จนกว่าแท็บที่สองจะเปิด แล้วการแก้ไขจะเขียนทับกัน ข้อผิดพลาดการตรวจสอบจะแสดงผิดที่ และ Save อัปเดตเรคคอร์ดผิด เมื่อแต่ละแท็บมีบันเดิลของตัวเอง การชนแทบจะหายไปโดยการออกแบบ
ข้อผิดพลาดทั่วไปที่ทำให้เกิดบั๊กและโค้ดยุ่ง
บั๊กในแผงผู้ดูแลส่วนใหญ่ไม่ใช่ “บั๊กของ Vue” แต่มาจากการจัดการสถานะ: ข้อมูลอยู่ผิดที่ สองส่วนของหน้าไม่เห็นตรงกัน หรือสถานะแก่ยังคงอยู่เงียบๆ
รูปแบบที่พบบ่อย:
การโยนทุกอย่างเข้า Pinia โดยปริยายทำให้ความเป็นเจ้าของไม่ชัดเจน สโตร์โกลบอลดูเรียบร้อยในตอนแรก แต่ทุกหน้าจะอ่านและเขียนวัตถุเดียวกัน การทำความสะอาดกลายเป็นเรื่องเดาได้ยาก
การใช้ provide/inject โดยไม่มีสัญญาที่ชัดเจนสร้างการพึ่งพาแบบลับ ๆ ถ้าลูก inject filters แต่ไม่มีความเข้าใจร่วมกันว่าใคร provide และ action ไหนเปลี่ยนมัน จะมีการอัปเดตที่ไม่คาดคิดเมื่ออีกลูกเริ่มแก้ไขวัตถุนั้น
การผสมสถานะเซิร์ฟเวอร์กับสถานะ UI ในสโตร์เดียวทำให้เกิดการเขียนทับโดยไม่ตั้งใจ เรคคอร์ดที่ fetch มามีพฤติกรรมต่างจาก “drawer เปิด?” หรือ “แท็บปัจจุบัน” เมื่ออยู่ด้วยกัน การ refetch อาจเขียนทับ UI หรือการเปลี่ยน UI อาจไปแก้ cache ของข้อมูล
การข้าม lifecycle cleanup ทำให้สถานะรั่ว ฟิลเตอร์จากมุมมองหนึ่งส่งผลต่ออีกมุมมอง และร่างยังคงอยู่หลังจากออกจากหน้า ครั้งต่อไปที่ใครสักคนเปิดเรคคอร์ดต่างออกไป พวกเขาจะเห็นการเลือกเก่าและคิดว่าแอปพัง
การตั้งคีย์ร่างไม่ดีเป็นตัวทำลายความไว้วางใจเงียบ ๆ ถ้าคุณเก็บร่างภายใต้คีย์เดียวเช่น draft:editUser การแก้ไข User A แล้ว User B จะเขียนทับร่างเดียวกัน
กฎง่าย ๆ ป้องกันส่วนใหญ่: เก็บสถานะให้ใกล้กับที่ใช้งานที่สุดเท่าที่จะทำได้ และยกขึ้นเฉพาะเมื่อต้องแชร์จริง ๆ เมื่อแชร์ ให้กำหนดความเป็นเจ้าของ (ใครเปลี่ยนได้) และตัวตน (คีย์อย่างไร)
เช็คลิสต์ด่วนก่อนเลือก local, provide/inject, หรือ Pinia
คำถามที่มีประโยชน์ที่สุดคือ: ใครเป็นเจ้าของสถานะนี้? ถ้าตอบประโยคเดียวไม่ได้ แปลว่าสถานะนั้นอาจทำหลายสิ่งและควรแยก
ใช้เช็คลิสต์เหล่านี้เป็นตัวกรองด่วน:
- คุณระบุเจ้าของได้ไหม (คอมโพเนนต์ หน้าหนึ่ง หรือทั้งแอป)?
- มันต้องอยู่รอดเมื่อเปลี่ยน route หรือรีโหลดไหม? ถ้าใช่ วางแผนการเก็บถาวรแทนการหวังว่าบราวเซอร์จะเก็บให้
- จะมีการแก้ไขหลายเรคคอร์ดพร้อมกันไหม? ถ้าใช่ ให้ตั้งคีย์ตาม ID เรคคอร์ด
- สถานะถูกใช้เฉพาะคอมโพเนนต์ใต้ page shell เดียวไหม? ถ้าใช่
provide/injectมักจะเหมาะ - คุณต้องตรวจสอบการเปลี่ยนแปลงและเข้าใจว่าใครเปลี่ยนอะไรไหม? ถ้าใช่ Pinia มักเป็นที่ที่สะอาดที่สุดสำหรับส่วนนั้น
จับคู่อุปกรณ์แบบง่าย ๆ:
ถ้าสถานะเกิดและดับภายในคอมโพเนนต์เดียว (เช่น ธงเปิด/ปิด dropdown) ให้เก็บเป็น local ถ้าหลายคอมโพเนนต์บนหน้าเดียวต้องการ context เดียวกัน (filter bar + table + summary) ให้ provide/inject แทนการทำให้มันโกลบอล ถ้าสถานะต้องแชร์ข้ามหน้าจอ อยู่รอดเมื่อนำทาง หรืออยากให้อัปเดตคาดเดาได้และดีบักง่าย ให้ใช้ Pinia และตั้งคีย์ตาม ID เรคคอร์ดเมื่อต้องจัดการร่าง
ถ้าคุณกำลังสร้าง UI แอดมิน Vue 3 (รวมถึงที่สร้างโดยเครื่องมืออย่าง AppMaster) เช็คลิสต์นี้ช่วยหลีกเลี่ยงการโยนทุกอย่างเข้าสโตร์เร็วเกินไป
ขั้นตอนถัดไป: พัฒนาสถานะโดยไม่ทำให้เละ
วิธีที่ปลอดภัยที่สุดในการปรับปรุงการจัดการสถานะในแผงผู้ดูแลคือเติบโตแบบเล็ก ๆ และน่าเบื่อ เริ่มจาก local สำหรับสิ่งที่อยู่ในหน้าหนึ่ง เมื่อเห็นการใช้ซ้ำจริง (โค้ดซ้ำ, คอมโพเนนต์ที่สามต้องการสถานะเดียวกัน) ย้ายขึ้นหนึ่งระดับ แล้วค่อยพิจารณาสโตร์ที่แชร์
เส้นทางที่ใช้ได้สำหรับทีมส่วนใหญ่:
- เก็บสถานะเฉพาะหน้าเป็น local ก่อน (ฟิลเตอร์, การเรียง, การแบ่งหน้า, แผงเปิด/ปิด)
- ใช้
provide/injectเมื่อหลายคอมโพเนนต์บนหน้าเดียวต้องการ context ร่วม - เพิ่มสโตร์ Pinia ทีละชิ้นสำหรับความต้องการข้ามหน้าจอ (draft manager, tab manager, workspace ปัจจุบัน)
- เขียนกฎการรีเซ็ตและปฏิบัติตาม (อะไรรีเซ็ตเมื่อนำทาง ออกจากระบบ Clear filters ทิ้งการเปลี่ยนแปลง)
กฎการรีเซ็ตฟังดูเล็ก แต่ป้องกันเกือบทุกช่วงเวลา “ทำไมมันเปลี่ยน?” ตัดสินใจ เช่น จะทำอย่างไรกับร่างเมื่อผู้ใช้เปิดเรคคอร์ดอื่นแล้วกลับมา: คืนค่า, เตือน, หรือรีเซ็ต แล้วทำให้พฤติกรรมนั้นสอดคล้องกัน
ถ้าคุณแนะนำสโตร์ ให้ทำให้มันเป็นรูปทรงของฟีเจอร์ สโตร์ร่างควรจัดการการสร้าง การกู้คืน และการล้างร่าง แต่ไม่ควรควบคุมฟิลเตอร์ของตารางหรือธงเลย์เอาต์ UI
ถ้าต้องการต้นแบบแผงผู้ดูแลอย่างรวดเร็ว AppMaster (appmaster.io) สามารถสร้างแอปเว็บ Vue3 พร้อม backend และตรรกะธุรกิจให้ แล้วคุณยังคงปรับโค้ดที่สร้างขึ้นได้เมื่อจำเป็น ขั้นตอนที่แนะนำคือสร้างหน้าจอหนึ่งให้ครบวงจร (เช่น ฟอร์มแก้ไขที่กู้คืนร่างได้) และดูว่าสิ่งใดจริง ๆ ต้อง Pinia และอะไรอยู่ได้เป็น local
คำถามที่พบบ่อย
ใช้สถานะท้องถิ่นเมื่อข้อมูลส่งผลต่อคอมโพเนนต์เพียงตัวเดียวและสามารถรีเซ็ตเมื่อคอมโพเนนต์ถูกถอดได้ ตัวอย่างทั่วไปได้แก่ ไฟล์ล็อกการเปิด/ปิดไดอะล็อก แถวที่เลือกในตารางเดียว และส่วนของฟอร์มที่ไม่ได้ถูกนำกลับมาใช้ที่อื่น
ใช้ provide/inject เมื่อต้องการแหล่งความจริงเดียวสำหรับหลายคอมโพเนนต์บนหน้าเดียว และการส่ง props เริ่มส่งผลให้โค้ดยุ่งเกินไป ให้จำกัดสิ่งที่ให้ไว้ให้น้อยและมีความตั้งใจเพื่อให้หน้าอ่านง่าย
ใช้ Pinia เมื่อสถานะต้องแชร์ข้ามเส้นทาง ต้องอยู่รอดเมื่อเปลี่ยนหน้าหรือเมื่อคุณต้องการดูและดีบักสถานะในที่เดียว ตัวอย่างทั่วไปคือ workspace ปัจจุบัน สิทธิ์ ผู้ตั้งค่า feature flag และตัวจัดการข้ามหน้าที่แชร์เช่น drafts หรือ tabs
เริ่มด้วยการตั้งชื่อประเภทของสถานะ (UI, server, form, cross-screen) แล้วกำหนดขอบเขต (คอมโพเนนต์เดียว หน้าหนึ่ง หลายเส้นทาง) ช่วงชีวิต (รีเซ็ตเมื่อ unmount อยู่ต่อหลังการนำทาง อยู่ต่อหลังรีโหลด) และความขนาน (แก้ไขครั้งละหนึ่งหรือหลายแท็บ) เครื่องมือมักจะตามมาจากป้ายเหล่านี้
ถ้าผู้ใช้คาดหวังว่าจะแชร์หรือคืนค่ามุมมอง ให้เก็บฟิลเตอร์และการแบ่งหน้าใน query params เพื่อให้รอดรีโหลดและคัดลอกได้ ถ้าผู้ใช้คาดหวังว่า ‘กลับมาที่รายการเหมือนเดิม’ ข้ามเส้นทาง ให้เก็บโมเดลรายการใน Pinia มิฉะนั้นเก็บไว้เป็นสถานะของหน้าจะเหมาะกว่า
แยกแยะระหว่างเรคคอร์ดที่บันทึกล่าสุดกับการแก้ไขร่างของผู้ใช้ และส่งข้อมูลกลับเฉพาะเมื่อผู้ใช้กด Save เท่านั้น ติดตามกฎ dirty ที่ชัดเจน และกำหนดพฤติกรรมเมื่อนำทาง (เตือน ออโต้เซฟ หรือเก็บไว้ให้กู้คืน) เพื่อไม่ให้ผู้ใช้สูญเสียงาน
ให้ตัวแก้ไขแต่ละแท็บมีชุดสถานะของตัวเองที่มีคีย์เฉพาะ (มักเป็น ID ของเรคคอร์ด และบางครั้งมี tab key) แทนการใช้ตัวแปร global เดียว เช่น currentDraft วิธีนี้จะป้องกันไม่ให้การแก้ไขและข้อผิดพลาดจากแท็บหนึ่งไปเขียนทับแท็บอื่น
ถ้าโฟลว์การแก้ไขทั้งหมดอยู่บน route เดียว การใช้ provide/inject ที่เป็นเจ้าของหน้าเพียงหน้าเดียวทำงานได้ดี แต่ถ้าต้องอยู่ข้ามการนำทางหรือเข้าถึงจากนอกหน้าจอแก้ไข Pinia พร้อมโครงแบบ draftsById[recordId] มักจะจัดการได้ง่ายและคาดเดาได้กว่า
อย่าเก็บสิ่งที่คำนวณได้ เช่น แบนเนอร์จำนวนฟิลเตอร์ที่ใช้อยู่ ป้ายสรุป หรือธง “สามารถบันทึกได้” ให้ใช้ค่า computed จากสถานะปัจจุบันแทน เพื่อไม่ให้ค่าเหล่านี้หลุดออกจากกัน
การโยนทุกอย่างเข้าไปใน Pinia โดยไม่คิด ทำให้ความเป็นเจ้าของไม่ชัดเจน การผสมสถานะเซิร์ฟเวอร์กับสวิตช์ UI การไม่ทำความสะอาดเมื่อเปลี่ยนหน้า และการใช้คีย์ร่างเดียวนำไปสู่ปัญหาที่พบบ่อย ตรวจสอบให้แน่ใจว่าแต่ละชิ้นของสถานะอยู่ใกล้กับที่ใช้งานจริง และกำหนดการเป็นเจ้าของและการตั้งคีย์เมื่อแชร์


