Skip to content

導航守衛 (Navigation Guards)

重新認識 Vue.js: 008 天絕對看不完的 Vue.js 3.x 指南

前四節回答「這個 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'),
  },
]

只有會員頁加上 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 },
      }
    }
  })
}

守衛的回傳值就是導航決策:

  • 回傳 undefined(或不寫 return 值):放行。
  • 回傳 false:取消,留在原頁。
  • 回傳路徑字串或路由位置物件:改去另一個位置。

現代寫法直接 return,不需要舊版常見的第三個 next 參數。to.name === 'login' 是明確的無限重導防線;雖然登入頁目前沒有 requiresAuth,這個條件仍把「不能把登入頁再導向自己」寫進守衛契約。

常見錯誤:未登入一律導向登入頁

若守衛完全不看目標,前往 /login 也會再回傳 /login,形成無限重導:

js
router.beforeEach(() => {
  if (!isLoggedIn.value) return '/login' // ❌ /login 也被攔
})

正確做法是只攔 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
  })
}

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' }
  },
}

beforeEnter 只在進入這筆 route record 時執行。從首頁進 /books/1 會執行;但 /books/1/books/2、只改 query 或 hash,仍是同一筆 record,不會再執行。若參數變化也必須重新驗證,改用全域 beforeEach 或元件內的 onBeforeRouteUpdate(),不要把 beforeEnter 誤當參數 watcher。

導航守衛不是生命週期 Hook

onBeforeRouteLeave() 雖然也以 on 開頭,卻是 Vue Router 提供的守衛,不是 Vue 的生命週期 Hook:

導航守衛生命週期 Hook
觸發原因路由準備改變元件掛載、更新或卸載
處理重點放行、取消或重新導向初始化資源、同步畫面、清理副作用
能否決定導航可以不可以
例子beforeEachonBeforeRouteLeaveonMountedonUnmounted

兩者有時會接連發生,但不是同一件事。從 /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>

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('/'),
    }),
  },
]

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
  })
}

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 router

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')

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>

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>

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>

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/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>

src/views/OrdersView.vue

vue
<template>
  <section>
    <h1>我的訂單</h1>
    <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 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>

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 專案即可實際操作:未登入點「我的訂單」會前往登入頁;登入後可進訂單與編輯頁;在編輯摘要輸入內容後離開,取消確認會留在原頁。

路由主線到這裡完成了。下一節用另一個小案例練習把路由設定交給 AI 產生,再用本章的規則逐項把關。