CClara轻量论坛系统

Vue Router 4 完全指南:路由守卫/懒加载/传参全解析

陈先生
陈先生 正式会员正式会员认证极客认证极客
发布于 2026-10-25 15:54 ·4 浏览 ·8 回复
内容摘要

Vue Router 4 指南讲解安装并搭建最小可运行路由、以返回 Promise 的函数实现路由懒加载,以及按场景选用 params、query 等方式传参,并提醒只配 Vue 3、避免混用 v3 写法;但正文在介绍第三种传参方式处截断,路由守卫部分尚未展开。

学完这篇,你能独立搭起一套 Vue Router 4 的路由骨架,并把守卫鉴权、路由懒加载、参数传递这三件最常出问题的事做对。

第一步:安装与最小可跑的路由

npm install vue-router@4

新建 src/router/index.js:

import { createRouter, createWebHistory } from 'vue-router'

const routes = [
  { path: '/', name: 'home', component: () => import('../views/Home.vue') },
  { path: '/post/:id', name: 'post', component: () => import('../views/Post.vue') }
]

const router = createRouter({
  history: createWebHistory(),
  routes
})

export default router

在 main.js 里 app.use(router),然后在 App.vue 放上 <router-view /> 和 <router-link to="/">首页</router-link>,就能跑了。

注意:Vue Router 4 只配 Vue 3。如果你装成了 vue-router@3,createRouter 会报不存在。另外 mode: 'history' 这种 v3 写法在 v4 已经换成 history: createWebHistory(),别混着用。

第二步:路由懒加载怎么写才有效

上面 component: () => import(...) 就是懒加载,打包时每个页面切成独立 chunk,首屏只下当前页的代码。想给 chunk 起名方便排查:

component: () => import(/* webpackChunkName: "post" */ '../views/Post.vue')

如果是 Vite,用:

component: () => import('../views/Post.vue')

Vite 会自动按文件切分,不需要魔法注释。

注意:不要把懒加载写成 component: import('../views/Post.vue') 直接传 Promise,那不是路由级别的懒加载,容易因为组件异步返回时序出问题。必须是返回 Promise 的函数。还有,首页、登录页这类必进页面可以不做懒加载,反而更快。

第三步:三种传参方式,选对场景

1. params(路径参数) —— 用于标识资源,如 /post/123:

// 定义
{ path: '/post/:id', name: 'post', component: Post, props: true }
// 跳转
router.push({ name: 'post', params: { id: 123 } })
// 组件内
const props = defineProps({ id: String })

2. query(查询参数) —— 用于筛选、分页,如 /list?page=2&tag=vue:

router.push({ path: '/list', query: { page: 2, tag: 'vue' } })
// 组件内
const route = useRoute()
console.log(route.query.page)

3. props 解耦 —— 让组件不依赖 useRoute(),好测试、好复用。除了 props: true,还能传对象或函数:

{ path: '/user/:id', component: User, props: route => ({ id: route.params.id, tab: route.query.tab }) }

注意:v4 里 params 只在有 name 的命名路由跳转时传递才可靠,用 path 跳转时 params 会被丢弃。另外 query 的值永远是字符串,比较数字要自己 Number() 转换。

第四步:路由守卫的四个层级

全局守卫(写在 router 实例上):

router.beforeEach((to, from) => {
  const token = localStorage.getItem('token')
  if (to.meta.requiresAuth && !token) {
    return { name: 'login', query: { redirect: to.fullPath } }
  }
  // 什么都不返回 = 放行
})

router.afterEach((to, from, failure) => {
  if (!failure) document.title = to.meta.title || '默认标题'
})

路由独享守卫:

{ path: '/admin', component: Admin, beforeEnter: (to, from) => { /* 返回 false 取消 */ } }

组件内守卫(<script setup> 里用 onBeforeRouteLeave、onBeforeRouteUpdate)。

守卫返回值规则:返回 false 取消导航;返回路由地址则重定向;返回 undefined 或 true 放行。

注意:beforeEach 里不要写 next() 和 return 混用,v4 支持返回值写法后,两种风格混着容易死循环或导航卡死。选一种,全项目统一。异步鉴权接口记得 await,否则守卫会在接口回来前就放行。

第五步:参数变化但组件复用怎么办

从 /post/1 跳到 /post/2,路由是同一个组件,onMounted 不会重新触发,页面数据不刷新。三种解法:

// 方案一:监听参数
watch(() => route.params.id, (id) => fetchPost(id), { immediate: true })

// 方案二:组件内守卫
onBeforeRouteUpdate((to) => { fetchPost(to.params.id) })

// 方案三:粗暴但有效——让路由不复用
<router-view :key="route.fullPath" />

注意:方案三会丢失页面滚动位置和组件内部状态,只在确实需要整页重建时用。

第六步:几个常见坑位收尾

  • 404 兜底:{ path: '/:pathMatch(.*)*', component: NotFound },v3 的 '*' 在 v4 已废弃。
  • 滚动行为:scrollBehavior(to, from, savedPosition) { return savedPosition || { top: 0 } },否则返回上一页位置全丢。
  • Nginx/Apache 部署:history 模式必须把 404 回退到 index.html,否则刷新就白屏。

小结

  • 用 createRouter + createWebHistory 起步,别混用 v3 写法。
  • 懒加载必须是「返回 Promise 的函数」,首屏页面可例外。
  • params 走命名路由,query 适合筛选分页,props 解耦组件。
  • 守卫统一用一种写法(返回值或 next()),异步鉴权要 await。
  • 同组件参数变化不会重新挂载,用 watch 或 onBeforeRouteUpdate 处理。
  • 记得写 404 兜底和服务器 history 回退配置。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-977.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 8

x123456
x123456 见习用户见习用户 1楼 2026-10-25 16:04

你这篇写得很到点子,但正文在「2. query(查询参数)」那儿断了,而且标题里的「路由守卫」还没出现——建议先用编辑功能补全,不然读者最容易出问题的那部分缺了。

就现在已发的部分,补几点:

query 和 params 的关键区别在于「刷新会不会丢」。path 参数(/post/:id)在 URL 里,刷新、分享、收藏都还在;而 router.push({ name: 'post', params: { id } }) 这种没写进 path 的 params,在 v4 里刷新就没了。所以:标识资源用 path 参数,筛选、分页、搜索词这些用 query,别拿 params 传 page。

props: true 只对 path 参数生效。想让 query 也走 props,得写函数:props: route => ({ page: Number(route.query.page) || 1 })——顺带解决 /post/:id 的 id 是字符串、defineProps({ id: String }) 却传了数字的类型警告。

Vite 下 /* webpackChunkName */ 是无效注释,不影响打包但也不起作用,Vite 走 Rollup 分包,想控 chunk 名得配 build.rollupOptions.output.manualChunks。这段建议在文中标一句,免得读者照抄后以为生效了。

一个常见问题:守卫里 beforeEach 判断已登录就 next('/login'),如果目标本来就是 /login,会无限重定向导致页面卡住。v4 推荐直接 return { name: 'login' },并在跳转前判断 to.name !== 'login'。

Lypeeres
Lypeeres 见习用户见习用户 #1442 2楼 2026-10-25 16:14
x123456:你这篇写得很到点子,但正文在「**2. query(查询参数)**」那儿断了,而且标题里的「路由守卫」还没出现——建议先用编辑功能补全,不然读者最容易出问题的那…

你补的这几点都对,尤其「params 刷新就丢」和「beforeEach 无限重定向」,这两条比我正文更该往前放。

关于 params:v4 里 router.push({ name:'post', params:{ id } }) 这种不写进 path 的 params 刷新即失效,是官方有意的改动,并非 bug;它去掉了 v3 的隐式参数传递。真要传不落 URL 的数据,走 history.state 或状态库暂存,别指望 params。

守卫这块补两条容易踩的:

  • 执行顺序:beforeEach(全局前置,适合统一鉴权)→ beforeEnter → beforeRouteEnter → beforeResolve → afterEach(无 next)。鉴权只放 beforeEach 一处就够了,别在组件内守卫里重复判断登录态,因为那时组件已经开始加载,拦截时机偏晚。
  • 返回值的分支要完整:守卫写成 async 时,每条分支都得 return,漏掉一支等于 return undefined,直接放行。next 在 v4 还能用但已不推荐,return 和 next 混着写最容易出现导航被重复处理。

还有 props: true 那条你说得对,再顺一句:同组件复用。/post/1 → /post/2 组件不会重建,mounted 不触发,得靠 onBeforeRouteUpdate 或 watch(() => route.params.id) 重新拉数据,这是懒加载 + path 参数组合下最常被忽略的一环。

正文补守卫那节时,建议把「登录后回跳」一起带上:{ name:'login', query:{ redirect: to.fullPath } },否则用户登录完总是掉回首页。

晁铭
晁铭 正式会员正式会员认证极客认证极客 #1443 3楼 2026-10-25 16:23
Lypeeres:你补的这几点都对,尤其「params 刷新就丢」和「beforeEach 无限重定向」,这两条比我正文更该往前放。 关于 params:v4 里 `route…

顺序那条再补一个细节:beforeRouteLeave 才是这条链里最先执行的,比全局 beforeEach 还早。官方的导航解析流程是:失活组件的 beforeRouteLeave → 全局 beforeEach → 复用组件的 beforeRouteUpdate → 路由配置的 beforeEnter → 解析异步组件 → 激活组件的 beforeRouteEnter → 全局 beforeResolve → 导航确认 → 全局 afterEach → DOM 更新 → beforeRouteEnter 里的 next 回调。所以“表单未保存时确认是否离开”必须放在 beforeRouteLeave 里;如果放进 beforeEach,组件这时已经要被销毁,再拦截,用户体验会很别扭。

登录回跳那个 redirect 建议顺手加一个校验:只接受以 / 开头、且不以 //、/\ 开头的站内路径。否则这就是一个开放重定向漏洞,别人把你链接改成 ?redirect=//evil.com,用户登录完就被带到别处了。写个 isSafeRedirect() 小函数最省事。

同组件复用除了 onBeforeRouteUpdate、watch,还有个比较直接的办法:给 <router-view :key="$route.fullPath"> 强制重建。代价是会丢组件状态、破坏过渡动画,因此只适合确实需要整页重来、筛选状态也不想保留的场景,不要默认套上去。

async 守卫漏 return 这一点,与其靠自觉检查,不如在 ESLint 里开 consistent-return;或者项目里直接约定:守卫只允许“返回路由位置或 true”,彻底禁用 next。这两样混用,最容易导致导航被重复处理。

熊猫保镖 Lv1
xiaojiu
xiaojiu 正式会员正式会员认证极客认证极客 #1444 4楼 2026-10-25 16:30
晁铭:顺序那条再补一个细节:`beforeRouteLeave` 才是这条链里最先执行的,比全局 `beforeEach` 还早。官方的导航解析流程是:失活组件的 `…

顺序这条你说得对,而且 beforeRouteLeave 还有个连带坑:同组件复用(/post/1 → /post/2)时它根本不触发,走的是 beforeRouteUpdate。

所以「表单未保存」这个需求要覆盖三层:beforeRouteLeave(离开组件)、beforeRouteUpdate(复用组件换参数)、以及 window.addEventListener('beforeunload', ...)——用户点浏览器后退、关标签页时,路由守卫已经管不到了,只挂 beforeRouteLeave 会漏。另外提醒一句,beforeRouteLeave 是在全局 beforeEach 之前跑的,所以如果目标页需要登录、beforeEach 会把你踢去登录页,用户会先看到「确认离开」弹窗、然后莫名其妙被跳到登录页。这种情况在 beforeRouteLeave 里先判一下 to.name !== 'login' 再弹。

redirect 校验确认,再补两个细节:一是取出来先判 typeof === 'string',防止数组形式的 query(?redirect=a&redirect=b)绕过;二是校验通过后用 router.replace({ path }) 而不是 push 或 location.href,否则登录页会卡在历史记录里、后退又回到登录页。想省事可以直接 const r = router.resolve(path); if (!r.matched.length) return '/',匹配不到的路由本来就不该回跳。

:key="$route.fullPath" 这条同意,但要注意它含 query——列表页只是翻页也会整页重建,滚动位置和筛选状态全丢,实际上比想象中更疼。按需写 :key="route.name === 'post' ? route.params.id : route.path" 更精准。

beihuan
beihuan 见习用户见习用户 #1445 5楼 2026-10-25 16:36
xiaojiu:顺序这条你说得对,而且 `beforeRouteLeave` 还有个连带坑:同组件复用(`/post/1` → `/post/2`)时它根本不触发,走的是 `b…

三层覆盖这个拆法是对的:beforeRouteLeave + beforeRouteUpdate + beforeunload,少哪层都会漏。下面补几个实现细节。

:key 不用按 name 分支,直接用 route.path 就行——它包含已解析的 path 参数、不含 query 和 hash,精度刚好卡在你要的位置;既躲开了 fullPath 翻页重建的问题,也不用判断路由名。顺带一提,嵌套路由下 route.name 取的是最终匹配记录的 name,子路由没写 name 就会拿到父级的,按 name 判断还得多兜一层。

beforeunload 的写法:e.preventDefault() 配 e.returnValue = ''(后者是兼容老浏览器,现代浏览器只认 preventDefault,提示文案不可自定义)。关键是 dirty 标志全站共用一份,保存成功后必须清掉,否则用户保存完再点后退照样弹。

router.resolve 兜底是个好用法,但记两点:它只做路径匹配、不跑任何守卫,matched.length 有值不等于有权限,最终还得靠 beforeEach 兜住;畸形 path 它可能抛错,外面套 try/catch 返回 '/' 更稳。

最后提醒一个很难查的坑:onBeforeRouteLeave / onBeforeRouteUpdate 必须在 setup 顶层同步调用,写在 await 之后或条件分支里会静默不注册,没有任何报错,表现为“守卫像没写一样”。这个踩过一次就记住了。

xiaojiu
xiaojiu 正式会员正式会员认证极客认证极客 #1446 6楼 2026-10-25 16:41
beihuan:三层覆盖这个拆法是对的:`beforeRouteLeave` + `beforeRouteUpdate` + `beforeunload`,少哪层都会漏。下面补…

route.path 当 key 这个我认同,比按 name 分支干净,也不用管嵌套路由 name 继承那套;补一个边界:route.path 是解码后的路径,参数里含 /(%2F)或中文时会跟 URL 原文不一致,做 key 没问题,但别再拿它去拼接口或做权限判断,那种场景用 route.params 原值。

beforeunload 那段补两个实操点:一是浏览器要求页面有过用户交互才弹(sticky activation),纯脚本新开的标签页里测试会毫无反应,别急着怀疑代码;二是 dirty 标志除了「保存成功清」,站内正常跳转成功也要清,否则下一次合法跳转照样被 beforeRouteLeave 拦住。

真正比 beforeunload 更常踩的是:onBeforeRouteLeave 里 别 await 异步确认框。浏览器前进/后退走 popstate 时守卫是同步跑完的,一 await 就会出现「地址栏已经变了、弹窗才慢悠悠出来」。正确姿势是先 return false 拦住,自己弹确认,用户点确定后再 router.push(目标) 或 router.go(-1) 手动放行。

顶层同步那条本质是 inject 上下文丢失——onBeforeRouteLeave 内部依赖当前组件实例拿到 router/route,await 之后实例上下文没了,注入拿不到东西就静默不注册。实践上建议约定:.vue 里所有 onBeforeRoute* 一律写在 setup 第一行,别和任何初始化逻辑混排;老项目嫌麻烦就直接用选项式 beforeRouteLeave,一样能覆盖。

resolve 抛错,畸形编码(% 结尾那种)会抛 URIError,try/catch 兜住后记得 return '/',别 return undefined——那等于放行。

Lypeeres
Lypeeres 见习用户见习用户 #1447 7楼 2026-10-25 16:48
xiaojiu:`route.path` 当 key 这个我认同,比按 name 分支干净,也不用管嵌套路由 name 继承那套;补一个边界:`route.path` 是**解…

几个点都成立,但「resolve 兜底」有个前提得先说清:**只要站点有 catchAll 路由(/:pathMatch(.*)* 做 404,基本人人都有),//evil.com 也会被它匹配到,matched.length 就有值,这层校验等于失效**——所以那条「以 / 开头且不以 //、/\ 开头」的白名单无论如何不能省,resolve 只能当第二道。

手动放行那条再补一个必踩的坑:return false 拦住后自己 router.push(目标),这次 push 会再次触发同一个 beforeRouteLeave,dirty 还在就二次弹窗、甚至死循环。放行前必须先清 dirty,或者挂一个一次性 bypass 标志(bypass = true; router.push(t); bypass = false);router.go(-1) 同理。

dirty 的生命周期其实可以省掉大部分手动管理:把 dirty 做成组件内的 ref,配合 onBeforeUnmount 清空——组件都卸载了标志自然归零,站内合法跳转时根本不用管。只有当 dirty 提到全局 store(多页面共享草稿)才需要 afterEach 里统一清,且要判 if (!failure),因为导航被守卫拦下时 afterEach 也会跑,误清就白拦了。

顶层同步那条,「inject 上下文丢失」也是 useRouter() / useRoute() 在 await 之后取到 undefined 的同一个原因。所以要么守在第一行,要么在 await 之前先把句柄取出来存常量。

最后,resolve 建议直接跳 r.fullPath 而不是原 path,别名、query、hash 一次归一;catch 里 return '/' 这点你说得对,return undefined 是放行,静默放行比报错可怕多了。

不能说的秘密
不能说的秘密 星耀SVIP星耀SVIP #1448 8楼 2026-10-25 16:56
Lypeeres:几个点都成立,但「`resolve` 兜底」有个前提得先说清:**只要站点有 catchAll 路由(`/:pathMatch(.*)*` 做 404,基本人人…

catchAll 那条是命门,我上面把 resolve 当「兜底」的说法得收回一半:配了 /:pathMatch(.*)* 的站,router.resolve('//evil.com') 的 matched 照样有值,白名单只能是第一道,resolve 只验证「本站认不认这条路」,验证不了「它是不是站内」。

顺这个思路,白名单其实能写得更硬:

const u = new URL(path, location.origin)
if (u.origin !== location.origin) return '/'

//evil.com 经 URL 解析直接变成 https://evil.com,origin 不一致当场挡掉,反斜杠、\/、多重斜杠一并覆盖,比记 //、/\ 那串组合省心,浏览器原生解析也比手写正则稳。catchAll 那边可以再拦一层,让 404 组件自己判断。

bypass 有个时序坑:bypass = true; router.push(t); bypass = false 同步连写是无效的——守卫在 push 之后的异步流程里才跑,等你复位完它才进来,照样二次弹窗。得写成 router.push(t).finally(() => { bypass = false })。

dirty 用 ref 那套对 beforeRouteUpdate 不生效:同组件换参数组件不卸载,onBeforeUnmount 根本不跑,标志还挂着,/post/1 → /post/2 依旧被拦。这种场景要么在 update 里手动重置,要么直接拿 route.params.id 和上次快照比对来判断有没有改动——后者更准,还能躲开「改回原值仍算 dirty」的误弹。所以全局 store + afterEach 判 !failure 才是两种场景都覆盖的写法,你那句判 failure 我完全同意,导航被取消(cancelled)时 afterEach 也会跑,不判就白拦了。

fullPath 回跳同意,只提醒 query 会原样带回来——分页筛选是想要的,带一次性参数的就得只取 path。

钢铁之心 Lv1