☰
Vue前端路由与工程化实践:vue-router、守卫、scoped与mixin
2026/10/1 8:09:05 网站建设 项目流程

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] || (() => '<h1>404</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 3,Vue 3 配 vue-router 4,装错了会直接报Cannot read property 'use' of undefined之类的错。

# Vue 2 项目 npm install vue-router@3 # Vue 3 项目 npm install vue-router@4

目录上我建议一开始就分清楚,别所有页面都堆在src/views里。我的习惯是这样:

src/ ├── router/ │ ── index.js # 路由表与实例,唯一出口 ├── views/ # 页面级组件,一个 URL 一个 │ ├── Login.vue │ ├── Home.vue │ └── NotFound.vue ├── components/ # 可复用的功能性组件 └── App.vue

views放页面,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 id="app"> <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 } }) // 带查询参数,会出现在地址栏 ?tab=profile 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.params

3. 登录跳转实战:一个能直接抄的完整案例

前面都是零件,这一段把它们组装成一个真实场景:未登录用户访问任何业务页面,都被踢回登录页;登录成功后,自动回到他原本想去的那个页面。这个需求虽然基础,但把路由守卫、状态存储、重定向回跳三件事一次串清楚了,值得完整走一遍。

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 class="login"> <h2>登录</h2> <input v-model="form.username" placeholder="用户名" /> <input v-model="form.password" type="password" placeholder="密码" /> <button @click="handleLogin">登录</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?tab=order,用它做回跳更完整。判断已登录用户访问登录页时要送回首页,否则会出现"登录成功 → 跳登录页"的循环死锁。

提醒:如果守卫里写了异步逻辑(比如调接口校验 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?tab=order(比如从别人分享的链接进来)→ 守卫发现无 token → 跳到/login?redirect=%2Fuser%3Ftab%3Dorder→ 用户登录成功 → 读取 redirect →replace('/user?tab=order')→ 守卫发现有 token 且页面向鉴权 → 放行。整个过程用户无感知,落在原本想要的位置。我在实际项目里发现,这个回跳细节对用户体验影响很大,尤其是后台系统里用户从一个深层级的列表页被登出,回来后能直接原位,比每次都被丢回首页顺手得多。

4. scoped 样式:让组件的 CSS 只作用于自己

说完了路由,换个话题说样式。Vue 单文件组件里给<style>加上scoped属性,是几乎每个项目都会做的事,但真正理解它怎么生效、什么时候会失效的人不多。我先说结论:scoped不是黑魔法,它是编译期给选择器和元素都加上同一个唯一属性来实现隔离的。

4.1 scoped 的编译原理

你写这样的代码:

<template> <div class="box"> <p>内容</p> </div> </template> <style scoped> .box { color: red; } .box p { color: blue; } </style>

编译产出大致是这样:

<div class="box">.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; } </style>

Vue 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:递归合并,同名属性以组件自身为准。也就是组件里定义了page,mixin 里的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 class="my-btn"><slot /></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 或调了两次检查守卫所有分支都有且仅有一次 next
scoped 样式改不到子组件子组件元素没有>

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询