theydy / notebook

记录读书笔记 + 知识整理,vuepress 迁移中 https://theydy.github.io/notebook/
0 stars 0 forks source link

keep-alive 原理 #33

Open theydy opened 3 years ago

theydy commented 3 years ago

keep-alive 本质上只是存缓存和拿缓存的过程。

首先看下 keep-alive 的 render 函数。

{
    render: function render () {
      var slot = this.$slots.default;
      /**
       * getFirstComponentChild 找到第一个组件节点
       */
      var vnode = getFirstComponentChild(slot);
      var componentOptions = vnode && vnode.componentOptions;
      if (componentOptions) {
        // check pattern
        var name = getComponentName(componentOptions);
        var ref = this;
        var include = ref.include;
        var exclude = ref.exclude;

        /**
         * 不匹配直接返回 vnode
         */
        if (
          // not included
          (include && (!name || !matches(include, name))) ||
          // excluded
          (exclude && name && matches(exclude, name))
        ) {
          return vnode
        }

        var ref$1 = this;
        var cache = ref$1.cache;
        var keys = ref$1.keys;

        /**
         * 优先使用 key 属性做为缓存的 keys,否则使用 cid + tag 拼接做为 keys
         */
        var key = vnode.key == null
          // same constructor may get registered as different local components
          // so cid alone is not enough (#3269)
          ? componentOptions.Ctor.cid + (componentOptions.tag ? ("::" + (componentOptions.tag)) : '')
          : vnode.key;

        if (cache[key]) {
          /**
           * 命中缓存,直接将缓存的 vnode 上的 instance 赋给新 vnode
           * 接着更新当前组件的调用顺序
           */
          vnode.componentInstance = cache[key].componentInstance;
          // make current key freshest
          remove(keys, key);
          keys.push(key);
        } else {
          /**
           * 没有命中缓存,缓存 vnode
           * LRU 最近最少使用 根据 max 更新缓存 map
           */
          cache[key] = vnode;
          keys.push(key);
          // prune oldest entry
          if (this.max && keys.length > parseInt(this.max)) {
            pruneCacheEntry(cache, keys[0], keys, this._vnode);
          }
        }

        /**
         * 打上 keepAlive 标记
         * 在 prepatch 钩子中用到
         */
        vnode.data.keepAlive = true;
      }

      /**
       * keep-alive 最后无论是否命中缓存都需要返回 vnode。
       * 区别在于 vnode 上的 componentInstance 和 keepAlive 属性。
       * 初次渲染只有 keepAlive 属性
       * 重新渲染则 componentInstance 和 keepAlive 都存在。
       */
      return vnode || (slot && slot[0])
    }
}

大致流程如下:

有个地方需要注意的是,vnode 上是会保存 DOM 的。

// vnode 上会保存 DOM 节点
vnode.elm = vnode.componentInstance.$el

由此可知,为什么 keep-alive 需要一个 max 来限制缓存组件的数量,原因就是 keep-alive 缓存的组件数据除了包括 vnode 这一描述对象外,还保留着真实的 DOM 节点。

初次渲染

初次渲染和普通的流程一致,只是在 keep-alive 内部缓存了 vnode,vnode.data.keepAlive = true

唯一需要注意的就是在 patch 最后调用了 invokeInsertHook 函数,执行了 insert 钩子,在执行 callHook(componentInstance, 'mounted'); 后,keepAlive 的判断为 true,会额外进入 activateChildComponent 函数,调用 callHook(vm, 'activated');

初次渲染的 activated 钩子直接在这里调用,重新渲染的 activated 钩子是 queueActivatedComponent 放进数组中,最后在 flushSchedulerQueue 中调用。

{
    insert: function insert (vnode) {
      var context = vnode.context;
      var componentInstance = vnode.componentInstance;
      if (!componentInstance._isMounted) {
        componentInstance._isMounted = true;
        callHook(componentInstance, 'mounted');
      }
      if (vnode.data.keepAlive) {
        if (context._isMounted) {
          queueActivatedComponent(componentInstance);
        } else {
          activateChildComponent(componentInstance, true /* direct */);
        }
      }
    }
}
function flushSchedulerQueue () {
    currentFlushTimestamp = getNow();
    flushing = true;
    var watcher, id;

    queue.sort(function (a, b) { return a.id - b.id; });

    // ...

    // keep copies of post queues before resetting state
    var activatedQueue = activatedChildren.slice();
    var updatedQueue = queue.slice();

    resetSchedulerState();

    // call component updated and activated hooks
    callActivatedHooks(activatedQueue);
    callUpdatedHooks(updatedQueue);

    // devtool hook
    /* istanbul ignore if */
    if (devtools && config.devtools) {
      devtools.emit('flush');
    }
  }

重新渲染

重新渲染就有不同了,假设一个案例如下:

// app.vue

<template>
  <div id="app">
    <keep-alive>
      <component 
        :is="compName"
      />
    </keep-alive>
    <button @click="change">switch</button>
  </div>
</template>
<script>
import A from './components/A';
import B from './components/B';

export default {
  name: 'app',
  components: {
    A,
    B,
  },
  data () {
    return {
      compName: 'A'
    }
  },
  methods: {
    change() {
      this.compName = this.compName === 'A' ? 'B' : 'A';
    }
  }
}
</script>

首先渲染 A 组件,然后渲染 B 组件,这两次都是初次渲染,所以 keep-alive 中缓存了 A,B 组件的 vnode。

接着再次切换为 A 组件,首先触发的是 app.vue 的更新,也就是会执行 app.vuerender 函数,进入 patch 过程。

app.vue 新旧 vnode diff 时,会发现 keep-alive 节点没有变化,所以会进 patchVnode 函数中,这个函数里面最重要的是会执行 prepatch 钩子函数,这个钩子函数会更新组件的 props、listeners、children 等。

{
    prepatch: function prepatch (oldVnode, vnode) {
      var options = vnode.componentOptions;
      var child = vnode.componentInstance = oldVnode.componentInstance;
      updateChildComponent(
        child,
        options.propsData, // updated props
        options.listeners, // updated listeners
        vnode, // new parent vnode
        options.children // new children
      );
    },
}

updateChildComponent 流程大致如下:

对于 keep-alive 来说,必有 children,所以通过 $forceUpdate 进入 keep-alive 的 render 函数中,此时命中缓存,返回的 A 组件 vnode 上带有缓存的 componentInstance 和 keepAlive 标记。

进入 keep-alive 的 patch 过程,新旧 vnode 一个是 A 组件,一个是 B 组件,所以是根据新 vnode 生成 DOM 再替换旧 vnode 的 DOM 这一个过程。

createElm → createComponent

    function createComponent (vnode, insertedVnodeQueue, parentElm, refElm) {
      var i = vnode.data;
      if (isDef(i)) {
        var isReactivated = isDef(vnode.componentInstance) && i.keepAlive;
        if (isDef(i = i.hook) && isDef(i = i.init)) {
          i(vnode, false /* hydrating */);
        }
        if (isDef(vnode.componentInstance)) {
          initComponent(vnode, insertedVnodeQueue);
          insert(parentElm, vnode.elm, refElm);
          if (isTrue(isReactivated)) {
            reactivateComponent(vnode, insertedVnodeQueue, parentElm, refElm);
          }
          return true
        }
      }
    }

此时,isReactivated 的值是 true ,进入 init 钩子。

{
     init: function init (vnode, hydrating) {
      if (
        vnode.componentInstance &&
        !vnode.componentInstance._isDestroyed &&
        vnode.data.keepAlive
      ) {
        // kept-alive components, treat as a patch
        var mountedNode = vnode; // work around flow
        componentVNodeHooks.prepatch(mountedNode, mountedNode);
      } else {
        var child = vnode.componentInstance = createComponentInstanceForVnode(
          vnode,
          activeInstance
        );
        child.$mount(hydrating ? vnode.elm : undefined, hydrating);
      }
    }
}

这时候会进第一个分支逻辑,执行 prepatch 去更新 vnode 上保存的组件实例,而不是走新建实例、挂载的流程。

接着回到 patch 函数中。

{  
      { 
         // destroy old node
          if (isDef(parentElm)) {
            removeVnodes([oldVnode], 0, 0);
          } else if (isDef(oldVnode.tag)) {
            invokeDestroyHook(oldVnode);
          }
      }
      invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch);
}

大概是这么个流程,先 invokeDestroyHook 调用旧 vnode 的 destroy 钩子,因为 keepAlive 的关系,不会真的销毁而是 callHook(vm, 'deactivated');

接着的 invokeInsertHook 调用新 vnode 的 insert 钩子,向 activatedChildren 队列中加入当前实例,最后在 flushSchedulerQueue 中遍历调用 callHook(vm, 'activated');