前阵子有个朋友找我排查一个“页面跳动”的问题。他的Vue项目点菜单跳转时,页面总会闪一下白底,然后新内容才出现。我看完代码,发现问题的根源不在CSS,也不在某段异步逻辑,而是整份代码里完全没有引入vue-router,全用window.location.href在跳转。这相当于把单页应用做成了老式多页应用,每次跳转浏览器都要重新拉一遍HTML文档,不白屏才怪。
这件事给留给我的印象很深:很多前端新人把“用Vue框架”等同于“用了组件化”,却忽略了路由才是SPA的骨架。vue-router不是可有可无的插件,它决定了URL怎么跟界面联动、刷新后能不能保持位置、多级页面怎么组织、权限拦截写在哪里。这篇内容就从vue-router的定位讲起,覆盖安装配置、重定向、路由模式这几个核心话题,最后把我在实际项目里踩过的坑也一并写出来,希望对正在系统学Vue的人有帮助。
1. 为什么单页应用必须要有前端路由
1.1 从多页面跳转到SPA:一次观念转变
在没有Vue、React这类框架的年代,网站普遍是MPA(Multi-Page Application)结构。你有几个页面就放几个HTML文件,index.html、about.html、contact.html,跳转靠<a>标签,或者直接写window.location.href。每次跳转,浏览器都要向服务器重新发起请求,拿回一个完整的HTML文档,然后整页白屏、重绘、重新加载资源。整套流程用户能明显感觉到“卡了一下”,尤其网络不好时,那个转圈等待的过程非常劝退。
SPA(Single Page Application,单页应用)的思路则完全不同。整个应用只加载一个index.html壳子,后续所有“页面切换”都不再向服务器要HTML文档,而是由JavaScript动态替换页面内容显示区域。这样带来的体验提升是质变的:切换速度接近原生应用,页面状态可以保留,来回跳转也不会丢失表单里填了一半的内容。
不过,SPA也顺手丢掉了传统网页最基础的东西——浏览器地址栏。如果所有内容都在同一个页面里动态切换,那地址栏里的URL就是个摆设,用户没法收藏某个具体页面,也没法把一个带参数的列表页链接发给同事。
1.2 前端路由的本质:URL与组件之间的映射关系
前端路由解决的就是上面这个问题。它把URL变化与组件的渲染绑定在一起:URL从/user/list变成/user/detail/1时,路由会拦下这次变化,解析出/user/detail/1这笔规则,找出应该渲染哪个组件,再通过<router-view>这个容器把组件渲染出来。你可以把它理解成一张翻译表:URL是用户的输入语言,组件是前端最终要展示的内容,路由就是中间那个翻译官。
真正转起来之后,你会发现路由要处理的事情远不止“几个if else判断URL”那么简单。URL的解析、动态参数提取、路由守卫、懒加载、嵌套路由、重定向、404兜底,这些全是路由的活。手写一套简易路由其实不难,但边界情况会磨到你怀疑人生。vue-router之所以成为Vue生态里最基础的库之一,正是因为它把这一整套链路都成熟地封装好了,我们只需要维护那张路由表,剩下的事情交给它。
2. 安装与初始化:vue-router版本选对了吗
2.1 Vue2配vue-router 3,Vue3配vue-router 4
在动手敲第一条路由之前,先确认你的Vue版本,这是新手很容易踩的第一道坎。
Vue2对应的是vue-router 3.x,Vue3对应的是vue-router 4.x。如果直接在项目里执行npm install vue-router,npm会默认装最新大版本4.x,拿到Vue2项目里一跑,控制台立刻报错,最常见的表现是this.$router拿不到、组件里显示不出路由出口。Versions不匹配导致的报错,往往比“路由配置写错了”更难排查,因为错误提示并不直观。
安装时明确指定版本号:
# Vue 2 项目 npm install vue-router@3 # Vue 3 项目 npm install vue-router@4如果是用Vite从零创建Vue3项目,也可以执行npm create vue@latest,创建向导里会有一个“是否安装Vue Router”的交互选项,选“是”之后脚手架自动把路由目录结构也一并生成好。这种方式最适合新项目,省去自己写初始化配置的功夫。
2.2 最小化配置:路由实例如何在入口文件注册
无论用哪个版本,注册路由的核心步骤是三件套:定义路由表、创建路由实例、挂载到应用实例。
Vue3的最小化配置长这样:
import { createRouter, createWebHashHistory } from 'vue-router' import HomeView from './views/HomeView.vue' const routes = [ { path: '/', name: 'home', component: HomeView } ] const router = createRouter({ history: createWebHashHistory(), routes }) export default router然后在main.js里注册:
import { createApp } from 'vue' import App from './App.vue' import router from './router' createApp(App).use(router).mount('#app')Vue2的写法不太一样,需要Vue.use(Router),4.x之后统一改用createApp(...).use(router)。
这里有一个我比较坚持的习惯:路由配置一定要单独放src/router/index.js,不要让入口文件承担路由表的维护工作。项目规模一大,路由表几百行是很正常的事,全都挤在main.js里,后期根本没法维护。配合按模块拆分路由数组再合并,维护体验会好很多。
3. 第一个路由:从URL到组件的完整链路
3.1 routes数组的四个基础字段
一条最基础的路由记录,通常由这几个字段组成:
path:URL的路径,必须以/开头。name:路由名称,方便在跳转时用router.push({ name: ... }),避免硬编码URL字符串。component:URL匹配后需要渲染的组件。children:子路由,后面嵌套路由那一节再细说。
下面是一个最基础的双页面示例:
const routes = [ { path: '/', name: 'home', component: HomeView }, { path: '/about', name: 'about', component: AboutView } ]浏览器地址栏变成http://localhost:8080/#/about时,vue-router解析出/about,去routes数组里匹配到第二条记录,取出AboutView组件,渲染到App根组件里的<router-view>位置。
这里需要补一个容易踩的小坑:路由表的匹配不是“先到先得”,而是根据路由声明的优先级和路径匹配规则来决定的。不过对于静态路径,只要不出现完全相同的path,一般都不冲突。真正会出现诡异的优先级问题,多半是在动态路由和静态路由混用的时候,后面再说。
3.2 router-view和router-link的分工
<router-view>是个动态容器,告诉vue-router“匹配到的组件往这里放”。<router-link>则是<a>标签的进化版,我们用它声明跳转,而不是手写href。
<nav> <router-link to="/">首页</router-link> <router-link to="/about">关于</router-link> </nav> <router-view /><router-link>最终渲染出来的确实是一个<a>标签,但vue-router会拦截它的点击事件,阻止浏览器默认的整页请求,改走前端路由的跳转逻辑。这一点和直接写href有本质区别:后者会触发浏览器发起新的HTTP请求、重新加载文档,SPA的优势就全丢了。另外,<router-link>内置了激活状态管理,访问/about时,对应的链接会自动加上router-link-active等class,方便做导航高亮,自己手写判断逻辑很容易漏掉边界情况。
3.3 路由懒加载:什么时候用import()
项目页面的规模稍微大一点,全部组件都打包进一个JS文件,首屏加载时间会非常难看。Vue官方推荐的做法是按需加载,也就是懒加载。
{ path: '/about', name: 'about', component: () => import('../views/AboutView.vue') }component字段传入一个箭头函数,箭头函数内部import()会返回一个Promise。Vite和webpack都能识别这种写法,把AboutView.vue单独拆成一个chunk,只有当用户真正访问/about时才去加载对应的JS文件。
开发模式下几乎感受不到差异,但打包之后看dist目录,会发现多出了很多独立的JS文件,那个就是懒加载拆包后的结果。实际业务里,除了首屏必须展示的根组件和布局组件,我基本都懒加载。唯一需要小心的是,拆包粒度太细也不行,一个页面一个文件会导致碎片化请求过多,常见经验是“页面级组件用懒加载,同页面的弹窗、子组件打包在一起”。
4. 重定向与404兜底:别让用户撞上空白页
4.1 redirect的三种写法
重定向是路由里一个非常重要的能力。最常见的场景是:用户收藏了旧链接,老项目迁移后路径变了,希望访问旧地址时自动跳到新地址;或者根路径/希望默认展示某个子页面;再比如登录状态失效时,统一踢回登录页。
redirect最基本的用法就是直接写目标路径:
const routes = [ { path: '/', redirect: '/home' }, { path: '/home', component: HomeView } ]如果目标路由是用name管理的,也可以写对象形式:
{ path: '/', redirect: { name: 'home' } }更复杂的场景下,redirect还支持函数形式,函数接收目标路由信息to作为参数,根据当前查询参数、用户状态动态决定跳去哪个地址:
{ path: '/old-entry', redirect: (to) => { if (to.query.redirect) { return to.query.redirect } return '/home' } }有一点必须强调:redirect和component是互斥的。一条路由记录里一旦配置了redirect,component字段会被忽略,因为用户访问这个path时根本不会渲染组件,而是直接被送到目标地址。
4.2 alias别名:同一个组件响应多个路径
很多人分不清alias和redirect的区别。假设业务上希望/user和/user/profile都展示同一个个人资料页,但URL保持不变,这就是alias的用武之地:
{ path: '/user/profile', component: UserProfile, alias: '/user' }访问/user/profile和/user渲染的是同一个UserProfile组件,地址栏不会跳转。也就是说,redirect是“转向新地址”,alias是“同一地址的多个入口”。
实际工作中,我用到alias最多的时候是接口迁移期。比如老活动页面的二维码已经投出去了,路径不能变,但新页面已经上线,就用alias把旧路径指到新组件上,等旧链接自然淘汰后再清理掉。
4.3 404兜底路由:永远留给用户一个出口
任何一个面向用户的Web应用,都得处理“用户手输了一个不存在的路径”这种情况。与其让页面白屏,不如做一个友好的404页,并在路由里兜底。
vue-router 4的兜底写法:
{ path: '/:pathMatch(.*)*', name: 'NotFound', component: NotFoundView }vue-router 3的写法则是:
{ path: '*', component: NotFoundView }为什么4.x要用/:pathMatch(.*)*这么长的表达式?因为它会把用户输入的错误路径作为参数放进route.params.pathMatch里,404页面可以读取这个参数并展示“你访问的 /xxx 不存在”,体验会细致一些。还有个容易被忽略的地方是:404路由要放在路由表最末尾,否则会抢先匹配到正常路由。
5. 路由模式:hash、history、memory,生产环境怎么选
5.1 hash模式:省心,但URL不够体面
vue-router默认使用的就是hash模式。使用createWebHashHistory()时,URL长这样:http://localhost:8080/#/user/list,#号后面的部分由前端路由全权控制。
hash模式最大的优势在于,URL中#后面的片段变化不会触发浏览器向服务器发送请求。这意味着不管用户怎么刷新,只要你的index.html能打开,路由就一定能正常定位到对应组件。部署时服务器几乎不需要做额外配置,这对很多没有专职运维的团队来说非常友好。
代价是:URL里的#看起来有点“丑”,在某些平台分享时内容会丢失,对SEO极其不友好——搜索引擎爬虫很早期就决定不索引#之后的链接。如果项目是一个完全不需要SEO的内部管理系统,hash模式是完全够用的。
5.2 history模式:地址干净,但必须后端配合
把createWebHashHistory()换成createWebHistory(),URL就变成http://localhost:8080/user/list,干净利落,和普通多页网站的URL没有区别。
但代价接踵而至:当用户直接通过地址栏访问/user/list时,浏览器会真的向服务器发起一个GET /user/list请求。如果服务器上并不存在这个路径对应的静态文件或接口,那么服务器会返回404,页面就白屏了。
解决办法是让服务器把“所有未匹配到文件资源的请求”全部指向index.html,后续路径解析交给前端路由。以最常见的Nginx为例:
location / { try_files $uri $uri/ /index.html; }开发模式下,Vite或webpack-dev-server默认都带了这个能力,所以本地跑永远没问题,一到线上就暴露。不少初学者第一次遇到“history模式刷新404”时,都会一脸懵,排查半天发现是服务器配置问题。
另外,如果项目部署在服务器的子目录下,比如https://example.com/admin/,还要额外处理publicPath和路由的base路径,否则CSS和JS资源路径会错乱,页面样式全丢。
5.3 三种模式对比与适用场景
vue-router 4还提供了createMemoryHistory(),这个模式不会操作浏览器地址栏,路由状态保存在内存中。它主要用在SSR、单元测试以及一些非浏览器环境中,日常业务开发基本用不到。
| 模式 | 创建函数(v4) | URL形式 | 是否需服务器配合 | 适用场景 |
|---|---|---|---|---|
| hash | createWebHashHistory | 带# | 不需要 | 内网系统、快速上线、不关心SEO |
| history | createWebHistory | 干净 | 需要 | 生产部署、重视URL与SEO |
| memory | createMemoryHistory | 无地址栏变化 | 不需要 | SSR、测试、非浏览器环境 |
面试里常问的一个问题“history模式为什么刷新会404”,本质上就是考你有没有想清楚“前端路由拦截的是请求,但服务器不一定提前知道你的路由表”。
6. 动态路由、嵌套路由与参数传递
6.1 动态路径参数::id是怎么回事
真实的业务页面很少是固定静态路径的。用户详情页、商品详情页、文章详情页,这类“用一个组件展示不同数据”的场景,需要动态路由:
{ path: '/user/:id', name: 'user-detail', component: UserDetail }匹配规则很简单:/user/1、/user/abc都能命中这条路由,/user/1/orders就不会。路径里的:id是一个动态段,匹配到的具体值可以通过路由参数拿到。
Vue3组合式API里的读取方式:
import { useRoute } from 'vue-router' const route = useRoute() console.log(route.params.id)Vue2选项式API里则是:
this.$route.params.id6.2 route.params和route.query的分工
除了路径参数,另一种常见的传参方式是query,也就是URL里?后面的片段:
// 跳转时 router.push({ path: '/user/list', query: { page: 2, size: 10 } }) // 读取时 const route = useRoute() console.log(route.query.page)我个人的经验标准是:URL中有语义化资源ID时用params,比如/user/42;筛选、分页、排序这些辅助参数用query,比如/user/list?page=2。这样URL既能表达资源层级,又能承载列表状态,别人把一个完整链接发给你时,你能凭URL就大概知道页面状态。
6.3 用props配置把路由参数解耦出来
直接在组件里写route.params.id,用起来方便,但会带来一个副作用:组件和路由强耦合了,单独测试这个组件时要先mock路由上下文,非常麻烦。vue-router提供了props配置,巧妙地把路由参数映射成组件props。
{ path: '/user/:id', component: UserDetail, props: true }这样在UserDetail组件里只需要把id当普通prop接收:
const props = defineProps(['id'])props除了布尔值,还可以是对象或函数。对象形式适合传一些静态的配置数据,函数形式则可以根据当前路由动态生成props,比如从query里读分页参数后一起传给组件。这种解耦方式在页面组件复用和单元测试时价值很大,建议尽早养成习惯。
6.4 嵌套路由:children解决真实页面层级
多数B端项目都有“外层布局+内层页面”的结构:最外一层是侧边栏、顶部导航,点击菜单时整页切换的只有中间内容区。这种结构如果跑平铺路由,会造成布局组件反复卸载和挂载,体验很差。
正确做法是用children嵌套:
{ path: '/dashboard', component: DashboardLayout, children: [ { path: '', name: 'dashboard-home', component: DashboardHome }, { path: 'user', name: 'dashboard-user', component: DashboardUser } ] }这里有两个非常关键的细节。第一,children里的path不要以/开头,如果写成/user,它会被当作顶层路由处理,外层布局就失效了。第二,子路由的最终访问路径是父路径加上子路径拼接而成的,上面user最终的访问地址是/dashboard/user。
还有一个需要区分的地方:当path为空字符串时,表示父路由页面本身也要渲染子组件。比如访问/dashboard时,DashboardLayout内的<router-view>默认渲染DashboardHome,这个写法在“默认子路由”场景里非常常用。
7. 上线前必须处理的坑:死循环、组件复用与刷新404
7.1 “将您重定向的次数过多”的排查链路
很多Vue开发者在项目里见过浏览器报“将您重定向的次数过多”这个错。我在实际项目里遇到过两次。
第一次是全局前置守卫里写了类似这样的逻辑:
router.beforeEach((to, from, next) => { next('/home') })不管用户想去哪个页面,都无条件next('/home')。结果访问/home时再次触发守卫,又执行next('/home'),无限循环。
第二次是路由表里两个记录互相重定向:/a跳到/b,/b又跳回/a。这种循环浏览器会在20次左右之后报错,不会真的无限请求,但页面已经完全无法操作了。
排查思路其实很直接:打开DevTools的Network面板,看循环里有哪些URL在反复跳转,基本就能定位是守卫的问题还是路由表redirect互相指的问题。不要只盯着View里报错提示,去看实际请求最直观。
7.2 同一个路由组件被复用:created为什么不再触发
场景很常见:列表页点击不同用户,进入同一个UserDetail组件,URL从/user/1变成/user/2。你会发现created生命周期没有再次执行,因为vue-router出于性能考虑,复用了同一个组件实例。
正确的处理方式是监听路由参数的变化,在参数改变时重新请求数据:
import { watch } from 'vue' import { useRoute } from 'vue-router' const route = useRoute() watch(() => route.params.id, (newId, oldId) => { // 在这里重新拉取用户数据 fetchUser(newId) })Vue2的对应方案是watch: { '$route': ... }。这个问题面试也常考,本质是测试你有没有真正理解“组件实例复用”和“数据刷新”两件事的区别。
7.3 部署后的刷新404:基本都是服务器少了try_files
本地开发一切正常,npm run build打包后扔到服务器,从首页点进子页面没问题,但只要在子页面按F5刷新,就变成404。
这个问题在history模式下几乎是100%发生的,原因我在第5节写过:浏览器把/user/list当成了一个真实请求发给服务器,服务器没有这个页面,于是返回404。解决办法就是用try_files做fallback。
如果你没有权限改服务器配置,那唯一省心的退路就是改用hash模式。这也是“历史遗留项目里为什么还有那么多hash模式路由”的原因之一。
7.4 路由守卫里的鉴权逻辑别到处复制
关于路由守卫,最后分享一个我自己的习惯。不要在每个组件里写if (localStorage.getItem('token'))这种判断,太散、太容易漏。我的做法是在路由表里预先声明哪些页面需要登录:
{ path: '/admin', component: AdminLayout, meta: { requiresAuth: true } }然后在全局前置守卫里统一处理:
router.beforeEach((to, from, next) => { if (to.meta.requiresAuth && !isLogin()) { next({ path: '/login', query: { redirect: to.fullPath } }) } else { next() } })登录成功后再通过redirect参数跳回原来想去的页面。这种集中式的鉴权方案,后面规则再复杂也只会改动一个文件,不会出现“漏改一个页面导致权限漏洞”的情况。
最后再补充一个个人小习惯:每次新建路由文件时,我都会顺手把routes数组按业务模块拆成小块,最后再合并到一起。比如userRoutes、orderRoutes、settingsRoutes各自独立,最后[...userRoutes, ...orderRoutes, ...settingsRoutes]汇入主表。前期多花五分钟,后期维护路由表时能省下大量反复查找的时间。对于Vue路由这套东西,理解一遍原理之后,剩下的就是把每一个细节在实际项目中多敲几遍,遇到问题时的排查经验会比任何文档都管用。