核心原理
1、什么是前端路由
在Web前端单页面应用SPA(Single Page Application)中,路由描述的是URL与UI之间的映射关系,这种映射关系是单向的,即URL变化引起UI更新(无需刷新页面)。
2、如何实现前端路由
要实现前端路由,需要解决两个核心:
- 如何改变URL却不引起页面刷新
- 如何检测URL变化
下面分别使用hash和history两种实现方式回答上面的两个核心问题。
hash实现
hash是URL中hash(#)及后面的那部分,常用作锚点在页面内进行导航,改变URL中hash部分不会引起页面刷新,通过hashchange事件监听URL的变化,改变URL的方式只有以下几种:
- 通过浏览器前进、后退改变URL
- 通过
<a>标签改变URL - 通过window.location改变URL
history实现
history提供pushState和replaceState两个方法,这两个方法改变URL的path部分不会引起页面刷新,history提供类似hashchange事件的popstate事件,但popstate事件有些不同:
- 通过浏览器前进、后退改变URL时会触发
popstate事件 - 通过
pushState、replaceState、<a>标签改变URL不会触发popstate事件(可以通过拦截pushState、replaceState的调用和<a>标签的点击事件来检测URL变化) - 通过js调用history的
back、go、forward方法可触发该事件
所以监听URL变化可以实现,只是没有hash方式那么方便。
原生js实现前端路由
1、基于hash实现
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>hash</title>
</head>
<body>
<ul>
<!-- 定义路由 -->
<li><a href="#/home">home</a></li>
<li><a href="#/about">about</a></li>
</ul>
<!-- 渲染路由对应的UI -->
<div id="routerView"></div>
</body>
<script>
let routerView = document.getElementById('routerView')
window.addEventListener('hashchange', () => {
let hash = location.hash
routerView.innerHTML = hash
})
window.addEventListener('DOMContentLoaded', () => {
if (!location.hash) { // 如果不存在hash,重定向到#/
location.hash = '/'
} else {
let hash = location.hash
routerView.innerHTML = hash
}
})
</script>
</html>
可以看到,我们通过a标签的href属性来改变URL的hash值(触发浏览器的前进、后退按钮也可以,或者在控制台输入
window.location赋值来改变hash)。监听
hashchange事件,触发事件就可以改变routerView的内容(在Vue中,改变的是router-view组件中的内容)。为什么又监听了
DOMContentLoaded事件?因为首次加载完页面不会触发hashchange。
2.基于history实现
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>history</title>
</head>
<body>
<ul>
<!-- 定义路由 -->
<li><a href="/home">home</a></li>
<li><a href="/about">about</a></li>
</ul>
<!-- 渲染路由对应的UI -->
<div id="routerView"></div>
</body>
<script>
let routerView = document.getElementById('routerView')
function onLoad () {
routerView.innerHTML = location.pathname
let arrLink = document.querySelectorAll('a[href]')
arrLink.forEach(el => el.addEventListener('click', function (e) {
e.preventDefault()
console.log(el.getAttribute('href'))
history.pushState(null, '', el.getAttribute('href'))
routerView.innerHTML = location.pathname
}))
}
window.addEventListener('DOMContentLoaded', onLoad)
window.addEventListener('popstate', () => {
routerView.innerHTML = location.pathname
})
</script>
</html>
通过a标签的href属性来改变URL的path值(触发浏览器的前进、后退按钮也可以,或者在控制台输入
history.go、history.back、history.forward赋值来触发popstate事件),需要注意的是,a标签点击改变path值时,默认会触发页面的跳转,所以需要拦截<a>标签点击事件默认行为,使用pushState修改URL并手动更新UI,从而实现点击链接更新URL和UI。监听
popstate事件,触发则更新UI
hash模式也可以使用history.go、history.back、history.forward来触发hashchange,不管什么模式,浏览器都会有一个栈来保存记录。
基于Vue实现vue-router
首先,我们创建一个Vue的项目,主要看一下App.vue、About.vue、Home.vue、router/index.js,代码如下:
App.vue
<template>
<div id="app">
<div id="nav">
<router-link to="/">Home</router-link> |
<router-link to="/about">About</router-link>
</div>
<router-view/>
</div>
</template>
router/index.js
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')
}
]
const router = new VueRouter({
mode: 'history',
base: process.env.BASE_URL,
routes
})
export default router
Home.vue
<template>
<div class="home">
<h1>这里是Home组件</h1>
</div>
</template>
About.vue
<template>
<div class="about">
<h1>这是About组件</h1>
</div>
</template>
启动项目,运行成功后展示如下:
现在我们决定创建自己的VueRouter,于是创建myVueRouter.js文件,目录如下:
再将
VueRouter引入改成myVueRouter.js
import Vue from 'vue'
import VueRouter from './myVueRouter'
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',
base: process.env.BASE_URL,
routes
})
export default router
剖析vue-router本质
首先抛出个问题,Vue项目是怎么引入vue-router。
- 通过npm安装
vue-router,再通过import VueRouter from 'vue-router'引入 - 使用
new VueRouter({...})创建router对象 - 通过
Vue.use(VueRouter)使用
Vue.use()的原则就是执行对象的install方法,所以可以大胆试想一下myVueRouter.js的框架如下:
//myVueRouter.js
class VueRouter{
}
VueRouter.install = function () {
}
export default VueRouter
分析Vue.use
Vue.use(plugin);
(1)参数
{ Object | Function } plugin
(2)用法
安装 Vue.js 插件。如果插件是一个对象,必须提供 install 方法。如果插件是一个函数,它会被作为 install 方法。install 方法调用时,会将 Vue 作为参数传入。
该方法需要在调用 new Vue() 之前被调用。
当 install 方法被同一个插件多次调用,插件将只会被安装一次。
(3)作用
注册插件,此时只需要调用install方法并将Vue作为参数传入即可。
(4)实现
export function initUse(Vue: GlobalAPI) {
Vue.use = function (plugin: Function | any) {
const installedPlugins =
this._installedPlugins || (this._installedPlugins = [])
if (installedPlugins.indexOf(plugin) > -1) {
return this
}
// additional parameters
const args = toArray(arguments, 1)
args.unshift(this)
if (isFunction(plugin.install)) {
plugin.install.apply(plugin, args)
} else if (isFunction(plugin)) {
plugin.apply(null, args)
}
installedPlugins.push(plugin)
return this
}
}
源码解读:
1、在Vue.js上新增一个use方法,接收一个参数plugin
2、首先判断插件是否注册,注册过则返回
3、toArray方法就是将类数组转成真正的数组。将除第一个参数之外的所有参数转成数组赋值给args,因为install方法被调用时,会将 Vue 作为参数传入
4、由于plugin参数支持对象和函数类型,所以通过isFunction判断plugin.install和plugin哪个是函数,即可知用户使用哪种方式注册的插件,然后执行用户编写的插件并将args作为参数传入
5、最后将插件添加到installedPlugins中,保证相同的插件不会反复被注册。(ps:为什么插件不会被重新加载!!!总算是明白了💔)
上面用法中讲到,需要把Vue作为install的第一个参数,所以我们可以把Vue保存起来:
//myVueRouter.js
let vuePlugin = null
class VueRouter{
}
VueRouter.install = function (plugin) {
vuePlugin = plugin
}
export default VueRouter
然后再通过传进来的Vue创建两个组件router-link和router-view
//myVueRouter.js
let Vue = null
class VueRouter{
}
VueRouter.install = function (plugin) {
Vue = plugin
console.log(plugin)
Vue.component('router-link', {
render(h) {
return h('a', {}, '首页')
}
})
Vue.component('router-view', {
render(h) {
return h('h1', {}, '首页视图')
}
})
}
export default VueRouter
运行项目,结果如下:
完善install方法
install一般是给Vue实例添加东西的,在这里就是给组件添加$router和$route。每个组件添加的$router是同一个,$route也是同一个。
接下来看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值,而其他组件还没有,所以我们需要让其他组件也拥有这个router。因此我们可以这样完善install:
//myVueRouter.js
let Vue = null
class VueRouter{
}
VueRouter.install = function (plugin) {
Vue = plugin
Vue.mixin({
beforeCreate() {
if (this.$options && this.$options.router) { // 如果是根组件
this._root = this // 把当前实例挂载在_root上
this._router = this.$options.router
} else { // 如果是子组件
this._root = this.$parent && this.$parent._root
}
Object.defineProperty(this, '$router', {
get() {
return this._root._router
}
})
}
})
Vue.component('router-link', {
render(h) {
return h('a', {}, '首页')
}
})
Vue.component('router-view', {
render(h) {
return h('h1', {}, '首页视图')
}
})
}
export default VueRouter
源码解读:
1、install方法调用时,会将 Vue 作为参数传入。
2、mixin的作用是将内容混入到 Vue 的初始参数 options 中。
3、为什么是beforeCreate而不是created呢?因为组件beforeCreate生命周期也会使用this.$router。
4、如果是根组件,将我们传入的router和_root挂到根组件实例上;如果是子组件,就将_root根组件挂载到子组件。(此处是引用的复制,改变了内部指针的指向)因此每个组件都拥有了同一个_root根组件挂载在它自身。
为什么当前组件是子组件,就可以直接从父组件拿到_root根组件呢?(父组件和子组件的执行顺序)
父beforeCreate-> 父created -> 父beforeMounte -> 子beforeCreate ->子create ->子beforeMount ->子 mounted -> 父mounted
可以看到,在执行子组件的beforeCreate的时候,父组件已经执行完beforeCreate了,所以父组件已经有了_root。
然后我们通过
Object.defineProperty(this, '$router', {
get() {
return this._root._router
}
})
将$router挂载到组件的实例上。其实这也是一种代理思想,我们获取组件的$router,其实返回的是根组件的_root.router。到这里 install 还没完全实现,因为$route还没实现,接着来~
完善VueRouter类
我们先看看创建VueRouter实例时,传了什么
import Vue from 'vue'
import VueRouter from './myVueRouter'
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',
base: process.env.BASE_URL,
routes
})
export default router
可以看到,传入了一个routes的数组、mode路由模式、base,因此我们可以先这样实现VueRouter:
class VueRouter{
constructor (options) {
this.mode = options.mode || 'hash'
this.routes = options.routes || []
}
}
初始化2个属性,但是我们直接处理routers是十分不方便的,所以我们要先转换成key:value的格式。
class VueRouter{
constructor (options) {
this.mode = options.mode || 'hash'
this.routes = options.routes || []
this.routesMap = this.createMap(this.routes)
}
createMap (routes) {
return routes.reduce((pre, current) => {
pre[current.path] = current.component
return pre
}, {})
}
}
通过createMap我们将
const routes = [
{
path: '/',
name: 'Home',
component: Home
},
{
path: '/about',
name: 'About',
component: () => import(/* webpackChunkName: "about" */ '../views/About.vue')
}
]
转变成
路由中需要存放当前路径,来表示当前的路径状态,为了方便管理,可以用一个对象来表示:
let Vue = null
class HistoryRoute {
constructor() {
this.current = null
}
}
class VueRouter{
constructor (options) {
this.mode = options.mode || 'hash'
this.routes = options.routes || []
this.routesMap = this.createMap(this.routes)
this.history = new HistoryRoute()
}
createMap (routes) {
return routes.reduce((pre, current) => {
pre[current.path] = current.component
return pre
}, {})
}
}
但是现在我们发现这个current为null,所以我们需要初始化,判断是hash还是history模式,然后将当前路径的值保存到current中。
let Vue = null
class HistoryRoute {
constructor() {
this.current = null
}
}
class VueRouter{
constructor (options) {
this.mode = options.mode || 'hash'
this.routes = options.routes || []
this.routesMap = this.createMap(this.routes)
this.history = new HistoryRoute()
this.init()
}
createMap (routes) {
return routes.reduce((pre, current) => {
pre[current.path] = current.component
return pre
}, {})
}
init () {
if (this.mode === 'hash') {
// 先判断打开有没有hash值,没有的话跳转到#/
location.hash ? '' : location.hash = '/'
window.addEventListener('load', () => {
this.history.current = location.hash.slice(1)
})
window.addEventListener('hashchange', () => {
this.history.current = location.hash.slice(1)
})
} else {
location.pathname ? '' : location.pathname = '/'
window.addEventListener('load', () => { // 页面加载完毕
this.history.current = location.pathname
})
window.addEventListener('popstate', () => {
this.history.current = location.pathname
})
}
}
}
完善$route
目前为止,我们已经可以获取当前路径,可以着手实现$route
VueRouter.install = function (plugin) {
Vue = plugin
Vue.mixin({
beforeCreate() {
if (this.$options && this.$options.router) { // 如果是根组件
this._root = this // 把当前实例挂载在_root上
this._router = this.$options.router
} else { // 如果是子组件
this._root = this.$parent && this.$parent._root
}
Object.defineProperty(this, '$router', {
get() {
return this._root._router
}
})
Object.defineProperty(this, '$route', {
get() {
return this._root._router.history.current
}
})
}
})
Vue.component('router-link', {
render(h) {
return h('a', {}, '首页')
}
})
Vue.component('router-view', {
render(h) {
return h('h1', {}, '首页视图')
}
})
}
完善router-view组件
现在我们可以根据当前路径从路由表中获取对应的组件进行渲染
Vue.component('router-view', {
render(h) {
let current = this._root._router.history.current
let routesMap = this._root._router.routesMap
return h(routesMap[current])
}
})
render函数中的this指向的是一个Proxy代理对象,代理Vue组件。所以我们可以获取路由表,然后把获得的组件放在h()里进行渲染。
现在已经实现了router-view组件的渲染,但是还存在一个问题,改变路径的时候,视图没有重新渲染,所以需要将_router.history进行响应式化。
Vue.mixin({
beforeCreate() {
if (this.$options && this.$options.router) { // 如果是根组件
this._root = this // 把当前实例挂载在_root上
this._router = this.$options.router
Vue.util.defineReactive(this, 'cui', this._router.history)
} else { // 如果是子组件
this._root = this.$parent && this.$parent._root
}
Object.defineProperty(this, '$router', {
get() {
return this._root._router
}
})
Object.defineProperty(this, '$route', {
get() {
return this._root._router.history.current
}
})
}
})
这里使用了Vue提供的API:defineReactive,使得this._router.history对象得到监听。
因此首次渲染router-view组件的时候,获取到this._router.history这个对象,会把组件的依赖watcher收集到对应的dep收集器中,每次this._router.history改变时,dep会通知router-view组件依赖的watcher进行update(),从而使router-view重新渲染。(Vue响应式原理)
现在我们测试一下,看看改变url上的值,router-view是否会重新渲染
path修改为/about
实现了当前路径的监听~。
完善router-link组件
我们先看看router-link是怎么使用的
<div id="nav">
<router-link to="/">Home</router-link>
<router-link to="/about">About</router-link>
</div>
通过父组件的to参数传递路径进去,因此我们可以这样实现:
Vue.component('router-link', {
props: {
to: String
},
render(h) {
let mode = this._root._router.mode
let to = mode === 'hash' ? '#' + this.to : this.to
return h('a', {attrs:{href:to}}, this.$slots.default)
}
})
我们把router-link渲染成a标签,点击可以切换url上的路径,从而实现视图的重新渲染。
打开页面测试的时候,发现history模式下,点击router-link组件切换页面的时候发现页面整体进行了刷新,显然这不符合我们的预期,所以需要对a标签进行处理:
Vue.component('router-link', {
props: {
to: String
},
render(h) {
let mode = this._root._router.mode
let to = mode === 'hash' ? '#' + this.to : this.to
return h('a', {
attrs:{href:to},
on: {
click: (e) => {
if (mode !== 'hash') {
e.preventDefault()
this._root._router.history.current = to
window.history.pushState(null, '', to)
}
}
}
}, this.$slots.default)
}
})
重写a标签的点击事件,阻止事件冒泡,然后手动修改path的路径。
完整的VueRouter代码:
let Vue = null
class HistoryRoute {
constructor() {
this.current = null
}
}
class VueRouter{
constructor (options) {
this.mode = options.mode || 'hash'
this.routes = options.routes || []
this.routesMap = this.createMap(this.routes)
console.log(this.routesMap)
this.history = new HistoryRoute()
this.init()
}
createMap (routes) {
return routes.reduce((pre, current) => {
pre[current.path] = current.component
return pre
}, {})
}
init () {
if (this.mode === 'hash') {
// 先判断打开有没有hash值,没有的话跳转到#/
location.hash ? '' : location.hash = '/'
window.addEventListener('load', () => {
this.history.current = location.hash.slice(1)
})
window.addEventListener('hashchange', () => {
this.history.current = location.hash.slice(1)
})
} else {
location.pathname ? '' : location.pathname = '/'
window.addEventListener('load', () => { // 页面加载完毕
this.history.current = location.pathname
})
window.addEventListener('popstate', () => {
this.history.current = location.pathname
})
}
}
}
VueRouter.install = function (plugin) {
Vue = plugin
Vue.mixin({
beforeCreate() {
if (this.$options && this.$options.router) { // 如果是根组件
this._root = this // 把当前实例挂载在_root上
this._router = this.$options.router
Vue.util.defineReactive(this, '', this._router.history)
} else { // 如果是子组件
this._root = this.$parent && this.$parent._root
}
Object.defineProperty(this, '$router', {
get() {
return this._root._router
}
})
Object.defineProperty(this, '$route', {
get() {
return this._root._router.history.current
}
})
}
})
Vue.component('router-link', {
props: {
to: String
},
render(h) {
let mode = this._root._router.mode
let to = mode === 'hash' ? '#' + this.to : this.to
return h('a', {
attrs:{href:to},
on: {
click: (e) => {
if (mode !== 'hash') {
e.preventDefault()
this._root._router.history.current = to
window.history.pushState(null, '', to)
}
}
}
}, this.$slots.default)
}
})
Vue.component('router-view', {
render(h) {
let current = this._root._router.history.current
let routesMap = this._root._router.routesMap
return h(routesMap[current])
}
})
}
export default VueRouter
ok,完美搞定!!!😄😄😄