VueRouter核心原理及实现
前言
本文主要介绍 Vue Router 的核心原理以及如何实现一个简易版本的 Vue Router(history 和 hash 模式)。
前置知识
hash
和 history
模式区别
- 表现形式上:hash 模式 url music.163.com/#/playlist?… , 带有 # 号。
- 原理区别:
hash
模式基于锚点,以及onhashchange
事件。history
模式是基于HTML5
中的history
模式。history.pushState、replaceState
在 IE10 以后才支持,存在兼容性问题。push
会向服务器发送请求,使用pushState
不会发送请求,但是会在浏览器端产生历史记录,形成客户端路由。
history
模式使用
- 该模式需要服务器端的支持。
- 单页应用中,服务端不存在 www.testurl.com/login,地址会返回 404,提示找不到页面。
history
模式下前后端工作过程:history 模式下,刷新页面会向服务器进行网络请求,后端处理history
模式,需要将默认的html
文件返回给前端,前端获取到文件后再根据路由自行处理。
Vue Router
使用方式
比较常规的使用方式,避免文章篇幅过长,这里就不一一赘述了。基础步骤如下:
- 在
Vue 2.6.11
模板项目中, 安装依赖:
npm install vue-router
src
目录下创建router
文件夹,并新建一个index.js
,内容如下:
import Vue from "vue";
import VueRouter from "vue-router";
import Home from "../components/Home";
import ComponentA from "../components/ComponentA";
Vue.use(VueRouter);
const routes = [
{
path: "/",
redirect: "/home",
component: Home
},
{
path: "/componentA",
name: "ComponentA",
component: ComponentA
},
];
const router = new VueRouter({
mode: "history",
routes
});
export default router;
- 在
main.js
中引入,在Vue
实例化时,作为Options
参数传递给Vue
的构造函数。
import Vue from "vue";
import App from "./App.vue";
import router from "./router/index";
Vue.config.productionTip = false;
const vm = new Vue({
router,
render: (h) => h(App)
}).$mount("#app");
console.log("vm: ", vm);
router
实例对象
Vue 实例化后,router 对象作用在何处? 我们在 main.js
中 Vue 实例化后的位置添加一行打印,看看 Vue
实例化后的对象上有什么。
在 vm.$options.router
中,我们看到了许多熟悉的对象属性与官方文档上提到的编程式导航方法。
实现 history
模式
CustomRouter
类
这里我们给自己的 Router
类命名为 CustomRouter
,定义如下:
export default class CustomRouter {
static install(Vue) {}
constructor(options) {}
init() {}
createRouteMap() {}
initComponents(Vue) {}
initEvent() {}
}
CustomRouter
类中的各个函数作用依次如下:
install
函数,Vue 插件需要对外暴露的install
方法,关于 Vue 插件开发,可以参考官方文档:cn.vuejs.org/v2/guide/pl… 。constructor
函数:构造函数。init
函数:全局初始化。createRouteMap
:创建路由配置与对应组件的映射,属性值是对应的组件。initComponents
:初始化Router-View
和Router-Link
组件。initEvent
: 监听浏览器history
的popstate
事件,当页面 URL 变化时,将会触发监听事件。
constructor
constructor(options) {
this.options = options;
this.routeMap = {};
this.data = _Vue.observable({
current: "home"
});
}
构造函数中的对象成员如下:
options
:options
中存储的是路由相关的配置。
routeMap
: 路由path
与component
的映射,是一个对象,对象属性值为path
,对象值为component
,在createRouteMap
函数中进行初始化。- data:通过
2.6.x
版本后提供的observable
api,将对象转换成响应式。
initEvent
函数
监听 popstate 事件,并更新当前的 current。
initEvent() {
window.addEventListener('popstate', () => {
this.data.current = window.location.pathname;
})
}
HashHistory 源码中 setupListeners 函数
class HashHistory extends History {
// 注册路由事件
setupListeners () {
if (this.listeners.length > 0) {
return
}
const router = this.router
const expectScroll = router.options.scrollBehavior
const supportsScroll = supportsPushState && expectScroll
/
if (supportsScroll) {
this.listeners.push(setupScroll())
}
const handleRoutingEvent = () => {
const current = this.current
if (!ensureSlash()) {
return
}
// 开启路由动画情况下
this.transitionTo(getHash(), route => {
if (supportsScroll) {
handleScroll(this.router, route, current, true)
}
if (!supportsPushState) {
replaceHash(route.fullPath)
}
})
}
// 判断 事件类型
const eventType = supportsPushState ? 'popstate' : 'hashchange'
// 添加路由监听事件
window.addEventListener(
eventType,
handleRoutingEvent
)
this.listeners.push(() => {
window.removeEventListener(eventType, handleRoutingEvent)
})
}
}
initComponent
initComponents(Vue) {
/**
* Router-Link 组件
*/Vue.component("router-link", {
props: {
to: String
},
render(h) {
return h(
"a",
{
// dom 对象属性attrs: {
href: this.to
},
on: {
click: this.clickHandler
}
},
[this.$slots.default]
);
},
methods: {
clickHandler(e) {
window.history.pushState({}, "", this.to);
this.$router.data.current = this.to;
e.preventDefault();
}
}
});
/**
* Router-View 组件
*/const self = this;
Vue.component("router-view", {
render(h) {
const component = self.routeMap[self.data.current];
return h(component);
}
})
}
当
this.data.current
变化时,所有依赖于该变量的组件(router-view)都会重新渲染,这也是为什么要使用Vue.observable
的原因。
Router-Link 组件源码
/* @flow */
import { createRoute, isSameRoute, isIncludedRoute } from '../util/route'
import { extend } from '../util/misc'
import { normalizeLocation } from '../util/location'
import { warn } from '../util/warn'
// work around weird flow bug
const toTypes: Array<Function> = [String, Object]
const eventTypes: Array<Function> = [String, Array]
const noop = () => {}
let warnedCustomSlot
let warnedTagProp
let warnedEventProp
export default {
name: 'RouterLink',
props: {
to: {
type: toTypes,
required: true
},
tag: {
type: String,
default: 'a'
},
custom: Boolean,
exact: Boolean,
exactPath: Boolean,
append: Boolean,
replace: Boolean,
activeClass: String,
exactActiveClass: String,
ariaCurrentValue: {
type: String,
default: 'page'
},
event: {
type: eventTypes,
default: 'click'
}
},
render (h: Function) {
const router = this.$router
const current = this.$route
const { location, route, href } = router.resolve(
this.to,
current,
this.append
)
const classes = {}
const globalActiveClass = router.options.linkActiveClass
const globalExactActiveClass = router.options.linkExactActiveClass
// Support global empty active class
const activeClassFallback =
globalActiveClass == null ? 'router-link-active' : globalActiveClass
const exactActiveClassFallback =
globalExactActiveClass == null
? 'router-link-exact-active'
: globalExactActiveClass
const activeClass =
this.activeClass == null ? activeClassFallback : this.activeClass
const exactActiveClass =
this.exactActiveClass == null
? exactActiveClassFallback
: this.exactActiveClass
const compareTarget = route.redirectedFrom
? createRoute(null, normalizeLocation(route.redirectedFrom), null, router)
: route
classes[exactActiveClass] = isSameRoute(current, compareTarget, this.exactPath)
classes[activeClass] = this.exact || this.exactPath
? classes[exactActiveClass]
: isIncludedRoute(current, compareTarget)
const ariaCurrentValue = classes[exactActiveClass] ? this.ariaCurrentValue : null
const handler = e => {
if (guardEvent(e)) {
if (this.replace) {
router.replace(location, noop)
} else {
router.push(location, noop)
}
}
}
const on = { click: guardEvent }
if (Array.isArray(this.event)) {
this.event.forEach(e => {
on[e] = handler
})
} else {
on[this.event] = handler
}
const data: any = { class: classes }
const scopedSlot =
!this.$scopedSlots.$hasNormal &&
this.$scopedSlots.default &&
this.$scopedSlots.default({
href,
route,
navigate: handler,
isActive: classes[activeClass],
isExactActive: classes[exactActiveClass]
})
if (scopedSlot) {
if (process.env.NODE_ENV !== 'production' && !this.custom) {
!warnedCustomSlot && warn(false, 'In Vue Router 4, the v-slot API will by default wrap its content with an <a> element. Use the custom prop to remove this warning:\n<router-link v-slot="{ navigate, href }" custom></router-link>\n')
warnedCustomSlot = true
}
if (scopedSlot.length === 1) {
return scopedSlot[0]
} else if (scopedSlot.length > 1 || !scopedSlot.length) {
if (process.env.NODE_ENV !== 'production') {
warn(
false,
`<router-link> with to="${
this.to
}" is trying to use a scoped slot but it didn't provide exactly one child. Wrapping the content with a span element.`
)
}
return scopedSlot.length === 0 ? h() : h('span', {}, scopedSlot)
}
}
if (process.env.NODE_ENV !== 'production') {
if ('tag' in this.$options.propsData && !warnedTagProp) {
warn(
false,
`<router-link>'s tag prop is deprecated and has been removed in Vue Router 4. Use the v-slot API to remove this warning: https://next.router.vuejs.org/guide/migration/#removal-of-event-and-tag-props-in-router-link.`
)
warnedTagProp = true
}
if ('event' in this.$options.propsData && !warnedEventProp) {
warn(
false,
`<router-link>'s event prop is deprecated and has been removed in Vue Router 4. Use the v-slot API to remove this warning: https://next.router.vuejs.org/guide/migration/#removal-of-event-and-tag-props-in-router-link.`
)
warnedEventProp = true
}
}
if (this.tag === 'a') {
data.on = on
data.attrs = { href, 'aria-current': ariaCurrentValue }
} else {
// find the first <a> child and apply listener and href
const a = findAnchor(this.$slots.default)
if (a) {
// in case the <a> is a static node
a.isStatic = false
const aData = (a.data = extend({}, a.data))
aData.on = aData.on || {}
// transform existing events in both objects into arrays so we can push later
for (const event in aData.on) {
const handler = aData.on[event]
if (event in on) {
aData.on[event] = Array.isArray(handler) ? handler : [handler]
}
}
// append new listeners for router-link
for (const event in on) {
if (event in aData.on) {
// on[event] is always a function
aData.on[event].push(on[event])
} else {
aData.on[event] = handler
}
}
const aAttrs = (a.data.attrs = extend({}, a.data.attrs))
aAttrs.href = href
aAttrs['aria-current'] = ariaCurrentValue
} else {
// doesn't have <a> child, apply listener to self
data.on = on
}
}
return h(this.tag, data, this.$slots.default)
}
}
export function guardEvent (e: any) {
// don't redirect with control keys
if (e.metaKey || e.altKey || e.ctrlKey || e.shiftKey) return
// don't redirect when preventDefault called
if (e.defaultPrevented) return
// don't redirect on right click
if (e.button !== undefined && e.button !== 0) return
// don't redirect if `target="_blank"`
if (e.currentTarget && e.currentTarget.getAttribute) {
const target = e.currentTarget.getAttribute('target')
if (/\b_blank\b/i.test(target)) return
}
// this may be a Weex event which doesn't have this method
if (e.preventDefault) {
e.preventDefault()
}
return true
}
function findAnchor (children) {
if (children) {
let child
for (let i = 0; i < children.length; i++) {
child = children[i]
if (child.tag === 'a') {
return child
}
if (child.children && (child = findAnchor(child.children))) {
return child
}
}
}
}
Route-view 源码
import { warn } from '../util/warn'
import { extend } from '../util/misc'
import { handleRouteEntered } from '../util/route'
export default {
name: 'RouterView',
functional: true,
props: {
name: {
type: String,
default: 'default'
}
},
render (_, { props, children, parent, data }) {
// used by devtools to display a router-view badge
data.routerView = true
// directly use parent context's createElement() function
// so that components rendered by router-view can resolve named slots
const h = parent.$createElement
const name = props.name
const route = parent.$route
const cache = parent._routerViewCache || (parent._routerViewCache = {})
// determine current view depth, also check to see if the tree
// has been toggled inactive but kept-alive.
let depth = 0
let inactive = false
while (parent && parent._routerRoot !== parent) {
const vnodeData = parent.$vnode ? parent.$vnode.data : {}
if (vnodeData.routerView) {
depth++
}
if (vnodeData.keepAlive && parent._directInactive && parent._inactive) {
inactive = true
}
parent = parent.$parent
}
data.routerViewDepth = depth
// render previous view if the tree is inactive and kept-alive
if (inactive) {
const cachedData = cache[name]
const cachedComponent = cachedData && cachedData.component
if (cachedComponent) {
// #2301
// pass props
if (cachedData.configProps) {
fillPropsinData(cachedComponent, data, cachedData.route, cachedData.configProps)
}
return h(cachedComponent, data, children)
} else {
// render previous empty view
return h()
}
}
const matched = route.matched[depth]
const component = matched && matched.components[name]
// render empty node if no matched route or no config component
if (!matched || !component) {
cache[name] = null
return h()
}
// cache component
cache[name] = { component }
// attach instance registration hook
// this will be called in the instance's injected lifecycle hooks
data.registerRouteInstance = (vm, val) => {
// val could be undefined for unregistration
const current = matched.instances[name]
if (
(val && current !== vm) ||
(!val && current === vm)
) {
matched.instances[name] = val
}
}
// also register instance in prepatch hook
// in case the same component instance is reused across different routes
;(data.hook || (data.hook = {})).prepatch = (_, vnode) => {
matched.instances[name] = vnode.componentInstance
}
// register instance in init hook
// in case kept-alive component be actived when routes changed
data.hook.init = (vnode) => {
if (vnode.data.keepAlive &&
vnode.componentInstance &&
vnode.componentInstance !== matched.instances[name]
) {
matched.instances[name] = vnode.componentInstance
}
// if the route transition has already been confirmed then we weren't
// able to call the cbs during confirmation as the component was not
// registered yet, so we call it here.
handleRouteEntered(route)
}
const configProps = matched.props && matched.props[name]
// save route and configProps in cache
if (configProps) {
extend(cache[name], {
route,
configProps
})
fillPropsinData(component, data, route, configProps)
}
return h(component, data, children)
}
}
function fillPropsinData (component, data, route, configProps) {
// resolve props
let propsToPass = data.props = resolveProps(route, configProps)
if (propsToPass) {
// clone to prevent mutation
propsToPass = data.props = extend({}, propsToPass)
// pass non-declared props as attrs
const attrs = data.attrs = data.attrs || {}
for (const key in propsToPass) {
if (!component.props || !(key in component.props)) {
attrs[key] = propsToPass[key]
delete propsToPass[key]
}
}
}
}
function resolveProps (route, config) {
switch (typeof config) {
case 'undefined':
return
case 'object':
return config
case 'function':
return config(route)
case 'boolean':
return config ? route.params : undefined
default:
if (process.env.NODE_ENV !== 'production') {
warn(
false,
`props in "${route.path}" is a ${typeof config}, ` +
`expecting an object, function or boolean.`
)
}
}
}
init
分别初始化 routeMap
、Router-View
、Router-Link
组件和 popstate
事件监听器。
init() {
this.createRouteMap();
this.initComponents();
this.initEvent();
}
vue-router源码中 init 函数
class VueRouter {
init (app: any /* Vue component instance */) {
process.env.NODE_ENV !== 'production' &&
assert(
install.installed,
`not installed. Make sure to call \`Vue.use(VueRouter)\` ` +
`before creating root instance.`
)
this.apps.push(app)
// set up app destroyed handler
// https://github.com/vuejs/vue-router/issues/2639
app.$once('hook:destroyed', () => {
// clean out app from this.apps array once destroyed
const index = this.apps.indexOf(app)
if (index > -1) this.apps.splice(index, 1)
// ensure we still have a main app or null if no apps
// we do not release the router so it can be reused
if (this.app === app) this.app = this.apps[0] || null
if (!this.app) this.history.teardown()
})
// main app previously initialized
// return as we don't need to set up new history listener
if (this.app) {
return
}
this.app = app
const history = this.history
// 判断 history 是否是 HTML5History 或者 HashHistory 实例
if (history instanceof HTML5History || history instanceof HashHistory) {
// 初始化滚动
const handleInitialScroll = routeOrError => {
const from = history.current
const expectScroll = this.options.scrollBehavior
const supportsScroll = supportsPushState && expectScroll
if (supportsScroll && 'fullPath' in routeOrError) {
handleScroll(this, routeOrError, from, false)
}
}
// 注册相应的监听事件
const setupListeners = routeOrError => {
history.setupListeners()
handleInitialScroll(routeOrError)
}
history.transitionTo(
history.getCurrentLocation(),
setupListeners,
setupListeners
)
}
history.listen(route => {
this.apps.forEach(app => {
app._route = route
})
})
}
}
install
static install(Vue) {
if (CustomRouter.install.installed) {
return;
}
CustomRouter.install.installed = true;
_Vue = Vue;
_Vue.mixin({
beforeCreate: function () {
if (this.$options.router) {
_Vue.prototype.$router = this.$options.router;
this.$options.router.init();
}
}
});
}
vue-router源码中 install 函数
import View from './components/view'
import Link from './components/link'
export let _Vue
export function install (Vue) {
// 判断是否已经注册过
if (install.installed && _Vue === Vue) return
install.installed = true
_Vue = Vue
const isDef = v => v !== undefined
const registerInstance = (vm, callVal) => {
let i = vm.$options._parentVnode
if (isDef(i) && isDef(i = i.data) && isDef(i = i.registerRouteInstance)) {
i(vm, callVal)
}
}
Vue.mixin({
beforeCreate () {
if (isDef(this.$options.router)) {
this._routerRoot = this
this._router = this.$options.router
// 执行init 事件 初始化Router
this._router.init(this)
// 将_route 变成响应式
Vue.util.defineReactive(this, '_route', this._router.history.current)
} else {
this._routerRoot = (this.$parent && this.$parent._routerRoot) || this
}
registerInstance(this, this)
},
destroyed () {
registerInstance(this)
}
})
// 在 prototype 添加 $router 属性 每个组件可访问
Object.defineProperty(Vue.prototype, '$router', {
get () { return this._routerRoot._router }
})
// 在 prototype 添加 $route 属性 每个组件可访问
Object.defineProperty(Vue.prototype, '$route', {
get () { return this._routerRoot._route }
})
// 注册 RouterView RouterLink 组件
Vue.component('RouterView', View)
Vue.component('RouterLink', Link)
const strats = Vue.config.optionMergeStrategies
// use the same hook merging strategy for route hooks
strats.beforeRouteEnter = strats.beforeRouteLeave = strats.beforeRouteUpdate = strats.created
}
实现 hash
模式
hash
模式整体代码实现上和 history
基本一致,它监听的是 load
和 hashchange
事件,并在 router-link
组件点击时,设置 window.location
为 hash
形式。差异代码如下:
router-link
Vue.component("router-link", {
props: {
to: String
},
render(h) {
return h(
"a",
{
attrs: {
href: "#" + this.to
},
on: {
click: this.clickHandler
}
},
[this.$slots.default]
);
},
methods: {
clickHandler(e) {
window.location.hash = "#" + this.to;
this.$router.data.current = this.to;
e.preventDefault();
}
}
});
a 标签的 href 修改为了 hash 形式,clickHandler 点击事件处理中,window.location.hash 会被赋值为对应路由的 hash 形式。
hashchange
和 load
事件监听
onHashChange() {
if (!window.location.hash) {
window.location.hash = "#/";
}
this.data.current = window.location.hash.substr(1);
}
initEvent() {
window.addEventListener("load", this.onHashChange.bind(this));
window.addEventListener("hashchange", this.onHashChange.bind(this));
}
监听
hashchange
和load
事件。
代码示例
https://codesandbox.io/s/build-vue-router-q7nk8p
history
模式:historyMode.js。
hash
模式:hashMode.js。