1. 前端路由的两种模式

  • 在以前,前后端是不分离的,这个阶段通常是由服务端渲染好页面(SSR),再发送页面给前端去展示;
  • 接着到了前后端分离的阶段,前端向静态资源服务器拿资源,再通过 js 渲染页面,此时仍然是一个 url 对应一份 html+css+js;
  • 再后来出现了 SPA 单页面应用的概念,实际上它只有一个页面,但是能监听 url 的改变并切换不同的视图,因此给我们带来多页面的体验。

SPA 的路由跳转都是在前端实现的,它需要解决两个问题:

  • 如何监听 url 的改变,进而渲染不同的视图
  • 如何使 url 改变,但不刷新整个页面(这里如果刷新了是会真的向服务端发送请求的,而服务端路径下并没有对应的资源)

hash 模式

  • url 中带有 hash 符号(#)。url 只有改变了才会触发请求,而 hash 的改变并不在它的考虑范围,因此 hash 改变不会触发请求

  • 每一次改变 hash 后的部分,都会在浏览器的访问历史中增加一个记录 ,这使得我们可以来回切换

可以手动修改 url 的 hash、或者使用浏览器的前进后退按钮,或者修改 location.hash 的值,从而改变 hash 值。每次 hash 值改变的时候,会触发 window.onhashchange 事件,在这个事件处理函数里面根据不同的 hash 切换不同的视图

history 模式

  • HTML5 提供了新的 window.history API。页面导航带来的 url 改变只是视觉改变,同样不会触发请求
  • 由于历史记录是在 history 栈压入或弹出的,这使得我们可以来回切换

可以使用浏览器的前进后退按钮、或者调用 history.pushStatehistory.replaceState,从而改变 url。每次 url 改变的时候,必须伴随着一个出栈操作,因此会触发 window.onpopstate 事件,在这个事件处理函数里面根据不同的 url 切换不同的视图。

如何解决刷新 404 问题

首先,在 history 模式下,如果只是通过页面按钮等方式进行导航,那么 url 只会有一个视觉上的变化,而这种变化触发了相关的函数去更新视图,从而带来一种多页面的错觉。

但是如果在地址栏按下回车或者点击刷新按钮,那么浏览器会将当前 url 视为一个真正的 url 并发起请求,问题是服务器这边根本没有请求路径对应的资源(因为路由控制都是在前端这里进行的),这时候就会返回 404 的错误状态码了。那么怎么解决这个问题呢?

我们知道,Nginx 的作用就是配置浏览器向服务器发出请求时,什么样的请求路径应该返回什么样的资源,所以这个问题可以让 Nginx 来解决。如果说是直接请求域名,那么我们自然返回应用的入口文件 index.html 即可,之后用户导航的时候,再全权由前端进行路由控制;但如果说是像上面那样请求了一个服务器实际不存在的页面路径 —— 实际上所有的页面路径在服务器这里都是不存在的,那么我们就需要在 try_files 里进行配置,提供一个 fallback 选项,统一返回 index.html

2. 关于 vue-router

SPA 是基于路由和组件的,其中路由可以看作是它的一个路径管理器,路由和组件之间互相映射,路由的切换就是组件的切换。

Vue 的前端路由使用 vue-router 实现。实例化 vue-router 时会传入一个对象,可以给对象一个 option,如 mode:'history',从而决定 vue-router 使用 hash 模式还是 history 模式。

3. 安装

在安装 vue-cli 的时候可以顺便安装 vue-router,或者之后我们通过 npm install 的方式手动安装。

4. 使用

如果是通过脚手架安装 vue-router,src 下会多出一个 router 文件夹,里面的 index.js 帮我们生成了配置的基础结构。

index.js 大致是这样的:

// 导入要使用到的组件
import VueRouter from 'vue-router';     
import Vue from 'vue';                
import Home from '../components/Home.vue';
import About from '../components/About.vue';

Vue.use(VueRouter);        // 安装 vue-router

const routes = [                  // 配置路由和组件的映射关系
    {
       path:'/',
       redirect: '/home'
    },
    {
       path:'/home',
       component: Home
    },
    {
       path:'/about',
       component: About
    }
]

const router = new VueRouter({         // 实例化 VueRouter 对象
	routes
})

export default router;                // 导出 router 对象

同时,main.js 文件中,导入 router 并在 Vue 实例下挂载:

// main.js

import Vue from 'vue'
import App from './App.vue'
import router from './router'

Vue.config.productionTip = false

new Vue({
  router,
  render: h => h(App)
}).$mount('#app')

接下来通过 <router-link><router-view> 使用路由:

// App.vue

<div id="app">
	<router-link to="/home">首页</router-link>	    
	<router-link to="/about">关于</router-link>
	<router-view></router-view>
</div>

5. 路由嵌套(二级路由)

假设 home 路由下嵌套着两个子路由 newsmessage,即 /home/news/home/message,那么首先创建两个对应的组件,之后在 index.js 下配置好映射关系:

import HomeNews from '../components/HomeNews.vue'
import HomeMessage from '../components/HomeMessage.vue'

const routes = [                  // 配置 url 和组件的映射关系
    {
       path:'/',
       redirect: '/home'
    },
    {
       path:'/home',
       component:Home,
       children:[
            {
               path:'/',                 // 默认展示 news
               redirect: 'news'
            },
            {
               path: 'news',
               component: HomeNews
            },
            {
               path: 'message',
               component: HomeMessage
            }
	   ]
    },
    {
       path:'/about',
       component:About
    }
]

Home.vue 中使用子路由:

// Home.vue

<div>
    <h1>我是Home</h1>
    <router-link to="/home/news">news</router-link>
    <router-link to="/home/messaage">message</router-link>
    <router-view></router-view>
</div>

5. 动态路由匹配

有的时候,path 并不是固定的。比方 /user/Tom/user/Jack,这些 path 根据用户不同而不同,但都是展示用户页面的,我们希望满足这种格式的 path 都映射 User 组件,怎么办呢?可以在 path 中使用动态路径参数。

import User from '../components/User.vue'

const routes = [
	{
        path:'/user/:userId',
        component: User
	}   
]

注意,这里我们不再是写 /user/,而是写 /user/:userId,这是因为路径 user 后面的东西是动态的。那么,对于下面的三种 <router-link>,最后都可以成功映射到 User 组件:

// App.vue

<router-link to="/user/Tom"></router-link>           // 路径:/user/Tom
<router-link to="/user/Jack"></router-link>          // 路径:/user/Jack
<router-link :to="`/user/${userId}`"></router-link>   // 路径:/user/Bake
<router-view></router-view>

<script>
    export default{
        data(){
            return {
                userId:'Bake'
            }
        }
    }
</script>

如果想要在具体的 User.vue 中展示用户名,可以通过 $route.params.userId 获取当前路径中的用户名:

// User.vue

<div>我的用户名是:{{$route.params.userId}}</div>
<div>我的用户名是:{{id}}</div>

<script>
    export default{
        computed:{
            id(){
                return this.$route.params.userId;
            }
        }
    }
</script>

6. 路由传参

6.1 基于动态路由

实际上,上面讲的动态路由就可以用来传递参数。上面例子的 path 还可以根据需要添加更多动态路径参数,如 '/user/:userId/:userJob/:userEmail',首先在 App.vue 拿到数据,传给<router-link>to,接着就可以在 User.vue 中通过 $route.params 去访问了。

6.2 给 to 传入对象

首先要明白,to 除了接收字符串之外,也可以接受对象。以前面的动态路由为例:

<!--可以这样写(直接传字符串)-->
<router-link :to="`/user/${userId}`"></router-link>

<!--也可以这样写(传对象,直接用字符串表示完整路径)-->
<router-link :to="{path:'`/user/${userId}`'}"></router-link>

那么,我们会自然想到,对于一个普通的路由,要传参的话把参数放在给 to 传入的对象中不就可以了吗?

于是可能会这么写:

<router-link :to="{path:'/article',params:{date:'2020-1-1',title:'A new year'}}"></router-link>

然而这是错误的用法,事实上我们应该将 path 改为 name。不过在这之前,我们还是先给路由一个 name吧:

import Article from '../components/Article.vue'

// 首先给路由一个名字(命名路由)
const routes = [
    {
        path:'/article',
    	name:'article',
        component: Article
	}
]

接着使用路由:

<router-link :to="{name:'article',params:{date:'2020-1-1',title:'A new year'}}"></router-link>

获取参数:

<!--Article.vue-->
 
<div>{{$route.params.date}}</div>
<div>{{$route.params.title}}</div>

6.3 使用 query

这和上面的是差不多的,不同的是我们不使用 params,而是使用 query。query 实际上就是 url 中的查询参数。

这种情况下,我们给 to 传入的对象可以使用 path,也可以使用 name

<router-link :to="{name:'article',query:{date:'2020-1-1',title:'A new year'}}"></router-link>
<!--等价于-->
<router-link :to="{path:'/article',query:{date:'2020-1-1',title:'A new year'}}"></router-link>

获取参数:

<!--Article.vue-->
 
<div>{{$route.query.date}}</div>
<div>{{$route.query.title}}</div>

7. 路由跳转:声明式 VS 编程式

前面介绍的路由跳转/导航是通过声明式<router-link :to='...'> 实现的,我们也可以使用编程式this.$router.push('...') 实现:

// App.vue

<div id="app">
	<router-link to='/home'>可以点击这里进入首页</router-link> 
    <!--
	or: <router-link :to="{path:'/home'}">	
	-->
    <button @click="change">也可以点击这里进入首页</button>
    <router-view></router-view>
</div>

<script>
    export default{
        name:'App',
        methods:{
			change(){
                this.$router.push('/home');
              //or: this.$router.push({path:'/home'}) 
            }
        }
    }
</script>    

<router-link :to='...'> 实质上也是在内部调用了 push 方法,从而向 history 栈压入新记录,由于是栈的数据结构,所以可以自由前进和后退。除了 push,还有 replace(注意是直接替换而不是采用入栈出栈方式),go,这些和 window.history 的 API 是类似的。

this.$router.push('...') 同样接受字符串参数或者对象参数。

8. $router$route 的区别

  • $router 是我们 new 出来的 VueRouter 实例,它提供了一些跳转方法(pushreplacego)和钩子函数(后面导航守卫部分会讲解);

  • $route路由信息对象,可以理解为是当前活跃的路由,包括 path,params,hash,query,fullPath,matched,name 等路由信息参数。

9. 导航守卫

路由的导航守卫其实就是一些钩子函数,可以在路由跳转的流程中针对性地进行操作控制。

0. 参数说明

to:即将前往的路由

from:从哪个路由过来,或者即将离开的路由

next():不传参数,表示正常放行,执行流程中的下一个钩子;传入 false,表示中断路由导航;传入路径,表示跳转到一个指定路由

1. 全局守卫

  • 全局前置守卫:router.beforeEach((to,from,next) => {...})。可以在 index.js 或者路由组件中使用(通过 this.$router),next 必须调用。
  • 全局解析守卫:router.beforeResolve
  • 全局后置守卫:router.afterEach((to,from) => {...})。可以在 index.js 或者路由组件中使用(通过 this.$router),next 不需要调用。

2. 路由独享守卫

单个路由独享的守卫只有 beforeEnter 这一个,可以在配置路由时定义。

const router = new VueRouter({
  routes: [
    {
      path: '/home',
      component: Home,
      beforeEnter: (to, from, next) => {
        // ...
        next()  
      }
    }
  ]
})

3. 组件守卫

组件守卫只能在路由组件中定义:

  • beforeRouteEnter((to,from,next) => {...}): 进入路由前触发,此时实例还没创建,无法获取到 this
  • beforeRouteUpdate((to,from,next) => {...}) :路由复用同一个组件时触发,比如 /user/Tom/user/Jack
  • beforeRouteLeave((to,from,next) => {...}): 离开路由前触发,此时可以用来提醒用户,或保存数据,或关闭定时器等等

4. 导航解析流程

来自官网的图:

以页面跳转为例,导航守卫的执行顺序如下图:

5. 应用场景

(1)离开页面前提醒用户保存数据

在一些写作平台创作的时候,如果当前内容还没有保存就想跳转到其它页面,此时通常会有一个弹窗提示用户,这个可以用 beforeRouteLeave 来实现:

// Edit.vue
<script>
	export default {
        // 在用户准备离开当前路由时进行拦截
        beforeRouteLeave((to,from,next) => {
        	const isSaved = getSaveState()
            // 如果已经保存了内容,则正常跳转
            if(isSaved){
                next()
            }
    		// 否则弹窗提示是否想要跳转
    		else {
                if(window.confirm('还没有保存内容,确定要跳转吗?')){
                    next()
                } else {
                    next(false)
                }
            }
    	})
    }
</script>

(2)路由的动态参数改变时重新请求数据

/user/Tom/user/Jack 这样的动态路由都是匹配 /user/:userId 的,都会复用 User 组件,而组件本身只会在创建的时候请求一次用户数据,如何实现在切换不同的动态参数时重新请求数据呢?这时候就可以使用 beforeRouteUpdate 钩子函数,它会在路由的动态参数改变时触发。

<script>
	export default {
        // 从 /user/Tom 切换到 /user/Jack 的时候触发该钩子函数
        beforeRouteUpdate((to,from,next) => {
        	// 重新请求新的用户数据
        	this.getUserData(to.params.userId)
    	})
    }
</script>

(3)访问页面的权限校验

用户可能在没有登录的情况下试图访问个人主页或其它需要登录才能访问的页面,此时要让页面自动跳转到登录页,这种权限校验也可以通过导航守卫来实现。因为像这样游客无法访问的页面有很多,所以用 beforeEach 全局钩子统一进行拦截。

// router.js
const router = new VueRouter({
    routes: [
        {
            path: '/personal',
            // 添加路由元信息,表明该路由需要鉴权
            meta: {
                requireAuth: true
            }
        },
        {
            path: '/setting',
            meta: {
                requireAuth: true
            }
        }
    ]
})

router.beforeEach((to,from,next) => {
    const hasLogin = getLoginState()
    // 如果没有登录,并且访问的页面需要鉴权,则跳转到登录页
    if(!hasLogin && to.meta.requireAuth){
        next({
            path: '/login',
            // 跳转到登录页的时候顺便携带参数,这样用户登录成功后可以通过这个参数跳转到本来想要访问的目标页面
            query: {
                redirect: to.path
            }
        })
    } 
    // 如果已经登录了,则阻止用户访问登录页
    else if(hasLogin && to.path === '/login'){
        next(false)
    }    
    // 其它情况正常跳转
    else {
        next()
    }
})

如果一级路由需要权限校验,那么二级或者三级路由也同样需要,此时我们不得不给一级路由底下的每个子路由声明 requireAuth,这是非常麻烦的。不过还好,路由信息对象提供了一个 matched 数组,里面包含了与当前路由信息对象相关联的各个路由的记录。举个例子:

// 如果当前路由信息对象是 '/a',则:
matched = [{ path: '/a' }]
// 如果当前路由信息对象是 '/a/b',则:
matched = [
    {
        path: '/a'
    },
    {
        path: '/a/b'
    }
]
// 如果当前路由信息对象是 '/a/b/c',则:
matched = [
    {
        path: '/a'
    },
    {
        path: '/a/b'
    },
    {
        path: '/a/b/c'
    }
]

这意味着,我们可以只给一级路由声明 requireAuth,之后检测 to.matched中是否包含 requireAuth 为 true 的路由记录即可。只要包含这样的路由记录,则 to 一定属于需要鉴权的一级路由下面的子路由。

const router = new VueRouter({
    routes: [
        {
            path: '/setting',
            requireAuth: true,
            children: [
                {
                    path: '/setting/changeAccount'
                }
            ]
        }
    ]
})
router.beforeEach((to,from,next) => {
    const hasLogin = getLoginState()
    // 没有登录,且打算访问的页面需要鉴权,则跳转到登录页
    if(!hasLogin && to.matched.some(record => record.meta.requireAuth)){
        next({
            path: '/login',
            query: {
                redirect: to.path
            }
        })
    } else {
        next()
    }
})

10. 路由懒加载

懒加载也叫延迟加载,即在需要的时候进行加载,随用随载。在单页应用中,如果没有应用懒加载,运用 webpack 打包后的文件将会异常的大,导致进入首页时,需要加载的内容过多,延时过长,不利于用户体验;而运用懒加载则可以将页面进行划分,需要的时候加载页面,可以有效的分担首页所承担的加载压力,减少首页加载用时。

要使用路由懒加载,只需要将原来的 import 静态导入改为 import() 动态异步导入即可:

// import Home from '../components/Home';
// import About from '../components/About';

const Home = () => import('../components/Home');
const About = () => import('../components/About');

11. keep-alive

路由跳转的时候,比如 home -> about -> homehome 路由组件实际上是在不断地创建和销毁,我们可以用生命周期钩子函数证明这一点:

上图中,每次跳转的时候都会经历一次生命周期。但大部分时候,这种重新渲染是没有必要的,所以 Vue 提供了一个内置组件 keep-alive缓存组件内部状态,避免重新渲染

<keep-alive>
    <router-view></router-view>
</keep-alive>

keep-alive 包裹的路由/组件,状态会得到缓存。以上图为例,从 home 跳转到 about,home 不会被销毁,同样的,从 about 跳转到 home,about 不会被销毁,home 也不会被重新创建,而是用之前缓存好的组件。

  • keep-alive 提供了 activateddeactivated 两个钩子函数(在路由组件中定义),前者在当前路由组件激活时调用,后者在当前路由组件失活时调用。
  • keep-alive 提供了 3 个属性定义具体的缓存情况:
    • include 包含的组件(可以是字符串,数组,以及正则表达式,只有匹配的组件会被缓存)
    • exclude 排除的组件(可以是字符串,数组,以及正则表达式,任何匹配的组件都不会被缓存)
    • max 缓存组件的最大值(类型为字符或者数字,可以控制缓存组件的个数)

参考:

从头开始学习vue-router

可能比文档还详细–VueRouter完全指北