当我们切换login和home页面的时候,会发现浏览器路径后面多了个
/#/login
、/#/home
,并且在页面不刷新的情况下,就更新了局部的组件内容,而这就是由我们的路由系统控制的。路由系统是Vue项目中的核心功能,包括在我们前面的章节中,也曾多次的涉及到路由管理的部分,本小节中我们就来好好的聊一下前端路由的由来以及Vue3中的路由实现。
输入路由的时候是怎么获取到页面的
我们都知道所谓的路由就是在浏览器地址栏输入的那一行地址,那么在我们输入这一行地址的时候,发生了什么,又是怎么获取到正确的页面并显示出来的呢?
当我们在浏览器中输入一个地址后,浏览器会根据路径构建一个请求,接下来就会对输入的域名进行DNS解析,得到正确的IP地址,然后和得到的IP地址建立TCP链接,发送HTTP请求,服务器接收到请求后,就会返回响应的HTML内容。
完成了请求和响应后,浏览器拿到了返回的HTML字符串,转换成DOM树结构,经过对DOM的样式计算,最终生成布局,在页面上进行合成渲染。
这就是在一个页面上输入路由,到最终页面显示的大概过程了,但是听上去好像和前端没什么关系,路由对应的页面信息都是请求后端返回的,我们只需要对返回的页面进行渲染就可以了。
前端路由的发展
其实在最开始的时候,前端确实是没有自己的路由系统的,前端代码都是嵌入在后端代码中,由后端代码来实现路由的跳转刷新。
用户访问页面地址后,由后端来解析加载对应的前端页面返回给浏览器,这样做的好处是前端不用考虑路由管理,工作量无形的减少了,同时有利于SEO,SSR其实就是这种方式。但相应的也存在一些弊端,在每次更新路由时,页面都会被强制刷新,也增加了网络请求的次数。
随着ajax的出现,前后端项目逐渐分离,前端控制页面数据的渲染、用户的交互,后端则关注数据逻辑的处理,那么要实现页面模块的切换,前端也需要一套自己的路由系统。
现在主流的前端框架都已经实现了前端路由,项目中的各个模块都在统一的入口页面中展示,用户切换地址跳转的时候,并不会刷新页面,也不会向后端发送请求,而是由js监听到路由的改变,进行路径的解析处理,匹配加载对应的组件渲染到入口页面对应的位置,这就是我们常说的SPA(单页面应用)。
hash路由与history路由
我们使用vue-router创建路由时,提到了两个方法createWebHistory
,createWebHashHistory
,并表示这两个方法分别用来创建history模式与hash模式的路由,那么这两种路由方式有什么区别呢?
hash模式:从表现上来看,hash模式其实就是浏览器地址中/#/login
的部分,通过window.location.hash
可以获取到路径上的hash值。
当路径上的hash值发生改变的时候,不会导致页面刷新,也不会发送请求,我们可以通过hashchange事件来监听hash值的改变,进而渲染新的组件内容。
history模式:从表现上看,history模式在浏览器路径中不会存在#相关的内容,history模式之所以可以实现前端路由,主要由于在HTML5中,新增了pushState
和replaceState
方法,支持对浏览器的路由进行修改操作,浏览器不会对服务端发送请求并且也会触发一个监听事件popstate。
从上面的描述来看,要实现前端路由,需要满足几个条件:
- 更改页面路径,不会引起页面刷新,不会向服务端发送请求。
- 存在监听路径改变的事件,可以在路径改变后动态渲染页面。
hash模式和history模式都具备上面的特点,用各自不同的方法实现前端路由,当然这两种模式也存在一些区别:
- hash模式路径上会携带#,history模式没有。
- hash模式可以直接路径回车刷新,history模式不可以,需要后端配合。
- hash模式支持低版本浏览器,history模式需要支持HTML5。
怎么实现Vue3中的路由管理?
我们已经了解了前端路由的两种实现方式,接下来我们就来实现下Vue3中的hash模式路由。
在router文件夹下新建一个new-vue-router的文件夹,新增index.js子文件,我们将在这里实现路由管理的代码。
-- router
|-- router.js
|-- new-vue-router
|-- index.js
先让我们来看下,Vue3中是如何使用路由的:
// router.js
import {
createRouter, createWebHashHistory } from 'vue-router';
import Login from '../pages/login.vue';
import Home from '../pages/home.vue';
// 定义各个组件的路由地址
const routes = [{
path: '/login',
component: Login
}, {
path: '/home',
component: Home
}]
// 创建路由实例
export default createRouter({
history: createWebHashHistory(),
routes
});
// App.vue
import {
createApp } from 'vue'
import App from './App.vue'
import router from './router/router';
const app = createApp(App);
app.use(router).mount('#app');
从router.js中我们可以看到,import导入了两个方法createRouter, createWebHashHistory,所以新路由管理中也需要这两个函数,并且createRouter方法返回了一个路由对象,那么我们新增代码如下。
function createRouter() {
// 定义一个路由对象
const router = {
}
// 返回路由对象
return router;
}
function createWebHashHistory() {
}
export {
createRouter,
createWebHashHistory
}
好了, 一个非常简单的基础结构就完成了,两个方法都有了,并且createRouter方法可以返回一个空的路由对象,接下来就是要实现每个函数的具体功能。
从createRouter的使用中,我们可以看到传入了两个参数:
createRouter({
history: createWebHashHistory(),
routes
});
这两个参数分别用来说明使用的是什么路由模式,以及各个组件的路由地址,createRouter通过传入的参数来给router对象赋值。
我们先来实现createRouter方法:
function createRouter(params) {
// 获取传入的两个参数
const history = params.history;
const routes = params.routes;
const router = {
history,
routes
}
return router;
}
这样我们就是实现了给router实例的赋值。
因为要根据当前hash来动态选择组件渲染,所以router实例还需要保存当前的路径hash,当浏览器路径hash改变时,修改保存的hash值并通知其他地方渲染,学习过响应式章节后,我们知道可以将hash定义为响应式数据。
import {
ref} from 'vue';
function createRouter(params) {
const history = params.history;
const routes = params.routes;
const router = {
history,
routes,
// 增加当前路径hash的响应式变量
hash: ref(window.location.hash.slice(1)),
}
return router;
}
hash模式是通过hashchange事件来监听hash改变的,我们是根据传入的参数history来判断是否为hash模式,如果是则添加监听hashchange事件,当事件被触发时更新当前路径的hash:
function createWebHashHistory() {
// 区分路由模式
+ return 'WebHashHistory';
}
function createRouter(params) {
const history = params.history;
const routes = params.routes;
const router = {
history,
routes,
// 增加当前路径hash的响应式变量
hash: ref(window.location.hash.slice(1)),
}
// 增加监听事件
+ if (history === 'WebHashHistory') {
+ window.addEventListener('hashchange', () => {
+ // hash修改后更新变量
+ router.hash.value = window.location.hash.slice(1);
+ });
+ }
return router;
}
new-vue-router中的主要方法我们就已经搞定了,但只有这些,我们只能监听到路由hash的改变,还不足以根据hash将组件渲染到页面上,看下App.vue,原来vue-router还新增了两个组件,router-view
和router-link
用来实现渲染和跳转功能,那我们继续新增两个文件router-view.vue、router-link.vue。
先看下router-link的使用方式:
<router-link to="/login">login</router-link>
其实router-link就是一个a标签组件,传入了一个to参数,router-link的子元素使用插槽插入,代码实现如下:
<template>
<a :href="'#' + props.to">
<slot></slot>
</a>
</template>
<script setup>
import {
defineProps } from 'vue';
let props = defineProps({
to: String
})
</script>
router-view是组件动态渲染的地方,需要根据当前hash值,去匹配查询到对应的组件,那么我们需要获取到当前的hash和路由数组(就是router对象下的hash和routes),我们知道这两个信息存在createRouter创建的路由实例中,但要怎么传递到router-view中呢?我们暂时放一下这个问题,假设已经传过来了,先实现匹配渲染的功能。
<template>
<component :is="currentComponent"></component>
</template>
<script setup>
import {
computed, reactive, ref} from 'vue';
// 假设已经有当前路由和路由数组
let hash = ref('/');
let routes = reactive([]);
// hash地址为响应式,根据地址计算出组件
let currentComponent = computed(() => {
const targetComponent = routes.find((route) => {
return route.path === hash.value
})
return targetComponent.component
})
</script>
router-link和router-view就已经实现了,那么需要在App.use的时候将它们注册为全局组件,App.use()方法执行的时候,如果参数是个方法,会直接执行,如果是对象,会执行对象下的install方法。
我们知道import router from './router/router';
导入的是createRouter返回的路由对象,所以我们需要在router中添加一个install方法,注册全局组件。
import RouterLink from './router-link.vue';
import RouterView from './router-view.vue';
...
function createRouter(params) {
const history = params.history;
const routes = params.routes;
const router = {
history,
routes,
// 增加当前路径hash的响应式变量
hash: ref(window.location.hash.slice(1)),
+ install (app) {
+ // 注册全局组件
+ app.component('router-link', RouterLink);
+ app.component('router-view', RouterView);
+ }
}
if (history === 'WebHashHistory') {
window.addEventListener('hashchange', () => {
// hash修改后更新变量
router.hash.value = window.location.hash.slice(1);
});
}
return router;
}
不要忘记了,在router-view中,还有两个数据没有传递过去,既然router-view已经被注册成app下的组件了,那这不就是组件之间的传值,因为我们不确定router-view与app中间会嵌套几层,所以我们可以使用Provide和Inject进行跨层级传递参数,将router对象传递给router-view组件。
// index.js
...
function createRouter(params) {
const history = params.history;
const routes = params.routes;
const router = {
history,
routes,
// 增加当前路径hash的响应式变量
hash: ref(window.location.hash.slice(1)),
install (app) {
app.component('router-link', RouterLink);
app.component('router-view', RouterView);
+ app.provide('ROUTER', router);
}
}
if (history === 'WebHashHistory') {
window.addEventListener('hashchange', () => {
// hash修改后更新变量
router.hash.value = window.location.hash.slice(1);
});
}
return router;
}
// router-view.vue
<template>
<component :is="currentComponent"></component>
</template>
<script setup>
import {
computed, reactive, ref, inject} from 'vue';
import NoFind from './404.vue';
// 获取app组件的传值
const router = inject('ROUTER');
let hash = router.hash;
let routes = router.routes;
// hash地址为响应式,根据地址计算出组件
let currentComponent = computed(() => {
const targetComponent = routes.find((route) => {
return route.path === hash.value
})
return targetComponent?.component || NoFind
})
</script>
这样就将hash和routes的值传递到router-view中了,并且我们增加了一个404页面,当没有匹配的地址时,显示404页面内容。
到目前为止,我们只用了不到50行代码就实现了简单的hash模式路由,成功的完成了路由的切换和组件的动态渲染,history模式的实现方式大家可以自己去尝试下。
那么vue-router也是这样来实现的嘛?我们可以对比下vue-router的部分源码,看下有什么区别:
// router.ts
install(app: App) {
const router = this
// 注册RouterLink和RouterView的全局组件
app.component('RouterLink', RouterLink)
app.component('RouterView', RouterView)
// 将router实例绑定到app的全局属性$router上
app.config.globalProperties.$router = router
// 拦截对$router属性的访问
Object.defineProperty(app.config.globalProperties, '$route', {
enumerable: true,
// 如果当前路由是响应式变量,则返回value值,否则返回路由字符串
get: () => unref(currentRoute),
})
...
// 通过provide跨组件传值
app.provide(routerKey, router)
app.provide(routeLocationKey, reactive(reactiveRoute))
app.provide(routerViewLocationKey, currentRoute)
// app组件卸载时的相关处理逻辑
const unmountApp = app.unmount
installedApps.add(app)
app.unmount = function () {
installedApps.delete(app)
// the router is not attached to an app anymore
if (installedApps.size < 1) {
// invalidate the current navigation
pendingLocation = START_LOCATION_NORMALIZED
removeHistoryListener && removeHistoryListener()
currentRoute.value = START_LOCATION_NORMALIZED
started = false
ready = false
}
unmountApp()
}
...
},
在源码的router.ts文件中,也是注册了RouterLink和RouterView两个组件,通过provide方式传递了一些参数给子组件们,并且额外增加了对全局属性$router的赋值和访问的拦截处理,以及app组件卸载时的一些逻辑。再来看下createWebHashHistory方法。
// hash.ts
export function createWebHashHistory(base?: string): RouterHistory {
// Make sure this implementation is fine in terms of encoding, specially for IE11
// for `file://`, directly use the pathname and ignore the base
// location.pathname contains an initial `/` even at the root: `https://example.com`
base = location.host ? base || location.pathname + location.search : ''
// allow the user to provide a `#` in the middle: `/base/#/app`
if (!base.includes('#')) base += '#'
if (__DEV__ && !base.endsWith('#/') && !base.endsWith('#')) {
warn(
`A hash base must end with a "#":\n"${base}" should be "${base.replace(
/#.*$/,
'#'
)}".`
)
}
return createWebHistory(base)
}
createWebHashHistory方法的源码看上去就很简短了,只是处理了下基础路径,最终调用的竟然是createWebHistory,可见vue-router并没有像我们一样使用hashchange来监听,难道也是使用pushState和replaceState来处理hash模式的吗?大家可以自己去验证一下,想自己去尝试实现createWebHistory方法的同学可以去参考下vue-router源码的实现,这里就不多展开说明了。
从源码中我们可以看到,vue-router的实现原理与我们的基本类似,当然vue-router对更多的细节进行了处理,功能也更加强大丰富,感兴趣的同学可以下载源码研究一下vue-router。
总结
本节中,我们介绍了前端路由的由来,从开始的借用后端路由来实现页面刷新跳转,到后来的前后端分离,前端拥有了自己的路由系统,在SPA中被大量应用。
然后我们介绍了前端路由的两种模式,hash模式和history模式,解释了两种方式实现前端路由的原理和区别,最后我们在Vue3中实现了hash模式的路由管理。
我们实现的这个版本与最终版本还是存在非常大的差异的,vue-router还有一些进阶用法比如动态路由匹配,导航守卫等也是会经常使用到的,大家可以对照官方文档了解路由的更多用法,路由系统是Vue的核心功能之一,希望通过本节能让大家对前端路由有个更深的理解。