Vue 路由 vue-router 用法完全梳理(十一)
第一次做单页应用,我卡在一个很蠢的问题上:地址栏明明变了,页面为什么没刷新?后来才反应过来,这正是前端路由要干的事。把「URL 变化」和「向服务器要一个新页面」这两件事解绑,改成由前端自己决定该渲染哪个组件。理清这一层之后,vue-router 的那堆 API 就不再是零散的配置项了,它们各自对应着一类真实需求。这篇把路由基础、动态匹配、嵌套、编程式跳转、命名视图、传参这几块串起来讲一遍,顺带把原文里几处写错的配置改对。
在本篇文章中,我们将从浅入深,和大家一起学习以下知识:
- 前端路由是什么,它的优点和代价各在哪
hash和history两种模式的差别,以及history模式为什么会 404- 动态路由匹配,商品详情页这类场景怎么配
- 嵌套路由和命名视图分别解决什么布局问题
- 编程式导航的几种写法,
push和go的区别 query和params两种传参方式,为什么一般建议用query- 重定向和别名的区别,别名不是跳转
- Vue Router 4 的写法变化,老项目迁移要动哪里
# 一、路由基础
# 1.1 什么是前端路由
路由是根据不同的 url 地址展示不同的内容或页面。
前端路由就是把「不同的路由对应不同的内容或页面」这个任务交给前端来做。之前是通过服务端根据 url 的不同返回不同的页面实现的,现在这一步挪到了浏览器里。
实现上靠的是两套浏览器能力。一套是 hash,URL 里 # 后面的部分变化不会触发页面请求,监听 hashchange 事件就能知道该切哪个组件;另一套是 HTML5 的 History API,pushState 和 replaceState 可以在不刷新页面的前提下改地址栏,配合 popstate 事件感知前进后退。
# 1.2 什么时候使用前端路由
单页面应用里,大部分结构不变,只改变内容的场景。
比如后台管理系统,左侧菜单和顶部导航永远在,切菜单只换右侧那块内容区。这种结构用前端路由收益最大,因为不变的部分完全不需要重新渲染。
反过来,如果每个页面的结构差异很大、SEO 又很重要(比如内容站、电商详情页),前端路由就不一定划算。
# 1.3 前端路由的优点和代价
优点很直接:用户体验好,不需要每次都从服务器全部获取,切换是瞬时的。
代价这块,原文列了三条,这里逐条说清楚。
第一条是不利于 SEO。这是真的,因为搜索引擎爬虫拿到的是一个几乎空的 HTML 加一堆 JS。虽然现在主流爬虫具备执行 JS 的能力,但成本高、时机不可控,重视自然流量的站点还是得上服务端渲染。
第二条原文写的是「使用浏览器的前进后退键时会重新发送请求」。这条要修正一下:前进后退本身不会触发整页刷新,路由切换是在前端完成的。真正会重复请求的是数据,如果组件在 created 里发请求且没做缓存,每次回到这个路由都会再请求一次。所以问题不在路由机制,在数据层没有缓存,keep-alive 或者一个简单的内存缓存就能解决。
第三条是滚动位置。SPA 切换路由时页面不会重新加载,浏览器原生的滚动恢复也就不生效了,从列表页进详情再返回,回到的是顶部而不是原来那个位置。vue-router 提供了 scrollBehavior 配置来手动处理这件事,配合 keep-alive 记录 scrollTop 是常见做法。
# 二、用 vue-router 构建 SPA
# 2.1 从最小可运行的例子开始
在项目 src 目录下的 main.js 里注册插件:
import Vue from 'vue'
import VueRouter from 'vue-router'
Vue.use(VueRouter)
vue-router 底层就是对 History API 的封装,地址后面跟 # 用的是 hash 模式。
先定义两个模板:
<div id="box">
</div>
<!--定义模版-->
<template id="a">
<div>
第一个router
</div>
</template>
<template id="b">
<div>
第二个router
</div>
</template>
再把路由表和实例接起来:
// 定义路由表
var routes = [
{
path: "/one",
component: { template: "#a" }
},
{
path: "/two",
component: { template: "#b" }
}
];
// 创建路由实例
var router = new VueRouter({
routes
});
// 挂载到根实例上
new Vue({
el: "#box",
router
});
最后在模板里放入口和出口:
<div id="box">
<router-link to="/one">One</router-link>
<router-link to="/two">Two</router-link>
<router-view></router-view>
</div>
两个组件的分工是这样的:
router-link默认会被渲染成一个a标签,to是我们定义的路由路径router-view是出口,路由匹配到的组件将渲染在这里
为什么不直接用 a 标签?因为原生 a 标签点击会触发浏览器导航,整页刷新,SPA 的状态全丢。router-link 内部拦掉了默认行为,改成调用路由的跳转方法,同时还会自动给当前激活的链接加上 router-link-active 类名,做导航高亮不用自己写逻辑。
# 2.2 hash 模式和 history 模式
在 new Router 中指定 mode 为 history 即可去掉 #,这样地址看起来更接近传统页面:
new Router({
mode: "history",
routes: []
})
这里有个坑要注意,而且是上线才会暴露的那种。history 模式下地址栏是真实路径,用户在 /goods/123 这个页面按 F5,浏览器会真的向服务器请求 /goods/123 这个资源,而服务器上根本没有这个文件,返回 404。解决办法是在服务器上配一条兜底规则,把所有未匹配到静态资源的请求都指向 index.html。Nginx 里就是 try_files $uri $uri/ /index.html; 这一行。
hash 模式没有这个问题,因为 # 后面的内容压根不会发给服务器。代价是地址不好看,而且某些第三方分享、微信 JS-SDK 签名遇到 hash 会有额外麻烦。
跳转的两种方式,模板里用 router-link,代码里用 this.$router.push:
// router-link 跳转标签,当 a 标签使用,to 必须是一个绝对地址
// <router-link to="/goods/title"></router-link>
// 或者用编程式导航
this.$router.push({ path: "/goods/title" })
# 2.3 动态路由匹配
通过变化的地址去加载不同的信息,路径里用冒号声明参数段:
| 模式 | 匹配路径 | $route.params |
|---|---|---|
/user/:username |
/user/poetries |
{username:"poetries"} |
/user/:username/post/:post_id |
/user/poetries/post/123 |
{username:"poetries",post_id:"123"} |
原文表格第二行的匹配结果写成了 evan,和左边的路径对不上,这里改回一致的。另外注意 post_id 拿到的是字符串 "123" 而不是数字,URL 里没有类型这回事,要数字得自己转。
典型应用场景是商城的详情页,要变换商品的 id,根据商品的 id 去查对应商品的信息。
这里还有一个高频问题:从 /user/a 跳到 /user/b,两个路径匹配的是同一个组件,Vue 会复用组件实例而不是销毁重建,所以 created 和 mounted 都不会再跑一遍,你在里面写的请求也就不会重新发。解决办法是 watch 一下 $route,或者给 router-view 加一个以完整路径为值的 key 强制重建。前者性能好,后者写起来省事。
# 2.4 嵌套路由
嵌套路由就是路由里面再套路由,对应的是「页面里有一块区域自己还要切换」的布局:
new Router({
mode: "history",
routes: [
{
path: "/goods",
name: "GoodsList",
component: GoodsList,
children: [ // 定义子路由
{
path: "title", // 最终形式 /goods/title
name: "title",
component: Title
}
]
}
]
})
有一点容易漏:父组件 GoodsList 的模板里必须自己放一个 router-view,子路由的组件才有地方渲染。忘了放的话,地址变了但页面上什么都不显示,也不报错。
子路由的 path 不以斜杠开头时是相对父路径拼接的,写成 /title 就变成了绝对路径,会直接挂在根上。这个区别在配置多级菜单的时候经常出问题。
# 2.5 编程式导航
通过 JavaScript 来实现页面跳转:
// 方式一,直接传路径字符串
this.$router.push("/cart")
// 方式二,传对象
this.$router.push({ path: "/cart" })
// 方式三,路径里拼查询参数
this.$router.push({ path: "/cart?a=123" })
// 或者用 query 字段,更推荐
this.$router.push({ path: "/cart", query: { a: 123 } })
// 方式四,在历史记录里前进后退
this.$router.go(1) // 前进一步
this.$router.go(-1) // 后退一步,等价于 this.$router.back()
push 和 go 是两类东西。push 是往历史栈里压一条新记录,用户按返回键能回到原来的页面;go 是在已有的历史栈里移动。还有一个 replace,它替换当前记录而不是新增,登录成功后跳首页最适合用它,这样用户按返回不会又回到登录页。
拿传过去的参数:
this.$router.push("/cart?goodsId=123")
<!--在页面上拿 goodsId-->
<span>{{$route.query.goodsId}}</span>
原文这里写的是 $.route.query,中间多了一个点,是个笔误,正确的是 $route。顺带把两个容易混的对象说清楚:$router 是路由实例,用来做跳转这类动作;$route 是当前路由信息对象,用来读 path、params、query 这些数据。一个动词一个名词,记这个就不会搞反。
# 2.6 命名路由
有时通过一个名称来标识一个路由更方便,特别是在链接一个路由或者执行跳转的时候。可以在创建 Router 实例时,在 routes 配置中给某个路由设置名称:
new Router({
mode: "history",
routes: [
{
path: "/cart/:cartId",
name: "cart",
component: Cart
}
]
})
之前的跳转方式是写死路径: