本篇文章将借助基于vue3讲解vue-router4是如何实现的,简化了很多源码逻辑,只关注核心原理去实现基本的路由系统。
课程目录
写在前面
接上一章,已经实现了浏览器history历史记录栈状态的存储和更新,说白了就是数据层的操作,并未跟vue关联起来,本章将通过浏览器历史记录栈与vue的响应式。
实现思路
在vue-router中用户需要调用API来操作浏览器历史记录栈,vue-router会将路由的router路由方法route路由属性暴露,用户可以调用相应的API使用对应的方法,需要根据上一章内容,将浏览器的历史记录状态与之双向响应提供给用户调用,在调用push和replaceAPI时匹配到对应的路由组件返回。
具体实现
hash模式和history模式
先来看一下vue-router官方实例的路由配置
// 1. 定义路由组件.
// 也可以从其他文件导入
const Home = { template: '<div>Home</div>' }
const About = { template: '<div>About</div>' }
// 2. 定义一些路由
// 每个路由都需要映射到一个组件。
// 我们后面再讨论嵌套路由。
const routes = [
{ path: '/', component: Home },
{ path: '/about', component: About },
]
// 3. 创建路由实例并传递 `routes` 配置
// 你可以在这里输入更多的配置,但我们在这里
// 暂时保持简单
const router = VueRouter.createRouter({
// 4. 内部提供了 history 模式的实现。为了简单起见,我们在这里使用 hash 模式。
history: VueRouter.createWebHashHistory(),
routes, // `routes: routes` 的缩写
})
// 5. 创建并挂载根实例
const app = Vue.createApp({})
//确保 _use_ 路由实例使
//整个应用支持路由。
app.use(router)
app.mount('#app')
// 现在,应用已经启动了!
官方示例提供一个createRouter方法,接收一个对象作为参数,history路由模式和routes是路由定义,路由模式是通过createWebHashHistory和createWebHistory来配置hash路由还是history路由,然后将createRouter返回值用use安装路由插件。因此我们也照葫芦画瓢接着修改上一章的代码。
上一章的代码已经实现了history路由模式,因此我们只需要在hash.js中实现hash模式即可,在vue-router4中,由于无需做兼容处理,所以已经放弃使用hashchang来实现hash路由了,而是统一通过pushState和replaceState方法。
// hash.js
import { createWebHistory } from './html5.js';
function createWebHashHistory() {
return createWebHistory('#');
}
// index.js
import { createWebHistory } from './history/html5';
import { createWebHashHistory } from './history/hash';
function createRouter() {
}
export {
createRouter,
createWebHistory,
createWebHashHistory
}
- 代码5行,调用
createWebHistory方法,传入一个#标识创建hash模式。
修改createWebHistory代码
// html5.js
...省略代码
function createCurrentHistoryLocation(base) {
const { pathname, hash, search } = window.location;
let hashUrl = hash;
const hasWell = base.indexOf('#'); // hash模式,路径不要#号,把#号去掉
if (hasWell > -1) {
hashUrl = hashUrl.slice(1) === '' ? '' : hashUrl.slice(2); // 如果是空补充/
}
return pathname + hash + search;
}
function useHistoryStateNavigation(base) {
// 路由信息
const currentLocation = {
value: createCurrentHistoryLocation(base)
};
...省略代码
function changeLocation(to, state, isReplace) {
const hasWell = base.indexOf('#'); // hash模式,路径不要#号,把#号去掉
const url = hasWell > -1 ? base + to : to;
history[isReplace ? 'replaceState' : 'pushState'](state, null, to);
historyState.value = state;
}
}
function useHistoryListeners(base, historyState, currentLocation) {
function popStateHandler({ state }) {
const to = createCurrentHistoryLocation(base);
...省略代码
}
}
...省略代码
export function createWebHistory(base = '') {
const historyNavigation = useHistoryStateNavigation(base);
const historyListeners = useHistoryListeners(base,
...省略代码
}
- 代码34行,
createWebHistory新增参数base作为hash的标记 - 代码3行,接收参数,主要修改两个点
createCurrentHistoryLocation创建路由信息时,如果base存在,需要将location信息中的#号去掉。- 与之对应,在修改路由信息时
changeLocation需要重新将#号恢复再重新保存信息。
由于文章长度原因,省略无需修改的代码,完整代码可点击代码块script查看
路由配置
跟着官方配置我们自己的路由信息,src目录下新建router、index.js
.
├── README.md
├── index.html
├── jsconfig.json
├── package.json
├── src
│ ├── App.vue
│ ├── components
│ ├── main.js
│ ├── router
│ │ └── index.js
│ ├── view
│ │ ├── AboutView.vue
│ │ └── HomeView.vue
│ └── vue-router
│ ├── history
│ │ ├── hash.js
│ │ └── html5.js
│ └── index.js
└── vite.config.js
修改/router/index.js
// /router/index.js
import { createRouter, createWebHistory } from '@/vue-router'
import HomeView from '../views/HomeView.vue'
import About from '../views/AboutView.vue'
import A from '../components/A.vue';
import B from '../components/B.vue';
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/',
name: 'home',
component: HomeView,
children: [
{
path: '/a',
name: 'a',
component: A
},
{
path: '/b',
name: 'b',
component: B
}
]
},
{
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: About
}
]
})
export default router
修改main.js
import { createApp } from 'vue'
import App from './App.vue'
import router from '@/router'
createApp(App).use(router).mount('#app')
具体路由配置可自定义,这里仅作为示例
createRouter的实现
根据vue插件文档,定义插件需要提供install方法,在Vue.use方法中会默认调用install方法进行插件的注册,修改index.js。
function createRouter(options) {
const router = {
install(app) {
}
}
return router;
}
其中app是注入的vue实例,而options,就是在路由配置中传入的history路由模式和routes路由定义,先讲解下处理routes。
根据上述路由配置,假设当前需要跳转/a路由,那么就需要匹配出/a路由需要加载的组件HomeView和A两个,因此对于用户配置的路由信息,我们需要将其格式化一遍,方便后续路路由匹配出对应的组件。
将所有的路由记录
record拍平,并且构建一个父子关系,可以很方便的找到其父记录
// vue-router/index.js
/**
* 标化路由配置信息
*/
function normalizeRouteRecord(record) {
return {
path: record.path, // 路径
meta: record.meta || {},
children: record.children || [],
name: record.name,
beforeEnter: record.beforeEnter, // 路由守卫
components: {
default: record.component
}
};
}
// 构建父子关系
function createRouteRecordMatcher(record, parent = null) {
const matcher = {
path: record.path,
record,
parent,
children: []
};
if (parent) {
parent.children.push(matcher);
}
return matcher;
}
/**
* 格式化路由配置信息
* @param {Array} - routes
*/
function createRouterMatcher(routes) {
const matchers = [];
function addRoute(record, parent) {
let mainNormalizedRecord = normalizeRouteRecord(record);
const matcher = createRouteRecordMatcher(mainNormalizedRecord, parent);
if (mainNormalizedRecord.children.length) {
for (let i = 0; i < mainNormalizedRecord.children.length; i++) {
addRoute(mainNormalizedRecord.children[i], matcher);
}
}
matchers.push(matcher);
}
// 递归遍历所有的router
routes.forEach((route) => addRoute(route));
}
function createRouter(options) {
const matcher = createRouterMatcher(options.routes);
const router = {
install(app) {}
};
return router;
}
- 代码53行,接收到用户配置路由信息,传入声明的
createRouterMatcher进行格式化,这是一个路由匹配器,得到一个格式化后的结果。 - 代码36行,
createRouterMatcher内有addRoute方法,接收两个参数,第一个参数record路由信息,第二个参数parent- 代码49行,
forEach循环所有的路由信息,将其传入实现的addRoute方法。 - 代码39行,
normalizeRouteRecord方法是路由配置包含的的信息,返回值将其命名为mainNormalizedRecord - 代码41行,循环路由配置信息并递归调用
addRoute方法, - 代码19行,构建父子关系,如果有父亲,则把当前的
record推入父亲的children中。
- 代码49行,
最后打印看看结果便于理解代码:
路由的响应式
假设用户调用APIpush或replace时,需要根据传入的路径匹配到对应的路由信息交给页面进行渲染,那么数据改变页面更新就代表着这个值必须是响应式的,先看代码,再解释。
// vue-router/index.js
...省略代码
const STARE_LOCATION_NORMALIZED = {
// 初始化路由系统中的默认参数
path: '/',
// params: {}, // 路由参数
// query: {}, // 查询参数
matched: [] // 当前路径匹配到的record记录
};
function createRouter(options) {
const matcher = createRouterMatcher(options.routes);
const currentRoute = shallowRef(STARE_LOCATION_NORMALIZED); // 创建响应式数据,shallowRef进行浅代理
const router = {
install(app) {
// 将STARE_LOCATION_NORMALIZED所有属性设置成computed
const reactiveRoute = {};
for (let key in STARE_LOCATION_NORMALIZED) {
reactiveRoute[key] = computed(() => currentRoute.value[key]);
}
}
};
return router;
}
- 代码13行,当路由跳转时都是覆盖赋值的,因此我们只需要对
currentRoute进行浅代理。 - 代码17行,当插件注册时,通过
computed计算所有的currentRoute属性,这样做的好处是,我们结构reactiveRoute的属性时并不会失去响应式。
现在已经具备一个响应式的路由信息,当首次进入页面时,需要做两件事:
- 根据路径匹配路由信息。
- 将路由状态信息初始化到浏览器的
history.state。
function createRouter(options) {
const matcher = createRouterMatcher(options.routes);
const routerHistory = options.history;
const currentRoute = shallowRef(STARE_LOCATION_NORMALIZED); // 创建响应式数据,shallowRef进行浅代理
function finalizeNavigation(to, from, replace) {
// 这里需要注入路由钩子,在跳转之前可以做拦截
if (from === STARE_LOCATION_NORMALIZED || replace) {
// 代表第一次跳转路由,调用重定向
routerHistory.replace(to.path);
} else {
routerHistory.push(to.path);
}
currentRoute.value = to; // 更新最新路径
}
function pushWithRedirect(to) {
// 通过路径匹配对应的路由record记录,更新currentRoute
const targetLocation = matcher.resolve(to);
const from = currentRoute.value;
finalizeNavigation(targetLocation, from);
}
// 路由跳转
function push(to) {
return pushWithRedirect(to);
}
const router = {
push,
install(app) {
// 将STARE_LOCATION_NORMALIZED所有属性设置成computed
const reactiveRoute = {};
for (let key in STARE_LOCATION_NORMALIZED) {
reactiveRoute[key] = computed(() => currentRoute.value[key]);
}
if (currentRoute.value === STARE_LOCATION_NORMALIZED) {
// 如果等于默认的路由信息,代表是初始化时,路由系统需要进行一次跳转,让路径能匹配到路由
push(routerHistory.location);
}
}
};
return router;
}
- 代码3行,获取
options.history实例,也就是第一章中实现的push、replace方法和location、state历史记录信息。 - 代码33行,如果
currentRoute.value为初始值,则代表第一次进入- 代码35行,调用push方法,默认执行一次路由跳转
- 代码17行,调用匹配器的
resolve方法匹配路由记录,稍后实现。 - 代码5行,根据入参调用路由实例的
push或replace方法更新历史记录状态。 - 代码13行,把匹配结果更新到
currentRoute中。
接下来看看匹配器createRouterMatcher的匹配路由方法resolve实现
// 路由匹配器
function createRouterMatcher(routes) {
const matchers = [];
...代码省略
// 路由匹配
function resolve(location) { // / => { path: '/', matched: [HomeRecord] } /a => { path: '/a', matched: [HomeRecord, aRecord] }
const p = location.path;
const matched = [];
let matcher = matchers.find(item => item.path === p);
while(matcher) {
matched.unshift(matcher.record);
matcher = matcher.parent;
}
return {
path: p,
matched,
}
}
...代码省略
return {
resolve
};
- 代码9行,
find匹配到与之对应的路由记录 - 代码10行,循环匹配到的路由记录,并取出
parent记录,记住父记录必须要放在匹配结果的头部,因为渲染时是从头开始渲染的。
现在切换路由时已经拿到了对应的记录,还知道需要渲染的哪些组件,但还有一个弊端,就是用户操作浏览器前进、后退时也需要响应式的匹配路由记录,所以就用到了第一章所实现的listen方法。
...省略代码
function createRouter(options) {
let ready;
function markAsReady() {
if (ready) return;
ready = true;
routerHistory.listen((to) => {
const targetLocation = resolve(to);
const from = currentRoute.value;
finalizeNavigation(targetLocation, from, true);
});
}
function finalizeNavigation(to, from, replace) {
...省略代码
// 如果是初始化,需要调用listen去监听浏览器前进后退,触发更新currentRoute,只执行一次
markAsReady();
}
}
- 代码17行,因为每次路由跳转都会走到
finalizeNavigation方法,但是我们只需要调用一次监听popstate,所以需要确保只执行一次。 - 代码5行,
markAsReady方法,进入后将ready修改,确保只执行一次代码,调用routerHistory实例的listen方法传入回调,当触发浏览器前进、后退时重新匹配路由记录并更新信息。
最后将所有的路由属性和方法暴露给用户调用
const router = {
push,
install(app) {
// 将STARE_LOCATION_NORMALIZED所有属性设置成computed
const reactiveRoute = {};
for (let key in STARE_LOCATION_NORMALIZED) {
reactiveRoute[key] = computed(() => currentRoute.value[key]);
}
app.config.globalProperties.$router = router; // 路由的方法,包括push,replace
Object.defineProperty(app.config.globalProperties, '$route', { // 路由的属性
enumerable: true, // 可枚举
get: () => unref(currentRoute)
});
app.provide('router', router); // 路由的方法,包括push,replace
app.provide('route location', reactive(reactiveRoute)); // 路由的属性
if (currentRoute.value === STARE_LOCATION_NORMALIZED) {
// 如果等于默认的路由信息,代表是初始化时,路由系统需要进行一次跳转,让路径能匹配到路由
push(routerHistory.location);
}
}
};
- 代码9行,将当前的
router实例挂载到vue全局属性中 - 代码10行,进行了一次拆包,目的是为了调用
currentRoute时不需要再.value使用。 - 代码14行,关于
vue3的composition API需要调用路由方法useRoute和useRouter来获取路由的属性和方法,其中就是使用provide注入,然后再调用useRoute时利用inject获取该方法的。