keep-alive

在我们的平时开发工作中,经常为了组件的缓存优化而使用 <keep-alive> 组件,乐此不疲,但很少有人关注它的实现原理,下面就让我们来一探究竟。

内置组件

<keep-alive> 是 Vue 源码中实现的一个组件,也就是说 Vue 源码不仅实现了一套组件化的机制,也实现了一些内置组件,它的定义在 src/core/components/keep-alive.js 中:

  1. export default {
  2. name: 'keep-alive,
  3. abstract: true,
  4. props: {
  5. include: patternTypes,
  6. exclude: patternTypes,
  7. max: [String, Number]
  8. },
  9. created () {
  10. this.cache = Object.create(null)
  11. this.keys = []
  12. },
  13. destroyed () {
  14. for (const key in this.cache) {
  15. pruneCacheEntry(this.cache, key, this.keys)
  16. }
  17. },
  18. mounted () {
  19. this.$watch('include', val => {
  20. pruneCache(this, name => matches(val, name))
  21. })
  22. this.$watch('exclude', val => {
  23. pruneCache(this, name => !matches(val, name))
  24. })
  25. },
  26. render () {
  27. const slot = this.$slots.default
  28. const vnode: VNode = getFirstComponentChild(slot)
  29. const componentOptions: ?VNodeComponentOptions = vnode && vnode.componentOptions
  30. if (componentOptions) {
  31. // check pattern
  32. const name: ?string = getComponentName(componentOptions)
  33. const { include, exclude } = this
  34. if (
  35. // not included
  36. (include && (!name || !matches(include, name))) ||
  37. // excluded
  38. (exclude && name && matches(exclude, name))
  39. ) {
  40. return vnode
  41. }
  42. const { cache, keys } = this
  43. const key: ?string = vnode.key == null
  44. // same constructor may get registered as different local components
  45. // so cid alone is not enough (#3269)
  46. ? componentOptions.Ctor.cid + (componentOptions.tag ? `::${componentOptions.tag}` : '')
  47. : vnode.key
  48. if (cache[key]) {
  49. vnode.componentInstance = cache[key].componentInstance
  50. // make current key freshest
  51. remove(keys, key)
  52. keys.push(key)
  53. } else {
  54. cache[key] = vnode
  55. keys.push(key)
  56. // prune oldest entry
  57. if (this.max && keys.length > parseInt(this.max)) {
  58. pruneCacheEntry(cache, keys[0], keys, this._vnode)
  59. }
  60. }
  61. vnode.data.keepAlive = true
  62. }
  63. return vnode || (slot && slot[0])
  64. }
  65. }

可以看到 <keep-alive> 组件的实现也是一个对象,注意它有一个属性 abstract 为 true,是一个抽象组件,Vue 的文档没有提这个概念,实际上它在组件实例建立父子关系的时候会被忽略,发生在 initLifecycle 的过程中:

  1. // locate first non-abstract parent
  2. let parent = options.parent
  3. if (parent && !options.abstract) {
  4. while (parent.$options.abstract && parent.$parent) {
  5. parent = parent.$parent
  6. }
  7. parent.$children.push(vm)
  8. }
  9. vm.$parent = parent

<keep-alive>created 钩子里定义了 this.cachethis.keys,本质上它就是去缓存已经创建过的 vnode。它的 props 定义了 includeexclude,它们可以字符串或者表达式,include 表示只有匹配的组件会被缓存,而 exclude 表示任何匹配的组件都不会被缓存,props 还定义了 max,它表示缓存的大小,因为我们是缓存的 vnode 对象,它也会持有 DOM,当我们缓存很多的时候,会比较占用内存,所以该配置允许我们指定缓存大小。

<keep-alive> 直接实现了 render 函数,而不是我们常规模板的方式,执行 <keep-alive> 组件渲染的时候,就会执行到这个 render 函数,接下来我们分析一下它的实现。

首先获取第一个子元素的 vnode

  1. const slot = this.$slots.default
  2. const vnode: VNode = getFirstComponentChild(slot)

由于我们也是在 <keep-alive> 标签内部写 DOM,所以可以先获取到它的默认插槽,然后再获取到它的第一个子节点。<keep-alive> 只处理第一个子元素,所以一般和它搭配使用的有 component 动态组件或者是 router-view,这点要牢记。

然后又判断了当前组件的名称和 includeexclude 的关系:

  1. // check pattern
  2. const name: ?string = getComponentName(componentOptions)
  3. const { include, exclude } = this
  4. if (
  5. // not included
  6. (include && (!name || !matches(include, name))) ||
  7. // excluded
  8. (exclude && name && matches(exclude, name))
  9. ) {
  10. return vnode
  11. }
  12. function matches (pattern: string | RegExp | Array<string>, name: string): boolean {
  13. if (Array.isArray(pattern)) {
  14. return pattern.indexOf(name) > -1
  15. } else if (typeof pattern === 'string') {
  16. return pattern.split(',').indexOf(name) > -1
  17. } else if (isRegExp(pattern)) {
  18. return pattern.test(name)
  19. }
  20. return false
  21. }

matches 的逻辑很简单,就是做匹配,分别处理了数组、字符串、正则表达式的情况,也就是说我们平时传的 includeexclude 可以是这三种类型的任意一种。并且我们的组件名如果满足了配置 include 且不匹配或者是配置了 exclude 且匹配,那么就直接返回这个组件的 vnode,否则的话走下一步缓存:

  1. const { cache, keys } = this
  2. const key: ?string = vnode.key == null
  3. // same constructor may get registered as different local components
  4. // so cid alone is not enough (#3269)
  5. ? componentOptions.Ctor.cid + (componentOptions.tag ? `::${componentOptions.tag}` : '')
  6. : vnode.key
  7. if (cache[key]) {
  8. vnode.componentInstance = cache[key].componentInstance
  9. // make current key freshest
  10. remove(keys, key)
  11. keys.push(key)
  12. } else {
  13. cache[key] = vnode
  14. keys.push(key)
  15. // prune oldest entry
  16. if (this.max && keys.length > parseInt(this.max)) {
  17. pruneCacheEntry(cache, keys[0], keys, this._vnode)
  18. }
  19. }

这部分逻辑很简单,如果命中缓存,则直接从缓存中拿 vnode 的组件实例,并且重新调整了 key 的顺序放在了最后一个;否则把 vnode 设置进缓存,最后还有一个逻辑,如果配置了 max 并且缓存的长度超过了 this.max,还要从缓存中删除第一个:

  1. function pruneCacheEntry (
  2. cache: VNodeCache,
  3. key: string,
  4. keys: Array<string>,
  5. current?: VNode
  6. ) {
  7. const cached = cache[key]
  8. if (cached && (!current || cached.tag !== current.tag)) {
  9. cached.componentInstance.$destroy()
  10. }
  11. cache[key] = null
  12. remove(keys, key)
  13. }

除了从缓存中删除外,还要判断如果要删除的缓存并的组件 tag 不是当前渲染组件 tag,也执行删除缓存的组件实例的 $destroy 方法。

最后设置 vnode.data.keepAlive = true ,这个作用稍后我们介绍。

注意,<keep-alive> 组件也是为观测 includeexclude 的变化,对缓存做处理:

  1. watch: {
  2. include (val: string | RegExp | Array<string>) {
  3. pruneCache(this, name => matches(val, name))
  4. },
  5. exclude (val: string | RegExp | Array<string>) {
  6. pruneCache(this, name => !matches(val, name))
  7. }
  8. }
  9. function pruneCache (keepAliveInstance: any, filter: Function) {
  10. const { cache, keys, _vnode } = keepAliveInstance
  11. for (const key in cache) {
  12. const cachedNode: ?VNode = cache[key]
  13. if (cachedNode) {
  14. const name: ?string = getComponentName(cachedNode.componentOptions)
  15. if (name && !filter(name)) {
  16. pruneCacheEntry(cache, key, keys, _vnode)
  17. }
  18. }
  19. }
  20. }

逻辑很简单,观测他们的变化执行 pruneCache 函数,其实就是对 cache 做遍历,发现缓存的节点名称和新的规则没有匹配上的时候,就把这个缓存节点从缓存中摘除。

组件渲染

到此为止,我们只了解了 <keep-alive> 的组件实现,但并不知道它包裹的子组件渲染和普通组件有什么不一样的地方。我们关注 2 个方面,首次渲染和缓存渲染。

同样为了更好地理解,我们也结合一个示例来分析:

  1. let A = {
  2. template: '<div class="a">' +
  3. '<p>A Comp</p>' +
  4. '</div>',
  5. name: 'A'
  6. }
  7. let B = {
  8. template: '<div class="b">' +
  9. '<p>B Comp</p>' +
  10. '</div>',
  11. name: 'B'
  12. }
  13. let vm = new Vue({
  14. el: '#app',
  15. template: '<div>' +
  16. '<keep-alive>' +
  17. '<component :is="currentComp">' +
  18. '</component>' +
  19. '</keep-alive>' +
  20. '<button @click="change">switch</button>' +
  21. '</div>',
  22. data: {
  23. currentComp: 'A'
  24. },
  25. methods: {
  26. change() {
  27. this.currentComp = this.currentComp === 'A' ? 'B' : 'A'
  28. }
  29. },
  30. components: {
  31. A,
  32. B
  33. }
  34. })

首次渲染

我们知道 Vue 的渲染最后都会到 patch 过程,而组件的 patch 过程会执行 createComponent 方法,它的定义在 src/core/vdom/patch.js 中:

  1. function createComponent (vnode, insertedVnodeQueue, parentElm, refElm) {
  2. let i = vnode.data
  3. if (isDef(i)) {
  4. const isReactivated = isDef(vnode.componentInstance) && i.keepAlive
  5. if (isDef(i = i.hook) && isDef(i = i.init)) {
  6. i(vnode, false /* hydrating */)
  7. }
  8. // after calling the init hook, if the vnode is a child component
  9. // it should've created a child instance and mounted it. the child
  10. // component also has set the placeholder vnode's elm.
  11. // in that case we can just return the element and be done.
  12. if (isDef(vnode.componentInstance)) {
  13. initComponent(vnode, insertedVnodeQueue)
  14. insert(parentElm, vnode.elm, refElm)
  15. if (isTrue(isReactivated)) {
  16. reactivateComponent(vnode, insertedVnodeQueue, parentElm, refElm)
  17. }
  18. return true
  19. }
  20. }
  21. }

createComponent 定义了 isReactivated 的变量,它是根据 vnode.componentInstance 以及 vnode.data.keepAlive 的判断,第一次渲染的时候,vnode.componentInstanceundefinedvnode.data.keepAlive 为 true,因为它的父组件 <keep-alive>render 函数会先执行,那么该 vnode 缓存到内存中,并且设置 vnode.data.keepAlive 为 true,因此 isReactivatedfalse,那么走正常的 init 的钩子函数执行组件的 mount。当 vnode 已经执行完 patch 后,执行 initComponent 函数:

  1. function initComponent (vnode, insertedVnodeQueue) {
  2. if (isDef(vnode.data.pendingInsert)) {
  3. insertedVnodeQueue.push.apply(insertedVnodeQueue, vnode.data.pendingInsert)
  4. vnode.data.pendingInsert = null
  5. }
  6. vnode.elm = vnode.componentInstance.$el
  7. if (isPatchable(vnode)) {
  8. invokeCreateHooks(vnode, insertedVnodeQueue)
  9. setScope(vnode)
  10. } else {
  11. // empty component root.
  12. // skip all element-related modules except for ref (#3455)
  13. registerRef(vnode)
  14. // make sure to invoke the insert hook
  15. insertedVnodeQueue.push(vnode)
  16. }
  17. }

这里会有 vnode.elm 缓存了 vnode 创建生成的 DOM 节点。所以对于首次渲染而言,除了在 <keep-alive> 中建立缓存,和普通组件渲染没什么区别。

所以对我们的例子,初始化渲染 A 组件以及第一次点击 switch 渲染 B 组件,都是首次渲染。

缓存渲染

当我们从 B 组件再次点击 switch 切换到 A 组件,就会命中缓存渲染。

我们之前分析过,当数据发送变化,在 patch 的过程中会执行 patchVnode 的逻辑,它会对比新旧 vnode 节点,甚至对比它们的子节点去做更新逻辑,但是对于组件 vnode 而言,是没有 children 的,那么对于 <keep-alive> 组件而言,如何更新它包裹的内容呢?

原来 patchVnode 在做各种 diff 之前,会先执行 prepatch 的钩子函数,它的定义在 src/core/vdom/create-component 中:

  1. const componentVNodeHooks = {
  2. prepatch (oldVnode: MountedComponentVNode, vnode: MountedComponentVNode) {
  3. const options = vnode.componentOptions
  4. const child = vnode.componentInstance = oldVnode.componentInstance
  5. updateChildComponent(
  6. child,
  7. options.propsData, // updated props
  8. options.listeners, // updated listeners
  9. vnode, // new parent vnode
  10. options.children // new children
  11. )
  12. },
  13. // ...
  14. }

prepatch 核心逻辑就是执行 updateChildComponent 方法,它的定义在 src/core/instance/lifecycle.js 中:

  1. export function updateChildComponent (
  2. vm: Component,
  3. propsData: ?Object,
  4. listeners: ?Object,
  5. parentVnode: MountedComponentVNode,
  6. renderChildren: ?Array<VNode>
  7. ) {
  8. const hasChildren = !!(
  9. renderChildren ||
  10. vm.$options._renderChildren ||
  11. parentVnode.data.scopedSlots ||
  12. vm.$scopedSlots !== emptyObject
  13. )
  14. // ...
  15. if (hasChildren) {
  16. vm.$slots = resolveSlots(renderChildren, parentVnode.context)
  17. vm.$forceUpdate()
  18. }
  19. }

updateChildComponent 方法主要是去更新组件实例的一些属性,这里我们重点关注一下 slot 部分,由于 <keep-alive> 组件本质上支持了 slot,所以它执行 prepatch 的时候,需要对自己的 children,也就是这些 slots 做重新解析,并触发 <keep-alive> 组件实例 $forceUpdate 逻辑,也就是重新执行 <keep-alive>render 方法,这个时候如果它包裹的第一个组件 vnode 命中缓存,则直接返回缓存中的 vnode.componentInstance,在我们的例子中就是缓存的 A 组件,接着又会执行 patch 过程,再次执行到 createComponent 方法,我们再回顾一下:

  1. function createComponent (vnode, insertedVnodeQueue, parentElm, refElm) {
  2. let i = vnode.data
  3. if (isDef(i)) {
  4. const isReactivated = isDef(vnode.componentInstance) && i.keepAlive
  5. if (isDef(i = i.hook) && isDef(i = i.init)) {
  6. i(vnode, false /* hydrating */)
  7. }
  8. // after calling the init hook, if the vnode is a child component
  9. // it should've created a child instance and mounted it. the child
  10. // component also has set the placeholder vnode's elm.
  11. // in that case we can just return the element and be done.
  12. if (isDef(vnode.componentInstance)) {
  13. initComponent(vnode, insertedVnodeQueue)
  14. insert(parentElm, vnode.elm, refElm)
  15. if (isTrue(isReactivated)) {
  16. reactivateComponent(vnode, insertedVnodeQueue, parentElm, refElm)
  17. }
  18. return true
  19. }
  20. }
  21. }

这个时候 isReactivated 为 true,并且在执行 init 钩子函数的时候不会再执行组件的 mount 过程了,相关逻辑在 src/core/vdom/create-component.js 中:

  1. const componentVNodeHooks = {
  2. init (vnode: VNodeWithData, hydrating: boolean): ?boolean {
  3. if (
  4. vnode.componentInstance &&
  5. !vnode.componentInstance._isDestroyed &&
  6. vnode.data.keepAlive
  7. ) {
  8. // kept-alive components, treat as a patch
  9. const mountedNode: any = vnode // work around flow
  10. componentVNodeHooks.prepatch(mountedNode, mountedNode)
  11. } else {
  12. const child = vnode.componentInstance = createComponentInstanceForVnode(
  13. vnode,
  14. activeInstance
  15. )
  16. child.$mount(hydrating ? vnode.elm : undefined, hydrating)
  17. }
  18. },
  19. // ...
  20. }

这也就是被 <keep-alive> 包裹的组件在有缓存的时候就不会在执行组件的 createdmounted 等钩子函数的原因了。回到 createComponent 方法,在 isReactivated 为 true 的情况下会执行 reactivateComponent 方法:

  1. function reactivateComponent (vnode, insertedVnodeQueue, parentElm, refElm) {
  2. let i
  3. // hack for #4339: a reactivated component with inner transition
  4. // does not trigger because the inner node's created hooks are not called
  5. // again. It's not ideal to involve module-specific logic in here but
  6. // there doesn't seem to be a better way to do it.
  7. let innerNode = vnode
  8. while (innerNode.componentInstance) {
  9. innerNode = innerNode.componentInstance._vnode
  10. if (isDef(i = innerNode.data) && isDef(i = i.transition)) {
  11. for (i = 0; i < cbs.activate.length; ++i) {
  12. cbs.activate[i](emptyNode, innerNode)
  13. }
  14. insertedVnodeQueue.push(innerNode)
  15. break
  16. }
  17. }
  18. // unlike a newly created component,
  19. // a reactivated keep-alive component doesn't insert itself
  20. insert(parentElm, vnode.elm, refElm)
  21. }

前面部分的逻辑是解决对 reactived 组件 transition 动画不触发的问题,可以先不关注,最后通过执行 insert(parentElm, vnode.elm, refElm) 就把缓存的 DOM 对象直接插入到目标元素中,这样就完成了在数据更新的情况下的渲染过程。

生命周期

之前我们提到,组件一旦被 <keep-alive> 缓存,那么再次渲染的时候就不会执行 createdmounted 等钩子函数,但是我们很多业务场景都是希望在我们被缓存的组件再次被渲染的时候做一些事情,好在 Vue 提供了 activated 钩子函数,它的执行时机是 <keep-alive> 包裹的组件渲染的时候,接下来我们从源码角度来分析一下它的实现原理。

在渲染的最后一步,会执行 invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch) 函数执行 vnodeinsert 钩子函数,它的定义在 src/core/vdom/create-component.js 中:

  1. const componentVNodeHooks = {
  2. insert (vnode: MountedComponentVNode) {
  3. const { context, componentInstance } = vnode
  4. if (!componentInstance._isMounted) {
  5. componentInstance._isMounted = true
  6. callHook(componentInstance, 'mounted')
  7. }
  8. if (vnode.data.keepAlive) {
  9. if (context._isMounted) {
  10. // vue-router#1212
  11. // During updates, a kept-alive component's child components may
  12. // change, so directly walking the tree here may call activated hooks
  13. // on incorrect children. Instead we push them into a queue which will
  14. // be processed after the whole patch process ended.
  15. queueActivatedComponent(componentInstance)
  16. } else {
  17. activateChildComponent(componentInstance, true /* direct */)
  18. }
  19. }
  20. },
  21. // ...
  22. }

这里判断如果是被 <keep-alive> 包裹的组件已经 mounted,那么则执行 queueActivatedComponent(componentInstance) ,否则执行 activateChildComponent(componentInstance, true)。我们先分析非 mounted 的情况,activateChildComponent 的定义在 src/core/instance/lifecycle.js 中:

  1. export function activateChildComponent (vm: Component, direct?: boolean) {
  2. if (direct) {
  3. vm._directInactive = false
  4. if (isInInactiveTree(vm)) {
  5. return
  6. }
  7. } else if (vm._directInactive) {
  8. return
  9. }
  10. if (vm._inactive || vm._inactive === null) {
  11. vm._inactive = false
  12. for (let i = 0; i < vm.$children.length; i++) {
  13. activateChildComponent(vm.$children[i])
  14. }
  15. callHook(vm, 'activated')
  16. }
  17. }

可以看到这里就是执行组件的 acitvated 钩子函数,并且递归去执行它的所有子组件的 activated 钩子函数。

那么再看 queueActivatedComponent 的逻辑,它定义在 src/core/observer/scheduler.js 中:

  1. export function queueActivatedComponent (vm: Component) {
  2. vm._inactive = false
  3. activatedChildren.push(vm)
  4. }

这个逻辑很简单,把当前 vm 实例添加到 activatedChildren 数组中,等所有的渲染完毕,在 nextTick后会执行 flushSchedulerQueue,这个时候就会执行:

  1. function flushSchedulerQueue () {
  2. // ...
  3. const activatedQueue = activatedChildren.slice()
  4. callActivatedHooks(activatedQueue)
  5. // ...
  6. }
  7. function callActivatedHooks (queue) {
  8. for (let i = 0; i < queue.length; i++) {
  9. queue[i]._inactive = true
  10. activateChildComponent(queue[i], true) }
  11. }

也就是遍历所有的 activatedChildren,执行 activateChildComponent 方法,通过队列调的方式就是把整个 activated 时机延后了。

activated 钩子函数,也就有对应的 deactivated 钩子函数,它是发生在 vnodedestory 钩子函数,定义在 src/core/vdom/create-component.js 中:

  1. const componentVNodeHooks = {
  2. destroy (vnode: MountedComponentVNode) {
  3. const { componentInstance } = vnode
  4. if (!componentInstance._isDestroyed) {
  5. if (!vnode.data.keepAlive) {
  6. componentInstance.$destroy()
  7. } else {
  8. deactivateChildComponent(componentInstance, true /* direct */)
  9. }
  10. }
  11. }
  12. }

对于 <keep-alive> 包裹的组件而言,它会执行 deactivateChildComponent(componentInstance, true) 方法,定义在 src/core/instance/lifecycle.js 中:

  1. export function deactivateChildComponent (vm: Component, direct?: boolean) {
  2. if (direct) {
  3. vm._directInactive = true
  4. if (isInInactiveTree(vm)) {
  5. return
  6. }
  7. }
  8. if (!vm._inactive) {
  9. vm._inactive = true
  10. for (let i = 0; i < vm.$children.length; i++) {
  11. deactivateChildComponent(vm.$children[i])
  12. }
  13. callHook(vm, 'deactivated')
  14. }
  15. }

activateChildComponent 方法类似,就是执行组件的 deacitvated 钩子函数,并且递归去执行它的所有子组件的 deactivated 钩子函数。

总结

那么至此,<keep-alive> 的实现原理就介绍完了,通过分析我们知道了 <keep-alive> 组件是一个抽象组件,它的实现通过自定义 render 函数并且利用了插槽,并且知道了 <keep-alive> 缓存 vnode,了解组件包裹的子元素——也就是插槽是如何做更新的。且在 patch 过程中对于已缓存的组件不会执行 mounted,所以不会有一般的组件的生命周期函数但是又提供了 activateddeactivated 钩子函数。另外我们还知道了 <keep-alive>props 除了 includeexclude 还有文档中没有提到的 max,它能控制我们缓存的个数。

原文: https://ustbhuangyi.github.io/vue-analysis/extend/keep-alive.html