Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Vue源码-keep-alive #10

Open
wozien opened this issue Sep 7, 2020 · 0 comments
Open

Vue源码-keep-alive #10

wozien opened this issue Sep 7, 2020 · 0 comments
Labels

Comments

@wozien
Copy link
Owner

wozien commented Sep 7, 2020

当我们使用Vue的动态组件或者路由切换组件时,如果想要保存之前显示组件的状态,可以利用keep-alive内置组件包裹。现在通过源码来看看它的实现。

组件源码

keep-alive是Vue实现的内置组件,它和我们手写的组件一样有自己的组件配置,它定义在src/core/components/keep-alive.js

export default {
  name: 'keep-alive',
  abstract: true,

  props: {
    include: patternTypes,
    exclude: patternTypes,
    max: [String, Number]
  },

  created () {
    this.cache = Object.create(null)
    this.keys = []
  },

  destroyed () {
    for (const key in this.cache) {
      pruneCacheEntry(this.cache, key, this.keys)
    }
  },

  mounted () {
    this.$watch('include', val => {
      pruneCache(this, name => matches(val, name))
    })
    this.$watch('exclude', val => {
      pruneCache(this, name => !matches(val, name))
    })
  },

  render () {
    const slot = this.$slots.default
    const vnode: VNode = getFirstComponentChild(slot)  // 获取第一个组件节点
    const componentOptions: ?VNodeComponentOptions = vnode && vnode.componentOptions
    if (componentOptions) {
      // check pattern
      const name: ?string = getComponentName(componentOptions)
      const { include, exclude } = this
      if (
        // not included
        (include && (!name || !matches(include, name))) ||
        // excluded
        (exclude && name && matches(exclude, name))
      ) {
        return vnode
      }

      // 缓存vnode
      const { cache, keys } = this
      const key: ?string = 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.componentInstance = cache[key].componentInstance
        // make current key freshest
        remove(keys, key)
        keys.push(key)
      } else {
        cache[key] = vnode
        keys.push(key)
        // prune oldest entry
        if (this.max && keys.length > parseInt(this.max)) {
          pruneCacheEntry(cache, keys[0], keys, this._vnode)
        }
      }

      vnode.data.keepAlive = true
    }
    return vnode || (slot && slot[0])
  }
}

这个组件和我们平时写的组件不同的是多了一个abstract: true,表示这是一个抽象组件。抽象组件的实例是不会维护它的父子关系链的,在initLifecycle中:

let parent = options.parent
if (parent && !options.abstract) {
  // 找到不是抽象组件的父实例vm
  while (parent.$options.abstract && parent.$parent) {
    parent = parent.$parent
  }
  parent.$children.push(vm)
}

该组件定义了三个includeexcludemax三个props,分别表示该缓存的组件,和不该缓存的组件以及最多缓存个数。组件的created钩子定义了cache和keys分别表示缓存的数据和key。再来看看render函数:

const slot = this.$slots.default
const vnode: VNode = getFirstComponentChild(slot)  // 获取第一个组件节点
const componentOptions: ?VNodeComponentOptions = vnode && vnode.componentOptions

因为keep-alive组件可以缓存它的第一个子组件实例,所以要通过slot获取第一个组件节点的vnode。

const name: ?string = getComponentName(componentOptions)
const { include, exclude } = this
if (
  // not included
  (include && (!name || !matches(include, name))) ||
  // excluded
  (exclude && name && matches(exclude, name))
) {
  return vnode
}

这段代码是拿到组件的名称判断它是否需要进行缓存。matches函数主要是对字符串,数组和正则表达式几种情况的匹配处理:

function matches (pattern: string | RegExp | Array<string>, name: string): boolean {
  if (Array.isArray(pattern)) {
    return pattern.indexOf(name) > -1
  } else if (typeof pattern === 'string') {
    return pattern.split(',').indexOf(name) > -1
  } else if (isRegExp(pattern)) {
    return pattern.test(name)
  }
  /* istanbul ignore next */
  return false
}

接下来就是对拿到的子组件的vnode进行缓存操作:

// 缓存vnode
const { cache, keys } = this
const key: ?string = 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.componentInstance = cache[key].componentInstance
  // make current key freshest
  remove(keys, key)
  keys.push(key)
} else {
  cache[key] = vnode
  keys.push(key)
  // prune oldest entry
  if (this.max && keys.length > parseInt(this.max)) {
    pruneCacheEntry(cache, keys[0], keys, this._vnode)
  }
}

主要的逻辑就是根据子组件的key是否命中缓存。如果命中缓存,则在缓存中获取vnode对应的实例,这样之前组件的状态就保留了。然后在把key移动到末尾,这样的操作是为了让keys的最后一个元素永远是最近使用的,对应第一个元素就是最久远的。如果没有命中缓存,则把vnode存进缓存数组,然后还要判断缓存的组件个数是否超过了限制,超过要删除第一个缓存的元素,这就是keys数组的作用。来看下pruneCacheEntry方法:

function pruneCacheEntry (
  cache: VNodeCache,
  key: string,
  keys: Array<string>,
  current?: VNode
) {
  const cached = cache[key]
  if (cached && (!current || cached.tag !== current.tag)) {
    cached.componentInstance.$destroy()
  }
  cache[key] = null
  remove(keys, key)
}

删除的条件就是存在缓存并且不是当前渲染的节点。如果满足,则调用组件实例的$destroy方法进行卸载,然后删除对应的缓存和keys。在render函数最后,在vnode上标记这是一个被keep-alive缓存的组件:

vnode.data.keepAlive = true

因为keep-alive组件是一个抽象组件,所以在最后返回的是第一个子组件的vnode。于是,keep-alive组件实例的patch过程其实是对包裹的子组件进行操作。

另外,keep-alive组件在挂载后还监听了includeexclude,当它们变化时要重新处理缓存,把不匹配的缓存调用pruneCacheEntry删除掉:

mounted () {
  this.$watch('include', val => {
    pruneCache(this, name => matches(val, name))
  })
  this.$watch('exclude', val => {
    pruneCache(this, name => !matches(val, name))
  })
}

对应的pruneCache方法就是循环cache,判断它是否仍需要进行缓存:

function pruneCache (keepAliveInstance: any, filter: Function) {
  const { cache, keys, _vnode } = keepAliveInstance
  for (const key in cache) {
    const cachedNode: ?VNode = cache[key]
    if (cachedNode) {
      const name: ?string = getComponentName(cachedNode.componentOptions)
      if (name && !filter(name)) {
        pruneCacheEntry(cache, key, keys, _vnode)
      }
    }
  }
}

组件渲染

先来看下一个例子:

import Vue from 'vue';

const A = {
  template: `<p>I am A component</p>`,
  created() {
    console.log('create A');
  }
};

const B = {
  template: `<p>I am B component</p>`,
  created() {
    console.log('create B');
  }
};

new Vue({
  el: '#app',
  template: `
  <div>
       <keep-alive>
        <component :is="comp"></component>
       </keep-alive>
      <button @click="handleSwich">switch</button>
    </div>
  `,
  data: {
    comp: 'A'
  },
  methods: {
    handleSwich() {
      this.comp = this.comp === 'A' ? 'B' : 'A';
    }
  },
  components: { A, B }
});

利用component动态组件的方式来切换A和B组件,并用keep-alive进行缓存对组件进行缓存。首先会调用Vue实例的patch方法进行更新,首次渲染的vnode会调用createElm方法创建DOM,在该方法里面会先创建div节点的DOM,然后子vnode递归调用该函数进行创建。

在创建真实DOM的过程,如果遇到的是组件节点,会调用createComponent方法进行处理,从而创建组件对应的DOM。所以,当遇到keep-alive组件节点是就会调用该方法:

function createComponent (vnode, insertedVnodeQueue, parentElm, refElm) {
  let i = vnode.data
  if (isDef(i)) {
    const isReactivated = isDef(vnode.componentInstance) && i.keepAlive
    if (isDef(i = i.hook) && isDef(i = i.init)) {
      // 调用组件vnode的init钩子
      i(vnode, false /* hydrating */)
    }
    if (isDef(vnode.componentInstance)) {
      initComponent(vnode, insertedVnodeQueue)
      // 把组件对应的DOM替换组件占位符
      insert(parentElm, vnode.elm, refElm)
      if (isTrue(isReactivated)) {
        reactivateComponent(vnode, insertedVnodeQueue, parentElm, refElm)
      }
      return true
    }
  }
}

这个方法会首先执行组件vnode的init钩子:

init (vnode: VNodeWithData, hydrating: boolean): ?boolean {
  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 // 组件占位符所在的vm
    )
    child.$mount(hydrating ? vnode.elm : undefined, hydrating)
  }
}

对于首次渲染,会创建组件实例然后调用实例的$mount方法进行挂载,把组件对应的DOM插入到对应的占位vnode位置。在组件的挂载过程中,就会执行组件的render方法并进行patch,这个时候就会执行keep-alive组件的render函数。执行完后会把A组件的vnode存进cache中,然后返回A组件的vnode并调用patch方法。

在对A组件进行patch的过程,又会回到createComponent方法创建A组件对应的实例和获取对应的DOM。这样走下来,patch的过程比不用keep-alive组件是多了一步,但是结果是一样的。

当我们点击按钮切换到B组件是,因为响应式数据变化会触发当前实例的渲染watcher,也就是会重新执行renderpatch。在patch过程,然后新老的vnode是同个节点时,会调用patchVnode方法进行比对,如果是组件节点的话还会先调用prepatch钩子:

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

keep-alive组件就是在会在patch调用该钩子,然后调用updateChildComponent方法更新slot等属性:

if (needsForceUpdate) {
  vm.$slots = resolveSlots(renderChildren, parentVnode.context)
  vm.$forceUpdate()
}

在更新完slot后会调用vm.$forceUpdate强制刷新,也就是会重新执行keep-alive组件的render并进行patch。假设我们是点击了按钮两次,也就是说A组件被缓存了然后又从B切回A,这时候的A组件会命中缓存,并且组件的实例是从缓存中取:

if (cache[key]) {
  vnode.componentInstance = cache[key].componentInstance
}

然后在对A组件调用patch过程中,发现它是和oldVnode不是同一个节点,所以调用createComponent方法创建A组件的DOM。这个时候走到组件节点的init钩子的时候会命中下面逻辑:

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)
}

因为我们已经从keep-alive的缓存中取出组件的实例,所以不会重新去新建一个组件的实例,从而组件的状态得以维持。因为vnode.elm保存了之前的DOM,所以直接插入到对应的位置即可:

// src/core/vdom/patch.js
if (isDef(vnode.componentInstance)) {
  initComponent(vnode, insertedVnodeQueue)
  // 把组件对应的DOM替换组件占位符
  insert(parentElm, vnode.elm, refElm)
  if (isTrue(isReactivated)) {
    reactivateComponent(vnode, insertedVnodeQueue, parentElm, refElm)
  }
  return true
}

生命周期

从上面可以知道命中keep-alive缓存的组件在执行init钩子的时候不会去重新新建一个实例,所以激活的时候created等初始化钩子就不会再调用,但是为了满足业务需求,Vue在激活组件的时候加入了activateddeactivated钩子。

patch过程的最后面,也就是在vnode对应的DOM插入到父DOM后,会执行invokeInsertHook函数执行在patch过程中所有的insert钩子函数。对于组件节点,会执行它的insert钩子:

insert (vnode: MountedComponentVNode) {
  const { context, componentInstance } = vnode
  // 调用组件的mounted钩子
  if (!componentInstance._isMounted) {
    componentInstance._isMounted = true
    callHook(componentInstance, 'mounted')
  }
  if (vnode.data.keepAlive) {
    if (context._isMounted) {
      queueActivatedComponent(componentInstance)
    } else {
      activateChildComponent(componentInstance, true /* direct */)
    }
  }
}

当组件实例的_isMountedfalse,也就是还没标记挂载时调用mounted钩子函数。但组件的父环境还没挂载时,会调用activateChildComponent执行组件以及子组件的actived钩子:

export function activateChildComponent (vm: Component, direct?: boolean) {
  if (direct) {
    vm._directInactive = false
    if (isInInactiveTree(vm)) {
      return
    }
  } else if (vm._directInactive) {
    return
  }
  if (vm._inactive || vm._inactive === null) {
    vm._inactive = false
    for (let i = 0; i < vm.$children.length; i++) {
      activateChildComponent(vm.$children[i])
    }
    callHook(vm, 'activated')
  }
}

其中vm._inactive是为了让一个实例的钩子函数只调用一次。当父环境已经挂载的情况,会调用queueActivatedComponent方法把自身组件实例放到一个数组,然后在nextTick执行activateChildComponent方法:

// src/core/observer/scheduler.js

export function queueActivatedComponent (vm: Component) {
  // setting _inactive to false here so that a render function can
  // rely on checking whether it's in an inactive tree (e.g. router-view)
  vm._inactive = false
  activatedChildren.push(vm)
}

function callActivatedHooks (queue) {
  for (let i = 0; i < queue.length; i++) {
    queue[i]._inactive = true
    activateChildComponent(queue[i], true /* true */)
  }
}

对于deactivated钩子,会在组件节点的destory钩子中进行处理:

destroy (vnode: MountedComponentVNode) {
  const { componentInstance } = vnode
  if (!componentInstance._isDestroyed) {
    if (!vnode.data.keepAlive) {
      componentInstance.$destroy()
    } else {
      deactivateChildComponent(componentInstance, true /* direct */)
    }
  }
}

如果不是keep-alive下的组件直接调用实例的$destory方法。否则调用deactivateChildComponent方法执行组件以及子组件的deactivated钩子:

export function deactivateChildComponent (vm: Component, direct?: boolean) {
  if (direct) {
    vm._directInactive = true
    if (isInInactiveTree(vm)) {
      return
    }
  }
  if (!vm._inactive) {
    vm._inactive = true
    for (let i = 0; i < vm.$children.length; i++) {
      deactivateChildComponent(vm.$children[i])
    }
    callHook(vm, 'deactivated')
  }
}

总结

keep-alive的原理就是在render函数中把默认插槽的第一个组件vnode进行缓存,然后返回这个vnode。在组件进行patch过程中会调用prepatch钩子,更新slot内容后再执行渲染watcher,然后重新执行render函数。这个时候如果命中缓存,把缓存中的vnode对应实例直接赋值,这样在slot组件下次调用init钩子的时候跳过了新建实例的步骤,而是拿到原来的DOM直接插入。

@wozien wozien added the vue label Sep 15, 2020
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

1 participant