30分钟带你从Vue-Router的基本应用到模拟实现


Vue-Router原理实现

1、内容提要

  • Vue Router基础回顾(通过Vue Router的基本使用方式来分析他的实现)
  • Hash 模式和 History 模式(History模式要结合服务器一起使用)
  • 模拟实现自己的 Vue Router

2、Vue Router 基础回顾

router/index.js

import Vue from 'vue'
import VueRouter from 'vue-router'
import Index from '../views/Index.vue'
// 1. 注册路由插件
// Vue.use 用来注册插件,他里面需要接收一个参数,
// 如果这个参数是函数的话,Vue.use内部会直接调用这个函数来注册组件,
// 如果传入对象的话,Vue.use会调用传入对象的install方法来注册插件
Vue.use(VueRouter)

// 路由规则
const routes = [
  {
    path: '/',
    name: 'Index',
    component: Index
  },
  {
    path: '/blog',
    name: 'Blog',
    // route level code-splitting
    // this generates a separate chunk (about.[hash].js) for this route
    // which is lazy-loaded when the route is visited.
    component: () => import(/* webpackChunkName: "blog" */ '../views/Blog.vue')
  },
  {
    path: '/photo',
    name: 'Photo',
    // route level code-splitting
    // this generates a separate chunk (about.[hash].js) for this route
    // which is lazy-loaded when the route is visited.
    component: () => import(/* webpackChunkName: "photo" */ '../views/Photo.vue')
  }
]
// 2. 创建 router 对象
const router = new VueRouter({
  routes
})

export default router

main.js

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

Vue.config.productionTip = false

new Vue({
  // 3. 注册 router 对象,在创建vue实例的时候,如果配置了router选项,
  // 他会给vue实例注入两个属性,一个是$route,路由规则以及$router路由对象,
  // 我们通过路由对象可以调用一些相应的方法,比如push,back,go,等
  router,
  render: h => h(App)
}).$mount('#app')

App.vue

<template>
  <div id="app">
    <div>
      <img src="@/assets/logo.png" alt="">
    </div>
    <div id="nav">
      <!-- 5. 创建链接 -->
      <router-link to="/">Index</router-link> |
      <router-link to="/blog">Blog</router-link> |
      <router-link to="/photo">Photo</router-link>
    </div>
    <!-- 4. 创建路由组件的占位,当路径匹配到一个组件后,
	会把这个组件加载进来,并最终会替换掉 router-view 所处位置 -->
    <router-view/>
  </div>
</template>

<style>
#app {
  font-family: Avenir, Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
}

#nav {
  padding: 30px;
}

#nav a {
  font-weight: bold;
  color: #2c3e50;
}

#nav a.router-link-exact-active {
  color: #42b983;
}
</style>

总结:

  • 首先要去创建一些和路由相关的组件,也就是我们的视图。
  • 注册路由插件,调用Vue.use去注册vue-router。
  • 创建一个router对象,在创建路由对象的时候,我们要去配置一些路由规则。
  • 注册router对象,也就是我们在创建vue实例的时候要在选项里配置我们创建好的router对象。
  • 通过router-view来设置占位,当我们路径匹配成功之后,会把匹配到的组件替换掉router-view的位置。
  • 最后,我们通过router-link来创建一些链接。

3、动态路由

动态路由传参的两种方式:

router/index.js

import Vue from 'vue'
import VueRouter from 'vue-router'
import Index from '../views/Index.vue'

Vue.use(VueRouter)

const routes = [
  {
    path: '/',
    name: 'Index',
    component: Index
  },
  {
    // 路径中携带参数,:id相当于占位符,将来真正在使用时会传入对应id
    path: '/detail/:id',
    name: 'Detail',
    // 开启 props,会把 URL 中的参数传递给组件
    // 在组件中通过 props 来接收 URL 参数
    props: true,
    // route level code-splitting
    // this generates a separate chunk (about.[hash].js) for this route
    // which is lazy-loaded when the route is visited.
    // 路由懒加载,当用户访问时才会去加载对应组件
    component: () => import(/* webpackChunkName: "detail" */ '../views/Detail.vue')
  }
]

const router = new VueRouter({
  routes
})

export default router

views/Detail.vue

<template>
  <div>
    <!-- 动态路由传参方式1:通过当前路由规则,获取数据,但这种方式不太好,
	会让当前组件强依赖与路由,即在使用该组件时必须有路由传递相应参数。 -->
    通过当前路由规则获取:{{ $route.params.id }}

    <br>
    <!-- 动态路由传参方式2:路由规则中开启 props 传参,与父子组件传值的方式一样,
	不依赖与路由规则,所以该组件可以使用在任何地方,只需要传递一个id参数即可。推荐使用 -->
    通过开启 props 获取:{{ id }}
  </div>
</template>

<script>
export default {
  name: 'Detail',
  props: ['id']
}
</script>

<style>

</style>

4、嵌套路由

当多个路由组件都有相同内容时,我们可以把这些相同的内容提取到一个公共的组件中。

嵌套路由

在上图中,首页和详情页都有相同的头和尾,所以我们可以提取一个新组件layout,然后把头和尾放到layout中。而对于变化的位置,我们放一个router-view去占位,将来若我们去访问首页的地址的时候,他会把我们layout组件和首页的组件合并输出。

首页代码:views/Index.vue

<template>
  <div>
    这里是首页 <br>
    <router-link to="login">登录</router-link> |

    <router-link to="detail/5">详情</router-link>
  </div>
</template>

<script>
export default {
  name: 'Index'
}
</script>

<style>
</style>

详情页代码:views/Detail.vue

<template>
  <div>
    <!-- 方式1: 通过当前路由规则,获取数据 -->
    通过当前路由规则获取:{{ $route.params.id }}

    <br>
    <!-- 方式2:路由规则中开启 props 传参 -->
    通过开启 props 获取:{{ id }}
  </div>
</template>

<script>
export default {
  name: 'Detail',
  props: ['id']
}
</script>

<style>
</style>

登录页代码:Login.vue

<template>
  <div>
    这是登录页面
    <br>
    <router-link to="/">首页</router-link>
  </div>
</template>

<script>
export default {
  name: 'Login'
}
</script>

<style scoped>
</style>

Layout代码:components/Layout.vue

<template>
  <div>
    <div>
      <img width="25%" src="@/assets/logo.png">
    </div>
    <div>
      <router-view></router-view>
    </div>
    <div>
      Footer
    </div>
  </div>
</template>

<script>
export default {
  name: 'layout'
}
</script>

<style scoped>
</style>

上面的首页和详情页与之前的代码是一样的,但现在还有一个要求就是首页和详情页都需要有相同的头和尾。所以我们把相同的头和尾提取到了components/Layout.vue中,在Layout.vue中用一张图片作为头,用内容为Footer的div标签来作为尾。除了这些相同的部分之外,我们的首页和详情页也有自己独特的内容,这些都用router-view去占位,将来运行的时候,首先会去加载Layout组件然后再去加载首页或详情页,把我们首页或详情页对应的组件替换掉router-view的位置。而Login.vue组件是不需要有相同的头和尾,所以不需要使用嵌套路由。

接下来再看看路由是如何配置的:

router/index.js

import Vue from 'vue'
import VueRouter from 'vue-router'
// 加载组件
import Layout from '@/components/Layout.vue'
import Index from '@/views/Index.vue'
import Login from '@/views/Login.vue'

Vue.use(VueRouter)

const routes = [
  {
    name: 'login',
    path: '/login',
    component: Login
  },
  // 嵌套路由
  {
    path: '/',
    component: Layout,
    // 嵌套路由在使用的时候,会把我们外部路径和我们children里配置的路由规则中的路径进行合并,
    // 然后再分别去加载我们的Layout组件,然后再加载我们对应的组件。
    children: [
      {
        name: 'index',
        // children里面的path在配置的时候可以是相对路径也可以是绝对路径,像这里的空字符串就是相对路径
        // 这里用绝对路径可以是'/'
        path: '',
        component: Index
      },
      {
        name: 'detail',
        // 这里用绝对路径可以是'/detail/:id'
        path: 'detail/:id',
        props: true,
        component: () => import('@/views/Detail.vue')
      }
    ]
  }
]

const router = new VueRouter({
  routes
})

export default router

在router/index.js中,代码和之前都基本一样,我们主要来看一下路由规则是如何配置的。

在路由规则里我们首先配置的是登录页面,当访问登录页面的时候,直接去加载我们对应的Login.vue组件。

接下来是加载首页和详情页,因为我们的首页和详情页都有相同的头和尾,而我们的头和尾都提取到了Layout.vue组件中来,即我们使用了嵌套路由。

而嵌套路由在使用的时候,会把我们外部路径和我们children里配置的路由规则中的路径进行合并,然后再分别去加载我们的Layout组件,然后再加载我们对应的组件,把他们合并到一起。当我们外层的path是根路径的话,children里面首页的path可以写成空字符串,另外,children里面的path在配置的时候可以是相对路径也可以是绝对路径,像首页里的空字符串就是相对路径。使用相对路径时,当我们访问根的时候,会先加载layout,再去加载index,然后合并到一起,渲染出来。接下来再看详情页的配置,我们外面的path是斜杆,detail里配置的是相对路径,我们详情页的路径其实就是外部path拼接里面的path。

5、编程式导航

我们在页面跳转的时候,使用的是router-link超链接,但我们做登录页面的时候,需要点击按钮跳转到首页,这时候就需要使用编程式导航,调用$router.push方法。下面我们一起回顾一下编程式导航常用的一些方法。

views/Login.vue

<template>
  <div>
    用户名:<input type="text" /><br />
    密&nbsp;&nbsp;码:<input type="password" /><br />

    <button @click="push"> push </button>
  </div>
</template>

<script>
export default {
  name: 'Login',
  methods: {
    push () {
      // 调用$router.push的时候,参数可以接收两种方式,
      // 第一种方式是字符串,即我们需要跳转的路由地址
      this.$router.push('/')
      // 第二种方式是传对象,需要设置路由的name,
      // name是由router/index.js中的name定义的,称为命名式导航
      // this.$router.push({ name: 'Home' })
    }
  }
}
</script>

<style>

</style>

router/index.js

import Vue from 'vue'
import VueRouter from 'vue-router'
import Index from '../views/Index.vue'

Vue.use(VueRouter)

const routes = [
  {
    // 在router/index.js的路由选项中,除了设置path以外,还起了一个name,称为命名式导航
    path: '/',
    name: 'Index',
    component: Index
  },
  {
    path: '/login',
    name: 'Login',
    // route level code-splitting
    // this generates a separate chunk (about.[hash].js) for this route
    // which is lazy-loaded when the route is visited.
    component: () => import(/* webpackChunkName: "about" */ '../views/Login.vue')
  },
  {
    path: '/detail/:id',
    name: 'Detail',
    props: true,
    component: () => import(/* webpackChunkName: "detail" */ '../views/Detail.vue')
  }
]

const router = new VueRouter({
  routes
})

export default router

views/Index.vue

<template>
  <div class="home">
    <div id="nav">
      <router-link to="/">Index</router-link>
    </div>
    <button @click="replace"> replace </button>

    <button @click="goDetail"> Detail </button>
  </div>
</template>

<script>
export default {
  name: 'Index',
  methods: {
    replace () {
      // replace方法和push方法有些类似,都可以跳转到我们指定的路径,参数形式也是一样的
      // 不过replace方法不会记录本次历史,而会把我们当前的历史改变成/login
      this.$router.replace('/login')
    },
    goDetail () {
      // 在push中通过params来指定路由参数
      this.$router.push({ name: 'Detail', params: { id: 1 } })
    }
  }
}
</script>

views/Detail.vue

<template>
  <div>
    路由参数:{{ id }}

    <button @click="go"> go(-2) </button>
  </div>
</template>

<script>
export default {
  name: 'Detail',
  props: ['id'],
  methods: {
    go () {
      // go是跳转到历史中的某一次,可以是负数,即后退,如果是-1的话,和$router.back的效果是一样的
      this.$router.go(-2)
    }
  }
}
</script>

<style>

</style>

6、Hash 模式和 History 模式的区别

首先,我们要强调不管是Hash模式还是History模式,都是客户端路由的实现方式,也即当路由发生变化时,不会向服务器发送请求,而是用JS接收路径的变化,然后根据不同的地址,渲染不同的内容。如果需要服务器端内容的话,会发送AJAX请求来获取。

表现形式的区别

注意用History模式还需服务端配置。

原理的区别

  • Hash模式是基于锚点,以及onhashchange事件,通过锚点的值作为路由地址,当地址变化后触发onhashchange事件,然后根据路径决定页面呈现的内容。
  • History模式是基于HTML5的History API
    • history.pushState() IE 10以后才支持(与history.push的区别是push会改变路径并向服务器发送请求,而pushState只改变路径并记录到历史记录中,并不会发送请求「所有事情都在客户端完成」)
    • history.replaceState

开启History模式

const router = new VueRouter({
    // mode: 'hash',
    mode: 'history',
    routes
})

7、History模式

  • History模式需要服务器的支持
  • 在单页应用中,服务端不存在 http://www.testurl.com/login 这样的地址,会返回找不到该页面。
  • 在服务器端应该除了静态资源外都返回单页应用的 index.html

先来看以下代码:

views/404.vue,当用户访问不存在的路由地址的时候,就会输出该组件的内容。

<template>
  <div>
    您要查看的页面不存在
  </div>
</template>

<script>
export default {

}
</script>

<style>

</style>

App.vue,在App.vue中放了三个链接,前两个路由地址是存在的,当访问/video地址时,因没有配置该路由地址,所以会输出views/404.vue组件中的内容。

<template>
  <div id="app">
    <div id="nav">
      <router-link to="/">Home</router-link> |
      <router-link to="/about">About</router-link> |
      <router-link to="/video">Video</router-link>
    </div>
    <router-view/>
  </div>
</template>

<style>
#app {
  font-family: Avenir, Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
}

#nav {
  padding: 30px;
}

#nav a {
  font-weight: bold;
  color: #2c3e50;
}

#nav a.router-link-exact-active {
  color: #42b983;
}
</style>

router/index.js,路由的配置和之前大致是一样的,首先配置了首页的路由,然后又配置了/about页面的路由,但没有/video的路由地址,如果我们请求的路径不匹配这两个路由地址的话,那此时就会输出404页面。

import Vue from 'vue'
import VueRouter from 'vue-router'
import Home from '../views/Home.vue'

Vue.use(VueRouter)

const routes = [
  {
    path: '/',
    name: 'Home',
    component: Home
  },
  {
    path: '/about',
    name: 'About',
    // route level code-splitting
    // this generates a separate chunk (about.[hash].js) for this route
    // which is lazy-loaded when the route is visited.
    component: () => import(/* webpackChunkName: "about" */ '../views/About.vue')
  },
  {
    path: '*',
    name: '404',
    component: () => import(/* webpackChunkName: "404" */ '../views/404.vue')
  }
]

const router = new VueRouter({
  // 当创建VueRouter的时候,配置了mode选项,把他的值设置为history。
  // 因为默认情况下,他是hash模式,我们想要演示history模式就需要将mode改为'history'
  mode: 'history',
  routes
})

export default router

注意Vue-cli自带的web服务器已经配置好了对history模式的支持,所以我们使用vue-cli自带的web服务器时不会出现因服务器没配置history模式兼容而导致找不到文件的问题。所以我们还需要对项目进行打包到Node服务器或Nginx服务器上来。

8、History 模式 - Node.js

node代码

const path = require('path')
// 导入处理 history 模式的模块
const history = require('connect-history-api-fallback')
// 导入 express
const express = require('express')

const app = express()
// 注册处理 history 模式的中间件
app.use(history())
// 处理静态资源的中间件,网站根目录 ../web
app.use(express.static(path.join(__dirname, '../web')))

// 开启服务器,端口是 3000
app.listen(3000, () => {
  console.log('服务器开启,端口:3000')
})

将网站源代码打包后放在node代码文件所在文件夹上一层的web文件夹下,然后运行node文件。

在上面的代码中app.use(history())这行代码就是注册处理 history 模式的中间件,让服务端开启history模式。当我们去掉这一行并重新开启node服务器时,每次刷新页面,都会向服务端发起对应的请求,若服务端中没有相应的页面则会出错。而当我们开启服务端history模式支持的时候,我们每次刷新浏览器向服务器发送请求时,因我们开启了history模式的支持,服务器便会判断当前请求的页面服务器上有没有,如果没有,则会将我们单页应用默认的首页index.html返回给浏览器。浏览器接收到页面后会再去判断路由地址,加载对应组件内容并渲染到页面中。

9、History 模式 - nginx

Nginx服务器配置history模式步骤:

  • 从官网下载 Nginx 的压缩包

  • 把压缩包解压到 c 盘根目录,c:\nginx-1.23.1 文件夹

  • 修改nginx文件夹下的 conf\nginx.conf 文件:

location / {
	#指定当前网站所在根目录
    root html;
    #指定当前网站默认的首页
    index index.html index.htm;
    #新添加内容
    #尝试读取$uri(当前请求的路径),如果读取不到读取$uri/这个文件夹下的首页
    #如果都获取不到返回根目录中的 index.html
    try_files $uri $uri/ /index.html;
}
  • 将打包好的前端项目拷贝到nginx文件夹下的html文件夹中(直接替换index.html)
  • 打开命令行,切换到 c:\nginx-1.23.1 文件夹。输入start nginx启动Nginx。注意Nginx会在后台启动,不会阻塞命令行运行。
  • 补充命令:nginx -s reload:重启Nginx(当修改了配置文件,则需要重启Nginx。)。nginx -s stop:停止Nginx

10、VueRouter 实现原理

复习完 Vue-Router 的基本使用后,接下来我们自己模拟一个 Vue Router,通过模拟 Vue Router 了解它的实现原理。(模拟中使用history模式)

Vue 前置知识:

  • 插件
  • 混入
  • Vue.observable()
  • 插槽
  • render 函数
  • 运行时和完整版的 Vue

回顾Vue-router的实现原理:Vue-router是前端路由,当路径切换的时候,在浏览器端判断当前路径,并加载当前路径对应的组件。

回顾 Hash 模式:

  • 把URL中 # 后面的内容作为路径地址,可以直接通过location.url来切换浏览器中的地址,如果只改变了#后的内容,浏览器不会向服务器请求这个地址,但会记录到浏览器的访问历史中。
  • 监听 hashchange 事件,当hash发生改变后,会触发hashchange事件,在hashchange事件中,我们记录当前路由地址,并找到该路径对应的组件重新渲染。

回顾 History 模式:

  • History模式的路径就是一个普通的URL,我们通过history.pushState()方法改变地址栏,并把当前地址记录到浏览器的访问历史中,不会真正跳转到指定路径(浏览器不会向服务器发送请求)。
  • 通过监听 popstate 事件,可以监听到浏览器历史操作的变化,在popstate的事件处理函数中可以记录改变后的地址。需要注意的是,当调用pushState或replaceState的时候,并不会触发popstate事件。当点击浏览器的前进,后退按钮或调用history的back或forward方法的时候该事件才会被触发。
  • 当地址改变后,要根据当前的地址,找到对应的组件进行重新渲染。

11、VueRouter 模拟实现-分析

首先,我们来分析一下如何实现一个自己的VueRouter。我们先来回顾一下VueRouter是如何使用的,通过VueRouter的使用我们可以快速分析出如何去实现。

下面是VueRouter在使用时的核心代码:

// router/index.js
// 调用Vue.use()去注册插件,Vue功能的强大主要就在于他的插件机制,VueRouter、VueX等组件都是通过插件去实现的
// Vue.use方法可以传入函数或对象,如果传入函数的话,Vue.use内部会直接调用这个函数,
// 如果传入对象的话,Vue.use内部会调用传入对象的 install 方法,
// 在这里传入的是一个对象,所以后续我们要去实现一个install方法。
Vue.use(VueRouter)
// 创建路由对象,可以看出VueRouter是一个构造函数或一个类,我们可以用类去实现,
// 并且这个类也应该有一个静态的install方法,因为上面我们直接把VueRouter传给了Vue.use,类也是对象
const router = new VueRouter({
    // 创建路由对象
    // VueRouter的构造函数需要接收一个参数,里面是对象的形式,有路由规则,用来记录路由名称,地址和组件等信息
    routes: [
    	{ name: 'home', path: '/', component: homeComponent }
    ]
})

// main.js
// 创建 Vue 实例,注册 router 对象
new Vue({
    router,
    render: h => h(App)
}).$mount('#app')

从上面的分析我们可以抽象出VueRouter的类图:

VueRouter的类图

类图是用来描述这个类中所有成员的,有了类图,我们剩下的工作就比较简单了,就是实现这个类中的属性和方法。

这个类图中有三部分,最上面是类的名字,中间部分是类的属性,最后是类中的方法。

  • 其中options属性的作用是记录构造函数中传入的对象。

  • routeMap是一个对象,用来记录我们路由地址和组件的对应关系,将来我们会把路由规则解析到routeMap中来。

  • data是一个对象,里面有一个属性current用来记录当前路由地址,此处设置data对象的目的是,我们需要一个响应式的对象,即data要是一个响应式的对象,因为路由地址发生变化后,对应的组件需要自动更新。我们可以调用Vue.observable方法把data对象设置成响应式对象。

  • 再往下是我们VueRouter中的方法,“+”代表这是对外公开的方法,“_”代表这是静态的方法。可以看到install就是一个静态的方法,它用来实现Vue的插件机制,我们会首先来实现这个方法。

  • 之后是构造函数Constructor,构造函数中帮我们初始化刚刚我们说过的属性,然后是几个初始化的方法。

  • init方法是用来调用下面的三个方法,这里是把不同的代码分割到不同的方法中去实现。

  • initEvent方法是用来注册popState事件,用来监听浏览器历史的变化。

  • CreateRouteMap方法是用来初始化routeMap属性的,它把构造函数中传入的路由规则转换成键值对的形式存储到routeMap中来。routeMap对象的键是路由的地址,值就是对应的组件,我们在router-view组件中会使用到routeMap。

  • 最后是initComponents方法,他用来创建router-link和route-view这两个组件。

12、VueRouter-install

下面就让我们开始自己来实现一个简单的VueRouter吧!(注意我们实现的VueRouter只需要接收routes路由地址。)

首先我们创建VueRouter中的静态方法install,当使用Vue.use注册插件的时候会调用install。

// 先声明一个全局变量_Vue,将来保存Vue构造函数
let _Vue = null
export default class VueRouter {
    // Vue.use中调用install方法的时候会传递两个参数,一个是Vue的构造函数,第二个是可选的选项对象
    static install (Vue) {
    // 1、如果插件已经安装直接返回
    if (VueRouter.install.installed && _Vue === Vue) return
    VueRouter.install.installed = true
    // 2、把Vue构造函数记录到全局变量中来,因为将来我们在VueRouter实例方法中还要使用该构造函数,
    // 如我们在创建router-view,router-link这两个组件的时候就要调用Vue.component去创建
    _Vue = Vue
    // 全局注册混入对象,并设置一个beforeCreate。
    // 在beforeCreate这个钩子函数中就可以获取到Vue实例,并给其原型上注入$router属性
    _Vue.mixin({
        beforeCreate () {
            // 注意当前混入的对象在所有Vue实例中都会有,即组件中也会存在beforeCreate,
            // 将来执行时beforeCreate就会执行很多次,而我们给Vue原型上挂载$router只希望执行一次
            // 所以我们还需要判断如果是组件的话就不执行,如果是Vue实例才执行。
            // 只需要判断当前实例的$options是否有router即可,因为只有Vue的$options选项中才有router属性,组件中是没有的。
            if (this.$options.router) {
                // 3、把 router 对象注入到所有 Vue 实例上(并且所有组件也都是Vue实例)
                _Vue.prototype.$router = this.$options.router
                }
            }
        })
    }
}

13、VueRouter-构造函数

在完成install后,我们再来实现VueRouter构造函数。通过之前的类图分析我们可以知道构造函数需要接收一个参数,options选项,并返回VueRouter对象。在构造函数中我们需要初始化3个属性:options、data以及routeMap,其中data是一个响应式的对象,因为data中需要存储当前的路由地址,当路由变化的时候要自动加载组件,所以data需要设置成响应式的对象。

// 构造函数需要接收一个options参数
constructor (options) {
    // 初始化options,记录传入的选项
    this.options = options
    // routeMap对象用来存储options中的routes路由规则,其中键是路由地址,值为路由组件。
    // 将来在router-view组件里会根据当前的路由地址在routeMap里找到对应组件并渲染到浏览器中来
    this.routeMap = {}
    // data应该是一个响应式的对象,其中有一个current属性用来记录当前我们的路由地址。
    // 可以使用Vue中提供的observable方法去创建一个响应式的对象,并可以直接用在渲染函数或计算属性里。
    this.data = _Vue.observable({
        // 当前的默认路径
        current:"/"
    })
}

14、VueRouter-createRouteMap

接下来,让我们再去实现一下createRouteMap方法。实现这个方法之前先回顾一下类图中提到的内容,createRouteMap方法的作用是把我们构造函数中传过来的选项中的routes路由规则转换为键值对的形式存储到routeMap里。routeMap中的键存储的是路由地址,值就是该路由地址对应的组件,将来在路由地址发生变化的时候,我们可以很容易地根据这个地址来routeMap这个对象里找到对应组件并渲染到视图中来。

createRouteMap(){
    //遍历所有的路由规则,并把路由规则解析成键值对的形式存储到routeMap中
    this.options.routes.forEach(route => {
        this.routeMap[route.path] = route.component
    });
}

本节,我们将会去实现initComponent方法,这个方法中,我们要创建两个组件,分别是router-link和router-view,我们先来实现router-link组件、

// initComponent方法接收一个参数,Vue的构造函数,通过参数传递而非直接使用之前保存的_Vue是为了减少这个方法和外部的依赖
initComponent(Vue){
    // router-link组件需要接收一个字符串类型的参数to,即超链接的地址。
    // 而最终渲染的内容在router-link标签之间。即将来我们要把router-link这个标签的内容渲染到a标签中去。
    // 使用Vue.component去创建组件,第一个参数是组件的名称,第二个参数是组件的选项
    Vue.component("router-link",{
        props:{
            to:String
        },
        template: '<a :href="to"><slot></slot></a>'
    })
}

在创建完router-link组件后,我们可以测试一下,在测试时我们需要先执行createRouteMap以及initComponents这两个初始化方法。但直接调用这两个方法会有些不方便,所以我们再创建一个init方法,来帮我们把这两个方法包装起来,让外部使用更方便些。

init(){
    this.createRouteMap()
    // 传入Vue构造函数,即全局变量_Vue
    this.initComponent(_Vue)
}

之后我们可以回到install方法中,调用init方法:

static install(Vue){
    if(VueRouter.install.installed){
        return;
    }
    VueRouter.install.installed = true
    _Vue = Vue
    _Vue.mixin({
        beforeCreate(){
            if(this.$options.router){
                _Vue.prototype.$router = this.$options.router
                // 调用init方法
				this.$options.router.init()
            }

        }
    })
}

之后在初始化vue项目的./src/router/index.js中导入我们自己的vuerouter

import Vue from 'vue'
// 导入我们自己的vuerouter
import VueRouter from '../vuerouter'
import Home from '../views/Home.vue'

Vue.use(VueRouter)

const routes = [
  {
    path: '/',
    name: 'Home',
    component: Home
  },
  {
    path: '/about',
    name: 'About',
    // route level code-splitting
    // this generates a separate chunk (about.[hash].js) for this route
    // which is lazy-loaded when the route is visited.
    component: () => import(/* webpackChunkName: "about" */ '../views/About.vue')
  },
]

const router = new VueRouter({
  mode: 'history',
  routes
})

export default router

之后打开终端,输入npm run serve启动服务器并打开相关地址,之后我们发现会报一个“模板编译器不可用”的错误。这是因为Vue的版本包括运行时版和完整版,运行时版本不支持template模板,需要打包的时候提前编译,把template编译成render函数。完整版包含运行时和编译器,但体积会比运行时版大10K左右,程序运行时会把模板转换成render函数,性能不如运行时版本。vue-cli创建的项目默认使用的是运行时版本的Vue,所以会出现之前的错误。下面我们会尝试使用两种方法去解决这个问题。

16、VueRouter-完整版的Vue

本节中通过使用完整版本的Vue去解决之前的问题。解决方法很简单,参考官方网站:配置参考 | Vue CLI (vuejs.org),即我们需要在项目根目录创建 vue.config.js 文件,并添加 runtimeCompiler属性:

// 项目根目录/vue.config.js
module.exports = {
	runtimeCompiler: true
}

之后关闭服务器并重新输入npm run serve启动服务器后就能正常显示内容了,并且我们也可以看到router-link组件经过渲染后也都变成了a标签了。

17、VueRouter-render

在使用完整版的Vue解决问题之后,让我们再来用另一种方式,解决之前的问题。我们知道运行时版本的Vue不带编译器,也即不支持组件中的template选项,但我们可以直接手写render函数来在运行时版本的Vue中运行。另外注意我们在使用单文件组件时,可以直接写template模板而正常工作的原因是在打包的过程中,会先经过预编译将单文件组件的template编译成render函数

下面我们来使用render函数去解决之前的问题(记得删除之前的 vue.config.js 并重启服务器)

initComponent(Vue){
    Vue.component("router-link",{
        props:{
            to:String
        },
        // render函数是渲染函数,这个函数有一个参数,一般命名为h,这个h函数的作用是帮我们创建虚拟DOM,
        // render函数中调用这个h函数并把它的结果返回,这个h函数是Vue传给我们的
        render(h){
            // h函数的使用方法有很多,这里我们只演示其中一种,h函数可以接收三个参数,
            // 第一个参数是我们要创建的元素对应的选择器,可以直接使用标签选择器。
            // 第二个参数可以给标签设置一些属性,设置属性的时候,DOM对象的属性用attrs
            // 第三个参数可以设置生成的元素中的子元素,所以是数组的形式
            return h("a",{
                attrs:{
                    // 这里因为我们实现的是history模式,所以不需要拼‘#’号
                    href: this.to
                },
                // 这里通过代码方式获取默认slot内容并放进数组中来(这里是a标签中的内容)
            },[this.$slots.default])
        },
    })
}

之后我们打开浏览器,可以发现结果和之前是一样的。

18、VueRouter-router-view

接下来,我们再创建router-view组件。router-view组件相当于一个占位符,在router-view组件内部我们要根据当前路由地址获取到对应的路由组件并渲染到router-view中的位置中来。

initComponent(Vue){
	// ......router-link......

    // 用self去保存this来供后面获取当前路由地址使用
    const self = this
    // 我们还是调用Vue.component去创建组件
    // 在router-view组件中,我们要获取到当前路由地址对应的组件,我们可以直接在render函数中完成
    Vue.component("router-view",{
        render(h){
            // 在render函数内部,我们先要找到当前路由的地址,
            // 再根据当前路由的地址去routeMap对象中找我们路由地址中对应的组件,
            // 之后再调用h函数,帮我们把找到的组件转换成虚拟DOM直接返回。
            // 这里h函数还可以直接把一个组件转成虚拟DOM。
            // 注意这里的this是在render函数中,所以之前需要先用self保存能获取当前路由地址的this
            const cm = self.routeMap[self.data.current]
            return h(cm)
        }
    })
}

我们打开浏览器测试一下效果,会发现每次点击超链接时浏览器都会直接向服务器发送请求,而在现在的单页应用中我们是不希望浏览器向服务器发请求的,我们想在客户端去处理路径变化的操作。router-view虽然写完了,但想要的效果还没达到,这里明显是缺一件事情,我们可以先分析一下。

因我们每次点击超链接的时候,浏览器都会向服务器发送请求,而在单页应用中,我们不希望浏览器发送请求,所以我们需要给超链接注册一个点击事件,取消超链接后续内容的执行,不让其跳转,并且同时我们还要将地址栏中的内容改成href中的值。

我们要改地址栏中的地址,又不让浏览器去请求服务器,这时我们应当能想到一个方法,history的pushstate方法。history的pushstate方法会改变地址栏中的路径,但不会向服务器发送请求,还会把这次路径记录到历史中来,另外pushState仅仅只是帮我们把浏览器中的地址改变了,对于显示的内容还需要我们去处理。即我们还需要把当前地址记录到我们的this.data.current中来,因current属性的作用就是记录当前地址的,且this.data是响应式的数据,也即当其发生变化时,会去自动更新视图。我们通过observable创建的响应式对象,可以用在我们组件的渲染函数,也就是render函数中。

接下来,根据之前的分析,我们要去处理一下超链接,给它注册一个点击事件,阻止它默认行为的执行。

initComponent(Vue){
    Vue.component("router-link",{
        props:{
            to:String
        },
        render(h){
            // 之前我们提到,h函数的第二个参数是给对应的DOM对象注册属性的,
            // 其实,我们也可以给DOM对象来注册事件
            return h("a",{
                attrs:{
                    href: this.to
                },
                // 用on属性给对应DOM对象注册事件,click表示点击事件,clickhander在methods中定义
                on:{
                    click: this.clickhander
                }
            },[this.$slots.default])
        },
        methods:{
            clickhander(e){
                // 先调用pushState方法来改变浏览器的地址栏(不会向服务器发送请求)。
                // pushState方法有三个参数,第一个参数data对象是将来去触发popState时传给popState事件对象的参数,
                // 第二个title表示网页标题,第三个url表示当前超链接要跳转的地址
                history.pushState({},"",this.to)
                // 把当前路径记录到data.current中,注意我们之前注册插件的时候把$router属性挂载到了原型属性上,
                // 所以所有的Vue实例都可以访问到$router对象。
                // 又因为data是响应式的对象,所以当其改变后会重新加载对应组件。
                this.$router.data.current = this.to
                // 用preventDefault方法来阻止事件的默认行为
                e.preventDefault()
            }
        }
    })
    
    // ......router-view......
}

到此,我们router-view组件就实现完毕了,看上去我们的功能都已经实现,但其实这里还有部分问题,就是当我们点击浏览器中的后退与前进按钮时,浏览器中的内容并没有发生改变,而对于该问题的解决方法我们将在下一节中填坑。

19、VueRouter-initEvent

最后,我们再来填上上一节中埋下的坑,即如何处理当点击浏览器中的后退与前进按钮时,浏览器中的内容并没有发生改变的问题。也即实现类图中的最后一个方法initEvent,在这个方法中我们要来注册一个事件popState,当地址栏改变时,改变data.current使浏览器加载对应的组件并渲染出来。popState事件是当历史发生变化的时候会被触发的,我们调用pushState或replaceState方法时是不会触发该事件的。

initEvent(){
    // 在事件处理函数中,我们要做的是将当前地址栏中的路径部分取出来并存储到this.data.current中,
    // 注意这里的this就是VueRouter对象,因为这里写的是箭头函数,不绑定this,this还是上一层的this
    window.addEventListener("popstate",() => {
        this.data.current = window.location.pathname
    })
}

到此,initEvent方法我们就已经完成了,但别忘记了还要调用该方法。这也是需要初始化的方法,所以我们在init里调用:

init(){
    this.createRouteMap()
    this.initComponent(_Vue)
    // 在init中调用initEvent方法
    this.initEvent()
}

之后我们打开浏览器进行测试,此时点击浏览器的前进后退就会显示正常的内容了。到此,我们才算是真正完成了一个简易版本的VueRouter。

附上全部代码:

let _Vue = null
class VueRouter {
    static install(Vue){
        if(VueRouter.install.installed){
            return;
        }
        VueRouter.install.installed = true
        _Vue = Vue
        _Vue.mixin({
            beforeCreate(){
                if(this.$options.router){
                    _Vue.prototype.$router = this.$options.router
                }
            }
        })
    }
    constructor(options){
        this.options = options
        this.routeMap = {}
        this.data = _Vue.observable({
            current:"/"
        })
        // 这里我们在构造函数中就调用了init方法
        this.init()

    }
    init(){
        this.createRouteMap()
        this.initComponent(_Vue)
        this.initEvent()
    }
    createRouteMap(){
        this.options.routes.forEach(route => {
            this.routeMap[route.path] = route.component
        });
    }
    initComponent(Vue){
        Vue.component("router-link",{
            props:{
                to:String
            },
            render(h){
                return h("a",{
                    attrs:{
                        href:this.to
                    },
                    on:{
                        click:this.clickhander
                    }
                },[this.$slots.default])
            },
            methods:{
                clickhander(e){
                    history.pushState({},"",this.to)
                    this.$router.data.current=this.to
                    e.preventDefault()
                }
            }
        })
        const self = this
        Vue.component("router-view",{
            render(h){
                const cm=self.routeMap[self.data.current]
                return h(cm)
            }
        })
        
    }
    initEvent(){
        window.addEventListener("popstate",()=>{
            this.data.current = window.location.pathname
        })
    }
}

文章作者: 智升
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 智升 !
评论
  目录