13.3 初次渲染

keep-alive之所以特别,是因为它不会重复渲染相同的组件,只会利用初次渲染保留的缓存去更新节点。所以为了全面了解它的实现原理,我们需要从keep-alive的首次渲染开始说起。

13.3.1 流程图

为了理清楚流程,我大致画了一个流程图,流程图大致覆盖了初始渲染keep-alive所执行的过程,接下来会照着这个过程进行源码分析。

13.3 初次渲染 - 图1

和渲染普通组件相同的是,Vue会拿到前面生成的Vnode对象执行真实节点创建的过程,也就是熟悉的patch过程,patch执行阶段会调用createElm创建真实dom,在创建节点途中,keep-alivevnode对象会被认定是一个组件Vnode,因此针对组件Vnode又会执行createComponent函数,它会对keep-alive组件进行初始化和实例化。

  1. function createComponent (vnode, insertedVnodeQueue, parentElm, refElm) {
  2. var i = vnode.data;
  3. if (isDef(i)) {
  4. // isReactivated用来判断组件是否缓存。
  5. var isReactivated = isDef(vnode.componentInstance) && i.keepAlive;
  6. if (isDef(i = i.hook) && isDef(i = i.init)) {
  7. // 执行组件初始化的内部钩子 init
  8. i(vnode, false /* hydrating */);
  9. }
  10. if (isDef(vnode.componentInstance)) {
  11. // 其中一个作用是保留真实dom到vnode中
  12. initComponent(vnode, insertedVnodeQueue);
  13. insert(parentElm, vnode.elm, refElm);
  14. if (isTrue(isReactivated)) {
  15. reactivateComponent(vnode, insertedVnodeQueue, parentElm, refElm);
  16. }
  17. return true
  18. }
  19. }
  20. }

keep-alive组件会先调用内部钩子init方法进行初始化操作,我们先看看init过程做了什么操作。

  1. // 组件内部钩子
  2. var componentVNodeHooks = {
  3. init: function init (vnode, hydrating) {
  4. if (
  5. vnode.componentInstance &&
  6. !vnode.componentInstance._isDestroyed &&
  7. vnode.data.keepAlive
  8. ) {
  9. // kept-alive components, treat as a patch
  10. var mountedNode = vnode; // work around flow
  11. componentVNodeHooks.prepatch(mountedNode, mountedNode);
  12. } else {
  13. // 将组件实例赋值给vnode的componentInstance属性
  14. var child = vnode.componentInstance = createComponentInstanceForVnode(
  15. vnode,
  16. activeInstance
  17. );
  18. child.$mount(hydrating ? vnode.elm : undefined, hydrating);
  19. }
  20. },
  21. // 后面分析
  22. prepatch function() {}
  23. }

第一次执行,很明显组件vnode没有componentInstance属性,vnode.data.keepAlive也没有值,所以会调用createComponentInstanceForVnode方法进行组件实例化并将组件实例赋值给vnodecomponentInstance属性, 最终执行组件实例的$mount方法进行实例挂载。

createComponentInstanceForVnode就是组件实例化的过程,而组件实例化从系列的第一篇就开始说了,无非就是一系列选项合并,初始化事件,生命周期等初始化操作。

  1. function createComponentInstanceForVnode (vnode, parent) {
  2. var options = {
  3. _isComponent: true,
  4. _parentVnode: vnode,
  5. parent: parent
  6. };
  7. // 内联模板的处理,忽略这部分代码
  8. ···
  9. // 执行vue子组件实例化
  10. return new vnode.componentOptions.Ctor(options)
  11. }

13.3.2 内置组件选项

我们在使用组件的时候经常利用对象的形式定义组件选项,包括data,method,computed等,并在父组件或根组件中注册。keep-alive同样遵循这个道理,内置两字也说明了keep-alive是在Vue源码中内置好的选项配置,并且也已经注册到全局,这一部分的源码可以参考组态组件小节末尾对内置组件构造器和注册过程的介绍。这一部分我们重点关注一下keep-alive的具体选项。

  1. // keepalive组件选项
  2. var KeepAlive = {
  3. name: 'keep-alive',
  4. // 抽象组件的标志
  5. abstract: true,
  6. // keep-alive允许使用的props
  7. props: {
  8. include: patternTypes,
  9. exclude: patternTypes,
  10. max: [String, Number]
  11. },
  12. created: function created () {
  13. // 缓存组件vnode
  14. this.cache = Object.create(null);
  15. // 缓存组件名
  16. this.keys = [];
  17. },
  18. destroyed: function destroyed () {
  19. for (var key in this.cache) {
  20. pruneCacheEntry(this.cache, key, this.keys);
  21. }
  22. },
  23. mounted: function mounted () {
  24. var this$1 = this;
  25. // 动态include和exclude
  26. // 对include exclue的监听
  27. this.$watch('include', function (val) {
  28. pruneCache(this$1, function (name) { return matches(val, name); });
  29. });
  30. this.$watch('exclude', function (val) {
  31. pruneCache(this$1, function (name) { return !matches(val, name); });
  32. });
  33. },
  34. // keep-alive的渲染函数
  35. render: function render () {
  36. // 拿到keep-alive下插槽的值
  37. var slot = this.$slots.default;
  38. // 第一个vnode节点
  39. var vnode = getFirstComponentChild(slot);
  40. // 拿到第一个组件实例
  41. var componentOptions = vnode && vnode.componentOptions;
  42. // keep-alive的第一个子组件实例存在
  43. if (componentOptions) {
  44. // check pattern
  45. //拿到第一个vnode节点的name
  46. var name = getComponentName(componentOptions);
  47. var ref = this;
  48. var include = ref.include;
  49. var exclude = ref.exclude;
  50. // 通过判断子组件是否满足缓存匹配
  51. if (
  52. // not included
  53. (include && (!name || !matches(include, name))) ||
  54. // excluded
  55. (exclude && name && matches(exclude, name))
  56. ) {
  57. return vnode
  58. }
  59. var ref$1 = this;
  60. var cache = ref$1.cache;
  61. var keys = ref$1.keys;
  62. var key = vnode.key == null
  63. ? componentOptions.Ctor.cid + (componentOptions.tag ? ("::" + (componentOptions.tag)) : '')
  64. : vnode.key;
  65. // 再次命中缓存
  66. if (cache[key]) {
  67. vnode.componentInstance = cache[key].componentInstance;
  68. // make current key freshest
  69. remove(keys, key);
  70. keys.push(key);
  71. } else {
  72. // 初次渲染时,将vnode缓存
  73. cache[key] = vnode;
  74. keys.push(key);
  75. // prune oldest entry
  76. if (this.max && keys.length > parseInt(this.max)) {
  77. pruneCacheEntry(cache, keys[0], keys, this._vnode);
  78. }
  79. }
  80. // 为缓存组件打上标志
  81. vnode.data.keepAlive = true;
  82. }
  83. // 将渲染的vnode返回
  84. return vnode || (slot && slot[0])
  85. }
  86. };

keep-alive选项跟我们平时写的组件选项还是基本类似的,唯一的不同是keep-ailve组件没有用template而是使用render函数。keep-alive本质上只是存缓存和拿缓存的过程,并没有实际的节点渲染,所以使用render处理是最优的选择。

13.3.3 缓存vnode

还是先回到流程图的分析。上面说到keep-alive在执行组件实例化之后会进行组件的挂载。而挂载$mount又回到vm._render(),vm._update()的过程。由于keep-alive拥有render函数,所以我们可以直接将焦点放在render函数的实现上。

  • 首先是获取keep-alive下插槽的内容,也就是keep-alive需要渲染的子组件,例子中是chil1 Vnode对象,源码中对应getFirstComponentChild函数。
  1. function getFirstComponentChild (children) {
  2. if (Array.isArray(children)) {
  3. for (var i = 0; i < children.length; i++) {
  4. var c = children[i];
  5. // 组件实例存在,则返回,理论上返回第一个组件vnode
  6. if (isDef(c) && (isDef(c.componentOptions) || isAsyncPlaceholder(c))) {
  7. return c
  8. }
  9. }
  10. }
  11. }
  • 判断组件满足缓存的匹配条件,在keep-alive组件的使用过程中,Vue源码允许我们是用include, exclude来定义匹配条件,include规定了只有名称匹配的组件才会被缓存,exclude规定了任何名称匹配的组件都不会被缓存。更者,我们可以使用max来限制可以缓存多少匹配实例,而为什么要做数量的限制呢?我们后文会提到。

拿到子组件的实例后,我们需要先进行是否满足匹配条件的判断,其中匹配的规则允许使用数组,字符串,正则的形式。

  1. var include = ref.include;
  2. var exclude = ref.exclude;
  3. // 通过判断子组件是否满足缓存匹配
  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. // matches
  13. function matches (pattern, name) {
  14. // 允许使用数组['child1', 'child2']
  15. if (Array.isArray(pattern)) {
  16. return pattern.indexOf(name) > -1
  17. } else if (typeof pattern === 'string') {
  18. // 允许使用字符串 child1,child2
  19. return pattern.split(',').indexOf(name) > -1
  20. } else if (isRegExp(pattern)) {
  21. // 允许使用正则 /^child{1,2}$/g
  22. return pattern.test(name)
  23. }
  24. /* istanbul ignore next */
  25. return false
  26. }

如果组件不满足缓存的要求,则直接返回组件的vnode,不做任何处理,此时组件会进入正常的挂载环节。

  1. render函数执行的关键一步是缓存vnode,由于是第一次执行render函数,选项中的cachekeys数据都没有值,其中cache是一个空对象,我们将用它来缓存{ name: vnode }枚举,而keys我们用来缓存组件名。因此我们在第一次渲染keep-alive时,会将需要渲染的子组件vnode进行缓存。

    1. cache[key] = vnode;
    2. keys.push(key);
  2. 将已经缓存的vnode打上标记, 并将子组件的Vnode返回。vnode.data.keepAlive = true

13.3.4 真实节点的保存

我们再回到createComponent的逻辑,之前提到createComponent会先执行keep-alive组件的初始化流程,也包括了子组件的挂载。并且我们通过componentInstance拿到了keep-alive组件的实例,而接下来重要的一步是将真实的dom保存再vnode

  1. function createComponent(vnode, insertedVnodeQueue) {
  2. ···
  3. if (isDef(vnode.componentInstance)) {
  4. // 其中一个作用是保留真实dom到vnode中
  5. initComponent(vnode, insertedVnodeQueue);
  6. // 将真实节点添加到父节点中
  7. insert(parentElm, vnode.elm, refElm);
  8. if (isTrue(isReactivated)) {
  9. reactivateComponent(vnode, insertedVnodeQueue, parentElm, refElm);
  10. }
  11. return true
  12. }
  13. }

insert的源码不列举出来,它只是简单的调用操作domapi,将子节点插入到父节点中,我们可以重点看看initComponent关键步骤的逻辑。

  1. function initComponent() {
  2. ···
  3. // vnode保留真实节点
  4. vnode.elm = vnode.componentInstance.$el;
  5. ···
  6. }

因此,我们很清晰的回到之前遗留下来的问题,为什么keep-alive需要一个max来限制缓存组件的数量。原因就是keep-alive缓存的组件数据除了包括vnode这一描述对象外,还保留着真实的dom节点,而我们知道真实节点对象是庞大的,所以大量保留缓存组件是耗费性能的。因此我们需要严格控制缓存的组件数量,而在缓存策略上也需要做优化,这点我们在下一篇文章也继续提到。

由于isReactivatedfalse,reactivateComponent函数也不会执行。至此keep-alive的初次渲染流程分析完毕。

如果忽略步骤的分析,只对初次渲染流程做一个总结:内置的keep-alive组件,让子组件在第一次渲染的时候将vnode和真实的elm进行了缓存。