keep-alive 原理

192 阅读4分钟

一、keep-alive 介绍与应用

1.1、是什么?

keep-alive 是一个抽象组件:它自身不会渲染一个 DOM 元素,也不会出现在父组件链中;使用 keep-alive 包裹动态组件时,会缓存不活动的组件实例,而不是销毁它们。

1.2、使用场景

用户在某个列表页面选择筛选条件过滤出一份数据列表,由列表页面进入数据详情页面,再返回该列表页面,我们希望:列表页面可以保留用户的筛选(或选中)状态(图1.2.1)。keep-alive 就是用来解决这种场景。当然 keep-alive 不仅仅是能够保存页面/组件的状态这么简单,它还可以避免组件反复创建和渲染,有效提升系统性能。
总的来说,keep-alive 用于保存组件的渲染状态。

图1.2.1

1.3、使用方法

1.3.1、动态组件

<keep-alive :include="whiteList" :exclude="blackList" :max="amount">

<component :is="currentComponent"></component>

</keep-alive>

1.3.2、vue-router

<keep-alive :include="whiteList" :exclude="blackList" :max="amount">

<router-view></router-view>

</keep-alive>
include 定义缓存白名单,keep-alive 会缓存命中的组件;exclude 定义缓存黑名单,被命中的组件将不会被缓存;max 定义缓存组件上限,超出上限使用 LRU 的策略置换缓存数据。

二、源码浅析

源码位置:src/core/components/keep-alive.js

2.1、生命周期

created:初始化一个 cache、keys,前者用来存缓存组件的虚拟 dom 集合,后者用来存缓存组件的 key 集合。
mounted:更新缓存的组件、实时监听 include、exclude 这两个的变化,然后实时地更新(删除)this.cache 对象数据。
destroyed:删除掉所有缓存相关的东西。
updated: 更新缓存的组件。

2.2、render 函数

第一步:获取 keep-alive 包裹着的第一个子组件对象及其组件名;
第二步:根据设定的黑白名单(如果有)进行条件匹配,决定是否缓存。不匹配,直接返回组件实例(VNode),否则执行第三步;
第三步:根据组件 ID 和 tag 生成缓存 Key,并在缓存对象中查找是否已缓存过该组件实例。如果存在,直接取出缓存值并更新该 key 在 this.keys 中的位置(更新 key 的位置是实现 LRU 置换策略的关键),否则执行第四步;
第四步:存储 cache 对象和 key 值,触发 updated 声明周期,在 this.cache 对象中存储该组件实例并保存 key 值,之后检查缓存的实例数量是否超过 max 的设置值,超过则根据 LRU 置换策略删除最近最久未使用的实例(即是下标为 0 的那个 key);
第五步:最后并且很重要,将该组件实例的 keepAlive 属性值设置为 true,渲染和执行被包裹组件的钩子函数需要用到。

三、渲染

3.1、keep-alive 它不会生成真正的 DOM 节点,这是怎么做到的?

源码位置:src/core/instance/lifecycle.js

Vue在初始化生命周期的时候,为组件实例建立父子关系会根据abstract属性决定是否忽略某个组件。在keep-alive中,设置了abstract: true,那Vue就会跳过该组件实例。

最后构建的组件树中就不会包含keep-alive组件,那么由组件树渲染成的DOM树自然也不会有keep-alive相关的节点了。

export function initLifecycle (vm: Component) {
  const options = vm.$options

  // locate first non-abstract parent
  // 找到第一个非abstract的父组件实例
  let parent = options.parent
  if (parent && !options.abstract) {
    while (parent.$options.abstract && parent.$parent) {
      parent = parent.$parent
    }
    parent.$children.push(vm)
  }

  vm.$parent = parent
  // ...
}

3.2、keep-alive 包裹的组件是如何使用缓存的?

源码位置:src/core/vdom/patch.js

function createComponent (vnode, insertedVnodeQueue, parentElm, refElm) {
  let i = vnode.data
  if (isDef(i)) {
    // 首次加载加载被包裹组件时,由keep-alive.js中的render函数可知,vnode.componentInstance的值是undefined,keepAlive的值是true,因为keep-alive组件作为父组件,它的render函数会先于被包裹组件执行;
    // 那么就只执行到i(vnode, false /* hydrating */),后面的逻辑不再执行
    const isReactivated = isDef(vnode.componentInstance) && i.keepAlive
    if (isDef(i = i.hook) && isDef(i = i.init)) {
      i(vnode, false /* hydrating */)
    }
    // after calling the init hook, if the vnode is a child component
    // it should've created a child instance and mounted it. the child
    // component also has set the placeholder vnode's elm.
    // in that case we can just return the element and be done.
    // 再次访问被包裹组件时,vnode.componentInstance的值就是已经缓存的组件实例,那么会执行insert(parentElm, vnode.elm, refElm)逻辑,这样就直接把上一次的DOM插入到了父元素中
    if (isDef(vnode.componentInstance)) {
      // 保留真实dom到vnode中
      initComponent(vnode, insertedVnodeQueue)
      insert(parentElm, vnode.elm, refElm)
      if (isTrue(isReactivated)) {
        reactivateComponent(vnode, insertedVnodeQueue, parentElm, refElm)
      }
      return true
    }
  }
}

源码位置:src/core/vdom/create-component.js

const componentVNodeHooks = {
  init (vnode: VNodeWithData, hydrating: boolean): ?boolean {
    // 可以看出,当vnode.componentInstance和keepAlive同时为truly值时,不再进入$mount过程,那mounted之前的所有钩子函数(beforeCreate、created、mounted)都不再执行
    if (
      vnode.componentInstance &&
      !vnode.componentInstance._isDestroyed &&
      vnode.data.keepAlive
    ) {
      // kept-alive components, treat as a patch
      const mountedNode: any = vnode // work around flow
      componentVNodeHooks.prepatch(mountedNode, mountedNode)
    } else {
      const child = vnode.componentInstance = createComponentInstanceForVnode(
        vnode,
        activeInstance
      )
      child.$mount(hydrating ? vnode.elm : undefined, hydrating)
    }
  }

	// ....
}

四、页面缓存后如何更新数据?

4.1、activated 生命周期

activated() {
// 在首次挂载、
// 以及每次从缓存中被重新插入的时候调用
},
deactivated() {
// 在从 DOM 上移除、进入缓存
// 以及组件卸载时调用
}

4.2、beforeRouteEnter

beforeRouteEnter(to, from, next){
    next(vm=>{
        console.log(vm)
        // 每次进入路由执行
        vm.getData()  // 获取数据
    })
}

注:LRU(Least recently used,最近最少使用)策略根据数据的历史访问记录来进行淘汰数据。LRU 策略的设计原则是,如果一个数据在最近一段时间没有被访问到,那么在将来它被访问的可能性也很小。也就是说,当限定的空间已存满数据时,应当把最久没有被访问到的数据淘汰。

📎keep-alive.js

vue源码地址:github.com/vuejs/vue/t…