解决方案
- 使用视图嵌套路由相同的方式,让子页面的路由包含父页面的路由,从而保证父页面必须的路由参数存在
- 改造 router-view 组件,使其不仅能够满足嵌套路由的需求同时能够满足嵌套路由中某层不包含组件配置项的需求
代码
- routes.ts
import { RouteConfig } from "vue-router";
import orderFormListVue from "./order-form-list.vue";
// 根据功能块划分页面集合,在 views 中建立对应的文件夹
// 下面的例子中,订单列表页 是 订单详情页 的父页面,订单详情页 是 订单物流信息页 的父页面
// 面包屑在导航时会存在父级页面的参数无从获取的问题,所以通过类似视图视图嵌套的写法实现路由嵌套,
// 使子页面的的路由包含父页面的路由,但是这只能解决通过 params 传递的参数,无法解决通过 query 传递的参数
// 在页面开发时,应当为通过 query 传递的参数设置默认值
// 例如:订单的物流信息页 通过 面包屑 跳转到 订单详情页 时可以拿到 orderFormId,因为它的 url 中已经包含了这个参数,
// 但是却无法拿到通过 query 传递的参数,比如:?status=edit(编辑)/see(查看)。一般建议在页面中给 status 初始值为 see 来解决。
const route: RouteConfig = {
// 每个功能块的根 path,和这个功能块的文件名相同,避免不同功能块之间冲突
path: "/order-form",
// 按照vue-touter的规范,含有children的视图嵌套,每一个层级都要有对应的组件
// 在router-view这个组件中会根据嵌套的层级去找到对应使用的组件
// 这里为了实现面包屑导航对router-view进行了改造,它将不再寻找相同层级的组件,而是会查找匹配路径中含有组件的下一个层级
// 比如:'/order-form/'匹配到了当前路由和 name:'order-form-list',本来 router-view 将按层级渲染当前路由中的组件,这将会造成错误
// 改造之后 auto-match-router-view 将向下一层匹配的路由寻找是否存在可以被渲染的组件,于是便匹配到了 order-form-detail.vue
children: [
// 订单列表页
{
path: "",
name: "order-form-list",
component: orderFormListVue,
meta: {
// 约定:面包屑的标题,只有含有这个标题的才会作为面包屑的一个层级
title: "订单列表页",
},
},
{
path: "detail/:orderFormId",
children: [
// 订单详情页
{
path: "",
name: "order-form-detail",
component: () =>
import(
/* webpackChunkName: "orderForm" */ "./order-form-detail.vue"
),
meta: {
title: "订单详情页",
},
},
// 订单的物流信息页
{
path: "logistics/:logisticsId",
name: "order-form-logistics",
component: () =>
import(
/* webpackChunkName: "orderForm" */ "./order-form-logistics.vue"
),
meta: {
title: "订单的物流信息页",
},
},
],
},
],
};
export default route;
- auto-match-router-view.vue
- vue3 的 router 默认支持,不需要改造
- vue2 考虑用重定向或别名来实现
<script>
function getMatchedDepth(matched, startIndex) {
for (let i = startIndex; i < matched.length; i++) {
const m = matched[i];
if (Object.values(m.components).some((c) => c)) {
return i;
}
}
return startIndex;
}
function warn(condition, message) {
if (!condition) {
typeof console !== "undefined" && console.warn(`[vue-router] ${message}`);
}
}
function extend(a, b) {
for (const key in b) {
a[key] = b[key];
}
return a;
}
function handleRouteEntered(route) {
for (let i = 0; i < route.matched.length; i++) {
const record = route.matched[i];
for (const name in record.instances) {
const instance = record.instances[name];
const cbs = record.enteredCbs[name];
if (!instance || !cbs) continue;
delete record.enteredCbs[name];
for (let i = 0; i < cbs.length; i++) {
if (!instance._isBeingDestroyed) cbs[i](instance);
}
}
}
}
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;
// 其他代码来源于 router-view,增加了这个匹配序号获取,并在后面的组件选择中使用到
// 视图嵌套路由中,根据嵌套层级寻找匹配到的路由中含组件的最近路由,并返回序号
let matchedDepth = getMatchedDepth(route.matched, 0);
let inactive = false;
while (parent && parent._routerRoot !== parent) {
const vnodeData = parent.$vnode ? parent.$vnode.data : {};
if (vnodeData.routerView) {
depth++;
matchedDepth = getMatchedDepth(route.matched, matchedDepth + 1);
}
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[matchedDepth];
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.`
);
}
}
}
</script>
- router.ts
import Vue from "vue";
import VueRouter, { RouteConfig } from "vue-router";
import { setBreadcrumbData } from "@/common/breadcrumb";
import { setPageCacheData } from "@/common/pageCache";
Vue.use(VueRouter);
// 自动加载 views/*/routes.ts 文件生成路由列表,避免所有开发都修改这个文件引起冲突
const routeContext = require.context(
"../views",
true,
/^\.\/[^/]+\/routes.ts$/
);
const routes: Array<RouteConfig> = routeContext.keys().map((key) => {
return routeContext(key).default;
});
const router = new VueRouter({
mode: "history",
base: process.env.BASE_URL,
routes,
});
// 设置面包屑的数据
router.afterEach(setBreadcrumbData);
export default router;
- breadcrumb.ts
import router from "@/router";
import { Ref, ref } from "vue";
import { Route, RouteRecord } from "vue-router";
import { compile } from "path-to-regexp";
import { Dictionary } from "vue-router/types/router";
export const breadcrumbData: Ref<Route[]> = ref([]);
export function getFatherPageRoute(
record: RouteRecord,
params: Dictionary<string>
) {
const { path } = record;
const toPath = compile(path);
const pagePath = toPath(params) + "/";
const { route: pageRoute } = router.resolve(pagePath);
if (pageRoute.meta?.title) return pageRoute;
return undefined;
}
export function setBreadcrumbData(to: Route) {
const routes: Route[] = [];
const { matched } = to;
for (let i = 0; i < matched.length; i++) {
const fatherPageRoute = getFatherPageRoute(matched[i], to.params);
if (fatherPageRoute) {
routes.push(fatherPageRoute);
}
if (fatherPageRoute?.fullPath === to.fullPath) break;
}
breadcrumbData.value = routes;
}