产品演示前,“拾页书房”的侧栏已经能在首页、书库和笔记之间切换。你点开一本书,同事说:“把这个详情地址发给我吧。”链接发过去后,对方打开看到的却仍然是首页。
表面上,现有实现一直很顺:点一下导航,currentView 改变,v-if 就换出对应组件。可首页、书库和详情页始终共用同一个地址:刷新正在查看的详情,内存里的选择立刻丢失;连按两次浏览器后退,也回不到刚才看过的书库。界面虽然已经很多,浏览器却完全不知道用户走过了哪些地方。
第 12 章已经把请求、监听和清理收进了 composable,功能逻辑有了清晰边界;现在暴露出的是另一层问题:页面身份仍然只存在 Vue 内存里,没有进入浏览器的导航系统。我们需要把 currentView 背后那个模糊的“当前在哪里”,升级成可复制、可刷新恢复、也能被前进后退记住的 URL。更稳的理解因此是:路由是一套从 URL 到界面树的映射规则。 URL 不只告诉应用“显示哪个页面”,还可以同时描述资源身份、筛选状态和界面层级。Vue Router 读取地址,匹配路由记录,再把匹配到的组件放进一层层的 <RouterView>。导航只是改变这份“界面说明书”的方式之一。
现有 Vite 应用不必重起炉灶,原有单文件组件和 composable 都可以继续保留。迁移从根组件开始:首页和书库的显示权从 currentView 交给 URL;当书籍详情需要被分享、筛选结果需要被恢复,书籍 ID 和筛选条件也随之进入地址;课程界面出现稳定外壳和内层内容时,URL 继续向下描述这棵界面树。笔记页开始要求登录、页面代码开始影响首屏、用户又可能输入未知地址后,守卫、懒加载与 404 就成了实际需求推出的应用边界。第 12 章练过的 ref、getter 和清理会直接用在路由参数驱动请求、离开界面前收尾这些地方。

我们不把路由 API 拆成十几个彼此无关的知识点,而是顺着同一次迁移往前走:
currentView + v-if
→ URL 接管顶层界面
→ params 和 query 描述详情与筛选
→ 嵌套路由表达课程界面树
→ 守卫、懒加载和 404 补齐真实应用边界
→ 用复制、刷新、前进后退验收整体结果这条路线的起点是“页面切得动”,终点是“地址能完整恢复当时的界面树”。中间每一个 API 都会回答迁移中出现的一个具体问题,而不是另起一篇独立的文章。
回到演示失败前的实现。现有应用用一个 currentView 变量控制组件:
<script setup>
import { ref } from 'vue'
import HomeView from './views/HomeView.vue'
import BooksView from './views/BooksView.vue'
const currentView = ref('home')
</script>
<template>
<button @click="currentView = 'home'">首页</button>
<button @click="currentView = 'books'">书库</button>
<HomeView v-if="currentView === 'home'" />
<BooksView v-else />
</template>它能切换画面,但浏览器地址始终不变。用户无法复制“书库”地址发给朋友,刷新后也无法回到刚才的页面,前进与后退按钮更不知道该回到哪里。页面状态只活在内存里,没有被写进浏览器的导航系统。
路由把其中适合分享、收藏和恢复的状态放进 URL:
这里最值得记住的是“树”。App.vue 里有顶层 <RouterView>,所以它承接顶层匹配;CourseLayout.vue 里还有一个 <RouterView>,所以它承接课程下面的子路由。嵌套路由不是把路径写得更长而已,而是让 URL 的层级和组件布局的层级彼此对应。
不必把所有状态都塞进 URL。弹窗开合、输入框是否聚焦这类短暂状态通常留在组件里;书籍 ID、搜索词、分类和页码这类需要分享、刷新恢复或支持前进后退的状态,更适合放进 URL。
前面章节一直使用的已经是 Vue 3 + Vite 应用。在它的项目根目录执行:
npm install vue-router
npm run dev第一条把 vue-router 加入现有依赖,第二条重新启动开发服务。之前写好的 .vue 组件不需要重写,我们只会新增路由表,再把根组件里的 currentView + v-if 切换改成路由出口。
如果你想要一份与自己项目完全隔离的复现环境,可以另建一个 Vite 练习项目,再安装 vue-router。这只是可选实验;本章默认你正在继续改造前面的应用。
改造完成后,现有目录会增长成下面的结构:
page-garden/
├─ index.html
├─ package.json
└─ src/
├─ main.js
├─ App.vue
├─ data/
│ └─ library.js
├─ router/
│ └─ index.js
├─ utils/
│ └─ auth.js
└─ views/
├─ HomeView.vue
├─ BooksView.vue
├─ BookDetailView.vue
├─ CourseLayout.vue
├─ CourseOverviewView.vue
├─ ChapterView.vue
├─ LoginView.vue
├─ NotesView.vue
└─ NotFoundView.vueviews 只是一个方便沟通的目录名,里面仍然是普通 Vue 组件。它们之所以被称为“页面视图”,只是因为路由表把它们放在了页面入口处;组件本身没有特殊血统。
createRouter、history 与第一张路由表先看最小可用的 src/router/index.js:
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'
import BooksView from '../views/BooksView.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'home',
component: HomeView,
},
{
path:
createRouter() 创建的是整个应用共用的路由器实例。它最少需要两类信息:
history 决定 Vue Router 如何与浏览器地址和前进后退记录合作。routes 是映射表,告诉路由器某个 path 匹配后要渲染哪个 component。这里使用 createWebHistory(),地址会是 /books 这种自然形式。import.meta.env.BASE_URL 让它尊重 Vite 的部署基础路径:站点部署在根目录时通常是 /,部署到子目录时则可以跟随 Vite 配置变化。
接着在 src/main.js 中把路由器安装到 Vue 应用。注意 use(router) 必须出现在 mount() 之前:
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
createApp(App)
.use(router)
.mount('#app')安装插件后,应用才会认识 <RouterLink>、<RouterView>、useRoute() 和 useRouter()。如果漏了 .use(router),常见表现不是“路由配置写错”,而是路由组件无法解析,组合式函数也拿不到正确上下文。
浏览器项目里最常见的是两种:
createMemoryHistory() 不主动操作浏览器地址,常见于服务端渲染、测试或没有地址栏的环境。普通 Vite 单页应用优先选 createWebHistory();如果你无法配置静态服务器,又必须部署到只会按文件查找的环境,Hash 模式会更省事。
开发服务器会替你处理 history 回退,所以本地刷新详情页通常正常。这不代表生产服务器已经配置正确。部署后一定要直接在地址栏访问一个深层 URL 再刷新,单独验证服务器回退。
RouterLink 和 RouterView 如何协作把路由器装好不等于页面会自动出现。根组件还要给它一个渲染出口:
<script setup>
import { RouterLink, RouterView } from 'vue-router'
</script>
<template>
<header>
<RouterLink to="/">首页</RouterLink>
<RouterLink to="/books">书库</RouterLink>
</header>
<main>
<RouterView
两者分工很清楚:
<RouterLink> 负责描述“想去哪里”。它最终会渲染成可访问的 <a> 元素,但普通的站内点击由路由器接管,因此无需整页刷新。<RouterView> 负责声明“匹配到的组件放在哪里”。它像墙上的画框,路由表决定此刻装入哪幅画。点击“书库”时,大致发生四步:读取 to、计算目标地址、更新浏览历史并匹配路由记录、把 BooksView 渲染到 <RouterView>。模板不是在运行时做字符串替换,.vue 模板会先编译成渲染逻辑;RouterLink 和 RouterView 都是参与组件树渲染的真实组件。

RouterLink普通 <a href="/books"> 会让浏览器向服务器请求一份新文档,当前 JavaScript 运行环境随之重建。<RouterLink to="/books"> 会保留应用外壳,只更新 URL 和需要变化的路由视图,同时保留正确的链接语义、右键打开新标签页能力以及键盘可访问性。
外部站点、下载文件和刻意要求整页刷新的地址仍然应该使用 <a>。不要为了“统一”而把所有链接都强行改成 RouterLink。
RouterLink 会根据当前匹配添加 router-link-active 与 router-link-exact-active 类。前者允许祖先路由也保持激活,后者要求更精确的匹配:
.router-link-active {
color: #276749;
}
.router-link-exact-active {
font-weight: 700;
}当地址是 /books/vue-design 时,指向 /books 的导航可能仍被视为处于相关路由分支中。导航栏通常需要这种“栏目仍然亮着”的效果;如果你只想让完全一致的入口高亮,就使用精确激活类。
route 与 router这两个名字只差一个字母,却代表完全不同的角色:
<script setup>
import { useRoute, useRouter } from 'vue-router'
const route = useRoute()
const router = useRouter()
</script>可以把它们想成“车票”和“司机”:
route 是当前这一次匹配的结果。它包含 path、fullPath、name、params、query、hash、meta 和 matched 等信息。你读取它,回答“我现在在哪、这次地址携带了什么”。router 是能够执行导航的路由器。它提供 push()、replace()、back()、go()、resolve() 等方法。你调用它,表达“接下来要去哪里”。因此,下列用法分别是合理的:
const bookId = route.params.bookId
const currentUrl = route.fullPath
router.push({ name: 'book-detail', params: { bookId: 'router-map' } })
router.replace({ name: 'books', query: { category: '前端' } })而 route.push()、router.params 都是在把两者职责混在一起。
模板里可以直接使用 $route 和 $router,但在 <script setup> 中推荐用 useRoute() 与 useRouter()。如果模板只展示 $route.fullPath,没必要为了这一行额外创建变量;一旦脚本里要计算、监听或导航,再调用组合式函数。
params 与查询参数 query假设书库有很多书。我们当然不想给每本书各写一条路由,而是定义一个带动态字段的模式:
{
path: '/books/:bookId',
name: 'book-detail',
component: BookDetailView,
}: 表示这段不是固定文字。于是 /books/vue-design 和 /books/router-map 会匹配同一条路由记录、渲染同一个组件,只是 route.params.bookId 分别为 'vue-design' 与 'router-map'。
查询参数位于 ? 后,更适合描述对同一批资源的筛选、排序和分页:
/books?keyword=路由&category=前端&page=2组件中可以这样读取:
const route = useRoute()
const keyword = computed(() => String(route.query.keyword ?? '').trim())
const category = computed(() => String(route.query.category ?? '全部'))
const page = computed(() => {
const value = Number(route.query.page)
return

params 回答“是哪一本书”,query 回答“用什么条件看这批书”。把参数删掉再想一遍:
params。query。这不是不可违背的语法规定,而是 URL 设计的可读性约定。好的地址应让人仅凭外形就能猜到页面含义。
来自 URL 的值应当被视为外部输入。页码看起来像数字,读取时仍常是字符串;同名查询参数重复出现时,还可能得到数组。组件应先规范化再使用,而不是直接拿去做乘法或传给接口。
function readPositivePage(value) {
const raw = Array.isArray(value) ? value[0] : value
const page = Number(raw)
return Number.isInteger(page) && page > 0 ? page : 1
}路径参数也不能代替业务校验。/books/not-exist 能匹配 /books/:bookId,只说明形状正确,不代表这本书真的存在。组件查不到数据时,要显示明确的“书籍不存在”状态,或导航到专门的 404 页面。
推荐这样生成详情地址:
<RouterLink
:to="{
name: 'book-detail',
params: { bookId: book.id },
}"
>
{{ book.title }}
</RouterLink>命名路由会根据路由表生成路径并正确编码参数。一个经典坑是同时传 path 与 params:
// 不要这样写:给了 path 后,params 不会替你填进这个字符串
router.push({ path: '/books/:bookId', params: { bookId: 'vue-design' } })
// 用 name + params
router.push({ name: 'book-detail', params: { bookId: 'vue-design' } })
// 或者自己提供完整 path
router.push('/books/vue-design')route 不是一张永远不变的快照,它是响应式的。当 URL 从 /books/vue-design 变成 /books/router-map 时,两者匹配的是同一条路由记录和同一个 BookDetailView。Vue Router 通常会复用当前组件实例,只更新路由信息。这很高效,但也意味着 onMounted() 不会因为参数换了就再次执行。
下面的写法只读取初始值,后续参数变化不会让 bookId 自动变成新字符串:
const route = useRoute()
const bookId = route.params.bookId仅用于展示时,可以用计算属性:
const bookId = computed(() => String(route.params.bookId))需要在参数变化时重新请求数据,则精确监听那个参数:
<script setup>
import { ref, watch } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const book = ref(null)
const loading = ref(false)
const error = ref('')
watch(
()
这里有三个细节:
{ immediate: true } 负责首次进入时加载,不再依赖“先 mounted,再 watch”的两套逻辑。route.params.bookId,改变无关的 hash 或查询参数不会重复请求书籍。route 中包含很多会变化的字段。监听整个对象意味着修改页码、hash、无关查询甚至某些匹配信息时,都可能触发同一份副作用。你很快会分不清“这次请求究竟是哪个字段引起的”,还可能制造重复请求。
更好的策略是让副作用和依赖一一对应:请求书籍就监听 route.params.bookId,刷新搜索就监听 [route.query.keyword, route.query.page],处理锚点就监听 route.hash。依赖越精确,行为越容易解释和测试。
如果你需要在参数变化前决定是否允许导航,例如有未保存内容,则使用 onBeforeRouteUpdate();如果只是根据新参数获取数据,精确 watch() 往往更直接。
课程页面通常有稳定的外壳:课程标题和侧栏不变,右侧内容在“概览”与“章节”之间切换。界面天然是嵌套的,路由也应该表达这个结构:
{
path: '/courses/:courseId',
component: CourseLayout,
props: true,
children: [
{
path: '',
name: 'course-overview',
component: CourseOverviewView,
props: true,
},
{
path: 'chapters/:chapterId',
name: 'chapter-detail',
component: ChapterView,
props: true,
},
父组件必须提供子出口:
<template>
<section class="course-layout">
<aside>课程侧栏</aside>
<RouterView />
</section>
</template>于是两种地址会形成两棵相近的界面树:
/courses/vue-basic
App
└─ CourseLayout
└─ CourseOverviewView
/courses/vue-basic/chapters/reactivity
App
└─ CourseLayout
└─ ChapterView
children 里的 path: '' 是默认子路由:当父路径刚好匹配时,它填补父组件中的子出口。path: 'chapters/:chapterId' 前面没有 /,所以它会接在父路径后面。若写成 /chapters/:chapterId,它就变成从站点根开始的绝对路径,不再自然地表达当前层级。
页面代码如果到处拼接路径:
router.push(`/courses/${courseId}/chapters/${chapterId}`)一旦路径从 /courses 调整为 /learn,所有拼接点都要跟着找。给路由唯一名称后,组件只表达目的地和参数:
router.push({
name: 'chapter-detail',
params: { courseId, chapterId },
})名称必须在整个路由器中唯一,不是只在某个 children 数组里唯一。名称适合稳定地指向业务目的地,路径则可以随信息架构调整。
props: true 让页面更像普通组件动态参数可以通过 useRoute() 读取,也可以通过 props: true 交给路由组件:
<script setup>
const props = defineProps({
courseId: {
type: String,
required: true,
},
chapterId: {
type: String,
required: true,
},
})
</script>这样组件不必知道数据来自 URL,测试时直接传 props 即可。需要编程导航、查询参数或 meta 时,继续使用 useRoute() 并没有问题;两种方式可以按职责选择,不必追求绝对统一。
能提前写进模板的入口优先用 <RouterLink>。导航依赖表单提交结果、权限判断或异步操作时,再使用 router:
const router = useRouter()
async function saveAndOpen(bookId) {
await saveDraft()
await router.push({
name: 'book-detail',
params: { bookId },
})
}常用方法的差别在浏览历史:
router.push(target) 新增一条记录,用户按后退可以回到当前页。router.replace(target) 替换当前记录,适合登录完成、纠正无效查询、同步频繁筛选等不希望堆积历史的场景。router.back() 相当于在历史中后退一步。router.go(-2) 按指定步数移动,正数向前,负数向后。push() 和 replace() 都返回 Promise。若后续代码依赖导航已经结束,可以 await:
await router.replace({ name: 'books', query: { category: '前端' } })
searchInput.focus()没有唯一答案,关键是你希望后退按钮怎么工作。
push,让他后退回提交前状态。replace,否则历史记录会塞满 ?keyword=路、?keyword=路由 这样的中间状态。push,因为用户往往期待后退回上一页。路由 API 的技术差异很小,真正要设计的是浏览器历史的用户体验。
导航守卫不是“每次跳转都做点什么”的万能事件。它最重要的职责是回答:这次导航是否可以成立,如果不成立,要停下还是改去别处?
本章项目给笔记页添加 meta.requiresAuth:
{
path: '/notes/:bookId',
name: 'book-notes',
component: () => import('../views/NotesView.vue'),
props: true,
meta: { requiresAuth: true },
}全局前置守卫读取目标路由的元信息:
router.beforeEach((to) => {
if (to.meta.requiresAuth && !isLoggedIn()) {
return {
name: 'login',
query: { redirect: to.fullPath },
}
}
})现代写法更推荐直接返回结果,而不是依赖第三个 next 参数。返回值的含义可以完整归纳为:

异步守卫可以等待权限结果:
router.beforeEach(async (to) => {
if (!to.meta.requiresAuth) return true
const allowed = await checkPermission(to)
if (!allowed) return { name: 'login' }
})页面标题、访问统计这类不决定“能不能去”的逻辑,更适合放在 afterEach:
router.afterEach((to) => {
document.title = `${to.meta.title ?? '拾页书房'} · 拾页书房`
})下面的守卫会把所有未登录导航都送到登录页;可登录页本身也会再次经过守卫,于是它又被送到登录页:
// 错误:登录页也满足 !isLoggedIn()
router.beforeEach(() => {
if (!isLoggedIn()) return { name: 'login' }
})稳妥做法是只保护明确声明的页面:
router.beforeEach((to) => {
if (to.meta.requiresAuth && !isLoggedIn()) {
return { name: 'login', query: { redirect: to.fullPath } }
}
})也可以额外检查 to.name !== 'login',但从长期维护看,requiresAuth 更清楚:公开页面默认放行,只有受保护页面进入权限分支。
笔记编辑页知道自己是否有未保存内容,因此“离开前确认”应该写在组件里:
onBeforeRouteLeave(() => {
if (!dirty.value) return true
const shouldLeave = window.confirm('笔记尚未保存,确定离开吗?')
if (!shouldLeave) return false
})守卫取消的是路由导航,不是所有关闭行为。关闭浏览器标签页属于浏览器生命周期,如确有需要,应另外处理 beforeunload,并且只在脏状态期间注册。
静态导入会把页面组件拉进初始依赖图:
import NotesView from '../views/NotesView.vue'路由页面天然适合代码分块,可以把 component 改成返回动态导入 Promise 的函数:
{
path: '/notes/:bookId',
name: 'book-notes',
component: () => import('../views/NotesView.vue'),
}构建工具会把它拆成独立代码块,用户第一次进入笔记页时才加载;成功加载后会缓存,不是每次切换都重新下载。首屏必需且很小的首页可以静态导入,较重或低频页面适合懒加载。不要为了追求“全部动态”而把每个微小组件都做成路由级分块,真正的边界是页面和大型功能区。
把捕获路由放在最后:
{
path: '/:pathMatch(.*)*',
name: 'not-found',
component: () => import('../views/NotFoundView.vue'),
}/:pathMatch(.*)* 可以匹配前面都没接住的地址,包括多段路径。访问 /some/deep/missing-page 时,应用仍然启动,只是顶层出口显示 NotFoundView。

应用 404 发生在 Vue 已经启动之后:路由器找不到匹配记录,于是渲染 NotFoundView。
服务器 404 发生得更早:用户直接请求 /books/vue-design,服务器把它当成磁盘路径查找,找不到文件就返回 404,Vue 根本没有机会启动。使用 createWebHistory() 时,生产服务器要配置一条回退规则:找不到真实静态文件的请求统一返回 index.html,再交给前端路由决定显示详情页还是应用 404。
这也解释了一个看似奇怪的现象:从首页点进详情正常,刷新详情却失败。前者是客户端导航,后者是浏览器直接向服务器请求深层 URL。
下面把沿途改过的部分收回现有的“拾页书房”,查看它们如何在同一个应用里协作。示例数据继续放在本地,方便只关注路由;将来换成接口时,路由结构无需推倒重来。
src/data/library.jsexport const books = [
{
id: 'vue-design',
title: 'Vue 设计小记',
category: '前端',
author: '林溪',
summary: '从组件、模板与响应式三个方向理解 Vue 的设计。',
},
{
id: 'router-map',
title: '把地址画成地图',
category: '前端',
author: '周屿',
summary: '用界面树的视角拆解客户端路由。',
src/utils/auth.jsconst LOGIN_KEY = 'page-garden-login'
export function isLoggedIn() {
return sessionStorage.getItem(LOGIN_KEY) === 'yes'
}
export function login() {
sessionStorage.setItem(LOGIN_KEY, 'yes')
}
export function logout() {
sessionStorage.removeItem(LOGIN_KEY)
}这只是演示导航控制,不是真实安全方案。真正的权限必须由服务端验证;前端守卫的价值是尽早给用户正确界面,不能阻止别人直接调用接口。
src/router/index.jsimport { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'
import BooksView from '../views/BooksView.vue'
import BookDetailView from '../views/BookDetailView.vue'
import CourseLayout from '../views/CourseLayout.vue'
import CourseOverviewView from '../views/CourseOverviewView.vue'
import LoginView from '../views/LoginView.vue'
import { isLoggedIn } from '../utils/auth'
const router = createRouter({
父路由没有 name,默认子路由有 name: 'course-overview'。这样导航到课程概览时会同时得到父布局和默认子页面,目的地更明确。meta 会出现在最终的 to.meta 上,可统一用于标题和权限。
src/main.jsimport { createApp } from 'vue'
import App from './App.vue'
import router from './router'
createApp(App)
.use(router)
.mount('#app')src/App.vue<script setup>
import { RouterLink, RouterView } from 'vue-router'
</script>
<template>
<div class="app-shell">
<header class="site-header">
<RouterLink class="brand" :to="{ name: 'home' }">
拾页书房
</RouterLink>
<
src/views/HomeView.vue<script setup>
import { RouterLink } from 'vue-router'
</script>
<template>
<section class="panel">
<p class="muted">今天也从一页开始</p>
<h1>让每一个地址,都能带你回到准确的内容</h1>
<p>
浏览书籍、进入课程,或者打开一份需要登录的读书笔记。
留意地址栏如何跟随界面一起变化。
</p
这里的 <h1> 是项目组件里的运行时页面标题,不会改变本文档只有一个 Markdown H1 的结构。
src/views/BooksView.vue<script setup>
import { computed } from 'vue'
import { RouterLink, useRoute, useRouter } from 'vue-router'
import { books } from '../data/library'
const route = useRoute()
const router = useRouter()
const keyword = computed(() => String(route.query.keyword ?? '').trim())
const
筛选值始终以 URL 为数据源。复制当前地址到新标签页,筛选会恢复;点击浏览器后退,界面也会跟着恢复。keyword 高频变化使用 replace,分类这种明确选择使用 push,两者有意展示不同的历史体验。
src/views/BookDetailView.vue<script setup>
import { computed } from 'vue'
import { RouterLink } from 'vue-router'
import { findBook } from '../data/library'
const props = defineProps({
bookId: {
type: String,
required: true,
},
})
const book = computed(() => findBook(props.bookId))
</script>
props: true 会把 bookId 交给组件。相同组件实例被复用时,prop 也会更新,computed 因而重新查找书籍。
src/views/CourseLayout.vue<script setup>
import { computed } from 'vue'
import { RouterLink, RouterView } from 'vue-router'
import { findCourse } from '../data/library'
const props = defineProps({
courseId: {
type: String,
required: true,
},
})
const course = computed(() => findCourse(props.courseId))
</script
如果忘记这里的 <RouterView />,地址和父布局会正常变化,却永远看不到课程概览或章节详情。调试嵌套路由时,先查父组件有没有出口,通常比反复改 children 更快。
src/views/CourseOverviewView.vue<script setup>
import { computed } from 'vue'
import { RouterLink } from 'vue-router'
import { findCourse } from '../data/library'
const props = defineProps({
courseId: {
type: String,
required: true,
},
})
const course = computed(() => findCourse(props.courseId))
</script>
src/views/ChapterView.vue<script setup>
import { computed } from 'vue'
import { findCourse } from '../data/library'
const props = defineProps({
courseId: {
type: String,
required: true,
},
chapterId: {
type: String,
required: true,
},
})
const course = computed(() => findCourse(props.courseId))
src/views/LoginView.vue<script setup>
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { login } from '../utils/auth'
const route = useRoute()
const router = useRouter()
const safeRedirect = computed(() => {
const value = route.query.redirect
if (
使用 replace 是有意的:登录页只是访问受保护页面时经过的中转站。登录完成后按后退,不应该又回到这张中转页。redirect 来自 URL,所以先限制为站内绝对路径再使用。
src/views/NotesView.vue<script setup>
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { onBeforeRouteLeave } from 'vue-router'
defineProps({
bookId: {
type: String,
required: true,
},
})
const content = ref('')
const dirty = ref(false)
function save() {
src/views/NotFoundView.vue<script setup>
import { RouterLink, useRoute } from 'vue-router'
const route = useRoute()
</script>
<template>
<section class="panel">
<p class="muted">404</p>
<h1>这一页还没有被写下</h1>
<p>未匹配地址:{{ route.fullPath }}</
运行 npm run dev 后,这个项目已经包含声明式导航、编程式导航、动态参数、查询参数、嵌套与命名路由、全局和组件内守卫、懒加载、滚动行为以及 404。
不要只从首页一路点击。真正理解路由,需要把 URL 当成输入逐个测试:
还要做三组行为测试:
这些测试会逼着我们从“按钮是否能点”升级到“地址是否真的描述了页面”。
路由问题看起来很散,其实可以按固定顺序排查。
地址不变,先检查点击事件、RouterLink 的 to、守卫是否取消导航。地址变了但页面不变,再去看路由匹配和出口,不要一开始就怀疑组件响应式。
resolve 检查目标不真正导航也能让路由器解析目标:
const target = router.resolve({
name: 'chapter-detail',
params: {
courseId: 'vue-basic',
chapterId: 'routing',
},
})
console.log(target.href)
console.log(target.matched)href 不对,通常是名称或参数错;matched 为空,说明没有任何路由记录接住它。还可以在开发控制台查看全部记录:
console.table(
router.getRoutes().map((record) => ({
name: record.name,
path: record.path,
})),
)RouterView顶层页面空白,看 App.vue;子页面空白,看父布局。路由表能匹配到组件,却没有对应出口时,组件没有地方渲染。嵌套有几层,就要能在对应父组件树上找到几层出口。
路由表写 :bookId,代码却读 route.params.id,结果只会是 undefined。参数名区分拼写,重命名时要同时改路由表、命名导航和组件 props。
/books/not-exist 匹配了 /books/:bookId,所以不会进入全局捕获路由。此时应由详情组件展示资源不存在。只有 URL 结构本身没有任何路由记录接住时,才进入 NotFoundView。
把 router 和 route 写反。 记住:多一个 r 的 router 是能做导航动作的机器,route 是当前结果。
只在 onMounted 里读动态参数。 同一条动态路由切换参数时组件会复用,挂载逻辑不重跑。用计算属性、精确 watch 或 onBeforeRouteUpdate。
监听整个 route。 无关字段变化也触发副作用。只监听真正决定数据的字段。
子路径误写成绝对路径。 children 中写 chapters/:chapterId 才会自然拼到父路径;开头 / 表示从根开始。
父组件漏了子出口。 这是“地址正确、侧栏正确、内容空白”的头号原因。
命名重复。 路由名称必须全局唯一。把名字写成稳定的业务目的地,如 book-detail、chapter-detail,比 detail1 更容易维护。
同时传 path 和 params。 需要参数编码与路径生成时用 name + params;提供 path 时就给出完整路径。
把 query.page 当数字。 URL 输入要解析、校验并提供默认值。
守卫里无条件重定向。 重定向会发起新导航,新导航又会经过守卫。让登录页公开,或只对 requiresAuth 页面重定向。
旧式 next 在分支中调用两次。 现代代码直接 return false、return 路由对象 或不返回,控制流会清楚很多。
误以为前端守卫等于安全。 用户可以修改本地状态和前端代码,服务端接口必须独立鉴权。
本地能刷新就认为部署没问题。 Vite 开发服务器的回退行为可能掩盖生产配置缺失。上线后直接请求深层地址验收。
用 <a> 承担普通站内导航。 它可能触发整页请求;站内路由用 RouterLink,站外和下载继续用 <a>。
增加 sort=title 与 sort=author 两种查询状态。要求刷新可恢复、复制 URL 可复现,并且切换排序不修改 params。思考排序操作应该使用 push 还是 replace,把决定写进注释。
新增 /authors/:authorId,给它唯一名称 author-detail。书籍详情中的作者名使用 RouterLink,通过 name + params 导航。访问不存在的作者时,在组件内展示资源不存在,而不是误入全局 404。
为课程添加 assignments 子路由,并在 CourseLayout 侧栏生成入口。先故意删除父组件的 <RouterView> 观察现象,再恢复它,用自己的话解释为什么 URL 会变而内容不出现。
给书库增加分页,分别访问:
/books?page=2
/books?page=0
/books?page=abc
/books?page=2&page=3把所有输入规范化成合法正整数,重复参数只取第一个,并在无效时回落到第 1 页。
新增 meta.roles = ['editor'],让守卫异步读取当前角色。无权限时返回专门的 forbidden 路由,不要把所有失败都重定向到登录页;已经登录但权限不足,与尚未登录,是两种不同状态。
执行生产构建,观察输出中是否出现独立页面代码块。打开浏览器网络面板,首次进入章节页和笔记页时记录新增的脚本请求,再次进入时观察缓存行为。
你已经真正掌握本章内容时,应该能不背 API 地回答这些问题:
RouterLink、路由表和 RouterView 分别负责什么?route 与 router 的职责为什么不能互换?params 还是 query,判断依据是什么?onMounted 可能不再执行?route 中真正需要的字段?<RouterView>?false、路由对象、不返回和抛出错误分别意味着什么?Vue Router 并没有凭空创造“页面”。它做的是把浏览器已经拥有的地址、历史记录和前进后退能力,与 Vue 的组件树连接起来。
一条路由记录描述 URL 与组件的映射,RouterLink 提出导航目标,RouterView 提供渲染位置;route 保存当前匹配结果,router 执行下一次导航。params 标识资源,query 描述可分享的筛选状态;嵌套路由让地址层级与界面层级对齐,命名路由让组件不必到处硬编码路径。守卫通过明确的返回值允许、取消或重定向导航,懒加载控制页面代码何时下载,捕获路由和服务器回退则分别接住前端与服务端的未知地址。
如果以后再遇到“地址变了但画面没变”,先别把它当成玄学。沿着这条链路检查:导航目标是否正确 → 路由记录是否匹配 → 每一层是否有出口 → 参数是否响应式更新 → 守卫是否改变了结果 → 服务器是否把入口交还给前端。 路由一旦被还原成这张可追踪的地图,大多数问题都会变得很具体。
现在试着把这张地图往前推一步:用户在 /books 把书加入购物车,以登录身份进入 /notes/vue-design,并写下一半草稿。此时再在书库和笔记之间往返,URL 能准确恢复界面,但购物车、用户身份和未完成草稿不能随着某个路由组件卸载就消失。它们该属于 BooksView、NotesView,还是更稳定的共同主人?
Router 到这里已经完成了自己的职责:它只描述用户当前位于哪棵界面树,不替路由组件保管跨页面的业务状态。购物车、用户身份和未完成草稿需要一个不依附 BooksView 或 NotesView 生命周期的稳定共同主人,这个架构选择自然落到 Pinia:用有名字、有边界的 Store 承接它们。