前端路由这个东西,我刚入行的时候觉得挺玄乎的,不就是地址栏 URL 变一下吗?后来真正接手一个大型管理系统,从“一个页面一个 HTML”的老项目切到单页应用,才意识到路由是整个前端架构的承重墙。它管的不只是“地址长什么样”,还决定了代码怎么组织、权限怎么拦、资源怎么加载、页面间怎么跳转、浏览器前进后退怎么配合,甚至连部署上线的方式都得跟着它走。
这篇文章我就从一个实际项目的角度,把前端路由系统从头到尾拆一遍:先说清楚它到底解决什么问题,再讲透 hash 和 history 两条技术路线的原理,然后落到 Vue Router 的具体配置和实战代码上,最后把我在生产环境里踩过的坑和排查思路整理成清单。不管你是刚接触 SPA 的新手,还是被路由跳转、权限拦截、懒加载折腾过的老开发,这份内容应该都能让你少走点弯路。
前端路由虽然各家框架都有对应实现,但底层逻辑是相通的。理解了原理,再去看 Vue Router、React Router 甚至小程序路由,基本都是一通百通的状态。
1. 先搞清楚路由到底解决了什么问题
1.1 没有路由的时代,前端是怎么玩的
我最早做网站的时候,页面切换是真·跳页面。用户点一个链接,浏览器向服务器发请求,服务器返回一个新的 HTML,整个页面重新加载,白屏一下,然后内容出现。这种模式现在叫 MPA(Multi-Page Application,多页应用),对应的是服务端路由:URL 和服务器上的文件路径一一对应。
MPA 模式有个很直观的问题:每次跳转都要请求一次完整文档。哪怕是局部变化很小,比如只是切换一个 Tab,整个 header、footer、脚本都要重新下一遍。更麻烦的是页面之间共享的 JavaScript 状态完全留不住——你在 A 页面填写到一半的输入框内容,跳到 B 页面再返回就全没了,因为浏览器重新加载了整个文档。
后来 Ajax 普及了,大家发现可以在不跳转页面的情况下获取数据、局部更新 DOM。最初的玩法很原始:页面里放几个 div,点击按钮动态切换 div 的显示隐藏,或者用 JavaScript 改 innerHTML。这种做法和 jQuery 时代的“选项卡”是同一套思路。
但问题马上出现了:应用变得复杂之后,纯靠 JavaScript 状态来切换视图,地址栏始终是一个固定地址。用户在 A 视图,刷新一下,直接回到初始状态;想把当前页面发给同事看,对方看到的永远只是入口页;浏览器前进后退也完全失效,因为所有视图切换根本没改变历史记录。
那时候各种临时方案都有,比如把当前状态存进 cookie、存进 localStorage,但都是在打补丁。这套混乱的局面,本质原因就是一个:视图和 URL 之间失去了对应关系。前端路由要解决的核心问题,就是把这个对应关系重新建立起来。
1.2 SPA 兴起后,路由为什么变成了刚需
单页应用(SPA,Single-Page Application)的思路,是把整个应用的所有视图都打包进同一组 JavaScript 文件,页面加载一次之后,后续的“跳转”不再向服务器要 HTML,而是在内存里切视图、动态改 DOM。代价是应用启动时需要加载的资源更多,收益是交互体验像原生应用一样顺滑:切页面不刷新、没有白屏闪烁、内部状态在视图切换间可以保留。
SPA 想要把视图切换和 URL 联动起来,必须有这么几个基础能力:
- 地址栏发生变化时,拦截浏览器默认的整页刷新行为,改成“仅切换视图”;
- 根据当前的 URL 解析出对应的视图组件,并渲染到指定区域;
- 视图切换时,把新的 URL 推入浏览器历史栈,让前进后退可用;
- 应用初始化或刷新时,能根据当前 URL 恢复出正确的视图状态。
这就是前端路由的核心职责。没有它,SPA 只能算是一个“用脚手架搭起来的复杂单页页面”,根本称不上“应用”。
也正因如此,现代框架把路由作为单独的一层来做,比如 Vue 生态里的 Vue Router、React 生态里的 React Router,它们不是简单的“URL 解析函数”,而是一整套状态管理工具:路由表、匹配规则、导航流程、守卫、懒加载机制,全都有标准实现。前端路由其实是在回答一个问题:当前 URL 应该渲染什么,以及用户从哪里来、要到哪里去、能不能去。
2. 路由的核心原理:hash 与 history 两条路线
2.1 hash 路由:为什么 # 后面不会刷新页面
Vue Router 默认用的就是 hash 模式,地址长这样:https://example.com/#/home。有人会疑惑,为什么 URL 里带个井号就能实现无刷新跳转?这得从 URL 规范说起。
URL 里的#后面那段叫 fragment(片段标识符),它的原始设计意图是页面内的锚点定位,比如https://example.com/doc#section-2表示定位到文档里 id 为section-2的元素。关键是:fragment 部分的变化不会触发浏览器向服务器发送新的请求,也不会导致页面重新加载,只会在当前文档内部做锚点滚动。
前端路由利用的正是这个机制:把 "#" 后面的内容当作路由路径来用,监听浏览器的hashchange事件,一旦#后面的值变了,就拿到这个值,匹配对应的视图组件,渲染到页面上。因为 URL 只是 fragment 变了,服务器根本感知不到任何变化,自然也不会发生整页刷新。
手动实现一个最简单的 hash 路由,核心代码其实就这部分:
window.addEventListener('hashchange', () => { const hash = window.location.hash.slice(1) || '/'; // 根据 hash 渲染对应视图 render(hash); }); // 编程式导航 function navigate(path) { window.location.hash = path; }hash 路由的实现成本低,兼容性极好,浏览器端和服务器端都不需要特殊配置。这是它至今还是默认模式的原因。但它的缺点也很明显:URL 里带着一个#,在视觉上不够正规;更关键的是它靠hashchange事件驱动,而 hash 是不会被作为请求发送到服务器的,导致 SEO 对这类页面内容的收录能力很弱。不过话说回来,现在的单页应用如果在意 SEO,通常会配合服务端渲染来做,也不会单纯靠 hash 模式硬扛。
2.2 history 路由:利用 History API 接管地址栏
HTML5 给浏览器引入了 History API,让 JavaScript 可以修改浏览器的历史记录和当前地址,而不触发页面刷新。Vue Router 的 history 模式,就是基于这里的pushState、replaceState和popstate事件来实现的。
history.pushState(state, title, url)可以把当前地址改成url,并把这一条记录推入历史栈,但不会触发浏览器跳转,也不会重新加载页面。注意,它甚至不会触发popstate事件,因为popstate只在用户点击浏览器前进/后退按钮,或者 JavaScript 调用history.back()、history.forward()时才触发。
所以 history 路由的驱动逻辑就分成两部分:
- 编程式导航(比如点击菜单):先用
pushState把地址改掉,然后直接手动渲染对应的视图组件; - 用户点击浏览器前进/后退:监听
popstate事件,拿到当前地址后解析并渲染。
一个最基本的 history 路由实现大概是这样的:
// 点击菜单时 document.querySelector('#menu-home').addEventListener('click', (e) => { e.preventDefault(); history.pushState(null, '', '/home'); render('/home'); }); // 浏览器前进/后退时 window.addEventListener('popstate', () => { const path = location.pathname + location.search; render(path); });history 模式的 URL 干净,没有#,前后端配合得好时可以直接做到“真地址访问”,对 SEO 的支持也比 hash 模式更好。但它有一个致命前提:由于pushState改的是真实路径,用户一旦直接访问/home这个地址,或者在这个地址下按 F5 刷新,浏览器就会向服务器发起真实的 HTTP 请求。如果服务器没有对/home这个路径做相应的回退处理,就会返回 404。这就是后面要重点讲的“部署配置”问题。
2.3 hash 和 history 到底选哪个
很多初学者会问“Vue Router 默认是 hash,是不是用 hash 就行了?”。我的建议是:不要偷懒,根据项目实际场景定。
| 对比维度 | hash 模式 | history 模式 |
|---|---|---|
| URL 形式 | 带#,不带#部分不发给服务器 | 干净,和普通路径无区别 |
| 部署要求 | 任何静态服务器都行 | 需要服务器把未知路径回退到入口页 |
| SEO 能力 | 弱,爬虫较难抓取内容 | 配合 SSR/预渲染才有效 |
| 兼容性 | 老浏览器也支持 | 现代浏览器均支持 |
| 适合场景 | 内网系统、快速开发、演示环境 | 面向公网的业务系统、对 URL 有要求的场景 |
我个人的做法是:做管理后台这类对 SEO 完全无感的系统,直接用 hash,省心;做公开访问的业务站点,则上 history 模式,并在部署时严格配置好 nginx 的 try_files 回退。
需要特意说一句,无论选哪种模式,前端代码解析 URL 的逻辑都要有兜底。hash 模式要处理“带 #”和“不带 #”两种情况,history 模式要处理路径末尾带不带斜杠、有没有 query、有没有 search 参数等边界情况。这些细节就是项目里路由 bug 的高发区。
3. Vue Router 实战:搭建一套完整的路由体系
3.1 安装与基础配置
Vue Router 4 对应 Vue 3,Vue Router 3 对应 Vue 2,这两个版本在 API 上有差异,下面的示例统一按 Vue 3 + Vue Router 4 来写。先用包管理器安装:
npm install vue-router@4安装完成后,在项目里新建一个router/index.js做集中配置。最基础的配置如下:
import { createRouter, createWebHistory } from 'vue-router'; const routes = [ { path: '/', name: 'Home', component: () => import('@/views/Home.vue') }, { path: '/about', name: 'About', component: () => import('@/views/About.vue') }, ]; const router = createRouter({ history: createWebHistory(), // 或 createWebHashHistory() routes, }); export default router;然后在入口文件里注册:
import { createApp } from 'vue'; import App from './App.vue'; import router from './router'; const app = createApp(App); app.use(router); app.mount('#app');这里有几个细节如果在项目里没注意,后面会很别扭。
第一,routes数组的顺序不是随意排的。Vue Router 在匹配时按顺序遍历路由记录,当两个路由模式可能覆盖同一段路径时,先声明的有优先权。比如{ path: '/:pathMatch(.*)*' }这种兜底路由必须放在最末尾,否则它会拦截所有路径。
第二,component选项强烈建议用动态 import(() => import(...)),而不是直接import进来。原因在懒加载部分专门讲,这里先记住:动态 import 是路由懒加载的基础。
第三,name字段别省略。后面编程式导航router.push({ name: 'About' })比直接传 path 更稳定,因为路径改了名字可以不动,反过来如果依赖 path 字符串写到处跑,项目一重构路径就得全文替换。
3.2 页面骨架:router-view 与 router-link
配置了路由表之后,页面里需要一个占位区域告诉 Vue Router “匹配到的组件渲染在哪里”,这个区域就是router-view。它放在哪个组件里,视图就渲染在那个组件的哪个位置。最典型的布局是一个根组件:
<template> <div class="app-layout"> <aside class="sidebar"> <router-link to="/">首页</router-link> <router-link to="/about">关于</router-link> </aside> <main> <router-view /> </main> </div> </template>router-link是 Vue Router 提供的导航组件,它和写<a href="/about">有本质区别。直接用原生<a>会导致浏览器发起完整请求、重新加载页面,而router-link内部会拦截点击事件,阻止默认跳转,走前端路由的逻辑,并且渲染出来的是一个加了正确href的<a>标签,方便右键、新标签页打开等原生行为。
router-link还有一个很实用的特性:当前路由匹配到目标路径时,它会自动加上一个router-link-active类;如果匹配的是关联路径,则加router-link-exact-active。做侧边栏菜单的高亮效果,直接用这两个类写样式就行:
.router-link-active { color: #409eff; background: #ecf5ff; }需要注意router-link-active是“包含匹配”,比如/about在访问/about/team时也会被激活。如果希望精确匹配,用exact-active-class或者给组件传exact属性。
我见过不少新手在这里犯迷糊:为什么给导航加了类名却没有高亮效果?多半是不知道这两个自动类名,或者混用了active-class与exact-active-class的触发条件。把包含匹配和精确匹配的概念理清楚,这个坑就基本避开了。
3.3 嵌套路由与命名视图
大部分真实项目都有多级页面结构,比如后台管理系统里的“用户管理”下面有“用户列表”“用户详情”,这两个页面共享一个二级菜单布局。Vue Router 用嵌套路由来实现这种结构,核心是children配置和组件内再加一层router-view。
const routes = [ { path: '/user', component: () => import('@/layouts/UserLayout.vue'), children: [ { path: '', name: 'UserList', component: () => import('@/views/user/List.vue') }, { path: ':id', name: 'UserDetail', component: () => import('@/views/user/Detail.vue') }, ], }, ];注意几个细节:
- children 里的
path不要加开头的/,加了之后 Vue Router 会把它当作绝对路径处理,嵌套关系就不成立了; - 父级组件(这里是
UserLayout.vue)的模板里必须放<router-view />,否则子路由永远渲染不出来; - 子路由的
path: ''表示访问/user时直接渲染这个子组件,这样父路径本身也有页面响应。
命名视图解决的是另一个问题:一个路由同时要渲染多个区域的组件,而且这些区域并不是嵌套关系。典型场景是后台页面的顶部栏、侧边栏、主内容区、底部区域同时切换,或者详情页头部的操作条和正文区由不同组件实现。
<template> <router-view name="header" /> <router-view name="sidebar" /> <router-view /> <!-- 默认命名为 default --> </template>const routes = [ { path: '/dashboard', components: { header: () => import('@/components/DashboardHeader.vue'), sidebar: () => import('@/components/DashboardSidebar.vue'), default: () => import('@/views/Dashboard.vue'), }, }, ];命名视图用起来不复杂,但它和嵌套路由容易混淆。我的区分方法是:嵌套路由解决“同一块区域里上下级页面”的问题,命名视图解决“同一页面多个兄弟区域”的问题。需要同时管理复杂布局时,两者可以组合使用:先用嵌套路由搭出层级,再在某一层里用命名视图渲染多个区域。
3.4 动态路由与参数传递
真实项目里很少只靠静态路径完成全部页面管理。详情页地址一般是/user/123或者/article/tech/2024-08-01这种形态,路径中间有一段是动态的。Vue Router 支持在路径里用冒号定义动态参数(动态段):
const routes = [ { path: '/user/:id', name: 'UserDetail', component: () => import('@/views/user/Detail.vue') }, { path: '/article/:category/:slug', name: 'ArticleDetail', component: () => import('@/views/article/Detail.vue') }, ];组件里通过route.params拿到动态参数。模板中可以直接用:
<template> <div>当前用户 ID:{{ route.params.id }}</div> </template>在组合式 API 里获取:
import { useRoute, useRouter } from 'vue-router'; const route = useRoute(); const router = useRouter(); // 编程式跳转 router.push({ name: 'UserDetail', params: { id: 123 } });这里有一个很容易踩的坑:当用户从/user/123导航到/user/456时,因为路由表匹配的是同一个组件,Vue Router 会复用这个组件实例,不会重新触发组件的setup()和生命周期钩子。这时如果只在onMounted里根据id发请求,二次跳转时请求根本不会重新发,页面内容还是上一个用户的。
解决办法是在组件里监听route对象变化,或者用onBeforeRouteUpdate守卫:
watch( () => route.params.id, async (newId) => { const data = await fetchUser(newId); // 更新页面状态 } );同时,传参时还要注意一个问题:当目标路由名相同、参数不同,如果参数是变化的,尽量用完整对象触发router.push,避免出现“看起来 push 了但页面没变”的错觉。关于组件复用,我在实际开发里吃过一次亏,当时是列表页点击不同行进入同一个详情组件,第一行点的时候正常,第二行却还是第一行的数据,排查半天才发现是组件实例复用导致的。所以这个点一定要记牢。
4. 路由守卫:权限拦截是刚需
4.1 三种守卫的工作时机
Vue Router 提供了一套导航守卫机制,核心作用是在路由跳转过程中插入逻辑,用来做权限校验、登录状态检查、页面访问统计、路由切换前的数据预加载等。
从触发顺序上,守卫分成三大类:
- 全局守卫:注册在 router 实例上,任何路由跳转都会触发;
- 路由级守卫:在路由配置对象里单独写
beforeEnter,只对进入该路由生效; - 组件内守卫:写在组件里,分别处理进入、更新、离开本组件的情况。
它们的执行顺序是有严格规定的。一次完整的导航流程大概是:
- 导航被触发;
- 在失活的组件里调用
beforeRouteLeave; - 调用全局的
beforeEach; - 在复用组件里调用
beforeRouteUpdate; - 调用路由配置里的
beforeEnter; - 解析异步路由组件;
- 在被激活的组件里调用
beforeRouteEnter; - 调用全局的
beforeResolve; - 导航被确认;
- 调用全局的
afterEach; - 触发 DOM 更新;
- 调用
beforeRouteEnter中传给next的回调函数,此时组件实例已经创建完成。
如果不熟悉这个顺序,可能会写出“以为在 A 步骤生效,实际在 B 步骤才生效”的守卫代码。尤其是组件内beforeRouteEnter,它在导航确认前执行,此时拿不到组件实例this,只能靠回调参数访问。
4.2 登录态拦截的完整写法
几乎所有业务系统都有“未登录不能访问首页、已登录不能回登录页”的需求。最常用的做法是把不需要登录的页面标记成public,其余默认要求登录。
在路由配置里加一个约定字段,比如meta:
const routes = [ { path: '/login', name: 'Login', component: () => import('@/views/Login.vue'), meta: { public: true } }, { path: '/dashboard', name: 'Dashboard', component: () => import('@/views/Dashboard.vue'), meta: { requiresAuth: true } }, ];然后在全局前置守卫里做判断:
router.beforeEach((to, from, next) => { const isLoggedIn = Boolean(localStorage.getItem('token')); if (!to.meta.public && !isLoggedIn) { // 记录原本想去的地址,登录后跳回来 next({ name: 'Login', query: { redirect: to.fullPath } }); return; } if (to.name === 'Login' && isLoggedIn) { next({ name: 'Home' }); return; } next(); });这里有两个容易被忽略的细节。
第一个是next的逻辑要确保所有分支都走且只走一次,否则项目里经常出现“页面跳转卡住没反应”的 bug。Vue Router 4 已经弱化了next的使用,我现在的写法更倾向于直接在守卫里返回目标路由对象或返回true:
router.beforeEach((to) => { if (!to.meta.public && !isLoggedIn) { return { name: 'Login', query: { redirect: to.fullPath } }; } return true; });这样不需要在多个分支里手动调用next(),心智负担小很多,也不会出现“下一次跳转被上一次守卫卡住”的情况。
第二个是权限校验不能只依赖守卫,后端的接口鉴权也必须做。前端拦截只是用户体验层面的“遮羞布”,真正能不能访问哪个接口的数据,接口自己得校验。我自己见过只靠前端路由守卫做权限控制的系统,用户改一下 localStorage 里的状态就能进入管理页,但好在接口层还有一层校验,不然整个后台就是裸奔状态。
4.3 路由元信息 meta 的实际用法
meta字段是路由配置里的一个自由对象,可以放任何自定义数据。很多团队只把它当成“能不能访问”的开关位,其实它还有很多务实用法:
- 控制页面标题:
router.afterEach((to) => { document.title = to.meta.title ? `${to.meta.title} - 管理后台` : '管理后台'; });- 控制面包屑:每级路由的 meta 里放
title,组件里遍历route.matched生成面包屑层级; - 控制菜单显隐:meta 里放
{ menu: true, icon: 'user' },侧边栏生成直接由路由表驱动; - 控制缓存开关:meta 里放
{ keepAlive: true },配合<keep-alive>决定哪些视图需要缓存; - 按钮级权限:meta 里放
permissions: ['user:delete'],组件里通过权限指令判断是否渲染删除按钮。
我用 meta 管理菜单的经历比较有代表性。刚接手一个旧后台,菜单在前端写死了一份,路由表又是一份,新增一个页面要同步改两处地方,漏改一次菜单和路由就对不上。后来我把菜单数据全部下沉为路由的 meta 配置,侧边栏组件直接遍历router.options.routes生成菜单,增删页面只需要改路由表一处,问题就再没出现过。
有一点要提醒:meta 字段只在当前路由的最终匹配记录上生效,但route.matched数组会包含父级路由记录。很多时候需要组合route.matched里所有层级的 meta 才能拿到完整信息,做面包屑尤其如此。
5. 路由懒加载与工程化组织
5.1 动态 import 拆包
单页应用最怕的就是首屏加载体积过大。把所有页面组件都打包到一个 JS 文件里,首屏哪怕只需要展示登录页,也得把整个应用几百 KB 甚至数 MB 的代码全部下载完。路由懒加载就是为了解决这个问题。
如果路由配置里是这样写的:
import Home from '@/views/Home.vue'; const routes = [{ path: '/', component: Home }];构建工具会把 Home 组件和其他所有静态 import 的组件一起打进主包。
改成动态 import 之后:
const routes = [ { path: '/', component: () => import('@/views/Home.vue') }, { path: '/about', component: () => import('@/views/About.vue') }, ];构建工具(Vite、Webpack 都支持)会自动识别这种写法,按路由拆成独立的 chunk。用户访问首页时只加载主包和首页 chunk,切换到关于页时才动态加载关于页的 chunk。首屏体积的降低非常明显。
动态 import 还有一个额外的好处:组件里再 import 体积较大的第三方库,只要这个第三方库没有在其他静态 import 的模块里被引用,也会跟随路由 chunk 一起拆分,不会塞进主包。
由于路由懒加载是异步的,切换路由瞬间会有一个“加载中”的空白期。Vue 官方的处理方式是配合动态组件做 loading 态,或者在router-view外套一层 Suspense(Vue 3 内置的异步依赖处理组件)。我个人的习惯更简单:在router.beforeEach里如果检测到组件尚未加载完成,显示一个全局 loading 遮罩,避免用户以为卡死了。
5.2 路由模块化拆分
项目规模大了之后,所有路由写在一个router/index.js里会变得不可维护。几百行路由配置塞在一个文件里,找一条路由全靠搜索,改起来还要小心翼翼不碰坏别人的配置。
我的组织方式是按业务模块拆文件,每个模块暴露一个路由数组,最后在入口文件里合并:
import { createRouter, createWebHistory } from 'vue-router'; import userRoutes from './modules/user'; import orderRoutes from './modules/order'; import reportRoutes from './modules/report'; const routes = [ { path: '/', component: () => import('@/layouts/BaseLayout.vue'), children: [ ...userRoutes, ...orderRoutes, ...reportRoutes, ], }, ]; const router = createRouter({ history: createWebHistory(), routes, });每个模块文件像这样:
export default [ { path: 'user/list', name: 'UserList', component: () => import('@/views/user/List.vue'), meta: { title: '用户列表' } }, { path: 'user/detail/:id', name: 'UserDetail', component: () => import('@/views/user/Detail.vue'), meta: { title: '用户详情' } }, ];这种组织方式有几个好处:一是每个人的工作范围被清晰隔离,合代码的时候不会连环冲突;二是模块内部可以自行维护自己的嵌套层级,入口文件只负责组装;三是做路由权限、菜单生成时可以按模块循环处理,逻辑非常统一。
需要注意的是,模块拆分之后要注意子路由的path写法。如果它们是挂在children下的,path就不要画蛇添足写/user/list,直接写user/list就能拼出/user/list。我在实际项目里看到过有人统一加了斜杠,导致嵌套路由配置完全失效。
6. 容易踩的坑与排查思路
6.1 刷新 404 问题
这个应该是 history 模式最经典的坑。本地开发时一切正常,部署到服务器之后,访问/没问题,点菜单跳转也没问题,但一旦直接刷新/user/detail/123,就得到 404 了。
原因前面已经铺垫过:history 模式下,前端把真实路径显示在地址栏,但服务器上没有对应的文件,浏览器向服务器请求这个路径时,服务器自然返回 404。
解决方案是在服务器端配置“兜底回退”。以 nginx 为例,需要把前端路由涉及的所有路径都回退到index.html:
location / { try_files $uri $uri/ /index.html; }如果项目不是部署在域名根路径下,比如部署在https://example.com/admin/,配置要对应调整:
location /admin/ { alias /var/www/myapp/; try_files $uri $uri/ /admin/index.html; }光配置好服务端还不够,前端如果用了createWebHistory(),也要跟着设置base参数:
const router = createRouter({ history: createWebHistory('/admin/'), routes, });如果用了 hash 模式,这个坑天然不存在,因为#后面的内容不会发给服务器。这也是很多后台系统宁可用 hash 的原因。但值得注意的是,即使是非业务系统,只要对外提供访问入口,我仍然建议尽早配置好服务端回退,防止某天切到 history 模式时出现大面积刷新 404 的问题。
6.2 重复导航报错
在 Vue Router 4 中使用编程式导航时,如果重复点击当前已激活的路由,控制台会抛一个Uncaught (in promise) TypeError: Avoided redundant navigation to current location的错误,或者类似的“Navigation cancelled”。
很多新人看到报错就慌了,以为是代码写错了需要修改点击逻辑。其实这个是 Vue Router 的一个“特性”,不是崩溃级别的错误——重复导航同一个地址时,它会直接取消。不过如果每次都往控制台抛红色错误,前端监控平台会持续告警,烦得很。
处理方式有几种。
在调用router.push的地方统一加上 catch:
router.push('/user/list').catch(() => {});或者按能力做一个判断,如果目标路由的fullPath和当前一致就不执行导航:
const go = (path) => { if (path === router.currentRoute.value.fullPath) return; router.push(path); };我自己更倾向给路由实例包一层封装,统一在内部吞掉这种重复导航异常,避免业务代码里到处散布 catch。
6.3 白屏和参数丢失
白屏是一个总称,原因多种多样,这里挑几个和路由强相关的展开说。
第一个是路由匹配不到任何组件。路径写错了、路由表没有对应项、嵌套路由子项没写router-view,都会导致页面渲染区域是空的。排查办法是在router.beforeEach里打印to.matched,看看匹配链是否正常。必要时配置一个兜底 404 页面放在路由表最后:
{ path: '/:pathMatch(.*)*', name: 'NotFound', component: () => import('@/views/NotFound.vue') }注意 Vue Router 4 的写法,和 Vue Router 3 的*通配符不一样,上面这个/:pathMatch(.*)*是官方推荐的写法。连404匹配都能配置错,说明对正则语法不熟,这个知识缺口值得补一下。
第二个是刷新之后参数丢失。这个问题常出现在用 query 传参的场景,比如列表页跳详情页时用router.push({ name: 'Detail', query: { id: row.id } }),此时地址栏是/detail?id=123。用户刷新页面,URL 里的 query 还在,理论上不会有问题。但如果有人把参数存在了组件内部的ref里,页面一刷新状态就重置了,再想拿id就拿到undefined。
方案很明确:涉及刷新后仍要保留的关键参数,必须挂在 URL query 或 params 上,而不是存内存状态。另一个判断是:如果参数是敏感数据或者体积很大,就不要放进 URL,应改为主数据标识加后端查询。
第三个是二级页面切换后父组件状态丢失。这类问题通常和路由组件没有加<keep-alive>有关。管理系统的列表页在切到详情页再返回,列表页的滚动位置、筛选条件如果全丢了,体验非常割裂。处理方法有两种:要么用<keep-alive>包住router-view并按路由 name 白名单缓存,要么把筛选条件同步到 URL query 里。我个人的经验是两者结合,核心筛选条件进 URL,非关键状态交给 keep-alive 缓存。
7. 根据我的经验,最后再啰嗦一句
前端路由看着只是一个“地址映射”的小模块,真正落在工程里,它会牵扯到页面架构、权限模型、资源加载、部署方案、用户体验这些方方面面。能写好路由配置,不代表就把路由搞明白了;把路由背后的原理和边界摸透,遇到问题时才有足够的视野去做判断。
我个人在最开始做项目时,偏好 hash 模式,图省事;后来做了一些公网业务,切成 history 模式,被 nginx 刷新 404 狠狠教育了一顿;再往后开始做复杂的权限后台,才真正体会到守卫顺序、meta 组织、组件复用的微妙之处。每一个坑都踩过之后,再回头看路由体系,才感觉它其实是一套很优雅的设计。
如果只能给一条建议,我希望是:不管项目多小,都要花点时间把路由配置规范起来,命名、meta、守卫、懒加载这些约定在一开始就定好。小项目不觉得痛,等到路由表超过几十条、多人协作时,前期这些投入会十倍地回报给你。