学Vue到第六天,这是个特别微妙的节点。前五天我还在跟模板语法、计算属性、组件通信较劲,真要把一个多页面应用跑起来的时候,才发现Vue项目如果没有路由,就像一栋只有房间没有走廊的楼——每个组件都做得再精致,也没法让用户在页面之间自由走动。所以从第六天开始,我正式把Vue Router拉进了学习清单,这一整理就从早上折腾到晚上。
这篇内容不是官方文档的翻译,也不打算讲太多空泛的理论。我把自己第六天从零安装Vue Router、配置路由、传参、嵌套、守卫,到逐个排查报错的过程完整复盘下来。如果你Vue基础语法已经过关、正在纠结“多个页面怎么跳转”,或者已经装了路由但总是遇到“跳了地址栏却不变”“刷新就404”这类怪问题,这篇应该能帮你少走不少弯路。
1. 为什么第六天该碰路由了
1.1 组件切换不等于页面跳转
前五天里,我经常用v-if或v-show去控制页面显示。思路很简单:一个变量等于1显示首页,等于2显示列表页,等于3显示详情页。这种写法应付两三个tab问题不大,但想真正做一个应用的时候,问题就会集中爆发:地址栏永远是同一个URL,刷一下页面就回到最初状态,浏览器后退按钮点了完全没反应,别人想直接打开你正在看的那个页面更是无从下手。
你可能会说,多包几个v-if不就行了?实际做过就知道,当页面层级超过两层,状态变量会爆炸式增长,而且代码里全是一坨一坨的显示逻辑,后期维护基本靠猜。更关键的是,这种切换本质上只是“某个区域的内容替换”,浏览器根本不知道你换了页面,所以地址、历史记录、刷新状态这些全都跟不上。
这也是路由存在的根本原因。Vue Router要做的事,说穿了就三件:让地址栏跟着页面内容改变,让浏览器前进后退能配合内容切换,让刷新之后还能停留在当前页面。它不是锦上添花的功能,而是单页应用能不能真正往“应用”方向走的骨架。
1.2 路由解决了什么
为了把路由的价值讲清楚,我对比了一下“用v-if硬切”和“用路由切页”的体验差异,这样理解起来更直观:
| 能力 | v-if/v-show切换 | Vue Router切换 |
|---|---|---|
| 地址栏变化 | 不变,永远是根路径 | 随路由变化,能直观看到当前位置 |
| 刷新恢复 | 直接回到初始状态 | 刷新后停留在当前路由对应的页面 |
| 浏览器前进/后退 | 不响应,按钮变灰 | 正常记录历史,能来回跳 |
| 链接分享 | 所有人打开都是同一个页面 | 可分享具体页面,带参数也能复原 |
| 代码结构 | 页面内容堆在一个组件里 | 每个页面独立,职责清晰 |
| 权限控制 | 基本靠手动判断 | 有路由守卫统一拦截 |
看完这张表,你大概能理解为什么第六天必须碰路由:再往后学组件通信、状态管理、接口请求,都需要建立在“项目有多个页面”的基础上。没有路由,后面那些内容都会变得很奇怪。
1.3 这天之前你需要掌握什么
在正式开始之前,稍微确认一下自己的基础。第六天学路由,最好已经知道Vue的单文件组件怎么写,props和emit大概怎么通信,ref/reactive这类响应式API在组件里怎么用。不用精通,能用就行。如果这些还没完全吃透,也不影响先跑通路由,等遇到组件内部逻辑卡住时再回头补也不迟。
另外,后面所有示例默认你现在用的是Vue 3 + Vite的工程,路由插件版本对应Vue Router 4。如果你还在Vue 2 + Vue Router 3,思路类似,但API差别比较大,最好先切到Vue 3再继续。
2. 路由的两种工作模式:hash还是history
2.1 hash模式:地址栏里那个#号
刚开始学路由,你可能会注意到地址栏里会出现一个#,比如http://localhost:5173/#/home。这就是hash模式。
hash模式的原理其实不复杂:URL中#后面的部分叫hash,只改变hash值不会触发浏览器向后端发起请求,但会触发hashchange事件,同时浏览器会把这个变化记入历史记录。Vue Router在hash模式下,就是监听hashchange事件,再按照hash对应的路径去匹配组件渲染。
这种模式最大的优势是部署省心。因为#后面的内容不会发到服务端,所以随便找个静态文件服务器托管都能跑,甚至双击本地html文件,在大多数场景下也能正常跑起来。我当时用hash模式的时候,真的是零配置,完全不用管服务端要做什么。
缺点也很明显:URL里带着#不好看,而且不够“正经”。有的场景下,比如做分享、做埋点统计,还要额外处理#带来的参数解析,多少有点别扭。
2.2 history模式:更像真实的多页面
写完hash模式后,我又切到了history模式。这种模式下URL看起来就是干净的那种:http://localhost:5173/home,和传统多页面应用的地址几乎没区别。
它背后的机制是基于History API,核心是pushState和replaceState这两个方法。它们可以在不刷新页面的情况下修改浏览器地址栏,并生成历史记录。配合popstate事件,就能做到“地址变了但页面没闪一下”。
听起来是比hash模式优雅,但代价是它需要服务端配合。因为地址栏变成一个真正的URL之后,浏览器刷新时会把请求发到服务端。如果服务端没有把/home这样的路径全部重定向到index.html,那刷新就直接404。开发环境之所以没暴露这个问题,是因为Vite的dev server自带了对history模式的支持,会自动把所有路径兜回index.html,但上线部署后如果忽略这步,立刻翻车。
2.3 我当时怎么选的
我第六天的选择很简单:直接用history模式。
原因有两个。第一,我练这个项目就是为了体验真实上线的感觉,不想等部署时再换,到时还要检查一堆路由跳转有没有问题。第二,开发阶段Vite已经帮我处理了刷新兜底,日常敲代码不会受任何影响,等部署时我再在服务端加一个try_files配置就能解决。当然,如果你只是为了快速做一个小demo,完全不想操服务端的心,hash模式也不丢人。在练习阶段,两种模式切换的成本很低,代码改动不过几行的事,重点是理解它们各自在什么场景下会踩坑。
3. 从零配置一个可用路由:核心三步
3.1 安装依赖并创建Router实例
先把插件装上,我用的是Vue Router 4:
npm install vue-router@4然后单独建一个路由配置文件,我习惯放在src/router/index.js里:
import { createRouter, createWebHistory } from 'vue-router' import Home from '../views/Home.vue' import About from '../views/About.vue' const routes = [ { path: '/', name: 'home', component: Home }, { path: '/about', name: 'about', component: About } ] const router = createRouter({ history: createWebHistory(), routes }) export default router这里有几个点需要解释。createRouter是Vue Router 4创建实例的方法,routes数组就是路由表,里面每个对象都表示“某个路径对应哪个组件”。createWebHistory()表示使用history模式,如果你在上一步决定用hash模式,把它换成createWebHashHistory()就行。
我在学习时把组件直接静态引入,是因为这样能直观看到路由表结构。实际项目里,更常见的写法是用动态导入做路由懒加载,比如component: () => import('../views/About.vue')。好处是首屏不会一次加载所有页面的代码,访问到对应页面时才去请求该页面的JS文件。第六天打基础阶段可以先了解,但别觉得“动态导入是高手才写的”,其实就是一个函数箭头包一层,很好理解。
3.2 注册全局Router并放置页面出口
路由实例创建了,还得让整个Vue应用知道它的存在。在main.js里做两步:
import { createApp } from 'vue' import App from './App.vue' import router from './router' const app = createApp(App) app.use(router) app.mount('#app')app.use(router)这一步是关键,它会注册路由相关的全局组件和生命周期逻辑。如果不做这一步,后面在组件里写router-link或useRoute都会直接报错。
然后在App.vue里,放一个router-view组件。这个组件就是路由的“出口”,路由匹配到哪个页面组件,它就会渲染哪个。再放一个或多个router-link作为导航入口:
<template> <div> <nav> <router-link to="/">首页</router-link> <router-link to="/about">关于</router-link> </nav> <router-view /> </div> </template>router-link最终会被渲染成一个a标签,但它和普通a标签有区别:普通a标签点击后浏览器会重新加载整个页面,而router-link默认会拦截点击,用路由内部的逻辑切换内容,避免整页刷新。这个细节很值得体会,它决定了单页应用的“快”是快在哪里的。
3.3 两个容易写错的小细节
第一次配路由时我犯了不少糊涂,有两个细节值得单独提醒:
第一,routes里的path不要带文件后缀,也不要在末尾加.html。/about就写/about,不要写/about.html或者/about/(如果是根路径/除外)。原因很简单,路由是在管理应用的页面状态,不是真的指向某个物理文件。
第二,redirect写绝对路径,别省略前面的斜杠。比如{ path: '/a', redirect: '/b' },如果写成redirect: 'b',浏览器解析时会按相对路径处理,结果就不是你想去的那个地址了。这种错误很好排查:点击跳转后看地址栏,对比一下目标地址是http://localhost:5173/b还是http://localhost:5173/current/b,一眼就能看出问题。
4. 路由传参:四种姿势怎么选
4.1 query传参:适合搜索条件和分享场景
先写第一种方式,query传参。它把参数拼在路径后面,和传统URL的?参数一样:
router.push({ path: '/detail', query: { id: 10086 } })跳转之后,地址栏会变成/detail?id=10086。在目标组件里通过useRoute拿参数:
import { useRoute } from 'vue-router' const route = useRoute() console.log(route.query.id)query传参最大的好处是参数完全暴露在URL里,刷新不丢,而且分享给别人时,对方打开链接就能恢复页面状态。所以搜索页、筛选条件、分页页码这类场景,用query很合适。它也有局限:如果放太复杂的对象,URL会变得巨长而且难以阅读。遇到这种情况,更建议只传ID或关键标识,拿到标识后自己再查一遍完整数据。
4.2 params传参与命名路由:语义化更强
第二种方式会让参数直接出现在路径里。比如详情页,往往希望地址是/detail/10086而不是/detail?id=10086。这时要先把路由表里的path配置成带占位符的形式:
{ path: '/detail/:id', name: 'detail', component: Detail }跳转时,有两条路:一条是直接用path拼接字符串:router.push('/detail/10086');另一条是用命名路由,把参数放在params里:
router.push({ name: 'detail', params: { id: 10086 } })这里有一个新手必踩的坑:很多教程会告诉你params传参,但如果你在跳转时用的是path而不是name,params会被直接忽略。也就是说,下面这种写法传不过去:
// 这样不行 router.push({ path: '/detail', params: { id: 10086 } })因为Vue Router在解析时,path的优先级很高,一旦提供了path,params就被扔掉了。想用params就老老实实用name,或者直接自己拼字符串。这个坑我踩了之后才知道,官方文档也写得很清楚,只是平时跳来跳去容易忽略。
4.3 用props解耦,让组件更干净
无论用query还是params,组件里拿参数都得靠useRoute(),这意味着组件和路由对象耦合在一起。如果有一天想把这个详情组件用在非路由的场景,还得再传一遍props,非常别扭。
更好的做法是开启路由的props选项:
{ path: '/detail/:id', name: 'detail', component: Detail, props: true }开启后,路由会自动把params里的字段作为props传给组件。组件里直接声明props即可:
defineProps(['id'])这样一来,组件内部不再依赖useRoute,也方便单元测试和维护。对于params里那些值,props: true会作用在动态路由段的参数上。如果你还希望query参数也能映射为props,就需要把props写成函数形式,手动处理一下:
{ path: '/detail', component: Detail, props: (route) => ({ id: route.query.id }) }两种写法我都试过,日常开发中能开props: true就尽量开,组件会清爽很多。
4.4 参数变化时,组件不会重新创建
这是我在做详情页时遇到的一个隐藏问题:从/detail/1跳到/detail/2,如果这两个路径都用同一个Detail组件,Vue会复用这个组件实例,不会重新走一遍挂载流程。也就是说,onMounted里的初始化逻辑不会再次执行。
这意味着在详情页里,光靠生命周期函数去拉数据是不行的。解决方案一般是在组件里监听路由参数:
watch( () => route.params.id, (newId) => { // 拿到新id,重新请求详情 }, { immediate: true } )加上immediate: true之后,组件第一次进入时就会执行一次,后面参数变化也会触发,逻辑就能统一起来。如果对这个了解不深,非常容易写出“第一次跳转正常、第二次跳转不刷新”的bug。
5. 嵌套路由与路由守卫:实际项目躲不开的功能
5.1 嵌套路由:页面里还有页面
第六天学到后面,我开始做一个带多个子页面的布局。比如用户中心,上面一个统一的侧边栏,左侧是导航,右侧是不同子页面的内容。这种场景很适合嵌套路由,也就是路由里套路由。
配置方式是在父路由下面加一个children数组:
{ path: '/user', component: UserLayout, children: [ { path: '', component: UserProfile }, { path: 'posts', component: UserPosts }, { path: 'settings', component: UserSettings } ] }注意两点:children里的path不要以/开头,否则会被当成根路径去匹配;path: ''表示在父路径下默认渲染的内容,访问/user时就会显示UserProfile。父组件UserLayout里需要再放一个router-view,这样子路由匹配到的组件才能在指定位置渲染。我在第一次写的时候总忘了在父组件里加这个出口,结果子页面永远是空白,排查了好一会儿才发现是视图出口没放。
嵌套的层级理论上不限,只要每一层都有对应的router-view承接就行。实际项目中我目前最多用到三层,层级太深会让路由表变得复杂,维护成本明显上升。
5.2 beforeEach全局守卫:登录拦截的常规做法
路由守卫是我觉得Vue Router最值得写一下的部分。原因很简单:它把一个“页面能不能进”的问题,从组件内部逻辑中彻底抽离出来,统一在路由层处理。
最常用的是全局前置守卫beforeEach。它会在每次路由跳转前执行,可以用来做登录拦截。比如进入任何页面之前,检查localStorage里有没有token,没有就去登录页:
router.beforeEach((to, from) => { const token = localStorage.getItem('token') if (!token && to.path !== '/login') { return '/login' } return true })这里的返回值很灵活:返回false表示取消本次跳转,返回一个路径表示重定向到那个路径,不写或者返回true表示放行。上面示例里,如果用户没登录且要去的不是登录页,就直接被扔到登录页。
这里有个我一开始没注意的陷阱:如果你在守卫里重定向到了/login,而/login本身也被同一个守卫拦住,那就可能死循环。所以我在判断条件里加了to.path !== '/login',确保登录页永远放行。
除了beforeEach,还有afterEach,它在跳转完成后执行,适合做页面标题更新、页面访问统计这类“事后”任务。当然守卫不止这两种,但第六天能先把全局守卫吃透就很够用了。
5.3 meta路由元信息:给页面加备注
路由配置里的每一项还可以挂一个meta字段,用来存一些和页面有关的信息。最常见的就是页面标题:
{ path: '/about', name: 'about', component: About, meta: { title: '关于我们' } }然后在全局afterEach里统一设置document.title:
router.afterEach((to) => { document.title = to.meta.title ? `${to.meta.title} - Vue Day6` : 'Vue Day6' })另外,meta还可以用来标记某个页面是否需要登录权限、菜单里要不要高亮、统计分类是什么等等。这样在守卫里可以组合判断,比如:
router.beforeEach((to) => { const token = localStorage.getItem('token') if (to.meta.requiresAuth && !token) { return '/login' } })把权限需求和路由表写在一起,比在组件里到处写判断干净得多。
6. 第六天实测:几个常见路由报错与排查思路
6.1 “No match found”与404页面配置
路由配置好了,点击跳转时控制台可能报No match found for location with path "/xxx"。这个报错的意思很简单:当前路径在你的路由表里匹配不到任何记录。
我当时排查的逻辑是几步走:先看地址栏里的路径到底是多少;再翻路由表里有没有对应的path;如果路由表里有动态路由段/detail/:id,还要确认实际访问的路径有没有正常填上参数。有时候是因为嵌套路由的children里path写多了/,导致父路径没匹配上。这种问题不难定位,因为报错信息里会把当前路径原样打出来。
更好的做法是给路由表加一个兜底配置,让所有没匹配上的路径都指向一个404页面:
{ path: '/:pathMatch(.*)*', name: 'not-found', component: NotFound }注意Vue Router 4里通配符的写法和Vue Router 3不一样,不再是*,而是/:pathMatch(.*)*。如果按旧写法配置,可能在升级项目时会踩到坑。把这个404路由放在最后,表示前面所有路由都匹配不上才会走到这里。
6.2 页面路径变了但内容没变
还有一个我折腾很久的现象:点击router-link后,地址栏的URL确实变了,但页面内容纹丝不动。
排这个问题的思路一般是先确认router-view有没有放对位置。如果在根组件里没有router-view,或者在某个子组件里又写了一个遮盖层把内容挡住了,就会出现“地址变了但什么都看不到”的情况。还有一个容易忽略的点是,如果router-view前面有v-if控制显示条件,条件不满足时路由匹配正常,但视图就出不来。
更隐蔽的情况是,路由表里写了两个相同path的配置,前面的组件不生效,后面的又没被匹配到,结果路由解析时按顺序匹配到了空组件。总之遇到“地址对了但内容没来”,先把路由表和router-view存在性这两件事查一遍,能解决八成问题。
6.3 刷新后404,问题的根源在服务端
这个是history模式下最经典的坑。开发环境几乎不会出现,因为dev server已经帮你把不存在路径的请求都兜回index.html了。但一旦部署到生产环境,服务端处理不当时,你在某个子页面一刷新,浏览器就会向服务器发一个真实请求,服务器找不到这个路径对应的文件,于是返回404。
解决思路也很明确:让服务端把前端子路径的请求统一重新定向到index.html,由前端路由自己决定渲染谁。以常见的nginx为例,需要在站点配置里加上:
location / { try_files $uri $uri/ /index.html; }意思是优先尝试访问真实文件或目录,都找不到时就回退到index.html。这样刷新后浏览器加载的是index.html,Vue Router会解析当前地址,重新渲染对应页面。
如果你用的是hash模式,就不存在这个问题,因为#后面的内容不会发到服务端。从这一点也能理解为什么很多人图省事会选hash模式。但我的网站想保持干净美观的URL,所以还是选了history模式,上线前给服务端补好配置就行。这项配置不要等到最后一刻才想起,部署前就应该写在部署文档里。
第六天过完,路由在我心里的分量从“一个会跳转的组件占位符”变成了“整个应用状态的骨架”。我后来写页面时会习惯性先想:这个页面属于哪个层级?参数应该放URL里还是query里?需不需要守卫拦截?这些思考基本都是从那天的报错和试错中长出来的。最后分享一个自己的小习惯:每次新建路由页面,我都顺手在路由表里给它写上meta.title,这样到后期做’多标签页’或‘面包屑’功能时,直接有数据可用。路由这东西,入门只需要一小时,但要在真实项目里不踩坑,每一层细节都值得多花几分钟琢磨。