组件怎么拼页面你已经会了,但整个网站只有一个 index.html——点导航怎么「换页」?这一章用 Vue Router 把 URL 和组件一一对应起来:不刷新换页、动态路由、嵌套路由、导航守卫、懒加载。学完你能独立规划一个单页应用的路由表,并给用户中心加上登录拦截。
<!-- 传统多页:每点一次链接,浏览器都重新请求一整页 -->
<a href="/article/1.html">查看《静夜思》</a>
import { createRouter, createWebHistory } from 'vue-router'
import Home from '../views/Home.vue'
import ArticleList from '../views/ArticleList.vue'
import ArticleDetail from '../views/ArticleDetail.vue'
// routes 就是那张对照表:每一项 = 一个地址 + 一个组件
const routes = [
{ path: '/', name: 'home', component: Home },
{ path: '/article/list', name: 'article-list', component: ArticleList },
{ path: '/article/:id', name: 'article-detail', component: ArticleDetail }
]
const router = createRouter({
// HTML5 History 模式:地址栏是干净的 /article/3,不带 #
history: createWebHistory(),
routes
})
export default router
<template>
<header class="nav">
<!-- router-link 渲染出来仍是 <a>,但点击由路由接管,不刷新页面 -->
<router-link to="/">首页</router-link>
<router-link to="/article/list">赏文学</router-link>
<!-- to 也可以写成对象:跳转不依赖具体路径 -->
<router-link :to="{ name: 'user-profile' }">用户中心</router-link>
</header>
</template>
光有链接还不够,还得告诉 Vue「页面内容渲染在哪儿」。在 App.vue 里放一个出口。
<script setup>
import AppHeader from './components/AppHeader.vue'
</script>
<template>
<AppHeader />
<main class="wrap">
<!-- 当前地址匹配到的组件,会渲染在这里 -->
<router-view />
</main>
</template>
// :id 就是那个「洞」,它匹配任意一段路径
// /article/3、/article/128、/article/abc 都命中这一条
{ path: '/article/:id', name: 'article-detail', component: ArticleDetail }
<script setup>
import { useRoute } from 'vue-router'
const route = useRoute() // 当前这条路由的信息都挂在它身上
</script>
<template>
<h2>作品 ID:{{ route.params.id }}</h2>
</template>
<script setup>
import { useRouter } from 'vue-router'
const router = useRouter()
// 用 name + params 跳转:不用背路径,路由表改了这里也不用动
function goDetail(id) {
router.push({ name: 'article-detail', params: { id } })
}
// 用 query 传查询参数,最终地址是 /article/list?keyword=唐诗
function search(keyword) {
router.push({ name: 'article-list', query: { keyword } })
}
function goBack() {
router.back() // 等于浏览器返回键,不会新增历史记录
}
</script>
<template>
<button @click="goDetail(3)">查看《念奴娇·赤壁怀古》</button>
<button @click="search('唐诗')">只看唐诗</button>
<button @click="goBack()">返回</button>
</template>
{
path: '/user',
component: UserLayout, // 父:只渲染公共外壳
meta: { requiresAuth: true }, // 这个 meta 会在第 7 节被守卫读取
children: [
// 子路由的 path 不写前导斜杠,最终地址是 /user/profile
{ path: 'profile', name: 'user-profile', component: UserProfile },
{ path: 'favorite', name: 'user-favorite', component: UserFavorite },
{ path: 'comment', name: 'user-comment', component: UserComment }
]
}
<script setup>
const tabs = [
{ path: '/user/profile', label: '个人资料' },
{ path: '/user/favorite', label: '我的收藏' },
{ path: '/user/comment', label: '我的书评' }
]
</script>
<template>
<div class="user-center">
<aside class="side">
<router-link v-for="t in tabs" :key="t.path" :to="t.path">{{ t.label }}</router-link>
</aside>
<section class="content">
<!-- 子路由渲染在这里;这一层不写,三个子页都会看不见 -->
<router-view />
</section>
</div>
</template>
import { createRouter, createWebHistory } from 'vue-router'
import { useUserStore } from '../stores/user'
// ...routes 定义略
const router = createRouter({ history: createWebHistory(), routes })
// 全局前置守卫:每一次路由跳转之前都会走这里
router.beforeEach((to, from, next) => {
const userStore = useUserStore()
// to.meta.requiresAuth 为 true 的页面才要求登录
if (to.meta.requiresAuth && !userStore.token) {
// 没登录:送去登录页,并用 query 记住原本想去哪
next({ name: 'login', query: { redirect: to.fullPath } })
return
}
next() // 放行
})
export default router
登录页拿到 redirect 之后,登录成功要「把人送回去」。
<script setup>
import { useRoute, useRouter } from 'vue-router'
import { useUserStore } from '../stores/user'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
function afterLogin(data) {
userStore.login(data) // 存 token 和用户信息
// 登录前想去哪就回哪,没有 redirect 就回首页
router.replace(route.query.redirect || '/')
}
</script>
const routes = [
// 首页首屏就要用,直接静态引入(同步打进主包)
{ path: '/', name: 'home', component: Home },
// 其余页面按需加载:打包时各自拆成独立文件,访问到才下载
{ path: '/article/list', name: 'article-list',
component: () => import('../views/ArticleList.vue') },
{ path: '/article/:id', name: 'article-detail',
component: () => import('../views/ArticleDetail.vue') },
{ path: '/user', component: () => import('../views/user/UserLayout.vue'),
meta: { requiresAuth: true },
children: [
{ path: 'profile', name: 'user-profile',
component: () => import('../views/user/UserProfile.vue') }
] }
]
<script setup>
import { ref, onMounted, watch } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const detail = ref(null)
async function loadDetail(id) {
// 第 9 章封装好 axios 之后,这里换成:
// detail.value = await request.get(`/article/detail/${id}`)
detail.value = { id, title: `作品 ${id} 的详情` }
}
// 第一次进入页面:进入即加载
onMounted(() => {
loadDetail(route.params.id)
})
// 组件被复用、参数变了:watch 接住这次变化
watch(() => route.params.id, (newId) => {
if (newId) loadDetail(newId)
})
</script>
<template>
<h2>{{ detail?.title }}</h2>
</template>
import { createRouter, createWebHistory } from 'vue-router'
import Home from '../views/Home.vue'
import ArticleList from '../views/ArticleList.vue'
import { useUserStore } from '../stores/user'
const routes = [
// 第 1 题:静态路径写前面,带参数的写后面
{ path: '/', name: 'home', component: Home },
{ path: '/article/list', name: 'article-list', component: ArticleList },
{ path: '/article/:id', name: 'article-detail',
component: () => import('../views/ArticleDetail.vue') },
{ path: '/login', name: 'login',
component: () => import('../views/Login.vue') },
// 第 2 题:用户中心用嵌套路由,父级打上 requiresAuth
{ path: '/user', component: () => import('../views/user/UserLayout.vue'),
meta: { requiresAuth: true },
children: [
{ path: 'profile', name: 'user-profile', component: () => import('../views/user/UserProfile.vue') },
{ path: 'favorite', name: 'user-favorite', component: () => import('../views/user/UserFavorite.vue') }
] },
// 第 3 题(红影赏析):表名和字段换了,路由骨架完全一样
{ path: '/movie/:id', name: 'movie-detail',
component: () => import('../views/MovieDetail.vue') }
]
const router = createRouter({ history: createWebHistory(), routes })
router.beforeEach((to, from, next) => {
const userStore = useUserStore()
if (to.meta.requiresAuth && !userStore.token) {
next({ name: 'login', query: { redirect: to.fullPath } })
return
}
next()
})
export default router
<script setup>
import { ref, onMounted, watch } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const movie = ref(null)
async function loadMovie(id) {
movie.value = { id, title: `电影 ${id}` }
}
// ① 从列表点进来:首次加载
onMounted(() => loadMovie(route.params.id))
// ② 在详情页点「相似推荐」:地址变了但组件复用,必须靠 watch
watch(() => route.params.id, (newId) => {
if (newId) loadMovie(newId)
})
</script>
第 3 题的关键:换的是「路径名和字段名」,不变的是「动态路由 + onMounted/watch 双入口」这套骨架。能迁移过去,说明你不是在背语法,而是真的懂了路由的工作方式。