业务背景,keep-alive+router-view实现多页签缓存,存在以下问题:
- 同组件缓存只有一份,解决方案:router-view加key为$route.fullPath
- 关闭同组件的页签时,如果include清空了该组件,则相同的组件打开的页签也会清除
解决方案:为每一个页面创建一个单独的组件
<router-view v-slot="{ Component, route }">
<keep-alive :include="cachedList">
<component :is="getComponent(Component,route)"/>
</keep-alive>
</router-view>
Com = {
name: path,
render() {
return h(Component);
},
}
这个是vue3的方案,但是vue2的router-view不支持slot
目前提供两种方案解决
- 修改
router-view源码使其支持 - 使用jsx方式编码,无需修改源码
ps: 个人使用浏览器内存分析,方案1暂未发现内存泄露情况。方案2存疑
修改router-view源码的方式
重写router-view,使其支持slot
在使用h函数的地方修改为使用renderChild
// render scoped slot
function renderChild(component, data, children) {
if (scopedSlots.default) {
return scopedSlots.default({ component, data, route, h });
}
return h(component, data, children);
}
使用新的router-view
注意例子中使用的是route.path,可能根据业务需要替换成route.fullPath
<!-- 业务文件 -->
<template>
<div>
<!-- 如果使用storeMap,则storeMap同时也要清除 -->
<button @click="cachedList = ['Index']">清除缓存</button>
<transition name="fade-transform" mode="out-in">
<keep-alive :include="cachedList">
<router-view v-if="!$route.meta.link">
<template v-slot:default="slotProps">
<component
:is="
getNameRouter(slotProps.h, slotProps.component, slotProps.route)
"
:key="key"
/>
</template>
</router-view>
</keep-alive>
</transition>
</div>
</template>
<script>
import RouterView from "./router-view";
// path路径转驼峰的大写开头,路径不能直接为name
function pathToCamelCase(path) {
return path
.replace(/-/g, "/")
.split("/")
.map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase())
.join("");
}
// 组件缓存
const storeMap = new Map();
export default {
components: { RouterView },
data() {
return {
// 在合适的时机新增
cachedList: [],
};
},
computed: {
key() {
return this.$route.path;
},
},
methods: {
getNameRouter(h, component, route) {
const path = this.$route.path;
// path => 驼峰
const name = pathToCamelCase(path);
let Com = storeMap.get(name);
// 不应该在这里加,这里只是为了测试
if (!this.cachedList.includes(name)) {
this.cachedList = [...this.cachedList, name];
}
// 每次使用最新的目前也没影响,storeMap可以去掉
// vue只检查了name
if (!Com) {
Com = {
name,
render: () => {
return h(component);
},
};
storeMap.set(name, Com);
}
return Com;
},
},
};
</script>
重写router-view的完整代码
// 重写router-view
function warn(condition, message) {
if (process.env.NODE_ENV !== "production" && !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, scopedSlots }) {
// 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 scoped slot
function renderChild(component, data, children) {
if (scopedSlots.default) {
return scopedSlots.default({ component, data, route, h });
}
return h(component, data, children);
}
// 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 renderChild(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 renderChild(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.`
);
}
}
}
使用jsx方式
预先生成router-view,然后不同路由构建不同组件。当删除缓存时,需要将组件缓存和keepalvie缓存同时删除。
如果简单一点,keep-alive始终缓存,只管理storeMap缓存。但是可能导致内存泄露,如果页面不多影响不大,页面过多最终可能卡死。
// 缓存组件
const storeMap = new Map();
// path路径转驼峰的大写开头
function pathToCamelCase(path) {
return path
.replace(/-/g, "/")
.split("/")
.map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase())
.join("");
}
export default {
data() {
return {
// keepalive缓存
cachedList: [],
};
},
computed: {
key() {
return this.$route.path;
},
},
methods: {
getComponent(h, component) {
const path = this.$route.path;
// path => 驼峰
const name = pathToCamelCase(path);
let Com = storeMap.get(name);
// 这里仅临时写法
if (!this.cachedList.includes(name)) {
this.cachedList = [...this.cachedList, name];
}
// 此处storeMap为必须
if (Com) {
return Com;
}
Com = {
name: name,
render: () => {
// 不能在这里渲染,将会有多个router-view实例
// return h("router-view", { key: this.key });
return component;
},
};
storeMap.set(name, Com);
return Com;
},
},
render(h) {
// const routerView = h("router-view", { key: this.key });
const that = this;
return h("section", { class: "app-main" }, [
h(
"el-button",
{
// 点击事件
on: {
click: () => {
that.cachedList = [];
storeMap.clear();
},
},
},
["移除缓存"]
),
h("transition", { attrs: { name: "fade-transform", mode: "out-in" } }, [
h("keep-alive", { includes: that.cachedList }, [
// 注意,此处h("router-view", { key: this.key })必须在这里构建
h(this.getComponent(h, h("router-view", { key: this.key }))),
]),
]),
]);
},
}