其他分享
首页 > 其他分享> > 解读keep-alive:Vue3中手动清理keep-alive组件缓存的一个解决方案

解读keep-alive:Vue3中手动清理keep-alive组件缓存的一个解决方案

作者:互联网

  用过vue的同学肯定对keep-alive组件不陌生,它允许我们使用key对组件进行缓存,当使用相同key的组件渲染时,就会使用缓存中的组件,这样可以加快渲染速度,特别是在使用路由跳转时,效果是很明显的,而缓存就意味着更多的内存消耗,但是很遗憾,keep-alive组件不允许我们手动释放,我们唯一能操作keep-alive组件的的地方就是三个属性:  

	interface KeepAliveProps {
		/**
		* 如果指定,则只有与 `include` 名称
		* 匹配的组件才会被缓存。
		*/
		include?: MatchPattern
		/**
		* 任何名称与 `exclude`
		* 匹配的组件都不会被缓存。
		*/
		exclude?: MatchPattern
		/**
		* 最多可以缓存多少组件实例。
		*/
		max?: number | string
	}
	
	type MatchPattern = string | RegExp | (string | RegExp)[]

  文档地址:https://cn.vuejs.org/api/built-in-components.html#keepalive

  想要随心所欲的清理keep-alive组件的缓存,显然这三个属性时满足不了我们的要求的。

  源码

  我们先看看keep-alive相关的源码:

KeepAlive相关部分源码
const KeepAliveImpl = {
    name: `KeepAlive`,
    // Marker for special handling inside the renderer. We are not using a ===
    // check directly on KeepAlive in the renderer, because importing it directly
    // would prevent it from being tree-shaken.
    __isKeepAlive: true,
    props: {
        include: [String, RegExp, Array],
        exclude: [String, RegExp, Array],
        max: [String, Number]
    },
    setup(props, { slots }) {
        const instance = getCurrentInstance();
        // KeepAlive communicates with the instantiated renderer via the
        // ctx where the renderer passes in its internals,
        // and the KeepAlive instance exposes activate/deactivate implementations.
        // The whole point of this is to avoid importing KeepAlive directly in the
        // renderer to facilitate tree-shaking.
        const sharedContext = instance.ctx;
        // if the internal renderer is not registered, it indicates that this is server-side rendering,
        // for KeepAlive, we just need to render its children
        if (!sharedContext.renderer) {
            return () => {
                const children = slots.default && slots.default();
                return children && children.length === 1 ? children[0] : children;
            };
        }
        const cache = new Map();
        const keys = new Set();
        let current = null;
        if ((process.env.NODE_ENV !== 'production') || __VUE_PROD_DEVTOOLS__) {
            instance.__v_cache = cache;
        }
        const parentSuspense = instance.suspense;
        const { renderer: { p: patch, m: move, um: _unmount, o: { createElement } } } = sharedContext;
        const storageContainer = createElement('div');
        sharedContext.activate = (vnode, container, anchor, isSVG, optimized) => {
            const instance = vnode.component;
            move(vnode, container, anchor, 0 /* MoveType.ENTER */, parentSuspense);
            // in case props have changed
            patch(instance.vnode, vnode, container, anchor, instance, parentSuspense, isSVG, vnode.slotScopeIds, optimized);
            queuePostRenderEffect(() => {
                instance.isDeactivated = false;
                if (instance.a) {
                    invokeArrayFns(instance.a);
                }
                const vnodeHook = vnode.props && vnode.props.onVnodeMounted;
                if (vnodeHook) {
                    invokeVNodeHook(vnodeHook, instance.parent, vnode);
                }
            }, parentSuspense);
            if ((process.env.NODE_ENV !== 'production') || __VUE_PROD_DEVTOOLS__) {
                // Update components tree
                devtoolsComponentAdded(instance);
            }
        };
        sharedContext.deactivate = (vnode) => {
            const instance = vnode.component;
            move(vnode, storageContainer, null, 1 /* MoveType.LEAVE */, parentSuspense);
            queuePostRenderEffect(() => {
                if (instance.da) {
                    invokeArrayFns(instance.da);
                }
                const vnodeHook = vnode.props && vnode.props.onVnodeUnmounted;
                if (vnodeHook) {
                    invokeVNodeHook(vnodeHook, instance.parent, vnode);
                }
                instance.isDeactivated = true;
            }, parentSuspense);
            if ((process.env.NODE_ENV !== 'production') || __VUE_PROD_DEVTOOLS__) {
                // Update components tree
                devtoolsComponentAdded(instance);
            }
        };
        function unmount(vnode) {
            // reset the shapeFlag so it can be properly unmounted
            resetShapeFlag(vnode);
            _unmount(vnode, instance, parentSuspense, true);
        }
        function pruneCache(filter) {
            cache.forEach((vnode, key) => {
                const name = getComponentName(vnode.type);
                if (name && (!filter || !filter(name))) {
                    pruneCacheEntry(key);
                }
            });
        }
        function pruneCacheEntry(key) {
            const cached = cache.get(key);
            if (!current || cached.type !== current.type) {
                unmount(cached);
            }
            else if (current) {
                // current active instance should no longer be kept-alive.
                // we can't unmount it now but it might be later, so reset its flag now.
                resetShapeFlag(current);
            }
            cache.delete(key);
            keys.delete(key);
        }
        // prune cache on include/exclude prop change
        watch(() => [props.include, props.exclude], ([include, exclude]) => {
            include && pruneCache(name => matches(include, name));
            exclude && pruneCache(name => !matches(exclude, name));
        }, 
        // prune post-render after `current` has been updated
        { flush: 'post', deep: true });
        // cache sub tree after render
        let pendingCacheKey = null;
        const cacheSubtree = () => {
            // fix #1621, the pendingCacheKey could be 0
            if (pendingCacheKey != null) {
                cache.set(pendingCacheKey, getInnerChild(instance.subTree));
            }
        };
        onMounted(cacheSubtree);
        onUpdated(cacheSubtree);
        onBeforeUnmount(() => {
            cache.forEach(cached => {
                const { subTree, suspense } = instance;
                const vnode = getInnerChild(subTree);
                if (cached.type === vnode.type) {
                    // current instance will be unmounted as part of keep-alive's unmount
                    resetShapeFlag(vnode);
                    // but invoke its deactivated hook here
                    const da = vnode.component.da;
                    da && queuePostRenderEffect(da, suspense);
                    return;
                }
                unmount(cached);
            });
        });
        return () => {
            pendingCacheKey = null;
            if (!slots.default) {
                return null;
            }
            const children = slots.default();
            const rawVNode = children[0];
            if (children.length > 1) {
                if ((process.env.NODE_ENV !== 'production')) {
                    warn(`KeepAlive should contain exactly one component child.`);
                }
                current = null;
                return children;
            }
            else if (!isVNode(rawVNode) ||
                (!(rawVNode.shapeFlag & 4 /* ShapeFlags.STATEFUL_COMPONENT */) &&
                    !(rawVNode.shapeFlag & 128 /* ShapeFlags.SUSPENSE */))) {
                current = null;
                return rawVNode;
            }
            let vnode = getInnerChild(rawVNode);
            const comp = vnode.type;
            // for async components, name check should be based in its loaded
            // inner component if available
            const name = getComponentName(isAsyncWrapper(vnode)
                ? vnode.type.__asyncResolved || {}
                : comp);
            const { include, exclude, max } = props;
            if ((include && (!name || !matches(include, name))) ||
                (exclude && name && matches(exclude, name))) {
                current = vnode;
                return rawVNode;
            }
            const key = vnode.key == null ? comp : vnode.key;
            const cachedVNode = cache.get(key);
            // clone vnode if it's reused because we are going to mutate it
            if (vnode.el) {
                vnode = cloneVNode(vnode);
                if (rawVNode.shapeFlag & 128 /* ShapeFlags.SUSPENSE */) {
                    rawVNode.ssContent = vnode;
                }
            }
            // #1513 it's possible for the returned vnode to be cloned due to attr
            // fallthrough or scopeId, so the vnode here may not be the final vnode
            // that is mounted. Instead of caching it directly, we store the pending
            // key and cache `instance.subTree` (the normalized vnode) in
            // beforeMount/beforeUpdate hooks.
            pendingCacheKey = key;
            if (cachedVNode) {
                // copy over mounted state
                vnode.el = cachedVNode.el;
                vnode.component = cachedVNode.component;
                if (vnode.transition) {
                    // recursively update transition hooks on subTree
                    setTransitionHooks(vnode, vnode.transition);
                }
                // avoid vnode being mounted as fresh
                vnode.shapeFlag |= 512 /* ShapeFlags.COMPONENT_KEPT_ALIVE */;
                // make this key the freshest
                keys.delete(key);
                keys.add(key);
            }
            else {
                keys.add(key);
                // prune oldest entry
                if (max && keys.size > parseInt(max, 10)) {
                    pruneCacheEntry(keys.values().next().value);
                }
            }
            // avoid vnode being unmounted
            vnode.shapeFlag |= 256 /* ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE */;
            current = vnode;
            return isSuspense(rawVNode.type) ? rawVNode : vnode;
        };
    }
};

function resetShapeFlag(vnode) {
    let shapeFlag = vnode.shapeFlag;
    if (shapeFlag & 256 /* ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE */) {
        shapeFlag -= 256 /* ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE */;
    }
    if (shapeFlag & 512 /* ShapeFlags.COMPONENT_KEPT_ALIVE */) {
        shapeFlag -= 512 /* ShapeFlags.COMPONENT_KEPT_ALIVE */;
    }
    vnode.shapeFlag = shapeFlag;
}

function getComponentName(Component, includeInferred = true) {
    return isFunction(Component)
        ? Component.displayName || Component.name
        : Component.name || (includeInferred && Component.__name);
}

function matches(pattern, name) {
    if (isArray(pattern)) {
        return pattern.some((p) => matches(p, name));
    }
    else if (isString(pattern)) {
        return pattern.split(',').includes(name);
    }
    else if (pattern.test) {
        return pattern.test(name);
    }
    /* istanbul ignore next */
    return false;
}

  查看源码可以发现,max、include、exclude三个属性的分别用在这几个地方:

  max的作用在渲染函数的最后:  

    if (cachedVNode) {
        // copy over mounted state
        vnode.el = cachedVNode.el;
        vnode.component = cachedVNode.component;
        if (vnode.transition) {
            // recursively update transition hooks on subTree
            setTransitionHooks(vnode, vnode.transition);
        }
        // avoid vnode being mounted as fresh
        vnode.shapeFlag |= 512 /* ShapeFlags.COMPONENT_KEPT_ALIVE */;
        // make this key the freshest
        keys.delete(key);
        keys.add(key);
    }
    else {
        keys.add(key);
        // prune oldest entry
        if (max && keys.size > parseInt(max, 10)) {
            pruneCacheEntry(keys.values().next().value);
        }
    }

  可以看到,如果没有设置max,keep-alive组件默认只缓存10个实例,而keys是一个Set对象,它不停地进行delete和add,就是调整组件的访问顺序,也就是说keys中最开头的那个就是最久未被访问的,这样当缓存达到上限后,就可以直接弹出第一个key进行释放。注意,这里仅仅是释放一个缓存,也就是说,如果开始时max=10,当缓存组件达到10个后,然后程序中将max设置成5,这时keep-alive组件只会清理一个缓存,也就是说,还有9个缓存组件!如果要将缓存组件数降下来,我们只能先设置max=9,然后设置max=8。。。。

  include、exclude属性作用在两个地方,一个是在渲染函数中,主要用于判断是否需要进行缓存:  

    let vnode = getInnerChild(rawVNode);
    const comp = vnode.type;
    // for async components, name check should be based in its loaded
    // inner component if available
    const name = getComponentName(isAsyncWrapper(vnode)
        ? vnode.type.__asyncResolved || {}
        : comp);
    const { include, exclude, max } = props;
    if ((include && (!name || !matches(include, name))) ||
        (exclude && name && matches(exclude, name))) {
        current = vnode;
        return rawVNode;
    }

  一个是在watch函数中,主要是监听include、exclude来调整缓存组件状态,需要注意的是,watch中flush设置为post:

    function pruneCache(filter) {
        cache.forEach((vnode, key) => {
            const name = getComponentName(vnode.type);
            if (name && (!filter || !filter(name))) {
                pruneCacheEntry(key);
            }
        });
    }
    function pruneCacheEntry(key) {
        const cached = cache.get(key);
        if (!current || cached.type !== current.type) {
            unmount(cached);
        }
        else if (current) {
            // current active instance should no longer be kept-alive.
            // we can't unmount it now but it might be later, so reset its flag now.
            resetShapeFlag(current);
        }
        cache.delete(key);
        keys.delete(key);
    }
    // prune cache on include/exclude prop change
    watch(() => [props.include, props.exclude], ([include, exclude]) => {
        include && pruneCache(name => matches(include, name));
        exclude && pruneCache(name => !matches(exclude, name));
    }, 
    // prune post-render after `current` has been updated
    { flush: 'post', deep: true });

  从上面的源码可以看到,无论在渲染函数中还是watch函数中,都需要使用两个重要的函数:getComponentName和matches  

	function getComponentName(Component, includeInferred = true) {
		return isFunction(Component)
			? Component.displayName || Component.name
			: Component.name || (includeInferred && Component.__name);
	}
	
	function matches(pattern, name) {
		if (isArray(pattern)) {
			return pattern.some((p) => matches(p, name));
		}
		else if (isString(pattern)) {
			return pattern.split(',').includes(name);
		}
		else if (pattern.test) {
			return pattern.test(name);
		}
		/* istanbul ignore next */
		return false;
	}

  getComponentName函数中的Component参数其实就是选项式Api中的 this.$.type 对象,或者组合式Api中setup中的 getCurrentInstance().type 对象,而这个type其实是组件的类型对象,同一个组件的多个实例共享同一个type对象,getComponentName函数中优先取它的name属性,没有则取 __name 属性。

  matches函数中的pattern参数可以是一个数组、逗号隔开的字符串、包含一个test函数的对象(不一定是ReExp对象,也可以是一个继承ReExp对象的自定义类型)。

  手动释放的一个解决方案

  从上面的源码可以看到,keep-alive组件的三个属性在能起到一些作用,如果想随心所欲的来清理keep-alive缓存,这三个属性就不够用了。

  除此之外,在源码中可以看到,keep-alive的缓存保存在一个Map对象中:

    const cache = new Map();
    const keys = new Set();
    let current = null;
    if ((process.env.NODE_ENV !== 'production') || __VUE_PROD_DEVTOOLS__) {
        instance.__v_cache = cache;
    }

  可以看到,这个缓存对象两种情况下会挂载到keep-alive组件中:

    1、process.env.NODE_ENV !== 'production':表明它是作用在开发环境下
    2、__VUE_PROD_DEVTOOLS__:表示是否启用了devtool

  对于process.env.NODE_ENV变量:

  如果是webpack4,可以在.env文件中设置NODE_ENV变量值进行覆盖。

  如果是webpack5,貌似它的值就不能改动了,一般在开发环境中,它的值是development,build之后就是production

  对于__VUE_PROD_DEVTOOLS__变量,我们只需要启用即可,添加vue.config.js,内容如下:  

    module.exports = {
      configureWebpack: {
        devtool: "source-map",  //设置成false表示关闭
      },
      chainWebpack: (config) => {
        config.plugin("define").tap((definitions) => {
          for (const definition of definitions) {
            if (definition) {
              Object.assign(definition, {
                __VUE_OPTIONS_API__: true, 
                __VUE_PROD_DEVTOOLS__: true, 
              });
            }
          }
          return definitions;
        });
      }
    };

  将缓存Map对象挂载到keep-alive组件之后,我们就可以通过$refs取到keep-alive组件对象,进而得到缓存Map对象了。

  为了方便使用,我们可以创建一个类来简化操作:  

handler.js
export class KeepAliveHandler {
  constructor() {
    this._ = {};
  }

  get keys() {
    const { cache } = this._
    if (!cache || !cache()) {
      return [];
    }
    return [...cache().keys()];
  }

  //绑定keepAlive信息
  bind(keepAlive) {
    if (keepAlive && keepAlive.$.__v_cache) {
      const sharedContext = keepAlive.$.ctx;
      const instance = keepAlive.$;
      const { suspense: parentSuspense, __v_cache: cache } = instance;
      const {
        renderer: { um: unmount },
      } = sharedContext;

      Object.assign(this._, {
        cache() {
          return cache;
        },
        unmount(vnode) {
          resetShapeFlag(vnode);
          unmount(vnode, instance, parentSuspense, true);
        },
        isCurrent(key) {
          return keepAlive.$.subTree && keepAlive.$.subTree.key === key
        }
      });
    } else {
      console.warn('当且仅当开发环境或者启用了devtool时生效')
    }
  }

  //删除指定key的缓存
  remove(key, reset = true) {
    pruneCache.call(this, k => key !== k, reset)
  }
  //清空
  clear() {
    pruneCache.call(this, () => false, false)
  }
}

function pruneCache(filter, reset) {
  const { cache, unmount, isCurrent } = this._
  if (!cache || !cache()) {
    return
  }
  const c = cache()
  c.set = new Map().set
  c.forEach((vnode, key) => {
    if (!filter(key)) {
      if (isCurrent(key)) {
        //重写set,因为渲染函数可能会重新执行
        //这样就会导致缓存重新添加,导致清除失败
        if (reset) {
          c.set = function () {
            c.set = new Map().set
          }
        }
        resetShapeFlag(vnode)
      } else {
        unmount(vnode);
      }
      c.delete(key);
    }
  });
}

function resetShapeFlag(vnode) {
  let shapeFlag = vnode.shapeFlag;
  if (shapeFlag & 256) {
    shapeFlag -= 256;
  }
  if (shapeFlag & 512) {
    shapeFlag -= 512;
  }
  vnode.shapeFlag = shapeFlag;
}

   一个简单的例子:

  创建一个vue3的项目,包含vue-router,然后创建两个vue组件:  

home.vue
 <template>
  <div class="home">
    <h1>This is an home page</h1>
    <h1>route:{{ $route.fullPath }}</h1>
    <h1>time:{{ now }}</h1>
  </div>
</template>

<script>
export default {
  name: "Home",
  data() {
    return {
      now: new Date().toLocaleString(),
    };
  },
  mounted() {
    console.log("home mounted");
  },
  unmounted() {
    console.log("home unmounted");
  },
};
</script>
about.vue
 <template>
  <div class="about">
    <h1>This is an about page</h1>
    <h1>route:{{ $route.fullPath }}</h1>
    <h1>time:{{ now }}</h1>
  </div>
</template>

<script>
export default {
  name: "About",
  data() {
    return {
      now: new Date().toLocaleString(),
    };
  },
  mounted() {
    console.log("about mounted");
  },
  unmounted() {
    console.log("about unmounted");
  },
};
</script>

  vue-router注册路由:  

route.js
 import { createRouter, createWebHistory } from "vue-router";
import Home from "../views/Home.vue";

const routes = [
  {
    path: "/",
    name: "Home",
    component: Home,
  },
  {
    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: () =>
      import(/* webpackChunkName: "about" */ "../views/About.vue"),
  },
];

const router = createRouter({
  history: createWebHistory(process.env.BASE_URL),
  routes,
});

export default router;

  如果不使用keep-alive,App.vue的内容如下:

    <template>
      <div>
        <div id="nav">
          <router-link to="/">Home</router-link> |
          <router-link to="/about">About</router-link>
        </div>
        <router-view></router-view>
      </div>
    </template>

    <style>
    #app {
      font-family: Avenir, Helvetica, Arial, sans-serif;
      -webkit-font-smoothing: antialiased;
      -moz-osx-font-smoothing: grayscale;
      text-align: center;
      color: #2c3e50;
    }

    #nav {
      padding: 30px;
    }

    #nav a {
      font-weight: bold;
      color: #2c3e50;
      cursor: pointer;
    }

    #nav a.router-link-exact-active {
      color: #42b983;
    }
    </style>

  这时运行后,来回切换home和about页面,会发现两个页面每次都是重新渲染,其中的now属性一直在获取当前最新的时间,如果开发者工具中看控制台输出,会发现里面不停的打印:about mounted、about unmounted、home mounted、home unmounted。

  

  如果使用keep-alive后,那么修改App.vue:

<template>
      <div>
        <div id="nav">
          <router-link to="/">Home</router-link>
          (<a href="#" @click="remove('/')">x</a>)|
          <router-link to="/about">About</router-link>
          (<a href="#" @click="remove('/about')">x</a>)
        </div>
        <router-view v-slot="{ Component }">
          <keep-alive ref="keepAlive">
            <component :is="Component" :key="$route.fullPath"></component>
          </keep-alive>
        </router-view>
      </div>
    </template>

    <script>
    import { KeepAliveHandler } from "@/handler";
    import { onMounted, getCurrentInstance } from "vue";

    export default {
      setup() {
        const instance = getCurrentInstance();
        const handler = new KeepAliveHandler();
        onMounted(() => {
          const keepAlive = instance.refs.keepAlive;
          handler.bind(keepAlive);
        });
        const remove = (key) => {
          handler.remove(key);
        };

        return {
          remove,
        };
      },
    };
    </script>
    <style>
    #app {
      font-family: Avenir, Helvetica, Arial, sans-serif;
      -webkit-font-smoothing: antialiased;
      -moz-osx-font-smoothing: grayscale;
      text-align: center;
      color: #2c3e50;
    }

    #nav {
      padding: 30px;
    }

    #nav a {
      font-weight: bold;
      color: #2c3e50;
      cursor: pointer;
    }

    #nav a.router-link-exact-active {
      color: #42b983;
    }
    </style>

  这里先创建一个KeepAliveHandler实例,然后在onMounted钩子中进行keep-alive组件的绑定,接下来就可以使用KeepAliveHandler实例的remove函数来删除指定key的缓存组件了。

  项目运行后,来回切换home和about页面,会发现页面中的时间没有更新了,而且组件的mounted也只调用了一次,这就是keep-alive组件缓存机制在起作用。

  接着,分别点击home和about旁边的 x ,再来回切换home和about页面,会发现页面中的事件更新了一次,而且组件对应的unmounted也执行了,这就说明成功清理了keep-alive的缓存,来回切换页面时重新渲染了页面并缓存

  结语

  其实keep-alive组件手动释放的问题由来已久,从原来vue2就开始有这个问题了,但是不知道为何vue的作者一直没有修复,github上也有不少吐槽这点的issue。

  虽然这样子可以解决手动释放keep-alive缓存的问题,但是需要production环境启用devtools或者将process.env.NODE_ENV设置成非production,但是遗憾的事,在生产环境,我们恰恰是相反的,所以这种不是一个好的解决方案,只是利用vue给我们开的一扇窗子而已。

  其次,经过测试,__VUE_PROD_DEVTOOLS__可以覆盖,但有时候又覆盖不了,貌似是版本的问题,我这边暂时使用的是webpack,有兴趣的可以使用vite试试,具体是可以参考github上给出的说明:https://github.com/vuejs/core/tree/main/packages/vue#bundler-build-feature-flags  

  后续有时间在仔细看看源码,看看有没有可以钻空子的地方。

 

标签:const,name,cache,alive,vnode,instance,key,Vue3,keep
来源: https://www.cnblogs.com/shanfeng1000/p/16692266.html