Appearance
auth store:與 Vue Router 整合
把登入狀態放進 store
第六章的導航守衛先用模組層級的 isLoggedIn ref 示範規則。它能完成「未登入不能進訂單頁」,但重新整理後 JavaScript 會重新載入,記憶體裡的 ref 又回到 false;導覽列和守衛也必須各自 import 同一個模組,登入者資料更沒有自然的集中位置。這一節把那份模組狀態升級成 auth store。
src/stores/auth.js:
js
import { ref } from 'vue'
import { defineStore } from 'pinia'
export const useAuthStore = defineStore('auth', () => {
const isLoggedIn = ref(false)
const user = ref(null)
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 }
})isLoggedIn 和 user 是 state,login、logout 是行為,canAccess 則是替未來 API 權限檢查留下的非同步邊界。現在先讓它預設放行,之後可以在 action 裡呼叫後端並依結果回傳 true 或 false。導覽列、登入頁和守衛呼叫的都是 useAuthStore(),因此不再各自維護登入狀態。
在導航守衛裡使用 store
route record 的政策仍然使用 CH6 的 meta.requiresAuth,不因為換成 Pinia 就改變:
src/router/routes.js:
js
export const routes = [
{
path: '/login',
name: 'login',
component: () => import('../views/LoginView.vue'),
},
{
path: '/orders',
name: 'orders',
component: () => import('../views/OrdersView.vue'),
meta: { requiresAuth: true },
},
]守衛的差別只有「登入狀態從哪裡來」。Pinia 已經在 main.js 中先以 app.use(pinia) 安裝,所以可以在 beforeEach 回呼執行時取得 store:
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
// 必須在 beforeEach 回呼內呼叫
const auth = useAuthStore()
if (!auth.isLoggedIn) {
return {
name: 'login',
query: { redirect: to.fullPath },
}
}
const allowed = await auth.canAccess(to)
if (!allowed) return false
})
}這份升級版和 CH6 的守衛契約逐項相同:/login 與沒有 requiresAuth 的公開頁先早退;未登入前往受保護頁時回傳 { name: 'login', query: { redirect: to.fullPath } };已登入後仍會 await 非同步權限檢查;檢查結果是 false 時回傳 false 取消導航。原本傳入 auth 物件的 installGuards(router, auth) 介面也不需要了,因為測試可以先用 setActivePinia() 建立測試 Pinia,再讓守衛取得同一個 store。
常見錯誤
錯誤寫法是在模組頂層呼叫 useAuthStore(),再把結果捕捉給守衛使用。router 模組可能在 Pinia 安裝前就被 import,這時沒有 active Pinia,會拋出「找不到 active Pinia」的錯誤。正確寫法是把 useAuthStore() 放在 beforeEach 回呼內,等 router 真正開始導航時再取得 store。
登入頁保留原目標
守衛把原本想去的頁面放進 redirect query,登入頁要在呼叫 store 的 login() action 成功後再回跳。沿用 CH6 的安全邊界,只接受以 / 開頭的字串;缺少 query、query 是陣列,或不是站內路徑時,一律回到 /orders。
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>這裡的 router.replace() 仍然只在登入 action 完成後執行,避免把登入頁本身留在瀏覽器歷史中。Pinia 改變的是登入狀態的擁有者,不改變 CH6 已建立的 redirect 契約。
導覽列讀取登入狀態
導覽列使用 storeToRefs() 讀取 state,登出則直接解構 action。這裡只負責顯示和發出行為,不再 import CH6 的模組層級 isLoggedIn:
src/App.vue:
vue
<script setup>
import { storeToRefs } from 'pinia'
import { useAuthStore } from './stores/auth.js'
const auth = useAuthStore()
const { isLoggedIn, user } = storeToRefs(auth)
const { logout } = auth
</script>
<template>
<header>
<nav aria-label="主要導覽">
<RouterLink :to="{ name: 'home' }">書籍</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>
</template>購物車 store 也可以在結帳或提交訂單時讀取 auth store,取得目前使用者;這種 store 互用應該透過公開的 state 或 action,並留意不要讓兩個 store 互相在模組頂層呼叫造成循環依賴。現在的重點是 auth store 成為登入狀態的唯一來源,其他功能只讀它或呼叫它的 action。
下一節會處理重新整理後的狀態恢復,並把 auth 與 cart 的持久化範圍分開定義。請接著閱讀持久化:讓狀態活過重新整理。
