vue2多路由指向同一组件 实现keep-alive缓存页面的解决方案

2,848 阅读3分钟

业务背景,keep-alive+router-view实现多页签缓存,存在以下问题:

  1. 同组件缓存只有一份,解决方案:router-view加key为$route.fullPath
  2. 关闭同组件的页签时,如果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

目前提供两种方案解决

  1. 修改router-view源码使其支持
  2. 使用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 }))),
        ]),
      ]),
    ]);
  },
}