การรักษาสถานะเปิด/ปิด Sidebar ด้วย localStorage พร้อมป้องกัน Flicker ในการ Render ครั้งแรก
บทนำ
หน้ารายการภาพยนตร์ของแอป DVD rental มี sidebar ทางซ้าย
เราได้ implement ข้อกำหนด: “ปิด sidebar หลังใช้ filter conditions” และ “รักษาสถานะปิดเมื่อเปิดครั้งถัดไป”
การบันทึกสถานะไปยัง localStorage เป็นเรื่องง่าย แต่เมื่อ implement จริงพบ “flicker (FOUC: Flash of Unstyled Content) ที่ sidebar เปิดชั่วครู่แล้วปิดเมื่อโหลดหน้า”
บทความนี้ครอบคลุมวิธีการ implement เพื่อป้องกัน flicker ดังกล่าว
ขั้นแรก การ Implement ที่ทำงานได้ (มี Flicker)
<!-- FilmsView.vue -->
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue'
// ค่าเริ่มต้นคือ open
const sidebarOpen = ref(true)
onMounted(() => {
// อ่านจาก localStorage หลัง mount
const saved = localStorage.getItem('sidebarOpen')
if (saved !== null) {
sidebarOpen.value = saved === 'true'
}
})
watch(sidebarOpen, (val) => {
localStorage.setItem('sidebarOpen', String(val))
})
</script>
<template>
<div class="layout">
<aside :class="{ 'sidebar--closed': !sidebarOpen }">
<!-- เนื้อหา Sidebar -->
</aside>
<main>
<!-- เนื้อหาหลัก -->
</main>
</div>
</template>
ปัญหาของการ implement นี้:
- DOM ถูกสร้างด้วย sidebar ที่เปิดอยู่ในสถานะค่าเริ่มต้น
ref(true) onMountedอ่านlocalStorageและกลายเป็นfalse- Vue รัน animation ปิดแบบ reactive → เปิดชั่วครู่แล้วปิด
วิธีแก้ปัญหาเพื่อป้องกัน Flicker
วิธีที่ 1: อ่านค่าเริ่มต้นจาก localStorage
// ฟังก์ชันสำหรับดึงสถานะ sidebar เริ่มต้นจาก localStorage
function getInitialSidebarState(): boolean {
const saved = localStorage.getItem('sidebarOpen')
if (saved !== null) return saved === 'true'
return true // ค่าเริ่มต้นคือเปิด
}
const sidebarOpen = ref(getInitialSidebarState())
การดึงค่าเริ่มต้นของ ref() จาก localStorage ทำให้ DOM ถูกสร้างด้วยสถานะที่ถูกต้องตั้งแต่ต้น
ไม่จำเป็นต้องรอจนถึงเวลา onMounted
วิธีที่ 2: การพิจารณาสำหรับ SSR Environment (Nuxt ฯลฯ)
เมื่อใช้ SSR (Server-Side Rendering) กับ Next.js (React) หรือ Nuxt.js (Vue), localStorage ไม่มีอยู่ฝั่ง server ทำให้เกิด error
function getInitialSidebarState(): boolean {
if (typeof window === 'undefined') return true // SSR environment
const saved = localStorage.getItem('sidebarOpen')
return saved !== null ? saved === 'true' : true
}
โปรเจกต์นี้ใช้ Vite + Vue 3 (CSR) จึงไม่จำเป็น แต่การเขียนเป็น generic code ทำให้ port ได้ง่ายขึ้น
การรวมกับ Animation
เมื่อใช้ CSS Transition สำหรับ animation เปิด/ปิดแบบ smooth, animation อาจรันในการ render ครั้งแรกด้วย
วิธีแก้: เปิดใช้งาน Animation หลัง Mount
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue'
function getInitialSidebarState(): boolean {
const saved = localStorage.getItem('sidebarOpen')
return saved !== null ? saved === 'true' : true
}
const sidebarOpen = ref(getInitialSidebarState())
const animationEnabled = ref(false) // ปิดใช้งาน animation ในตอนแรก
onMounted(() => {
// เปิดใช้งาน animation หลัง mount
animationEnabled.value = true
})
watch(sidebarOpen, (val) => {
localStorage.setItem('sidebarOpen', String(val))
})
</script>
<template>
<aside
:class="{
'sidebar--closed': !sidebarOpen,
'sidebar--animated': animationEnabled // Apply animation CSS หลัง mount
}">
</aside>
</template>
/* เปิดใช้งาน transition เฉพาะเมื่อ sidebar--animated ถูก attach */
.sidebar--animated {
transition: width 0.3s ease, opacity 0.3s ease;
}
.sidebar--closed {
width: 0;
opacity: 0;
overflow: hidden;
}
ในการ render ครั้งแรก แสดงสถานะที่ถูกต้องทันทีโดยไม่มี animation และ animation จะรันเฉพาะการโต้ตอบของผู้ใช้ที่ตามมาเท่านั้น
โค้ดสำเร็จรูป
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue'
function getInitialSidebarState(): boolean {
try {
const saved = localStorage.getItem('sidebarOpen')
return saved !== null ? saved === 'true' : true
} catch {
// Environment ที่ localStorage ไม่สามารถใช้ได้ (private browsing ฯลฯ)
return true
}
}
const sidebarOpen = ref(getInitialSidebarState())
const animationEnabled = ref(false)
onMounted(() => {
// เปิดใช้งาน animation ใน frame ถัดไป (ใช้ requestAnimationFrame เพื่อความปลอดภัย)
requestAnimationFrame(() => {
animationEnabled.value = true
})
})
function toggleSidebar() {
sidebarOpen.value = !sidebarOpen.value
}
watch(sidebarOpen, (val) => {
try {
localStorage.setItem('sidebarOpen', String(val))
} catch {
// Ignore ใน environment ที่ localStorage ไม่สามารถเขียนได้
}
})
</script>
เมื่อใช้ Pinia
เมื่อจัดการ state ด้วย Vuex หรือ Pinia สามารถ persist ได้ในรูปแบบ plugin
npm install pinia-plugin-persistedstate
// stores/sidebar.ts
import { defineStore } from 'pinia'
export const useSidebarStore = defineStore('sidebar', {
state: () => ({
isOpen: true,
}),
actions: {
toggle() {
this.isOpen = !this.isOpen
}
},
persist: true, // Auto-persist ไปยัง localStorage
})
อย่างไรก็ตาม ทำงานได้เฉพาะกับ CSR เท่านั้น SSR ต้องการ configuration
สรุป
| ปัญหา | สาเหตุ | วิธีแก้ |
|---|---|---|
| Flicker (FOUC) | อ่าน localStorage หลัง onMounted | อ่านโดยตรงในค่าเริ่มต้นของ ref() |
| Animation รันในการ render ครั้งแรก | CSS ถูก apply แล้วตอน DOM mount | เปิดใช้งาน animation CSS หลัง onMounted |
| Environment ที่ localStorage ไม่สามารถใช้ได้ | Private browsing ฯลฯ | ป้องกันด้วย try-catch |
การ implement แบบ “ขอให้ทำงานได้ก็พอ” มักทิ้ง flicker ไว้ ดังนั้นการตระหนักถึงวิธีดึงค่าเริ่มต้นและเวลาในการเปิดใช้งาน animation จึงสำคัญ
การ Implement ในแอปนี้
ในแอป DVD rental นี้ แทนที่จะเป็นสถานะ sidebar เรา persist cart และ watch-later list ไปยัง localStorage
นี่คือกลไกที่ป้องกัน “ภาพยนตร์ที่เพิ่มในตะกร้า” ไม่ให้หายไปแม้จะ reload หน้า
โดยประยุกต์ pattern ที่แนะนำในบทความ “ส่งค่าเริ่มต้นโดยตรงไปยัง ref”
และต่อยอดด้วยการ implement เป็น singleton composable ทำให้ component ใดก็ตามสามารถ reference สถานะเดียวกันได้
useCart composable (โค้ดจริง)
// composables/useCart.ts
import { computed, ref } from 'vue'
import type { PublicFilmSummary } from '../api/client'
type CartItem = PublicFilmSummary & { addedAt: string }
const STORAGE_KEY = 'dvd-rental-customer.cart'
const itemsRef = ref<CartItem[]>([]) // Module-scope ref (singleton)
let initialized = false
function loadFromStorage(): CartItem[] {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return []
const parsed = JSON.parse(raw) as CartItem[]
if (!Array.isArray(parsed)) return []
// Type validation: ยกเว้นข้อมูลที่ไม่ถูกต้อง
return parsed.filter((item) => typeof item.filmId === 'number' && typeof item.title === 'string')
} catch {
return [] // รับมือกับ environment ที่ localStorage ไม่สามารถใช้ได้ด้วย (private browsing ฯลฯ)
}
}
function ensureInitialized() {
if (initialized) return
if (typeof window === 'undefined') return // SSR safety (anticipating future Nuxt migration)
itemsRef.value = loadFromStorage() // ← Set ค่าเริ่มต้นของ ref ในการเรียกครั้งแรก
initialized = true
}
function addToCart(film: PublicFilmSummary): boolean {
ensureInitialized()
const exists = itemsRef.value.some((item) => item.filmId === film.filmId)
if (exists) return false // ป้องกันการเพิ่มซ้ำ
const next: CartItem = { ...film, addedAt: new Date().toISOString() }
itemsRef.value = [next, ...itemsRef.value]
localStorage.setItem(STORAGE_KEY, JSON.stringify(itemsRef.value))
return true
}
export function useCart() {
ensureInitialized()
return {
cartItems: computed(() => itemsRef.value),
cartCount: computed(() => itemsRef.value.length),
cartTotal: computed(() => itemsRef.value.reduce((sum, item) => sum + (item.rentalRate ?? 0), 0)),
addToCart,
removeFromCart,
clearCart,
isInCart,
}
}
การสร้าง structure การ initialize ของ ref() เป็น lazy initialization ผ่านฟังก์ชัน ensureInitialized() แทน onMounted
ทำให้ทำงานได้อย่างปลอดภัยในทุก call timing โดยไม่ขึ้นอยู่กับ component lifecycle
หน้าจอจริง
Cart drawer ที่รายการที่เพิ่มในตะกร้าถูกรักษาไว้หลัง reload:
