通过分析vue-router核心流程逻辑,分离出一个能跑的最小的框架。
前端路由
前端可以通过监听url的变化从而无刷新更新页面,比直接location.reload()体验好,我们熟悉的spa甚至微前端都是基于前端路由展开的。实现无刷更新页面有两种方式
hash, url中带有#,如#/ahistory, 不带#,如/a
hash路由
通过监听hashchange事件,hash一旦改变就会进入回调,此时我们可以做一些ajax操作无刷新更新页面内容。
<a href="#/a">A</a>
<a href="#/b">B</a>
<script>
window.addEventListener('hashchange', () => {
console.log(location.hash);
// dosomething Ajax rerender
})
</script>
history属于HTML5规范,可以监听popstate事件,但是popstate事件的回调只有下面才会触发
- 点击浏览器前进后退
- 手动调用
history,back,forward,go方法 - 改变当前锚点,每次改变
history都会增加一条活动条目
<a href="/a">home</a>
<a href="/b">about</a>
<p>触发popstate</p>
<script>
window.addEventListener('click', e => {
const target = e.target;
if (target.tagName === 'A') {
e.preventDefault();
history.pushState(null, '', target.getAttribute('href'))
}
})
window.addEventListener('popstate', () => {
console.log(location.pathname, location.hash);
// dosomething Ajax rerender
})
// 锚点改变
const p = document.querySelector('p');
p.addEventListener('click', () => {
// 会触发popstate
location.hash = '#/' + Math.random().toString(10).slice(2, 6)
})
</script>
分离出一个能跑的最小的框架,要先知道vue-router上面有什么特性,最起码我们知道VueRouter是一个class, 实例上面有push, go等方法。vue-router作为Vue的一个插件,那么就必须遵守插件的机制,需要暴露一个install方法。
最小框架结构
最小框架可以拆分为下面的部分,接下来我们来实现每个部分。
> tree
.
├── create-matcher.js // 创建路由映射表,匹配当前路由
├── history
│ ├── base.js // 路由基类
│ └── hash.js // 常用hashRouter
├── index.js // 主入口,暴露VueRouter
├── install.js // install方法用于Vue.use
├── link.js // router-link组件实现
├── route.js // 计算route的matched,用于多层级router-view
├── utils.js // 一些工具函数
└── view.js // router-view组件实现
Vue.install & install
node_modules/vue/src/core/global-api/use.js
/* @flow */
import { toArray } from '../util/index'
export function initUse (Vue: GlobalAPI) {
Vue.use = function (plugin: Function | Object) {
const installedPlugins = (this._installedPlugins || (this._installedPlugins = []))
// 避免重复安装同一个插件
if (installedPlugins.indexOf(plugin) > -1) {
return this
}
// 把arguments伪数组转换为真数组
const args = toArray(arguments, 1)
// 把Vue作为第一个参数传入 install(Vue) {}
args.unshift(this)
if (typeof plugin.install === 'function') {
// 调用插件的install方法
plugin.install.apply(plugin, args)
} else if (typeof plugin === 'function') {
plugin.apply(null, args)
}
installedPlugins.push(plugin)
return this
}
}
install.js
import View from './view';
import Link from './link';
export default function install(Vue) {
// 全局混入到构造函数的$options上,每个实例的beforeCreate都会追加一个下面的周期函数
Vue.mixin({
beforeCreate() {
// 说明当前组件是vue的根实例
if (this.$options.router) {
// 增加一个属性指向自己
this._routerRoot = this;
this._router = this.$options.router;
// 调用实例上init方法
this._router.init(this);
// _route将会是一个响应式属性,如果被修改那么就会触发渲染watcher更新,从而更新视图
Vue.util.defineReactive(this, '_route', this._router.history.current);
} else {
// 多叉树
// 如果是子组件,使子组件的_routerRoot都指向父组件实例
// 最终所有的子组件的_routerRoot都指向都指向根组件实例
this._routerRoot = (this.$parent && this.$parent._routerRoot) || this;
}
},
});
// 原型上定义响应式属性$router, 当我们在子组件组件使用时都是指向根组件上的_routerRoot的_router
Object.defineProperty(Vue.prototype, '$router', {
get() {
return this._routerRoot._router;
},
});
// 原型上定义响应式属性$route, 当我们在子组件组件使用时都是指向根组件上的_routerRoot的_route
Object.defineProperty(Vue.prototype, '$route', {
get() {
return this._routerRoot._route;
},
});
// 注册组件
Vue.component('router-view', View);
Vue.component('router-link', Link);
}
可以看到在install的时候,会注册两个全局的组件,router-view和router-link
router-link & router-view
router-link的props包含to、tag等。其中tag默认是a标签,那么router-link的实现可以是这样子。
<router-link to='/a'>GOTO A</router-link>
router-link.js
export default {
name: 'RouterLink',
props: {
to: {
type: String,
require: true,
},
tag: {
type: String,
default: 'a',
},
},
render(h) {
// 点击的时候路由跳转
const onClick = (e) => {
e.preventDefault();
this.$router.push(this.to);
};
// 直接使用render函数生成vnode,如果使用jsx或者SFC需要额外的编译处理
return h(
this.tag,
{
on: {
click: onClick,
},
},
// 默认插槽作为children
this.$slots.default
);
},
};
router-view.js
router-view是负责渲染当前路由匹配的组件,目前先给个固定展示,等实现了route再回来看。
export default {
name: 'RouterView',
functional: true,
render(h, props) {
return h('div', 'I am router-view');
}
}
create-matcher
我们往往都是这样去用VueRouter
import VueRouter from 'vue-router';
import Vue from 'vue';
Vue.use(VueRouter);
export default new VueRouter({
mode: 'hash',
routes: [
{
path: '/',
component: {
render(h) {
return <div>HOME</div>
},
}
},
{
path: '/me',
component: {
render(h) {
return <div>ME</div>
},
}
}
]
});
因此VueRouter需要接收一个这么一个参数
type Route = {
path: string,
component: ComponentInstance
}
type RouterOptions = {
mode: string,
routes: Route[]
}
所以router-view组件渲染的内容,可以通过遍历routes数组,找出匹配的,然后渲染匹配的component
export default {
name: 'RouterView',
functional: true, // 函数式组件,没有实例
render(h, props) {
// todo
// const matched 一些查找匹配的操作找到对应的路由,比如this.$router.match(currentHash)
return h(matched.component, props.data);
}
}
我们需要定义一种key-map结构用来映射路由关系,这样子每次通过path就可以直接匹配上,只需要一次遍历就可以生成key-map, 而不是每次通过遍历数组找到目标路由配置。
// path-map
const pathMap = {
'/a': {
path: '/a',
component: { render: f },
meta: {},
parent: null
},
'/b': {
path: '/b',
component: { render: f },
meta: {},
parent: null
},
}
create-matcher.js
- 创建一个路由映射表
pathMap - 提供一个匹配当前路由
match,返回的值就是平时我们经常操作的$route addRouteRecord递归生成键值对,拼接上父的path
import { normalizePath } from './utils';
import { createRoute } from './route';
export function createMatcher(routes) {
// 创建一个路由映射表
// {
// '/a': {
// path: '/a',
// component: { render: f },
// meta: {},
// parent: null
// },
// '/b': {
// path: '/b',
// component: { render: f },
// meta: {},
// parent: null
// },
// }
const pathMap = createRouteMap(routes);
const getRoutes = () => {
return pathMap;
};
const match = (raw) => {
// 兼容path是对象的情况,$router.push({path: '/a'})
const pathConfig = typeof raw === 'string' ? { path: raw } : raw;
// matchRoute是暴露给用户的路由,我们修改当前路由属性都是操作这个对象
// 结构是这样的,是不是很熟悉,使用vue-devtool的时候
// {
// fullPath: "/b"
// hash: ""
// matched: [{…}]
// meta: {}
// name: undefined
// params: {}
// path: "/b"
// query: undefined
// }
const matchRoute = createRoute(pathMap[pathConfig.path], pathConfig);
return matchRoute;
};
// 暴露路由匹配的方法
return {
getRoutes,
match,
};
}
export function createRouteMap(routes) {
const pathMap = Object.create(null);
routes.forEach((route) => {
addRouteRecord(pathMap, route, null);
});
return pathMap;
}
// dfs生成路由键值对
export function addRouteRecord(pathMap, route, parent) {
const path = route.path;
// 如果当前是children,那么拼接接上父的path
// {
// '/b/child': {
// path: 'b',
// component: { render: f },
// meta: {},
// parent: {path: '/b', component: { render: f }, }
// },
// }
// 拼接父的path的方法 '/b/child'
const normalizedPath = normalizePath(path, parent);
const record = {
path: normalizedPath,
component: route.component,
meta: route.meta,
parent,
};
if (route.children) {
route.children.forEach((child) => {
addRouteRecord(pathMap, child, record);
});
}
// 设置键值对
if (!pathMap[record.path]) {
pathMap[record.path] = record;
}
}
route.js
职责
- 定义当前匹配的路由
$route数据格式,比如query、path,meta参数等 - bfs计算
matched,这个参数用于route-view, 当前匹配的路由
export function createRoute(record, location) {
// 这个数据就是我们常常操作的$route, 表示当前匹配的
const route = {
name: location.name || (record && record.name),
meta: (record && record.meta) || {},
path: location.path || '/',
hash: location.hash || '',
query: location.query,
params: location.params || {},
fullPath: location.path,
// matched的作用是,为了处理嵌套类型的router-view
// 子组件存在children,那么router-view应该是先渲染父,然后父中的router-view再去渲染children
// <router-view>
// <router-view></router-view>
// </router-view>
// 那么对应的matched应该是这样的
// [
// {path: "/parent", components, ...},
// {path: "/parent/child", components, ...}
// ]
//
matched: record ? formatMatch(record) : [],
};
return Object.create(route);
}
function formatMatch(record) {
const res = [];
while (record) {
res.unshift(record);
record = record.parent;
}
return res;
}
// 一开始的初始化路由
export const START = createRoute(null, {
path: '/',
});
上面的matched参数,主要解决<route-view>嵌套的场景,如果一个路由配置有children,那么自身组件也是<route-view>的入口, 来完善一下route-view组件
export default {
name: 'RouterView',
functional: true,
render(_, { parent, data, children, props, _c }) {
// fuctional组件没有我们常用的this,他的第二个参数context代指当前上下文
// 给data加上一个标记
data.routeView = true;
let depth = 0;
// 在install方法的时候,每个组件都会有$route,都指向根组件的$route
const route = parent.$route;
// 为什么要向上遍历呢
// 因为vue的组件创建顺序是先父后子,这样一个dfs的过程,对于下面这种
// <router-view>
// <router-view></router-view>
// </router-view>
// 那么对应的matched应该是这样的
// [
// {path: "/parent", components, ...},
// {path: "/parent/child", components, ...}
// ]
while (parent) {
const vnodeData = parent.$vnode ? parent.$vnode.data : {};
if (vnodeData.routeView) {
depth++;
}
parent = parent.$parent;
}
// 此时depth=1
const component = route.matched[depth];
// _c就是$createElement, 负责生成一个vnode
return _c(component.component, data);
},
};
可以通过断点查看,当是二级路由时
index.js
- 导出了一个
class, 接收传入路由配置项,然后构造一个路由映射matcher表,其中match方法可以匹配当前路由 - 判断下当前的
mode, 默认是hash路由,实力化一个history, 这个实现我们放在后面 - 收集使用到
router的vue实例,每个增加监听一次的销毁周期函数,避免重复监听导致的内存问题 - 初始化当前history,增加路由变化时的回调
- 定义几个常用的方法
import install from './install';
import { createMatcher } from './create-matcher';
import { HashHistory } from './history/hash';
export default class VueRouter {
constructor(options) {
this.apps = [];
this.options = options;
this.matcher = createMatcher(options.routes);
this.mode = options.mode || 'hash';
switch (options.mode) {
case 'hash':
// 咋们只处理一个hash路由即可,histroy
this.history = new HashHistory(this);
break;
default:
console.error('invalid mode: ', mode);
}
}
match(raw) {
return this.matcher.match(raw);
}
init(app) {
// 为什么apps是一个数组,主要是有可能会出现一种全局的模态框,他是通过new Vue构造出来后拿到节点手动挂载到body尾部
// 那么这个vue实例跟根组件Root是隔离的,同时也想使用router的能力
// 那么此时路由更新,应该同时更新两个实例的状态,因此apps是一个数组
// 比如下面
// const globalDiaglog = props => {
// const vm = new Vue({
// router,
// store,
// render: h => h(Dialog, props)
// });
// const component = vm.$mount();
// // 将dialog的内容追加到body尾部
// document.body.appendChild(component.$el);
// return vm.$children[0];
// };
this.apps.push(app);
// 只监听一次卸载时间
app.$once('hook:destoryed', () => {
// 重置路由
// 移除监听事件
this.history.teardown();
});
// 防止多次初始化history
if (this.app) {
return
}
this.app = app;
const history = this.history;
// 首次主动渲染router-view
history.transitionTo(history.getCurrentLocation(), () => {
history.setupListeners();
});
// 当更改hash或者history,主动更新视图
history.listen((route) => {
// 批量更新
this.apps.forEach((app) => {
// 之前说过_route是通过defineProperty设置的响应式属性,
// 当_route被修改的时候会触发根实例的渲染watcher更新,从而更新视图
app._route = route;
});
});
}
replace(location, onComplete) {
this.history.replace(location, onComplete);
}
push(location, onComplete) {
this.history.push(location, onComplete);
}
go(n) {
this.history.go(n);
}
}
VueRouter.install = install;
显然上面的逻辑不算复杂,关键就是HashHistory类是怎么实现,而HashHistory是继承于父类History, 为什么要有父类,因为除了HashHistory,还有
HtmlHistory不带#AbstractHistory,服务端渲染使用的模式
他们都有一些公共的属性和方法,为了避免重复代码,需要一个父类统一模型,子类可以自行扩展装饰其他方法。
history/bash.js
- 定义核心方法
transitionTo, 他会匹配我们映射表,更新响应式根组件响应式属性$route, 视图更新 teardown移除监听事件,组件卸载的时候放在内存泄露问题
import { START } from '../route';
export class History {
constructor(router) {
this.router = router;
// 当前匹配的路由
this.current = START;
this.listeners = [];
}
// 这个方法是让响应式数据route变化,来触发异步更新策略
updateRoute(route) {
this.current = route;
this.cb && this.cb(route);
}
listen(cb) {
this.cb = cb;
}
// 核心方法
// 用于跳转切换路由
transitionTo(location, onComplete) {
// 这个match方法是是VueRouter我们自己定义的
const route = this.router.match(location);
// 更新路由,更新视图
this.updateRoute(route);
// 成功后的一个回调
onComplete && onComplete(route);
}
teardown() {
// clean up event listeners
// https://github.com/vuejs/vue-router/issues/2341
// 重置路由,删除监听事件
this.listeners.forEach((cleanupListener) => {
cleanupListener();
});
this.listeners = [];
// reset current history route
// https://github.com/vuejs/vue-router/issues/3294
this.current = START;
this.pending = null;
}
}
history/hash.js
- 首次完成路由跳转后,
setupListeners加上路由监听事件,为什么要加上,当你不使用api的方式push路由的时候,而是手动在地址栏修改hash+enter键,也能更新页面 - 暴露几个常用的
push、replace方法,底层调用的父类的的transition方法
import { History } from './base';
import { supportsPushState } from '../utils';
export class HashHistory extends History {
constructor(router) {
super(router); // 继承父类
}
setupListeners() {
if (this.listeners.length > 0) {
return;
}
const router = router;
const handleRoutingEvent = (e) => {
console.log('popstate', e)
// 父类base的跳转方法
this.transitionTo(getHash(), (route) => {
// 完成后同步同步更新下url地址
this.ensureURL();
});
};
// 之前说过改变锚点,实际上就是使用hash的时候改变hash,也会触发popstate事件
const eventType = supportsPushState ? 'popstate' : 'hashchange';
window.addEventListener(eventType, handleRoutingEvent);
this.listeners.push(() => {
window.removeEventListener(eventType, handleRoutingEvent);
});
}
// 确保hash是更新了
ensureURL() {
const { fullPath } = this.current.fullPath;
const hash = location.hash;
if (hash.substr(1) === fullPath) {
return false;
}
window.location.hash = hash;
}
push(location) {
this.transitionTo(location, (route) => {
// 更新hash
pushHash(route.fullPath);
});
}
replace(location) {
this.transitionTo(location, (route) => {
// 替换url
window.location.replace(getUrl(route.fullPath));
});
}
getCurrentLocation() {
return getHash();
}
go(n) {
window.history.go(n);
}
}
// 更新hash
function pushHash(path) {
window.location.hash = path;
}
export function getHash() {
// hack一下浏览器的差异
// We can't use window.location.hash here because it's not
// consistent across browsers - Firefox will pre-decode it!
let href = window.location.href;
const index = href.indexOf('#');
// empty path
if (index < 0) {
location.hash = '#/';
return '/';
}
href = href.slice(index + 1);
return href;
}
// 获取当前完整url
function getUrl(path) {
const href = window.location.href;
const i = href.indexOf('#');
const base = i >= 0 ? href.slice(0, i) : href;
return `${base}#${path}`;
}
最后
经过上面对每个核心文件的解析,我们已经抽取出最小能跑的框架,当然这是十分简陋,比如vue-router提供的各种路由钩子和怎么处理异步组件的,我们都没有提到,但是这是一个渐进的过程,通过断点源码调试慢慢学习即可。本人文笔有限,所以各位还是多通过断点调试学习吧