ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Vue Router从原理到实战:hash/history模式、路由守卫与常见坑解析

Vue Router从原理到实战:hash/history模式、路由守卫与常见坑解析 做前端这些年我越来越觉得路由往往是一个项目里最容易被忽视、却最值得花时间搞懂的基础设施。很多新手拿到 Vue Router 就直接复制官方示例import 几个组件配好 routes 就完事等遇到刷新 404、页面缓存错乱、权限拦截失效这些问题时才一脸懵。这篇文章我从浏览器导航模型说起把 Vue Router 的两种模式原理、工程化落地细节和常见坑位一次讲透适合已经能跑通 Vue 基础项目、想深入理解路由工程的同学。看完你至少能回答三个问题为什么不刷新也能切换页面、hash 和 history 到底差在哪、权限控制应该放在路由的哪个环节。1. 从浏览器导航模型开始先搞明白 SPA 路由到底在“模拟”什么1.1 浏览器原生导航到底做了什么先回到最原始的页面时代。传统多页面网站MPA里每一次点击链接、提交表单、修改地址栏回车浏览器都会做一轮完整的导航发起 HTTP 请求、接收 HTML 文档、解析并执行 CSS/JS、重建整个 DOM然后页面从白屏开始渲染。这个过程的背后是浏览器的导航模型——地址栏 URL 是页面状态的唯一标识URL 一变页面就整体换掉。这个模型简单可靠但缺点也极其明显每个页面都要重复下载公共资源每次跳转都有一整轮网络往返和文档重建用户能清晰感知到白屏和闪烁。用生活化的比喻多页面导航就像是搬家每换一个房间就得把全部家具搬过去再重新摆好而单页应用SPA想要的是在同一个房子里换房间家具都在只切换墙面装饰和局部物品。SPA 的核心诉求就是“无刷新切换内容”。我们希望应用的壳外壳布局、侧边栏、公共状态保持不变只替换中间的业务区域。可问题是浏览器的导航模型天然带着“URL 变了就重新加载文档”的惯性。要对抗这个惯性就得让浏览器在 URL 变化时不发起网络请求不重建文档再由前端自己决定视图怎么切换。1.2 SPA 路线不刷新也能切换页面状态于是路由器的角色就出现了。路由器的本质是一个“视图状态管理器”它把“URL 地址”和“页面状态”绑定起来让用户可以通过地址栏、前进后退按钮、分享链接来访问特定视图同时不触发完整的页面刷新。Vue Router 的工作链路大致是这样的监听地址变化 → 解析出当前 URL 对应的路由记录 → 匹配到的组件实例渲染到指定出口router-view→ 更新页面视图。这里面最关键的设计点是监听地址变化这件事不能触发浏览器原生的文档刷新。那怎么才能让地址变化而不刷新文档浏览器提供了两条路也就是 Vue Router 的 hash 模式和 history 模式。这两条路不是 Vue Router 发明的是浏览器底层的机制。Vue Router 只是在这两个机制之上做了封装补齐了解析、匹配、守卫、组件渲染等工程能力。理解到这一层你才能真正看懂 Vue Router 的源码实现也才能在遇到怪异问题时知道自己的排错方向。很多人只停留在“hash 模式 URL 带 #history 模式干净”这种表面认知这是不够的。1.3 两种模式的设计思路对比先看 hash 模式。URL 里 # 及其后面的部分叫 fragment片段标识符它的原始设计本意是让页面定位到某个锚点位置。但这个部分有个特殊性质修改 fragment 不会重新加载文档同时会触发 hashchange 事件。这两个特性合在一起恰好满足了 SPA 路由“地址变化但不刷新”的需求。再看 history 模式。HTML5 引入了 History APIpushState、replaceState 可以在不重新加载文档的前提下修改地址栏 URL配合 popstate 事件可以感知浏览器的前进后退操作。相比 hash它的 URL 是纯粹的路径形式服务端可以像处理多页面一样把请求指到同一个入口。两种模式我会在下一节展开细讲。这里先给一个全局结论hash 模式是“改地址但不告诉服务器我要换页面”history 模式是“改地址但只在我自己的应用里消化这个变化”。两种方案各有代价没有绝对的好坏只有适不适合当前部署环境和业务形态。2. 两种模式实现原理location.hash 与 History API 的深度拆解2.1 hash 模式为什么改 hash 不会刷新页面hash 模式的核心是 location.hash。你可以在控制台里直接实验随便打开一个网页在 Console 敲 location.hash foo页面不会刷新地址栏会出现 #foo并且 window 上会触发 hashchange 事件。Vue Router 的 hash 模式实现本质上就是监听 hashchange 事件拿到当前的 hash 值去匹配路由表然后渲染组件。用一段极简代码可以示意这个过程function parseHash() { // 拿到 # 后面的部分比如 #/user/1 - /user/1 const hash window.location.hash.slice(1) || /; return hash; } function handleHashChange() { const path parseHash(); const matched matchRoute(path); // 伪代码路由表匹配 render(matched.component); // 伪代码渲染组件 } window.addEventListener(hashchange, handleHashChange); // 首次进入时手动触发一次 handleHashChange();注意这里有个细节Vue Router 在 hash 模式下第一次加载时如果地址栏 hash 为空它会主动把 hash 设置成 #/目的就是让应用的默认路由有一个稳定定位。如果你部署到一个子路径比如 https://example.com/app那么地址会变成 https://example.com/app#/访问 /app 也能自动落到 #/ 并匹配默认路由。hash 模式最大的好处是对服务端零要求。因为 hash 部分根本不会发送到服务端无论你在哪个静态服务器上托管随便一个静态目录都能跑。它天然适合没有服务端路由配置能力的部署环境比如对象存储静态托管、一些以纯静态文件方式发布的内网项目。但 hash 模式也有代价。首先是 URL 不好看有个 # 总觉得像临时状态。其次hash 部分不会出现在浏览器的 HTTP 请求头里所以搜索引擎对 hash 后面的内容收录很差做 SEO 的项目基本不适合。还有一个容易被忽略的点用第三方统计埋点、分享链接时有些平台手动拼接 URL 参数会拼到 hash 后面导致参数解析出问题。比如 /#/list?page2 和 /?sourcewechat#/list 这种顺序一旦搞错取参数就会拿不到。2.2 history 模式pushState 与 replaceState 的正确理解history 模式基于 HTML5 History API。关键的两个方法history.pushState(state, title, url) 和 history.replaceState(state, title, url)。pushState 会在历史栈中推入一条新记录replaceState 则是替换当前记录。两个方法都不会触发网络请求也不会重新加载文档。配合监听 popstate 事件就能感知用户点击前进后退。但注意popstate 只在用户执行前进、后退或者调用 history.back()/history.forward() 时触发调用 pushState 和 replaceState 本身是不触发 popstate 的。Vue Router 内部对此做了处理pushState 之后由路由内部逻辑直接驱动状态更新而 popstate 事件则用来响应浏览器的前进后退。同样给一个极简示意function push(path) { window.history.pushState(null, , path); // pushState 不触发 popstate需要自己驱动视图更新 const matched matchRoute(path); render(matched.component); } window.addEventListener(popstate, function () { // 用户点了浏览器前进/后退或者调用了 history.back() const path window.location.pathname window.location.search; const matched matchRoute(path); render(matched.component); });history 模式最大的优势是 URL 干净与服务器路径语义一致方便 SEO、方便后端做服务端渲染或灰度的路径判断。但代价也很明显因为 URL 的路径部分会被发送到服务端所以部署时必须在服务端配置“所有未知路径都回退到入口 HTML”——也就是 fallback 规则。否则用户刷新 /user/1 时服务器找不到这个文件就直接 404 了。这个坑我见过的项目中命中率接近百分之百后面专门讲。还要注意一个边界场景history 模式下如果用户在地址栏手动输入一个应用内路径然后回车浏览器依然会向服务器发起真实请求。所以 history 不是从技术上消灭了网络请求而是把“地址变更”这个动作完全交给了前端管理只有刷新和直接输入地址才会产生真实的服务器请求。2.3 实战选型什么时候选 hash什么时候选 history这里给一份我实际项目中常用的选型参考场景推荐模式原因纯静态托管OSS/CDN/静态目录hash无需服务端 fallback 配置公司内部后台系统hash访问路径短、部署简单、不追求 SEO需要 SEO 的官网/内容站history路径干净有利于搜索引擎处理配合服务端渲染更佳有服务端路由兜底能力的系统history由 nginx 等统一 fallback 到 index.html混合 App 内嵌 H5hash 优先部分内嵌 WebView 对路径重写支持差hash 更稳我在实际项目中一般默认用 history前提是项目部署在自有服务器或者能改 nginx 配置。如果客户环境是对象存储加 CDN 的纯静态托管我会毫不犹豫切回 hash。选型不必纠结关键是搞清楚每种模式的成本和适配边界。提示路由模式配置是在 createRouter 时通过 createWebHistory 或 createWebHashHistory 决定的。切换模式时应用内所有的路由书写方式都不变Vue Router 帮你屏蔽了底层差异。但部署层的配置差异是框架管不了的。3. 路由工程落地嵌套路由、路由元信息与权限控制3.1 嵌套路由设计从页面布局到子页面渲染真实项目里几乎没有只有一个层级的路由。最常见的场景是外层是布局组件顶部导航、侧边栏、面包屑内部根据子路由渲染不同业务页面。Vue Router 用嵌套路由的 children 配置来解决这个问题。这里有个关键理解嵌套路由不是简单的“路由路径拼接”它对应的是组件树的嵌套渲染。父路由匹配到之后渲染父组件父组件内部要预留一个 router-view子路由的组件渲染到这个子出口里。如果父组件里没有写 router-view那么即使子路由匹配成功你也会看到页面空白。一个典型的配置长这样const routes [ { path: /layout, component: Layout, redirect: /layout/home, children: [ { path: home, component: HomePage }, { path: user/:id, component: UserDetail } ] } ];注意 children 里的 path 通常不写开头的斜杠这是相对路径写法实际完整路径会拼接成 /layout/home。如果你写了 /home那就变成了根路径不再受 /layout 前缀约束。这个细节初学特别容易踩。嵌套深度在项目里通常会对应界面布局的嵌套层级。比如系统最外层是主布局主布局里某个区域是双栏布局双栏布局的右侧又是一个 tab 页区域。这种场景的嵌套路由需要你提前规划好组件结构要和路由配置保持对应否则后期加一层布局就要大改路由。3.2 路由元信息与路由守卫把鉴权逻辑收口到一处路由元信息meta是路由工程里非常实用的设计。你可以给每条路由挂上任意自定义数据比如页面标题、是否需要登录、角色权限码、缓存标识等。工程上我几乎会把所有“跟这个页面相关的声明式配置”都塞进 meta让路由表成为一张可读性很强的配置表。配合路由守卫meta 能实现一个很核心的能力把鉴权逻辑收口到一处而不是散落在每个页面组件里。全局前置守卫 beforeEach 会在每个路由跳转前执行这里是最理想的鉴权点。一个典型流程是判断目标路由的 meta.requiresAuth 是否为 true如果是检查本地是否有合法的登录凭证比如 JWT token没有凭证重定向到登录页并记录来源路径有凭证但可能过期可以在合适的时机校验或直接放行交给后续接口层处理通过后调用 next()或者直接 return trueVue Router 4 的写法。Vue Router 4 里守卫的写法更简洁可以直接 return 目标路由或者 boolean。比如这样router.beforeEach((to, from) { if (to.meta.requiresAuth !isLoggedIn()) { return { path: /login, query: { redirect: to.fullPath } }; } return true; });这里有个很容易被忽略的细节return { path: /login, query: { redirect: to.fullPath } } 会把当前要去的完整路径记录下来登录成功之后可以用 router.replace(to.query.redirect) 跳回去。这个体验细节很多系统都没做导致用户登录后永远回到首页体验很割裂。另一个工程上的建议不要在组件里写太多 if (this.$route.meta.xxx) 的逻辑。路由守卫更适合做“断面式”的拦截而组件内部的细粒度权限控制比如按钮级权限应该走指令或自定义 hooks让职责更清晰。3.3 真实项目案例JWT 登录态与验证码登录下的路由拦截说到登录鉴权必须提到“SPA 项目开发之 JWT 验证码实现”这个真实场景。在实际的 SPA 项目里JWT 登录态的管理往往和路由守卫强耦合。我们常遇到的需求是用户输入账号密码加图形验证码后端校验成功后返回一个 JWT token前端把 token 存起来后续请求在 Authorization 头里带上。路由层面要做的事是在用户没有 token 或者 token 非法时拦截一切需要登录的页面跳转。但 JWT 有个特性——它本身是无状态的服务端不保存会话前端无法通过同步方式确认 token 是否过期除非提前做一次解码检查或者有 /auth/profile 之类的接口校验。所以在工程上路由守卫里一般只做“是否存在 token”的快速判断真正的有效性校验交给接口层的 401 统一拦截。我实际项目的处理方式是在 beforeEach 里只做一级拦截有没有 token配合一个 401 统一处理模块在 axios 响应拦截器里捕获 401清掉本地 token跳转到登录页并带上 redirect 参数。这样路由守卫保持轻量权限判断逻辑也不会分散。关于验证码这里补充一个细节图形验证码的 key 往往要和登录标识绑定前端在请求验证码时拿到一个 captchaId登录时把它和用户输入一起提交。这个 captchaId 可以存在前端状态或者 sessionStorage 里路由跳转不会影响它。而登录成功后的 token 放 localStorage 还是内存变量取决于安全策略如果安全等级高可以考虑把 token 放在内存变量里刷新页面后失效再配合刷新令牌机制重新获取这样能降低 XSS 窃取 token 的风险。这个案例的核心是说明路由守卫不是孤立存在的它要和登录态管理、请求拦截、错误处理串成一条完整的链路。很多项目的权限问题不是守卫写错了而是这条链路上的某个环节断掉了。4. 路由性能与工程进阶懒加载、动态路由与缓存复用4.1 路由懒加载把首屏体积拆开SPA 最大的敌人是首屏包体积。如果不做任何切分所有页面组件都会打包进一个巨大的 JS用户打开首页要下载完整个应用的所有代码体验可想而知。路由懒加载的核心思想就是只有访问到某个路由时才去加载对应的组件代码。Vue Router 支持动态 import 语法来实现懒加载const routes [ { path: /home, component: () import(/views/HomePage.vue) }, { path: /user/:id, component: () import(/views/UserDetail.vue) } ];这样构建时打包工具会自动把动态 import 的模块拆成独立的 chunk 文件。首屏只需要下载入口 chunk 和首页对应的 chunk。当你跳转到 /user/1 时浏览器才会加载 UserDetail 对应的 JS 文件。这个过程用户无感但首屏的下载量可能从几十 MB 降到几 MB感知差异巨大。这里有几个工程细节值得注意小页面不要过度拆如果一个页面组件很小单独拆成一个 chunk 的网络开销可能大于代码体积本身的收益建议用 webpackChunkName 魔法注释把同类的、体积小的页面合并到同一个 chunk。加载失败的兜底网络弱环境下code-split chunk 可能加载失败Vue Router 默认会报错并白屏。实践中我会配合错误监控对关键路由提供重试按钮或自动重试一次。路由级 prefetch如果预算允许可以在浏览器空闲时用 requestIdleCallback 去预加载某些“大概率会访问”的路由 chunk体感会更好。4.2 keep-alive 与路由组件缓存缓存策略的坑与解SPA 无刷新切换有个附带问题每次离开路由再回来组件默认会重新创建之前的滚动位置、表单输入、接口数据全都没了。为了保住这些状态我们需要对组件做缓存最常用的就是 keep-alive。keep-alive 包住 router-view 是标准做法但有个常见的坑如果你在 keep-alive 里用 include/exclude 控制哪些路由缓存组件名必须对应路由组件的 name而不是路由路径名。很多人配置了 include 却没用就是因为组件没有设置 name。另一个坑是缓存和表格数据的冲突。缓存后组件不会再走 created 生命周期如果你在 created 里拉数据第二次进入页面时数据不会刷新。正确做法是把数据初始化逻辑从 created 挪到 activated 生命周期里。Vue 的 keep-alive 会给被缓存组件额外触发 activated 和 deactivated 钩子activated 每次进入都会执行适合刷数据。我的经验是缓存列表页不缓存详情页。列表页往往有搜索条件和分页位置要保留详情页通常需要每次都拿到最新数据缓存反而会带来脏数据。include 用路由配置驱动会比较清晰可以在路由 meta 里加一个 cache 标记然后在 router-view 外层用 computed 动态计算 include 列表。这样维护成本低新增页面只需要在路由表里声明是否缓存。4.3 动态路由权限菜单如何驱动路由注册大型后台管理系统经常要求不同角色看到不同菜单、访问不同页面。动态路由是解决这类需求的常见方案。思路是登录后后端返回当前用户的菜单/权限列表前端根据这个列表动态生成路由并注册到 router 上。Vue Router 4 提供了 addRoute 方法可以在运行时增量注册路由。工程上一个常见流程是先注册静态路由登录页、首页、404、基础布局登录后请求 /user/permissions 拿到用户的菜单树和权限码前端把菜单树转换成路由配置递归调用 router.addRoute 注册再动态添加 404 兜底路由因为动态路由需要在所有真实路由注册之后才能正确匹配导航守卫里判断“已经登录但路由还没生成”时走完注册流程再放行。这里有一个非常经典的坑动态添加的路由如果用户在刷新页面时重新走一遍初始化路由表是在运行时生成的刷新后内存里的路由会清空必须重新从接口拉权限再注册。很多动态路由方案都会在刷新后出现“白屏”或“一直 404”根源就在这里。解决方案一般分两种要么把用户权限缓存在本地注意安全性和时效性要么刷新后必须先走一次权限请求再渲染应用。还有一个边界addRoute 添加的命名路由如果重名会覆盖之前的路由。所以动态路由的路由名设计要规范避免和后端菜单返回的 key 冲突最好统一加前缀。5. 高频问题与排查实录部署 404、刷新空白、路由失效5.1 history 模式部署后刷新 404 的根因与兜底这是 history 模式最经典的坑。本地开发一切正常部署到服务器后从首页点击跳转没问题一旦用户在 /system/user 这种深层路径直接刷新页面就变成 404。原因不复杂服务器没有 /system/user 这个物理文件默认就返回 404 了。而开发环境是 dev server 内部做了 fallback 到 index.html所以你本地测不出来。解决办法是在服务端配置 fallback。nginx 常见配置是这样location / { try_files $uri $uri/ /index.html; }网上有大量这种配置的讨论但我要提醒一个进阶问题try_files 的 fallback 要把入口 HTML 准确定位。如果应用部署在子路径下比如 /admin/那 fallback 应该写成 /admin/index.html同时 Vue Router 的 createWebHistory 也要传入 base: /admin/。两边不一致深层刷新依然会挂。还有一个容易被忽视的地方如果拿 history 模式做了服务端渲染或预渲染fallback 的优先级就不能无脑指向 index.html否则会影响 SEO 和部分爬虫。这类情况建议走专门的路径分发策略而不是一刀切的 try_files。5.2 路由参数变化但页面不更新的经典场景很多人在 /user/1 切到 /user/2 时发现页面数据没变原因在于 Vue Router 为了性能优化会复用同一个组件实例。也就是说从 /user/1 到 /user/2组件不会重新创建created 钩子不会执行如果你只在 created 里根据路由参数拉数据自然就不会更新。正确的做法有两种一是用 watch 监听路由参数变化在回调里重新拉数据二是用 beforeRouteUpdate 守卫来处理参数变化逻辑。Vue Router 4 里也支持 onBeforeRouteUpdate 组合式 API。我的建议是把业务数据的加载逻辑抽成一个 loadData 方法created 首次调用watch 到参数变化时再调用一次。这样代码结构清晰也不会踩到生命周期重复执行的坑。类似问题还会出现在父路由参数变化影响子路由组件数据时处理思路一致谁依赖了变化的参数谁就去响应变化。5.3 嵌套路由缺省路径与重定向的坑还有一个高频问题嵌套路由下父路径直接访问时页面空白。比如配置了 path: /layout 的父路由访问 /layout 时 children 还没匹配到任何子组件如果父组件里只有一个 router-view那页面自然是空的。解决方式是给父路由配一个 redirect指向默认子路由比如 redirect: /layout/home。另一个细节是 404 兜底路由的配置顺序。Vue Router 4 中放在 routes 数组最后的 catch-all 路由用 path: /:pathMatch(.) 匹配所有未命中路径。如果你用动态路由 addRoute 增加了业务路由那么 404 路由必须在所有 addRoute 执行完之后再注册否则有可能把合法的动态路由拦截掉。代码顺序上这一点特别容易踩。还有个小坑路由路径写全角斜杠、顺手多打一个空格、children 里 path 写绝对路径这些低级错误也会造成匹配失败而且 Vue Router 的警告信息有时候并不直观。排查这类问题我通常先在浏览器控制台里用 router.resolve(toPath) 看看能不能解析出匹配记录能极大提速。说真的路由这块内容我第一次带团队时也轻视过后来被线上事故教育过几轮才慢慢把上面这些点一条条补起来。我现在给团队定了一个规矩路由配置不是写出来的是设计出来的任何新增路由都要把部署模式、鉴权策略、缓存策略、404 兜底这四件事一起想清楚。这篇文章里写的都是我实际踩过的坑和验证过的方案尤其 history 模式部署 404 和动态路由刷新白屏这两个能帮你省下不少排查时间。如果哪里没写透欢迎评论区讨论也建议你自己动手把 hash 模式和 history 模式的最小示例各跑一遍感受一下浏览器底层行为这一遍跑过路由对你来说就不再是黑盒了。
返回列表