资讯动态

Vue前端路由与工程化实践:vue-router、守卫、scoped与mixin

发布时间:2026/10/1 8:09:07 来源:尧图企业网站定制
1. 前端路由到底解决了什么问题从多页跳转到单页的思路切换刚接触 Vue 的时候我最不理解的就是路由这一层——浏览器自己就有地址栏为什么还要在 JS 里再维护一套路由表后来接手一个后台管理系统左侧菜单四十多个如果每个菜单都对应一个独立的 HTML 文件用户点一下整页刷新一次左侧菜单的展开状态、表单里已经填了一半的内容、列表页滚动到的位置全部清零。这种东西交付出去用不了一天就会被吐槽。前端路由这个概念本质上就是为了解决页面切换但不丢状态这件事而存在的。简单说前端路由就是一套用 JavaScript 接管 URL 变化、按规则渲染不同组件、整个过程中不向服务器重新请求整页的机制。它解决的问题有三个一是切换体验页面不再整块刷新只替换需要变化的那部分二是状态保持父级组件、全局状态、滚动位置都能留住三是职责划分哪些 URL 对应哪个页面组件被收敛到一张路由表里统一管理后期加页面就是往表里加一条记录。这篇内容适合已经会写 Vue 单文件组件、但一到多页面组织就发懵的朋友。整套东西我会按概念原理 → vue-router 上手 → 登录跳转实战 → scoped 样式 → mixin 复用 → 插件机制 → 踩坑速查的顺序拆开讲每一块都附上能直接跑的代码。1.1 传统多页跳转的痛点在哪里传统多页应用的跳转靠a href...或者表单提交浏览器拿到新地址之后做三件事把当前页面彻底卸载、向服务器请求新文档、重新解析渲染整棵 DOM 树并执行所有脚本。这个过程在网络条件一般的情况下白屏感非常明显几百毫秒是常态。更麻烦的是内存里的东西全没了——你在第 3 页填了半天的表单点错一个链接跳走再返回内容就空了只能靠 localStorage 手动存草稿来兜底。而且服务器压力也大每个页面都要走一次完整的模板渲染和后端鉴权用户量一上来静态资源重复下载、重复请求的问题就会被放大。单页应用换了个思路整站只有一份 HTML 骨架首次加载之后所有页面组件都在同一个运行环境里切换时只把当前应该显示的那个组件挂上去、把上一个卸下来。DOM 的增删比整页重建便宜太多了而且 JS 上下文一直活着全局的登录态、用户信息、主题配置都不会因为切页面而重置。这就是路由这一层存在的现实意义。1.2 前端路由的本质URL 变了页面不刷新剥开框架的封装前端路由干的事其实就一句话监听 URL 的变化然后决定渲染什么。难点在于浏览器原生行为是URL 一变就跳转刷新我们得想办法把这个默认行为拦住或者绕过。浏览器提供了两套现成的能力正好对应两种前端路由模式第一种是hash 模式。URL 里#后面那一段就是 hash比如http://localhost:8080/#/user/list。hash 有个特殊性质改变它不会触发页面刷新也不会向服务器发请求浏览器只会在历史记录里加一条并触发hashchange事件。所以早期前端路由基本都靠它——监听hashchange读location.hash拿到/user/list这个路径去路由表里找对应的组件渲染出来。实现简单兼容性极好。// 极简版 hash 路由原理演示 const routes { /home: () h1首页/h1, /user: () h1用户中心/h1 } function render() { const path location.hash.slice(1) || /home const view document.getElementById(app) view.innerHTML (routes[path] || (() h1404/h1))() } window.addEventListener(hashchange, render) window.addEventListener(DOMContentLoaded, render)第二种是history 模式。HTML5 给history对象加了pushState和replaceState两个方法它们能修改地址栏的 URL 而不触发刷新配合popstate事件监听浏览器前进后退就能做出没有#的干净地址比如http://localhost:8080/user/list。代价是刷新页面或者直接输入地址访问时浏览器会真的拿这个路径去请求服务器服务器如果没有做所有未匹配路径都返回 index.html的配置就会 404。这个坑我在第一次用 history 模式时就踩了本地开发一切正常一部署到测试环境除了首页全都 404。1.3 hash 与 history 怎么选四个维度对比这两种模式没有绝对好坏看场景。我把实际项目里最影响决策的几个点整理成表对比维度hash 模式history 模式URL 观感带#如/index.html#/user干净如/user刷新页面不会 404天然支持需服务端配置回退到 index.html服务器配置零配置必须配 rewrite 规则SEO 友好度一般相对更好兼容性极好老浏览器都行依赖 HTML5 history API分享链接长一点但稳好看但对方环境没配好可能打不开我的个人经验是内部管理系统、投放后台这类不在乎 URL 美观的直接 hash 模式省心部署时不用麻烦运维改配置对外的官网、内容站、需要被搜索引擎收录的上 history 模式同时提前跟运维打个招呼配好回退规则Nginx 里一行try_files $uri $uri/ /index.html;就够了。别等到上线前一天才发现 404。2. Vue-router 快速上手从装包到跑通第一条路由理解了原理再看 vue-router 就没什么神秘的了它就是上面那套 hash/history 机制的工程化封装帮你把监听 URL → 匹配路由表 → 渲染组件标准化了还顺手提供了嵌套路由、命名路由、路由守卫、懒加载这些进阶能力。下面按 Vue 2 vue-router 3 的经典组合来讲Vue 3 的写法差异createRouter、createWebHistory我会在对应位置标注出来用法逻辑是通的。2.1 安装与目录结构规划装包命令不复杂注意版本对应关系是新手最容易翻车的地方Vue 2 配 vue-router 3Vue 3 配 vue-router 4装错了会直接报Cannot read property use of undefined之类的错。# Vue 2 项目 npm install vue-router3 # Vue 3 项目 npm install vue-router4目录上我建议一开始就分清楚别所有页面都堆在src/views里。我的习惯是这样src/ ├── router/ │ ── index.js # 路由表与实例唯一出口 ├── views/ # 页面级组件一个 URL 一个 │ ├── Login.vue │ ├── Home.vue │ └── NotFound.vue ├── components/ # 可复用的功能性组件 └── App.vueviews放页面components放零件这条线一开始划清后面组件多了不至于混乱。命名上页面组件首字母大写文件名和路由名尽量对应后期找文件会快很多。2.2 核心配置逐行拆解路由表是整个路由的总纲我把每行的意图都标出来// src/router/index.js import Vue from vue import VueRouter from vue-router import Home from /views/Home.vue import Login from /views/Login.vue // 全局注册插件这一步不能忘 Vue.use(VueRouter) const routes [ { path: /, redirect: /home // 根路径重定向避免访问 / 空白 }, { path: /login, name: Login, component: Login, meta: { title: 登录, requiresAuth: false } }, { path: /home, name: Home, // 路由懒加载只有访问时才去下载这个 chunk component: () import(/views/Home.vue), meta: { title: 首页, requiresAuth: true } }, { path: *, name: NotFound, component: () import(/views/NotFound.vue) } ] const router new VueRouter({ mode: hash, // 想用 history 就改成 history routes }) export default router几个关键点值得展开说。Vue.use(VueRouter)只在入口执行一次它的作用是往 Vue 原型上挂$router和$route同时注册router-view和router-link两个全局组件。redirect用来兜底根路径用户输http://xxx/时不至于看到一片空白。meta字段是我强烈建议一开始就加上去的——它是路由的备注位后面做登录拦截、动态标题、面包屑都靠它传递信息。path: *是通配符路由必须放在数组最后否则它会抢先匹配所有路径。组件懒加载也值得说一下。上面Home用了() import(...)形式构建时 webpack 会把每个懒加载组件单独切一个 JS 文件首屏只加载首页需要的部分。项目页面一多这个改动能让首屏体积下降非常明显。我做过一个后台四十多个页面全改懒加载之后首屏 JS 从 1.8MB 降到 400 多 KB加载体验立刻不一样。然后在main.js里挂上去import Vue from vue import App from ./App.vue import router from ./router new Vue({ router, // 挂载后所有组件都能用 this.$router / this.$route render: h h(App) }).$mount(#app)页面骨架里用两个内置组件占位router-link负责生成跳转链接router-view是路由出口——匹配到的组件就渲染在这个位置template div idapp nav router-link to/home首页/router-link router-link to/login登录/router-link /nav !-- 路由出口 -- router-view / /div /template注意router-link最终渲染成一个a标签但它内部阻止了默认跳转行为改由 vue-router 接管所以不会刷新页面。如果你手写a href/home那就是浏览器原生跳转会刷新。2.3 路由跳转的两种方式与参数传递跳转分声明式和编程式两类。声明式就是router-link适合导航菜单router-link :to{ name: Home, query: { tab: profile } }个人中心/router-link编程式是 JS 里调this.$router.push适合登录成功后跳转、按钮点击跳转这类场景// 字符串路径 this.$router.push(/home) // 对象写法推荐带 name路径改了不用改代码 this.$router.push({ name: Home, params: { id: 123 } }) // 带查询参数会出现在地址栏 ?tabprofile this.$router.push({ path: /home, query: { tab: profile } }) // 替换当前历史记录用户点返回不会回到上一页 this.$router.replace(/login)关于params和query的区别是面试高频问题也是实际开发常混的点。query会显示在 URL 上刷新后还在适合筛选条件、分页页码这类需要被分享或收藏的状态params配合命名路由使用时默认不显示在地址栏但刷新就丢只适合页面之间临时传递的一次性数据。我吃过这个亏——把订单详情 ID 放在params里传用户一刷新页面 ID 没了详情页直接报错。后来统一改成把关键标识写进路径参数比如/order/:id刷新和分享都不会出问题。const routes [ // 路径参数通过 this.$route.params.id 取值 { path: /order/:id, name: OrderDetail, component: () import(/views/OrderDetail.vue) } ] // 跳转 this.$router.push({ name: OrderDetail, params: { id: 1001 } }) // 取参 const { id } this.$route.params3. 登录跳转实战一个能直接抄的完整案例前面都是零件这一段把它们组装成一个真实场景未登录用户访问任何业务页面都被踢回登录页登录成功后自动回到他原本想去的那个页面。这个需求虽然基础但把路由守卫、状态存储、重定向回跳三件事一次串清楚了值得完整走一遍。3.1 需求拆解与页面规划我们把需求写清楚再动手避免边写边改。要实现的点有四个登录页不需要鉴权任何人都能访问除登录页外的其他页面都需要登录未登录访问受保护页面时跳转登录页并记录原本的目标地址登录成功后自动跳回目标地址没有记录就跳首页。页面上一共三个Login.vue、Home.vue、Layout.vue承载菜单的框架页可选。路由表里用meta.requiresAuth标记哪些页面受保护。const routes [ { path: /login, name: Login, component: Login, meta: { requiresAuth: false } }, { path: /, redirect: /home }, { path: /home, name: Home, component: Home, meta: { requiresAuth: true } }, { path: /user, name: User, component: () import(/views/User.vue), meta: { requiresAuth: true } } ]这里我把是否鉴权这个开关做成路由的元信息而不是写在守卫里的 if-else 列表。好处很直接以后加新页面只需要在新路由上补一句requiresAuth: true守卫代码一个字都不用动。这个思路叫数据驱动配置是写路由守卫最省心的方式。3.2 登录逻辑与状态存储登录页要做两件事收集账号密码、请求接口、拿到凭证后落库然后跳转。凭证我一般用 localStorage 存因为需要跨标签页、跨刷新保持如果项目要求更严也可以用 sessionStorage 或者 cookie配合后端 HttpOnly。!-- src/views/Login.vue -- template div classlogin h2登录/h2 input v-modelform.username placeholder用户名 / input v-modelform.password typepassword placeholder密码 / button clickhandleLogin登录/button /div /template script export default { name: Login, data() { return { form: { username: , password: } } }, methods: { async handleLogin() { // 这里换成你真实的登录接口 const res await this.$http.post(/api/login, this.form) if (res.code 0) { // 凭证落库 localStorage.setItem(token, res.data.token) localStorage.setItem(userInfo, JSON.stringify(res.data.user)) // 关键跳回用户原本想去的页面 const redirect this.$route.query.redirect || /home this.$router.replace(redirect) } else { this.$message.error(账号或密码错误) } } } } /script细心看会发现登录成功后我跳的是this.$route.query.redirect而不是硬编码的/home。这个参数是从守卫里带过来的下面马上讲。另外跳转用replace而不是push是为了不把登录页留在历史记录里——否则用户登录成功后点浏览器返回又回到登录页体验很怪。token存 localStorage 是个常见做法但它有个明显的安全问题XSS 一旦发生脚本能直接把 token 读走。如果项目安全要求高建议后端把 token 放在 HttpOnly 的 cookie 里前端只负责带上凭证读不到明文。这是个权衡不是绝对优劣看项目性质。3.3 路由守卫拦截未登录用户全局前置守卫router.beforeEach是这一整套的枢纽。它会在每次路由跳转前执行接收三个参数to要去哪、from从哪来、next放行函数。// src/router/index.js 末尾追加 router.beforeEach((to, from, next) { const token localStorage.getItem(token) // 动态设置页面标题 document.title to.meta.title || 我的应用 // 不需要鉴权的页面直接放行 if (to.meta.requiresAuth false) { // 已登录用户访问登录页直接送去首页 if (token to.name Login) { return next(/home) } return next() } // 需要鉴权且已登录放行 if (token) { return next() } // 需要鉴权但未登录踢回登录页带上目标地址 next({ path: /login, query: { redirect: to.fullPath } }) })几个细节值得单独抠一下。next方法必须被调用且只能调用一次漏调或者调两次都会导致路由卡死或抛错这是新手最常遇到的守卫问题。to.fullPath和to.path的差别在于前者包含 query比如/user?taborder用它做回跳更完整。判断已登录用户访问登录页时要送回首页否则会出现登录成功 → 跳登录页的循环死锁。提醒如果守卫里写了异步逻辑比如调接口校验 token 有效性一定要在 async 函数里正确处理 next最简单的方式是router.beforeEach(async (to, from, next) { ... await ...; next() })别在 Promise 外面提前 next。3.4 退出登录与重定向回跳验证退出登录逻辑简单但要记得清干净handleLogout() { localStorage.removeItem(token) localStorage.removeItem(userInfo) this.$router.replace(/login) }整条链路走一遍用户没登录直接访问/user?taborder比如从别人分享的链接进来→ 守卫发现无 token → 跳到/login?redirect%2Fuser%3Ftab%3Dorder→ 用户登录成功 → 读取 redirect →replace(/user?taborder)→ 守卫发现有 token 且页面向鉴权 → 放行。整个过程用户无感知落在原本想要的位置。我在实际项目里发现这个回跳细节对用户体验影响很大尤其是后台系统里用户从一个深层级的列表页被登出回来后能直接原位比每次都被丢回首页顺手得多。4. scoped 样式让组件的 CSS 只作用于自己说完了路由换个话题说样式。Vue 单文件组件里给style加上scoped属性是几乎每个项目都会做的事但真正理解它怎么生效、什么时候会失效的人不多。我先说结论scoped不是黑魔法它是编译期给选择器和元素都加上同一个唯一属性来实现隔离的。4.1 scoped 的编译原理你写这样的代码template div classbox p内容/p /div /template style scoped .box { color: red; } .box p { color: blue; } /style编译产出大致是这样div classbox>.box[data-v-f3f3eg9] { color: red; } .box p[data-v-f3f3eg9] { color: blue; }关键点每个组件都会生成一个唯一的>/* Vue 2 经典写法 */ style scoped .parent .child-inner { color: green; } /* 预处理器less/sass里用 /deep/ 或者 ::v-deep */ .parent /deep/ .child-inner { color: green; } .parent ::v-deep .child-inner { color: green; } /styleVue 3 里和/deep/都被移除了统一用:deep()style scoped .parent :deep(.child-inner) { color: green; } /style这里有个我踩过的坑/deep/在 Dart Sass 里会被当作除法运算符警告甚至报错后来项目改用::v-deep才清静。所以如果你用 scss优先上::v-deep或者 Vue 3 的:deep()别用在某些预处理器下根本不支持。4.3 常见坑与替代方案第一个坑scoped只作用于模板里的元素通过 JS 动态创建的 DOM、通过v-html渲染的内容都不会带上>// src/mixins/pagination.js export default { data() { return { list: [], page: 1, loading: false, finished: false } }, mounted() { this.loadMore() window.addEventListener(scroll, this.handleScroll) }, beforeDestroy() { window.removeEventListener(scroll, this.handleScroll) }, methods: { handleScroll() { const nearBottom window.innerHeight window.scrollY document.body.offsetHeight - 50 if (nearBottom !this.loading !this.finished) { this.loadMore() } }, async loadMore() { this.loading true const res await this.$http.get(/api/list, { params: { page: this.page } }) this.list.push(...res.data) this.page if (res.data.length 0) this.finished true this.loading false } } }组件里一句mixins: [pagination]就能用上这整套列表加载逻辑// src/views/UserList.vue import pagination from /mixins/pagination export default { name: UserList, mixins: [pagination] // 组件自身的 data、methods 会自动和 mixin 里的合并 }我特别喜欢拿 mixin 做两件事一是上面这种通用交互逻辑分页、下拉刷新、防抖搜索二是页面级的埋点上报——在mounted里统一上报页面曝光不用每个页面都写一遍。用好了能省不少重复代码。5.2 合并策略谁覆盖谁mixin 的合并规则必须搞清楚不然出事都不知道为什么。简单说分三类data递归合并同名属性以组件自身为准。也就是组件里定义了pagemixin 里的page会被覆盖。生命周期钩子同名钩子都执行mixin 里的先执行组件里的后执行。顺序很重要——如果你依赖 mixin 里 mouted 的初始化结果逻辑就不会出错。methods、computed、filters同名以组件自身为准mixin 里的被覆盖。watch同名时合并成数组都会触发。这些规则单看记不住最好的办法是实际写一段测试一下。我当年就是自己打了个小 demo在 mixin 和组件里都写同名的 data 和 mounted然后console.log看顺序一次就全记住了。合并策略理解了就能预判很多样式/数据没生效的疑惑。5.3 全局混入的风险与适用边界Vue.mixin()可以把 mixin 注入到每一个组件看起来很方便Vue.mixin({ mounted() { console.log(任何组件被挂载都会打印这句) } })但这个能力要慎用。全局混入会影响项目里所有组件包括你引入的第三方组件库。我见过有人在全局 mixin 里做了个mounted里判断用户权限的逻辑结果第三方组件实例里跑了一遍直接报错。另外全局 mixin 让数据来源变得不透明——一个新同事接手看到一个组件里用了this.someMethod()翻遍组件也找不到定义最后才发现藏在全局 mixin 里排查成本极高。我的判断标准是仅当一段逻辑确实需要影响项目里绝大多数组件时才用全局混入比如统一上报 PV或者继承某个平台特定的基础能力。其他一律用局部 mixin 或工具函数。顺带说一句 Vue 3 的情况mixin在 Vue 3 里仍然支持但官方更推荐用**组合式函数Composition API**替代因为组合式函数的来源、依赖关系在代码里看得清清楚楚比 mixin 的隐式合并更好维护。6. 插件机制把通用能力装进 Vue最后一块讲插件。路由、状态管理、UI 组件库本质都是 Vue 插件它们通过同一个约定被装进框架。理解插件的写法你就能自己封装一些通用能力了。6.1 插件的基本写法一个 Vue 插件就是一个对象或者一个函数它可以带一个install方法。Vue.use(plugin)时会调用install(Vue, options)// src/plugins/myGlobal.js export default { install(Vue, options) { // 1. 注册全局组件 Vue.component(MyButton, { template: button classmy-btnslot //button }) // 2. 挂全局方法组件里用 this.$formatTime 调用 Vue.prototype.$formatTime (timestamp) { const d new Date(timestamp) return ${d.getFullYear()}-${String(d.getMonth() 1).padStart(2, 0)}-${String(d.getDate()).padStart(2, 0)} } // 3. 注入全局指令 Vue.directive(focus, { inserted(el) { el.focus() } }) // 4. 接收外部传入的配置 Vue.prototype.$appConfig options || {} } }装进项目// main.js import MyGlobal from /plugins/myGlobal Vue.use(MyGlobal, { appName: 管理系统, version: 1.0 })插件做了四件事注册全局组件、挂原型方法、注册全局指令、注入配置。这四件事是插件最典型的能力集合vue-router 的install里也是类似的套路只不过它挂了$router/$route并注册了router-view/router-link。6.2 实战封装一个轻量的全局提示插件拿一个真实的需求练手项目里到处需要弹提示每次import组件太麻烦封装成插件直接用this.$toast(保存成功)。// src/plugins/toast/index.js import Toast from ./Toast.vue export default { install(Vue) { // 用 extend 生成 Toast 的构造器 const ToastConstructor Vue.extend(Toast) Vue.prototype.$toast function(message, duration 2000) { // 每次调用创建一个新实例挂到 body 上 const instance new ToastConstructor({ propsData: { message, duration } }) instance.$mount() document.body.appendChild(instance.$el) // 到时间自动卸载 setTimeout(() { document.body.removeChild(instance.$el) instance.$destroy() }, duration) return instance } } }Toast.vue就是个普通组件负责显示和淡出动画。这个插件封装的核心在于挂载到 body——因为要脱离当前组件的 DOM 层级避免父组件的overflow: hidden把弹窗裁掉也避免被父组件的 z-index 压住。这个坑我踩过一开始把弹窗放在组件内部结果在一个有滚动条的容器里弹窗被切掉了一半查了半天才反应过来是定位父级的问题。用起来就非常顺export default { methods: { save() { this.$toast(保存成功) } } }插件和 mixin 的区别我经常被问mixin 是往组件里混逻辑插件是往 Vue 全局装能力。一个组件里用不用 mixin 由组件自己决定而插件一旦use了全项目都能用。选哪个取决于这份能力是一部分组件的还是全项目的。7. 踩坑记录与常见问题速查写到这里路由、样式、复用、插件这条线都过了一遍。最后把我这些年攒下来的高频问题和排查思路整理出来方便遇到问题时直接对照。7.1 高频问题排查表现象大概率原因处理办法刷新页面 404history 模式没配服务端回退配try_files $uri /index.html或改 hash 模式路由跳转报 NavigationDuplicated重复 push 到同一个地址用replace或在 push 后.catch(() {})next后守卫逻辑没执行忘记调 next 或调了两次检查守卫所有分支都有且仅有一次 nextscoped 样式改不到子组件子组件元素没有>

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑