Appearance
持久化:讓狀態活過重新整理
重新整理為什麼會遺失狀態
Pinia 的 state 預設存在 JavaScript 記憶體裡。使用者登入後按重新整理,應用程式會重新建立 Pinia、store 和 router,原本的 isLoggedIn 又回到初始值;購物車也會遇到同樣問題。這正是第六章導航守衛結尾留下的問題:守衛規則正確,但它沒有記住上一次的登入結果。
持久化的責任可以拆成兩步:建立 store 時先從 localStorage 還原,state 改變時再寫回。「寫回」用的正是第二章的 watch:狀態一變就同步到外部系統,這是 watch 最典型的副作用場景;而副作用要有清楚的擁有者和邊界,則延續第五章的副作用心智,這裡的擁有者就是 store 自己。localStorage 只能保存字串,所以讀取要 JSON.parse(),寫入要 JSON.stringify();瀏覽器可能因為無痕模式、容量限制或資料損壞而拋錯,教學範例用 try/catch 把這個邊界包起來。
手寫 localStorage 持久化
auth 和 cart 使用不同的 key,避免登出時清掉不屬於登入狀態的購物車。核心規格可以先濃縮成下面這段:
js
watch([isLoggedIn, user], () => {
if (isLoggedIn.value === false) {
localStorage.removeItem(authKey)
return
}
localStorage.setItem(authKey, JSON.stringify({
isLoggedIn: isLoggedIn.value,
user: user.value,
}))
}, { deep: true })這裡採用 deep: true,因為 user 是物件,物件內的欄位改變也屬於要同步的 state;沒有指定 flush,使用 Vue watcher 的預設排程。登入時 watcher 寫入 auth key,isLoggedIn === false 時則執行 localStorage.removeItem(authKey),不把 { isLoggedIn: false } 之類的未登入物件寫回。logout() 只翻轉 store 狀態,不直接碰 localStorage,清除工作統一由 watcher 承擔。
cart 的 watcher 只管理 cart key。它不讀取 auth 狀態,也不會在 logout 時清除,因此訪客在登入前加入的商品仍然留在購物車裡。每次狀態變更後,測試或需要立刻讀取 storage 的程式都要先 await nextTick(),等預設 watcher 完成排程。
選擇該保存的資料
不要把整個 store 無條件序列化。登入狀態只保存重新載入畫面所需要的最小資料,購物車保存商品 id、名稱、價格和數量;暫時性錯誤訊息、載入中的旗標和可重新取得的 API 結果通常不值得保存。真正的 token 仍應依照後端的安全策略處理,放在可被 JavaScript 讀取的 storage 會增加 XSS 風險,較完整的 cookie、CSRF 與登入安全議題留到第十二章。
如果專案需要跨分頁同步、版本遷移或更完整的序列化策略,可以考慮 pinia-plugin-persistedstate 這類 plugin;本章先用手寫 watcher 讓 key、保存範圍和登出行為都清楚可見。
常見錯誤
錯誤寫法是把整個 store 直接 JSON.stringify(store),再在登出時清掉所有 storage。這會把不該保存的暫時資料一起寫入,也會誤刪訪客購物車。正確寫法是為 auth 和 cart 各自指定 key,只保存明確列出的欄位;登出只讓 auth watcher 移除 auth key,cart key 保持不變。
完整書店範例
下面是本章唯一一份完整可跑的書店 canonical。路由、兩個 store、入口、導覽列和所有 view 都從這組檔案組裝;routes.js 的 view 全部使用動態 import,方便直接放進 Vite + Vue 專案。後續的 fixture build 和 Pinia smoke test 也以這一組標籤為唯一來源。
完整可跑範例:Pinia、Vue Router 與 localStorage
src/stores/auth.js:
js
import { ref, watch } from 'vue'
import { defineStore } from 'pinia'
const authKey = 'bookstore-auth'
function readStorage(key, fallback) {
try {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : fallback
} catch {
return fallback
}
}
function writeStorage(key, value) {
try {
localStorage.setItem(key, JSON.stringify(value))
} catch {
// storage 不可用時,記憶體內的 state 仍然可以使用
}
}
export const useAuthStore = defineStore('auth', () => {
const saved = readStorage(authKey, {})
const isLoggedIn = ref(saved.isLoggedIn === true)
const user = ref(isLoggedIn.value ? saved.user ?? null : null)
watch([isLoggedIn, user], () => {
if (isLoggedIn.value === false) {
localStorage.removeItem(authKey)
return
}
writeStorage(authKey, {
isLoggedIn: true,
user: user.value,
})
}, { deep: true })
async function login() {
await Promise.resolve()
user.value = { id: 1, name: '示範讀者' }
isLoggedIn.value = true
}
function logout() {
user.value = null
isLoggedIn.value = false
}
async function canAccess() {
await Promise.resolve()
return true
}
return { isLoggedIn, user, login, logout, canAccess }
})src/stores/cart.js:
js
import { computed, ref, watch } from 'vue'
import { defineStore } from 'pinia'
const cartKey = 'bookstore-cart'
function readStorage(key, fallback) {
try {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : fallback
} catch {
return fallback
}
}
function writeStorage(key, value) {
try {
localStorage.setItem(key, JSON.stringify(value))
} catch {
// storage 不可用時,記憶體內的 state 仍然可以使用
}
}
export const useCartStore = defineStore('cart', () => {
const items = ref(readStorage(cartKey, []))
watch(items, (value) => {
writeStorage(cartKey, value)
}, { deep: true })
const itemCount = computed(() => {
return items.value.reduce((count, item) => count + item.quantity, 0)
})
const total = computed(() => {
return items.value.reduce(
(sum, item) => sum + item.price * item.quantity,
0,
)
})
function addItem(book) {
const existing = items.value.find((item) => item.id === book.id)
if (existing) {
existing.quantity++
return
}
items.value.push({ ...book, quantity: 1 })
}
function removeItem(bookId) {
const index = items.value.findIndex((item) => item.id === bookId)
if (index !== -1) items.value.splice(index, 1)
}
return { items, itemCount, total, addItem, removeItem }
})src/router/routes.js:
js
export const routes = [
{
path: '/',
name: 'home',
component: () => import('../views/BookListView.vue'),
},
{
path: '/books/:id',
name: 'book',
component: () => import('../views/BookDetailView.vue'),
props: true,
children: [
{
path: '',
name: 'book-intro',
component: () => import('../views/BookIntroView.vue'),
},
{
path: 'reviews',
name: 'book-reviews',
component: () => import('../views/BookReviewsView.vue'),
},
{
path: 'edit',
name: 'book-edit',
component: () => import('../views/BookEditView.vue'),
props: true,
meta: { requiresAuth: true },
},
],
},
{
path: '/cart',
name: 'cart',
component: () => import('../views/CartView.vue'),
},
{
path: '/orders',
name: 'orders',
component: () => import('../views/OrdersView.vue'),
meta: { requiresAuth: true },
},
{
path: '/login',
name: 'login',
component: () => import('../views/LoginView.vue'),
},
{
path: '/:pathMatch(.*)*',
name: 'not-found',
component: () => import('../views/NotFoundView.vue'),
props: (route) => ({
missingPath: route.params.pathMatch.join('/'),
}),
},
]src/router/guards.js:
js
import { useAuthStore } from '../stores/auth.js'
export function installGuards(router) {
router.beforeEach(async (to) => {
if (to.name === 'login' || !to.meta.requiresAuth) return
const auth = useAuthStore()
if (!auth.isLoggedIn) {
return {
name: 'login',
query: { redirect: to.fullPath },
}
}
const allowed = await auth.canAccess(to)
if (!allowed) return false
})
}src/router/index.js:
js
import { createRouter, createWebHistory } from 'vue-router'
import { installGuards } from './guards.js'
import { routes } from './routes.js'
const router = createRouter({
history: createWebHistory(),
routes,
})
installGuards(router)
export default routersrc/main.js:
js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router/index.js'
const app = createApp(App)
const pinia = createPinia()
app.use(pinia)
app.use(router)
app.mount('#app')src/App.vue:
vue
<script setup>
import { storeToRefs } from 'pinia'
import { useAuthStore } from './stores/auth.js'
import { useCartStore } from './stores/cart.js'
const auth = useAuthStore()
const cart = useCartStore()
const { isLoggedIn, user } = storeToRefs(auth)
const { itemCount } = storeToRefs(cart)
const { logout } = auth
</script>
<template>
<div class="app-shell">
<header class="site-header">
<nav class="site-nav" aria-label="主要導覽">
<RouterLink :to="{ name: 'home' }">書籍</RouterLink>
<RouterLink :to="{ name: 'cart' }">
購物車({{ itemCount }})
</RouterLink>
<RouterLink :to="{ name: 'orders' }">我的訂單</RouterLink>
<RouterLink v-if="!isLoggedIn" :to="{ name: 'login' }">
登入
</RouterLink>
<button v-else type="button" @click="logout">
登出 {{ user?.name }}
</button>
</nav>
</header>
<main class="page">
<RouterView />
</main>
</div>
</template>
<style scoped>
.app-shell {
max-width: 48rem;
margin-inline: auto;
padding: 1.5rem;
font-family: system-ui, sans-serif;
}
.site-nav {
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
.page {
padding-block: 1.5rem;
}
</style>src/views/BookListView.vue:
vue
<script setup>
const books = [
{ id: 1, title: '重新認識 Vue.js', price: 680 },
{ id: 2, title: '0 陷阱 JavaScript', price: 620 },
]
</script>
<template>
<section>
<h1>書籍列表</h1>
<ul>
<li v-for="book in books" :key="book.id">
<RouterLink :to="{ name: 'book', params: { id: book.id } }">
{{ book.title }}
</RouterLink>
</li>
</ul>
</section>
</template>src/views/BookDetailView.vue:
vue
<script setup>
import { computed } from 'vue'
import { useCartStore } from '../stores/cart.js'
const props = defineProps({
id: { type: String, required: true },
})
const cart = useCartStore()
const book = computed(() => ({
id: Number(props.id),
title: `書籍 ${props.id}`,
price: 680,
}))
function addToCart() {
cart.addItem(book.value)
}
</script>
<template>
<article>
<h1>{{ book.title }}</h1>
<p>價格:{{ book.price }} 元</p>
<button type="button" @click="addToCart">加入購物車</button>
<nav aria-label="書籍詳情分頁">
<RouterLink :to="{ name: 'book-intro', params: { id } }">簡介</RouterLink>
<RouterLink :to="{ name: 'book-reviews', params: { id } }">評論</RouterLink>
<RouterLink :to="{ name: 'book-edit', params: { id } }">編輯</RouterLink>
</nav>
<RouterView />
</article>
</template>src/views/BookIntroView.vue:
vue
<template>
<section>
<h2>內容簡介</h2>
<p>從元件心智走到完整的現代 Vue 應用。</p>
</section>
</template>src/views/BookReviewsView.vue:
vue
<template>
<section>
<h2>讀者評論</h2>
<p>這裡還沒有評論。</p>
</section>
</template>src/views/BookEditView.vue:
vue
<script setup>
import { ref } from 'vue'
import { onBeforeRouteLeave } from 'vue-router'
defineProps({
id: { type: String, required: true },
})
const draft = ref('')
const isDirty = ref(false)
function save() {
isDirty.value = false
}
onBeforeRouteLeave(() => {
if (!isDirty.value) return
return window.confirm('內容尚未儲存,確定要離開嗎?')
})
</script>
<template>
<section>
<h2>編輯書籍 {{ id }}</h2>
<textarea v-model="draft" rows="5" @input="isDirty = true"></textarea>
<button type="button" @click="save">儲存</button>
</section>
</template>src/views/LoginView.vue:
vue
<script setup>
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth.js'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
async function login() {
await auth.login()
const requested = route.query.redirect
const target = typeof requested === 'string' && requested.startsWith('/')
? requested
: '/orders'
await router.replace(target)
}
</script>
<template>
<section>
<h1>會員登入</h1>
<p>教學範例省略帳密欄位,按下按鈕即可切換登入狀態。</p>
<button type="button" @click="login">模擬登入</button>
</section>
</template>src/views/OrdersView.vue:
vue
<template>
<section>
<h1>我的訂單</h1>
<p>這是受登入狀態保護的會員頁。</p>
</section>
</template>src/views/CartView.vue:
vue
<script setup>
import { storeToRefs } from 'pinia'
import { useCartStore } from '../stores/cart.js'
const cart = useCartStore()
const { items, total } = storeToRefs(cart)
const { removeItem } = cart
</script>
<template>
<section>
<h1>購物車</h1>
<p v-if="items.length === 0">目前沒有商品。</p>
<ul v-else>
<li v-for="item in items" :key="item.id">
{{ item.title }},{{ item.quantity }} 本
<button type="button" @click="removeItem(item.id)">移除</button>
</li>
</ul>
<p>總計:{{ total }} 元</p>
</section>
</template>src/views/NotFoundView.vue:
vue
<script setup>
defineProps({
missingPath: { type: String, default: '' },
})
</script>
<template>
<section>
<h1>找不到頁面</h1>
<p v-if="missingPath">沒有符合 /{{ missingPath }} 的內容。</p>
<RouterLink :to="{ name: 'home' }">回書籍列表</RouterLink>
</section>
</template>把這組檔案放進 Vite + Vue 專案後,可以依序操作:加入商品並重新整理,購物車仍在;未登入進入 /orders 會導向 /login?redirect=/orders;按下登入後回到原目標;登出後重新整理維持未登入,但 cart key 仍然保留。
本章小結
Pinia 的主線到這裡完成。簡單記:
- 狀態先判斷範圍:元件自己的邏輯用 composable,跨頁面共享的資料才交給 store。
- Setup Store 是被 Pinia 接管的單例 composable:
ref是 state、computed是 getter、函式是 action,公開的內容要 return。 - 元件解構 state 和 getter 要用
storeToRefs();action 可以直接解構,修改規則集中在 action。 - 守衛裡的
useAuthStore()要在beforeEach回呼內呼叫;redirect 契約與第六章逐項相同。 - auth 與 cart 各用一個 localStorage key;清除由 auth watcher 承擔,登出不影響訪客購物車。
下一節是本章的可選單元:把建 store 這件事交給 AI 動手,我們負責把關。跳過它不影響主線;想看看實際流程,就翻到AI 協作:讓 AI 建 store。
