toy-vue-router

656 阅读6分钟

通过分析vue-router核心流程逻辑,分离出一个能跑的最小的框架。

前端路由

前端可以通过监听url的变化从而无刷新更新页面,比直接location.reload()体验好,我们熟悉的spa甚至微前端都是基于前端路由展开的。实现无刷更新页面有两种方式

  • hash, url中带有#,如#/a
  • history, 不带#,如/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组件实现

image.png

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-linkprops包含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数据格式,比如querypath, 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);
    },
};

可以通过断点查看,当是二级路由时

image.png 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提供的各种路由钩子和怎么处理异步组件的,我们都没有提到,但是这是一个渐进的过程,通过断点源码调试慢慢学习即可。本人文笔有限,所以各位还是多通过断点调试学习吧

toy-vue-router