# keepalive 的生命周期及本质

# keepalive 生命周期

keep-alive 这个词借鉴于 HTTP 协议。在 HTTP 协议中,KeepAlive 被称之为 HTTP 持久连接(HTTP persistent connection),其作用是允许多个请求或响应共用一个 TCP 连接。

在没有 KeepAlive 的情况下,一个 HTTP 连接会在每次请求/响应结束后关闭,当下一次请求发生时,会建立一个新的 HTTP 连接。频繁地销毁、创建 HTTP 连接会带来额外的性能开销,KeepAlive 就是为了解决这个问题而诞生的。

HTTP 中的 KeepAlive 可以避免连接频繁地销毁/创建,与 HTTP 中的 KeepAlive 类似,Vue 里面的 keep-alive 组件也是用于对组件进行缓存,避免组件被频繁的销毁/重建

回顾基本使用

简单回忆一下 keep-alive 的使用

<template>
  <Tab v-if="currentTab === 1">...</Tab>
  <Tab v-if="currentTab === 2">...</Tab>
  <Tab v-if="currentTab === 3">...</Tab>
</template>
1
2
3
4
5

根据变量 currentTab 值的不同,会渲染不同的 <Tab> 组件。当用户频繁地切换 Tab 时,会导致不停地卸载并重建 <Tab> 组件。为了避免因此产生的性能开销,可以使用 keep-alive 组件来解决这个问题:

<template>
  <keep-alive>
    <Tab v-if="currentTab === 1">...</Tab>
    <Tab v-if="currentTab === 2">...</Tab>
    <Tab v-if="currentTab === 3">...</Tab>
  </keep-alive>
</template>
1
2
3
4
5
6
7

这样,无论用户怎样切换 <Tab> 组件,都不会发生频繁的创建和销毁,因为会极大的优化对用户操作的响应,尤其是在大组件场景下,优势会更加明显。

另外 keep-alive 还可以设计一些属性来进行细节方面的把控:

  • include:指定要缓存的组件,支持的书写方式有字符串、正则表达式、数组
  • exclude:排除不缓存的组件
  • max:指定最大缓存组件数。如果缓存的实例数量即将超过指定的那个最大数量,则最久没有被访问的缓存实例将被销毁,以便为新的实例腾出空间。

keep-alive 生命周期

当一个组件挂载以及卸载的时候,是会触发相关的生命周期钩子方法。

当我们从组件 A 切换到组件 B 时,会依次出发:

  • 组件 A beforeUnmount
  • 组件 B created
  • 组件 B beforeMount
  • 组件 A unmounted
  • 组件 B mounted

这就是没有使用 keep-alive 缓存的情况,组件频繁的创建、销毁,性能上面会有损耗。

当我们添加 keep-alive 之后,组件得以缓存。但是这也带来一个新的问题,就是我们不知道该组件是否处于激活状态。比如某些场景下,我们需要组件激活时执行某些任务,但是因为目前组件被缓存了,上面的那些生命周期钩子方法都不会再次执行了。

此时,和 keep-alive 相关的两个生命周期钩子方法可以解决这个问题:

  • onActivated:首次挂载,以及组件激活时触发
  • onDeactivated:组件卸载,以及组件失活时触发

# keepalive 的本质

keep-alive 基本实现

keep-alive 组件的实现需要渲染器层面的支持。当组件需要卸载的时候,不能真的卸载,否则就无法维持组件当前的状态了。

因此正确的做法是:将需要 keep-alive 的组件搬运到一个隐藏的容器里面,从而实现“假卸载”。

image-20240528125458303

当 keep-alive 的组件需要重新挂载的时候,也是直接从隐藏的容器里面再次搬运到原来的容器。

image-20240528125719080

这个过程其实就对应了组件的两个生命周期:

  • activated
  • deactivated

一个最基本的 keep-alive 组件,实现起来并不复杂,代码如下:

const KeepAlive = {
  // 这是 keepalive 组件独有的属性,用于标识这是一个 keepalive 组件
  __isKeepAlive: true,
  setup(props, { slots }) {
    // 这是一个缓存对象
    // key:vnode.type
    // value: vnode
    const cache = new Map();
    // 存储当前 keepalive 组件的实例
    const instance = currentInstance;
    // 这里从组件实例上面解构出来两个方法,这两个方法实际上是由渲染器注入的
    const { move, createElement } = instance.keepAliveCtx;

    // 创建隐藏容器
    const storageContainer = createElement("div");

    // 这两个方法所做的事情,就是将组件从页面和隐藏容器之间进行移动
    // 这两个方法在渲染器中会被调用
    instance._deActivate = (vnode) => {
      move(vnode, storageContainer);
    };
    instance._activate = (vnode, container, anchor) => {
      move(vnode, container, anchor);
    };

    return () => {
      // 获取到默认插槽里面的内容
      let rawVNode = slots.default();

      // 如果不是对象,说明是非组件的虚拟节点,直接返回
      if (typeof rawVNode.type !== "object") {
        return rawVNode;
      }

      // 接下来我们从缓存里面找一下,看当前的组件是否存在于缓存里面
      const cachedVNode = cache.get(rawVNode.type);

      if (cachedVNode) {
        // 缓存中存在
        // 如果缓存中存在,直接使用缓存的组件实例
        rawVNode.component = cachedVNode.component;
        // 并且挂上一个 keptAlive 属性
        rawVNode.keptAlive = true;
      } else {
        // 缓存中不存在
        // 那么就添加到缓存里面,方便下次使用
        cache.set(rawVNode.type, rawVNode);
      }
      // 接下来又挂了一个 shouldKeepAlive 属性
      rawVNode.shouldKeepAlive = true;
      // 将 keepalive 组件实例也添加到 vnode 上面,后面在渲染器中有用
      rawVNode.keepAliveInstance = instance;
      return rawVNode;
    };
  },
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56

keep-alive 和渲染器是结合得比较深的,keep-alive 组件本身并不会渲染额外的什么内容,它的渲染函数最终只返回需要被 keep-alive 的组件,这样的组件我们可以称之为“内部组件”。

keep-alive 组件会对这些组件添加一些标记属性,以便渲染器能够根据这些标记属性执行一些特定的逻辑:

  • keptAlive:标识内部组件已经被缓存了,这样当内部组件需要重新渲染的时候,渲染器并不会重新挂载它,而是将其激活。
// 渲染器内部代码片段
function patch(n1, n2, container, anchor) {
  if (n1 && n1.type !== n2.type) {
    unmount(n1);
    n1 = null;
  }

  const { type } = n2;

  if (typeof type === "string") {
    // 省略部分代码
  } else if (type === Text) {
    // 省略部分代码
  } else if (type === Fragment) {
    // 省略部分代码
  } else if (typeof type === "object" || typeof type === "function") {
    // component
    if (!n1) {
      // 如果该组件已经被 KeepAlive,则不会重新挂载它,而是会调用_activate 来激活它
      if (n2.keptAlive) {
        n2.keepAliveInstance._activate(n2, container, anchor);
      } else {
        mountComponent(n2, container, anchor);
      }
    } else {
      patchComponent(n1, n2, anchor);
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
  • shouldKeepAlive:该属性会被添加到 vnode 上面,这样当渲染器卸载内部组件的时候,不会真正的去卸载,而是将其移动到隐藏的容器里面
// 渲染器代码片段
function unmount(vnode) {
  if (vnode.type === Fragment) {
    vnode.children.forEach((c) => unmount(c));
    return;
  } else if (typeof vnode.type === "object") {
    // vnode.shouldKeepAlive 是一个布尔值,用来标识该组件是否应该 KeepAlive
    if (vnode.shouldKeepAlive) {
      // 对于需要被 KeepAlive 的组件,我们不应该真的卸载它,而应调该组件的父组件,
      // 即 KeepAlive 组件的 _deActivate 函数使其失活
      vnode.keepAliveInstance._deActivate(vnode);
    } else {
      unmount(vnode.component.subTree);
    }
    return;
  }
  const parent = vnode.el.parentNode;
  if (parent) {
    parent.removeChild(vnode.el);
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
  • keepAliveInstance:该属性让内部组件持有了 KeepAlive 的组件实例,回头在渲染器中的某些场景下可以通过该属性来访问 KeepAlive 组件实例上面的 _deActivate 以及 _activate。

include 和 exclude

默认情况下,keep-alive 会对所有的“内部组件”进行缓存。

不过有些时候用户只期望缓存特定的组件,此时可以使用 include 和 exclude.

<keep-alive include="TextInput,Counter">
  <component :is="Component" />
</keep-alive>
1
2
3

因此 keep-alive 组件需要定义相关的 props:

const KeepAlive = {
  __isKeepAlive: true,
  props: {
    include: RegExp,
    exclude: RegExp,
  },
  setup(props, { slots }) {
    // ...
  },
};
1
2
3
4
5
6
7
8
9
10

在进入缓存之前,我们需要对该组件是否匹配进行判断:

const KeepAlive = {
  __isKeepAlive: true,
  props: {
    include: RegExp,
    exclude: RegExp,
  },
  setup(props, { slots }) {
    // 省略部分代码...

    return () => {
      let rawVNode = slots.default();
      if (typeof rawVNode.type !== "object") {
        return rawVNode;
      }

      const name = rawVNode.type.name;
      if (name && ((props.include && !props.include.test(name)) || (props.exclude && props.exclude.test(name)))) {
        return rawVNode;
      }

      // 进入缓存的逻辑...
    };
  },
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

缓存管理

目前为止的缓存实现如下:

const cachedVNode = cache.get(rawVNode.type);
if (cachedVNode) {
  rawVNode.component = cachedVNode.component;
  rawVNode.keptAlive = true;
} else {
  cache.set(rawVNode.type, rawVNode);
}
1
2
3
4
5
6
7

目前缓存的设计,只要缓存不存在,总是会设置新的缓存。这会导致缓存不断的增加,极端情况下会占用大量的内容。

为了解决这个问题,keep-alive 组件允许用户设置缓存的阀值,当组件缓存数量超过了指定阀值时会对缓存进行修剪

<keep-alive :max="3">
  <component :is="Component" />
</keep-alive>
1
2
3

因此在设计 keep-alive 组件的时候,新增一个 max 的 props:

const KeepAlive = {
  __isKeepAlive: true,
  props: {
    include: RegExp,
    exclude: RegExp,
    max: Number,
  },
  setup(props, { slots }) {
    // ...
  },
};
1
2
3
4
5
6
7
8
9
10
11

接下来需要有一个能够修剪缓存的方法:

function pruneCacheEntry(key: CacheKey) {
  const cached = cache.get(key) as VNode;

  // 中间逻辑略...

  cache.delete(key);
  keys.delete(key);
}
1
2
3
4
5
6
7
8

然后是更新缓存的队列:

const cachedVNode = cache.get(key);
if (cachedVNode) {
  // 其他逻辑略...

  // 进入此分支,说明缓存队列里面有,有的话就更新一下顺序
  // 保证当前这个在缓存中是最新的
  // 先删除,再添加即可
  keys.delete(key);
  keys.add(key);
} else {
  // 说明缓存中没有,说明是全新的,先添加再修剪
  keys.add(key);
  if (max && keys.size > parseInt(max as string, 10)) {
    // 进入此分支,说明当前添加进去的组件缓存已经超过了最大值,进行删除
    pruneCacheEntry(keys.values().next().value);
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
  • keep-alive 核心原理就是将内部组件搬运到隐藏容器,以及从隐藏容器搬运回来。因为没有涉及到真正的卸载,所以组件状态也得以保留。
  • keep-alive 和渲染器是结合得比较深的,keep-alive 会给内部组件添加一些特殊的标识,这些标识就是给渲染器的用,回头渲染器在挂载和卸载组件的时候,会根据这些标识执行特定的操作。
  • include 和 exclude 核心原理就是对内部组件进行一个匹配操作,匹配上了再进入后面的缓存逻辑
  • max:添加之前看一下缓存里面有没有缓存过该组件
    • 缓存过:更新到队列最后
    • 没有缓存过:加入到缓存里面,但是要看一下有没有超过最大值,超过了就需要进行修剪。