Appearance
導航守衛 (Navigation Guards)
前四節回答「這個 URL 顯示什麼」與「怎麼走過去」。還剩一個問題沒回答:這次導航能不能通過?未登入的人不能進訂單頁;權限檢查結束前不能先顯示受保護內容;編輯到一半的人離開前應該收到提醒。Vue Router 把這些決策點統稱為導航守衛。
用 meta 標記受保護路由
不要在守衛裡維護一長串受保護 path。route record 的 meta 適合描述這筆路由的政策:
src/router/routes.js:
js
export const routes = [
{
path: '/orders',
name: 'orders',
component: () => import('../views/OrdersView.vue'),
meta: { requiresAuth: true },
},
{
path: '/login',
name: 'login',
component: () => import('../views/LoginView.vue'),
},
]1
2
3
4
5
6
7
8
9
10
11
12
13
2
3
4
5
6
7
8
9
10
11
12
13
只有會員頁加上 requiresAuth。首頁、書籍列表與詳情、/login、404 都是公開頁;不加標記本身就是明確的預設政策。
全域 beforeEach
router.beforeEach() 在每次導航確認前執行。這裡把守衛包成 installGuards(),正式 router 與測試都安裝同一份行為;auth 可注入則讓測試能控制登入狀態:
src/router/guards.js:
js
import { ref } from 'vue'
export const isLoggedIn = ref(false)
export function installGuards(router, auth = { isLoggedIn }) {
router.beforeEach((to) => {
if (to.name === 'login' || !to.meta.requiresAuth) return
if (!auth.isLoggedIn.value) {
return {
name: 'login',
query: { redirect: to.fullPath },
}
}
})
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
守衛的回傳值就是導航決策:
- 回傳
undefined(或不寫return值):放行。 - 回傳
false:取消,留在原頁。 - 回傳路徑字串或路由位置物件:改去另一個位置。
現代寫法直接 return,不需要舊版常見的第三個 next 參數。to.name === 'login' 是明確的無限重導防線;雖然登入頁目前沒有 requiresAuth,這個條件仍把「不能把登入頁再導向自己」寫進守衛契約。
常見錯誤:未登入一律導向登入頁
若守衛完全不看目標,前往 /login 也會再回傳 /login,形成無限重導:
js
router.beforeEach(() => {
if (!isLoggedIn.value) return '/login' // ❌ /login 也被攔
})1
2
3
2
3
正確做法是只攔 to.meta.requiresAuth,並明確排除登入頁。404 也沒有受保護 meta,未登入時仍能看見正確的找不到頁面。
登入狀態暫時用模組層級 ref,是為了讓範例聚焦守衛。這個做法有一個馬上就能體驗到的限制:重新整理或直接在網址列輸入網址都是整頁載入,JavaScript 應用從頭啟動,記憶體裡的 isLoggedIn 又變回 false,使用者就這樣被登出了。正式應用還要處理 token、使用者資料與多個元件的共享狀態;第七章會交給 Pinia。
Async 守衛要等待結果
權限常常來自 API。守衛可以是 async 函式,但非同步檢查必須 await;否則導航會在結果回來前放行:
js
export function installGuards(router, auth = { isLoggedIn }) {
router.beforeEach(async (to) => {
if (to.name === 'login' || !to.meta.requiresAuth) return
if (!auth.isLoggedIn.value) {
return { name: 'login', query: { redirect: to.fullPath } }
}
const canAccess = auth.canAccess ?? (async () => true)
const allowed = await canAccess(to)
if (!allowed) return false
})
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
2
3
4
5
6
7
8
9
10
11
12
13
14
await 期間 Vue Router 會等候;Promise 完成後才依 allowed 放行或取消。真實專案還要替網路錯誤與逾時定義政策,不能讓未處理的例外把導航卡在半路。
路由專屬 beforeEnter
只跟某一筆路由有關的檢查,可以直接放在 route record 的 beforeEnter。例如進入詳情前確認書籍存在:
js
{
path: '/books/:id',
name: 'book',
component: () => import('../views/BookDetailView.vue'),
async beforeEnter(to) {
const exists = await checkBookExists(to.params.id)
if (!exists) return { name: 'not-found' }
},
}1
2
3
4
5
6
7
8
9
2
3
4
5
6
7
8
9
beforeEnter 只在進入這筆 route record 時執行。從首頁進 /books/1 會執行;但 /books/1 → /books/2、只改 query 或 hash,仍是同一筆 record,不會再執行。若參數變化也必須重新驗證,改用全域 beforeEach 或元件內的 onBeforeRouteUpdate(),不要把 beforeEnter 誤當參數 watcher。
導航守衛不是生命週期 Hook
onBeforeRouteLeave() 雖然也以 on 開頭,卻是 Vue Router 提供的守衛,不是 Vue 的生命週期 Hook:
| 導航守衛 | 生命週期 Hook | |
|---|---|---|
| 觸發原因 | 路由準備改變 | 元件掛載、更新或卸載 |
| 處理重點 | 放行、取消或重新導向 | 初始化資源、同步畫面、清理副作用 |
| 能否決定導航 | 可以 | 不可以 |
| 例子 | beforeEach、onBeforeRouteLeave | onMounted、onUnmounted |
兩者有時會接連發生,但不是同一件事。從 /books/1 前往 /books/2 時,Vue Router 可能重用同一個詳情元件,因此 onMounted() 不會重跑;導航守衛與 route.params 的監聽仍能對這次路由變化作出反應。不要用生命週期 Hook 猜測導航,也不要用導航守衛處理一般的元件資源清理。
元件內 onBeforeRouteLeave
離開確認跟編輯頁元件的本地狀態緊密相關,放回元件最自然:
src/views/BookEditView.vue:
vue
<script setup>
import { ref } from 'vue'
import { onBeforeRouteLeave } from 'vue-router'
const draft = ref('')
const isDirty = ref(false)
onBeforeRouteLeave(() => {
if (!isDirty.value) return
return window.confirm('內容尚未儲存,確定要離開嗎?')
})
</script>
<template>
<label>
編輯摘要
<textarea v-model="draft" @input="isDirty = true"></textarea>
</label>
</template>1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
confirm() 回傳 false 時,守衛也回傳 false,導航便取消。這只是最小示範;表單驗證、送出狀態與自動儲存留到第八章。
下面收錄完整可跑的書店應用:路由表、守衛、組裝、入口與所有頁面元件都在同一個摺疊區塊裡,routes.js 的 view 全部使用動態 import。
完整可跑範例:書店路由與守衛
src/router/routes.js:
js
async function checkBookExists(id) {
await Promise.resolve()
return ['1', '2', '3'].includes(String(id))
}
export const routes = [
{
path: '/',
name: 'home',
component: () => import('../views/BookListView.vue'),
},
{
path: '/books/:id',
name: 'book',
component: () => import('../views/BookDetailView.vue'),
props: true,
async beforeEnter(to) {
const exists = await checkBookExists(to.params.id)
if (!exists) {
return {
name: 'not-found',
params: { pathMatch: ['books', String(to.params.id)] },
}
}
},
children: [
{
path: '',
name: 'book-intro',
component: () => import('../views/BookIntroView.vue'),
},
{
path: 'reviews',
name: 'book-reviews',
component: () => import('../views/BookReviewsView.vue'),
},
],
},
{
path: '/books/:id/edit',
name: 'book-edit',
component: () => import('../views/BookEditView.vue'),
props: true,
meta: { requiresAuth: true },
},
{
path: '/login',
name: 'login',
component: () => import('../views/LoginView.vue'),
},
{
path: '/orders',
name: 'orders',
component: () => import('../views/OrdersView.vue'),
meta: { requiresAuth: true },
},
{
path: '/:pathMatch(.*)*',
name: 'not-found',
component: () => import('../views/NotFoundView.vue'),
props: (route) => ({
missingPath: route.params.pathMatch.join('/'),
}),
},
]1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
src/router/guards.js:
js
import { ref } from 'vue'
export const isLoggedIn = ref(false)
async function allowByDefault() {
await Promise.resolve()
return true
}
export function installGuards(router, auth = { isLoggedIn }) {
router.beforeEach(async (to) => {
if (to.name === 'login' || !to.meta.requiresAuth) return
if (!auth.isLoggedIn.value) {
return {
name: 'login',
query: { redirect: to.fullPath },
}
}
const canAccess = auth.canAccess ?? allowByDefault
const allowed = await canAccess(to)
if (!allowed) return false
})
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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 router1
2
3
4
5
6
7
8
9
10
11
12
2
3
4
5
6
7
8
9
10
11
12
src/main.js:
js
import { createApp } from 'vue'
import App from './App.vue'
import router from './router/index.js'
createApp(App).use(router).mount('#app')1
2
3
4
5
2
3
4
5
src/App.vue:
vue
<script setup>
import { isLoggedIn } from './router/guards.js'
function logout() {
isLoggedIn.value = false
}
</script>
<template>
<div class="app-shell">
<header class="site-header">
<nav class="site-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">登出</button>
</nav>
<p class="auth-status">目前狀態:{{ isLoggedIn ? '已登入' : '未登入' }}</p>
</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-header {
padding-bottom: 1rem;
border-bottom: 1px solid #d4d4d8;
}
.site-nav {
display: flex;
align-items: center;
gap: 1rem;
}
.site-nav button {
padding: 0.35rem 0.75rem;
}
.auth-status {
color: #52525b;
}
.page {
padding-block: 1.5rem;
}
.router-link-active {
color: #0f766e;
font-weight: 700;
}
</style>1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
src/views/BookListView.vue:
vue
<script setup>
const books = [
{ id: 1, title: '重新認識 Vue.js' },
{ id: 2, title: '0 陷阱!0 誤解!8 天重新認識 JavaScript!' },
{ id: 3, title: 'Vue 3 實戰手冊' },
]
</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>1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
src/views/BookDetailView.vue:
vue
<script setup>
defineProps({
id: { type: String, required: true },
})
</script>
<template>
<article>
<h1>書籍 {{ id }}</h1>
<nav class="book-tabs" 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>
<style scoped>
.book-tabs {
display: flex;
gap: 1rem;
margin-bottom: 1rem;
}
</style>1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
src/views/BookIntroView.vue:
vue
<template>
<section>
<h2>內容簡介</h2>
<p>從元件心智走到完整的現代 Vue 應用。</p>
</section>
</template>1
2
3
4
5
6
2
3
4
5
6
src/views/BookReviewsView.vue:
vue
<template>
<section>
<h2>讀者評論</h2>
<p>這裡還沒有評論。</p>
</section>
</template>1
2
3
4
5
6
2
3
4
5
6
src/views/LoginView.vue:
vue
<script setup>
import { useRoute, useRouter } from 'vue-router'
import { isLoggedIn } from '../router/guards.js'
const route = useRoute()
const router = useRouter()
async function login() {
isLoggedIn.value = true
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>1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
src/views/OrdersView.vue:
vue
<template>
<section>
<h1>我的訂單</h1>
<p>這是受登入狀態保護的會員頁。</p>
</section>
</template>1
2
3
4
5
6
2
3
4
5
6
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 markDirty() {
isDirty.value = true
}
function save() {
isDirty.value = false
}
onBeforeRouteLeave(() => {
if (!isDirty.value) return
return window.confirm('內容尚未儲存,確定要離開嗎?')
})
</script>
<template>
<section>
<h1>編輯書籍 {{ id }}</h1>
<label class="editor-field">
摘要
<textarea v-model="draft" rows="5" @input="markDirty"></textarea>
</label>
<button type="button" @click="save">儲存</button>
<p>{{ isDirty ? '有尚未儲存的變更' : '內容已儲存' }}</p>
</section>
</template>
<style scoped>
.editor-field {
display: grid;
gap: 0.5rem;
max-width: 28rem;
margin-bottom: 1rem;
}
</style>1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
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>1
2
3
4
5
6
7
8
9
10
11
12
13
2
3
4
5
6
7
8
9
10
11
12
13
把這組檔案放進任何 Vite + Vue 專案即可實際操作:未登入點「我的訂單」會前往登入頁;登入後可進訂單與編輯頁;在編輯摘要輸入內容後離開,取消確認會留在原頁。
路由主線到這裡完成了。下一節用另一個小案例練習把路由設定交給 AI 產生,再用本章的規則逐項把關。
