码上问学问代码之理,学编程之道

第 7 章 · 路由 Vue Router

组件怎么拼页面你已经会了,但整个网站只有一个 index.html——点导航怎么「换页」?这一章用 Vue Router 把 URL 和组件一一对应起来:不刷新换页、动态路由、嵌套路由、导航守卫、懒加载。学完你能独立规划一个单页应用的路由表,并给用户中心加上登录拦截。

单页应用为什么需要路由

URL 是用户的「坐标」
路由的本质:URL 变了 → 查路由表 → 换 router-view 里的组件(页面不刷新)浏览器地址栏/article/3路由表 routes/article/:id → 详情组件/about → 关于页匹配ArticleDetail.vue被装进 <router-view/>旧组件卸载 / 新组件挂载对比传统多页网站传统:每次点链接 = 重新请求完整 HTML → 白屏一闪,页面整体刷新SPA:只换「视图区」的组件,JS 数据在内存里接着用 → 丝滑、像原生 AppURL 仍然是真实地址:可以刷新、可以分享、可以前进后退(history 模式)router-link 负责「点」,router-view 负责「放」——一个出题一个答题
先讲为什么你在浏览器里点一个链接,地址栏变了、页面变了,还能按后退回到上一步——这套体验看着理所当然,但在单页应用(SPA)里是要你自己实现的。SPA 整站只有一个 index.html,所谓「换页」其实只是把页面上某块区域的内容换掉。
那问题来了:既然页面没真的换,浏览器怎么知道你刚才点的是哪一页?靠 URL。于是需要一个「地址 → 组件」的对照表,这张表就是路由。
动手做先看看「传统多页写法」是什么体验。这是没上路由之前最常见的写法:一个普通的 <a> 标签。
list.htmlhtml
<!-- 传统多页:每点一次链接,浏览器都重新请求一整页 -->
<a href="/article/1.html">查看《静夜思》</a>
页面效果浏览器渲染结果
运行结果: Name Method Status Type article/1.html GET 200 document detail.css GET 200 stylesheet detail.js GET 200 script (出现 document 请求 = 整页重新加载:页面闪白、滚动位置丢失、公共资源重新下载一次)
讲透:路由在浏览器里到底做了两件事
  • 拦下点击、改地址:<router-link> 渲染出来还是 <a>,但点击时被路由接管,用浏览器的 History API 改地址栏。
  • 按地址渲染组件:<router-view> 找到当前地址对应的组件,渲染进去。
  • 结果就是:地址栏变了,但一次 document 请求都没有。这就是「不刷新换页」的全部秘密。
避坑组内导航千万别手写 <a href="/article/3">。浏览器会把它当成真链接,整页重新加载——后果不只是闪一下白屏,而是 Pinia 里存的登录状态、token、列表数据全部清零。SPA 内部跳转一律用 <router-link to="/article/3">。

路由表:createRouter 与 routes

一张「地址 → 组件」的对照表
先讲为什么路由的核心就一个数据结构:一个数组,每一项写清楚「什么地址 → 用哪个组件」。这就像小区门牌号表——报出地址,就知道该敲哪扇门。表单里那些写法(动态参数、嵌套)都是在这个数组里做文章,所以先把这个数组写对。
动手做新建 src/router/index.js,配好「古典文学网」最先要用的三条路由。
src/router/index.jsjs
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
页面效果浏览器运行结果
运行结果: (在 main.js 里 app.use(router) 之后,按地址访问的效果) 访问地址 命中组件 / Home.vue /article/list ArticleList.vue /article/3 ArticleDetail.vue /xxx (无匹配)控制台警告:No match found for location with path "/xxx"
讲透:三个字段各管什么
  • path 是地址。/ 必须存在,它就是首页。
  • component 是「这个地址该渲染谁」。
  • name 是给这条路由起的名字。跳转时用 name 比用 path 更安全——哪天路径从 /article/list 改成 /works/list,用 name 的地方一行都不用改。
  • 别忘了一步:在 main.js 里 app.use(router),否则这张表和 Vue 毫无关系。
  • History 模式 vs Hash 模式:createWebHistory() 得到 /article/3,干净好看;createWebHashHistory() 得到 /#/article/3,丑但不需要服务端配合。生产项目一般选前者。
避坑History 模式下,用户在 /article/3 按 F5,浏览器会真的去请求这个地址。开发期 Vite 已经帮你兜住了,但线上会直接 404。生产环境的 Nginx 必须加一句 try_files $uri $uri/ /index.html;,让找不到的文件全都回到 index.html,剩下的交给前端路由处理。

router-link 与 router-view

一个负责「点」,一个负责「放」
动手做把顶部导航换成 <router-link>。
src/components/AppHeader.vuevue
<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>
页面效果组件渲染结果
运行结果: (浏览器实际渲染出来的 HTML) <header class="nav"> <a href="/" class="router-link-active router-link-exact-active">首页</a> <a href="/article/list">赏文学</a> <a href="/user/profile">用户中心</a> </header> 说明:当前匹配到的那一项会自动带 router-link-active 类名,可以拿它做高亮

光有链接还不够,还得告诉 Vue「页面内容渲染在哪儿」。在 App.vue 里放一个出口。

src/App.vuevue
<script setup>
import AppHeader from './components/AppHeader.vue'
</script>

<template>
  <AppHeader />

  <main class="wrap">
    <!-- 当前地址匹配到的组件,会渲染在这里 -->
    <router-view />
  </main>
</template>
页面效果组件渲染结果
运行结果: 浏览器地址 http://localhost:5173/article/list 渲染出来的结构: <header class="nav"> 首页 | 赏文学 | 用户中心 </header> <main class="wrap"> 这里是 ArticleList.vue 的内容 </main> 点「赏文学」→ 地址变为 /article/list,Network 面板里没有新的 document 请求
讲透
  • to 可以给字符串,也可以给对象。:to="{ name: 'article-detail', params: { id: 3 } }" 这种写法把「路径长什么样」交给路由表去拼,组件里不用关心。
  • <router-view> 是「占位出口」:路由匹配到哪个组件,就把它渲染到这个位置。它本身不渲染任何标签。
  • 一个页面可以有好几个出口(第 6 节的嵌套路由就是这么用的):外层一个放整体框架,里层一个放内容区。
  • 如果你改了地址却发现页面空白,九成是忘了写 <router-view>——组件明明加载了,但没有地方显示。

动态路由 /article/:id

几百篇作品,不可能配几百条路由
先讲为什么/article/1、/article/2、/article/3……作品有几百篇,难道要在路由表里写几百条?当然不。这些地址长得是同一个样,只是「变化的那一小段」不同——把变化的部分挖成一个洞,用一条路由套住全部,这个洞就叫动态参数。
动手做在路径里用冒号加一个名字,就是挖了个洞;组件里用 useRoute() 把它读出来。
src/router/index.js(片段)js
// :id 就是那个「洞」,它匹配任意一段路径
// /article/3、/article/128、/article/abc 都命中这一条
{ path: '/article/:id', name: 'article-detail', component: ArticleDetail }
页面效果浏览器运行结果
运行结果: /article/1 → 命中 article-detail,route.params.id = "1" /article/128 → 命中 article-detail,route.params.id = "128" /article/list → 也命中 article-detail,route.params.id = "list" ← 这就是下面那个坑
src/views/ArticleDetail.vuevue
<script setup>
import { useRoute } from 'vue-router'

const route = useRoute()   // 当前这条路由的信息都挂在它身上
</script>

<template>
  <h2>作品 ID:{{ route.params.id }}</h2>
</template>
页面效果组件渲染结果
运行结果: 地址栏 http://localhost:5173/article/1 → 页面显示「作品 ID:1」 地址栏 http://localhost:5173/article/3 → 页面显示「作品 ID:3」 在控制台里看这两个值: route.params → { id: "1" } typeof route.params.id → "string" ← 注意:路由参数永远是字符串
讲透
  • route.params 取路径里的洞,route.query 取 ? 后面的查询参数。比如 /article/list?keyword=唐诗&page=2,用 route.query.keyword 拿到 "唐诗"。
  • params 的值一定是字符串。要当数字用就自己转:const id = Number(route.params.id)。传给后端接口时数字和字符串一般都能接受,但做比较运算时别踩这个坑。
  • 这条动态路由就是作品详情页的入口:拿到 id 之后去调 /api/article/detail/<id> 取详情,至于怎么调,第 9 章讲。
避坑路由是按数组顺序一条条匹配的,谁先命中就用谁。上面例子里 /article/:id 排在 /article/list 前面,结果访问作品列表时 id 变成了 "list"。规矩:静态路径写在前面,带 :参数 的写在后面。

编程式导航 useRouter

不在链接上点,也要能跳
先讲为什么<router-link> 只能应付「用户点了这个链接」。但真实的业务里更多是这样的:提交表单成功后跳首页、点「查看详情」按钮带 id 跳详情、点标题跳到搜索结果页——这些跳转发生在代码里,不在标签上。这就是编程式导航。
动手做用 useRouter() 拿到路由实例,然后 push。
src/views/ArticleList.vue(片段)vue
<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>
页面效果组件渲染结果
运行结果: 点「查看《念奴娇·赤壁怀古》」→ 地址栏 http://localhost:5173/article/3 点「只看唐诗」 → 地址栏 http://localhost:5173/article/list?keyword=%E5%94%90%E8%AF%97 点「返回」 → 回到上一个地址,浏览器历史纪录里当前这条被弹出 说明:query 里的中文会被浏览器自动做 URL 编码,「唐诗」变成了 %E5%94%90%E8%AF%97
讲透
  • useRoute() 和 useRouter() 别搞混:前者是「当前在哪」(读参数),后者是「要去哪」(做跳转)。名字只差一个字母,写反了报错很费解。
  • push 加一条历史,replace 替换当前这条。登录成功后跳首页建议用 replace,否则用户按返回又会回到登录页。
  • 在 <script setup> 顶层不要直接调 router.push,一定放进事件处理函数或生命周期钩子里,否则组件一渲染就跳走。
  • 跳转前想确认一下当前地址?用 router.currentRoute.value.fullPath。

嵌套路由 children

用户中心:外壳不动,只换内容
嵌套路由:外壳不动、内容区换——用户中心的典型结构UserLayout.vue(外壳:侧边菜单 + 顶部标题)资料改资料我的收藏<router-view/>(内容区)children: [ { path:'profile', → UserProfile } { path:'edit', → UserEdit } { path:'favorites', → MyFavorites }]点侧边菜单 = 只换内容区组件,外壳与菜单保持原样——体验和原生 App 一致
先讲为什么「用户中心」下面有三个页面:个人资料、我的收藏、我的书评。它们在 UI 上是同一个样子——左边一列菜单,右边一块内容区,点菜单只有右边换。如果把这三个页面各写成一个独立路由,左边的菜单就得抄三遍。
正确做法是:父路由负责那层共享的外壳,子路由只负责内容区。
动手做在父路由里加 children 数组。
src/router/index.js(片段)js
{
  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 }
  ]
}
页面效果浏览器运行结果
运行结果: /user/profile → 命中 user-profile /user/favorite → 命中 user-favorite /user/comment → 命中 user-comment /user/xxx → 父路由匹配上了,但没有子路由匹配 → 内容区空白 (要给出提示,可以加一条 { path: ':pathMatch(.*)*', component: NotFound })
src/views/user/UserLayout.vuevue
<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>
页面效果组件渲染结果
运行结果: 浏览器地址 /user/profile 渲染结构: <div class="user-center"> <aside class="side"> 个人资料 | 我的收藏 | 我的书评 </aside> <section class="content"> 这里是 UserProfile.vue </section> </div> 点「我的收藏」→ 地址变为 /user/favorite,只有 <section class="content"> 里的内容换了,左侧菜单原样不动
讲透
  • 子路由的 path 不写开头的 /。'profile' 拼在父路径后面变成 /user/profile;写成 '/profile' 就变成根路径下的 /profile 了,跟父路由一点关系都没有。
  • 父组件的模板里必须有一个 <router-view>,那是给子路由留的位置。
  • 嵌套可以不止一层:/user/favorite 下还能再挂。但别为了炫技把层级做深,两层基本涵盖绝大多数后台与用户中心页面。
  • 父子共享的那部分(外壳、菜单、面包屑)只渲染一次,这是组件复用思想在路由层的体现——和第 6 章拆组件是同一个思路。

导航守卫 beforeEach

在「关卡」上把没登录的人拦下来
导航守卫 beforeEach:每次跳转都要过的「关卡」,不放行就原路退回发起跳转/user/favoritesbeforeEach 守卫to.meta.requiresAuth ?token 存在吗?有 token → next()没有 → 踢去登录页放行:进入目标页面重定向 /login?redirect=…登录成功后跳回原页面:router.push(redirect || '/')守卫里只做「判断和放行」,不要在里面发复杂请求——关卡要快to:去哪 from:从哪来 next():放行 —— 三个参数记住就够
先讲为什么用户中心、我的收藏必须登录才能看。最笨的办法是每个页面在 onMounted 里判断一次有没有 token——四个页面就抄四遍,将来加页面还得记得抄。
换个思路:别在页面里判断,在「进门的地方」判断。所有路由跳转都会经过同一个闸口,在那里查一次票,这就是导航守卫。
动手做先用 meta 给需要登录的路由打个标记,再在 router.beforeEach 里统一检查。token 从 Pinia 的 userStore 里取(Pinia 的细节第 8 章讲,这里你只要知道 userStore.token 存的就是登录凭证)。
src/router/index.jsjs
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
页面效果浏览器运行结果
运行结果: 【未登录】直接在地址栏输入 /user/profile → beforeEach 命中:to.meta.requiresAuth = true,userStore.token = '' → 地址被改成 /login?redirect=/user/profile 【已登录】token 有值,再访问 /user/profile → 直接 next() 放行,正常渲染用户中心 【访问首页】/ 没有 meta.requiresAuth → 同样走守卫,但条件不成立,直接放行

登录页拿到 redirect 之后,登录成功要「把人送回去」。

src/views/Login.vue(片段)vue
<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>
页面效果组件渲染结果
运行结果: 场景:未登录 → 访问 /user/profile → 被送到 /login?redirect=/user/profile 登录成功后: userStore.token 有值 router.replace('/user/profile') 执行 → 地址回到 http://localhost:5173/user/profile,用户中心正常显示 (用 replace 而不是 push:历史里不会留下登录页,按返回不会又回登录页)
讲透
  • 三个参数:to 是「要去哪」,from 是「从哪来」,next 是「放不放行」。
  • next 的三种用法:next() 放行;next(false) 取消这次跳转、留在原地;next('/login') 改道去别处。每次必须调一次且只调一次,漏了页面会卡死。
  • Vue Router 4 也支持 return 写法:直接 return { name: 'login' } 表示改道,return false 表示取消,什么都不返回就是放行。两种写法选一种,别混着用。
  • 守卫挂在路由实例上,不是挂在页面上,所以将来新增需登录的页面,只要加一个 meta: { requiresAuth: true } 就自动被保护。
  • 守卫只能拦住前端的跳转,它防的是「用户体验」,不是安全。真正的安全靠后端——每个需登录的接口都要校验 token(见课程二)。
避坑useUserStore() 必须写在守卫的回调函数里面。写到文件顶层的 import 下面,运行时会报 getActivePinia() was called but there was no active Pinia——因为在模块加载那一刻,main.js 里的 app.use(pinia) 还没执行。放进回调里,等真正跳转时 Pinia 早就装好了。

路由懒加载

别让用户打开首页就下载整个网站
先讲为什么项目做到最后有十几个页面。如果用 import 静态引入全部写进路由表,打包会产出一个巨大的 js 文件——用户只想看首页,却得先把用户中心、后台管理的代码全下载下来,首屏白屏时间直接翻倍。
但转念一想:首页需要用户中心的代码吗?不需要。那就让它用到的时候再下载。
动手做把 component 换成一个函数:() => import('../views/xxx.vue')。这一行小改动,打包结果完全不同。
src/router/index.js(片段)js
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') }
    ] }
]
页面效果浏览器运行结果
运行结果: 终端执行 npm run build: vite v5.4.10 building for production... ✓ 43 modules transformed. dist/index.html 0.45 kB │ gzip: 0.30 kB dist/assets/Home-3f2a1c8e.js 4.12 kB │ gzip: 1.86 kB dist/assets/ArticleList-8b7d0e21.js 6.44 kB │ gzip: 2.51 kB dist/assets/ArticleDetail-a91c40f7.js 3.87 kB │ gzip: 1.72 kB dist/assets/UserProfile-2d6f9c05.js 2.98 kB │ gzip: 1.41 kB dist/assets/index-1a2b3c4d.js 92.31 kB │ gzip: 35.72 kB dist/assets/index-7c2e5f10.css 18.24 kB │ gzip: 4.03 kB ✓ built in 1.86s (每个 import() 生成一个独立文件;首屏只下载 index + Home + css,点进详情页时才去下 ArticleDetail)
讲透:为什么这样就能减小首屏体积
  • import() 函数返回的是 Promise,Vite 一看就知道「这个模块可以先不加载」。
  • 打包工具会把每个 import() 切成一个独立 chunk。首屏 HTML 只引用主包,用户点进某个页面,浏览器才去下对应的那个小文件——这就是上面终端里那一长串 assets/xxx.js 的来历。
  • 换来的代价是:首次进入某个页面会多一次网络请求。这个延迟通常很短(几 KB),但首屏组件千万不要懒加载,否则首页会先白一下再出现。
  • 这就是路由配懒加载成为行业默认写法的原因——在路由表里几乎零成本,收益却很明显。

参数变化与组件复用

换了作品,内容却没换?
先讲为什么详情页里点「下一篇」,地址从 /article/1 变成 /article/2,可页面显示的还是第 1 篇。这不是 bug,而是 Vue Router 的性能优化:/article/1 和 /article/2 命中的是同一个组件,格式没变,那就复用同一个组件实例,把旧的销毁重建太浪费。
问题是:组件没被销毁重建,onMounted 自然就不会再触发,你在里面写的数据加载逻辑也就不会执行。
动手做用 watch 盯住 route.params.id,它一变就重新取数据。
src/views/ArticleDetail.vuevue
<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>
页面效果组件渲染结果
运行结果: 从列表点《静夜思》进入 /article/1 → onMounted 触发一次 → loadDetail("1") 在详情页点「下一篇」跳到 /article/2 → onMounted 不再触发(组件被复用了,这是正常的) → watch(() => route.params.id) 触发 → loadDetail("2") → 页面正确显示「作品 2 的详情」 如果删掉这段 watch:地址变了,页面还是第 1 篇的内容(新手最常见的「假 bug」)
讲透:两种解法,各有代价
  • 方案 A:watch(route.params)——推荐。组件实例保留,只重新取数据。写起来要多一个 watch,但可控、可按需触发。
  • 方案 B:强制重建组件。把出口写成 <router-view :key="route.fullPath" />,地址一变 key 就变,Vue 直接销毁旧实例、建新实例,onMounted 自然重新执行。
  • 方案 B 的代价:组件里所有状态(滚动位置、输入框内容)都会丢掉,而且频繁销毁重建更耗性能。适合「页面之间毫无关系」的场景,不适合详情页切下一条。
  • 能识别出这个现象,说明你理解了「生命周期是跟着组件实例走的」——这是第 5 章的知识在这里自然用上。
避坑只要组件里有「根据路由参数取数据」的逻辑,就必须同时写 onMounted 和 watch 两份入口:前者管「第一次进来」,后者管「人在页面里换了参数」。只写 watch 不管首次进入,只写 onMounted 管不了参数变化——两边都是坑。
动手练习
1给「古典文学网」写出 4 条路由:首页 /、作品列表 /article/list、作品详情 /article/:id、登录页 /login。其中详情页和登录页要做懒加载。
2给用户中心设计嵌套路由:父路径 /user,子页「个人资料」「我的收藏」;整个用户中心要求登录,未登录跳登录页并记住原来想去的地址。写出路由表与守卫关键片段。
3迁移练习:「红影赏析」的电影详情页地址是 /movie/8,页面里还有「相似推荐」,点推荐项就在同一个详情页里切换电影。请思考并写出:① 路由怎么写?② 组件里怎么拿到 movieId?③ 为什么在推荐位之间来回点,内容有时候不刷新?怎么解决?
参考答案
练习答案(router/index.js)js
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
页面效果浏览器运行结果
运行结果: /article/list → 列表页(未登录也能看) /article/3 → 详情页,route.params.id = "3" /login → 登录页 未登录访问 /user/favorite → 重定向到 /login?redirect=/user/favorite /movie/8 → 红影详情页,route.params.id = "8"
练习答案(MovieDetail.vue,第 3 题)vue
<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>
页面效果组件渲染结果
运行结果: /movie/8 → onMounted 触发,加载电影 8 点「相似推荐」里的《流浪地球》→ 地址变 /movie/12 → onMounted 不触发 → watch 触发 → 加载电影 12,页面正确刷新

第 3 题的关键:换的是「路径名和字段名」,不变的是「动态路由 + onMounted/watch 双入口」这套骨架。能迁移过去,说明你不是在背语法,而是真的懂了路由的工作方式。

本章小结