完全搞懂Vue中keep-alive的魔法(下)

在上一節完全搞懂Vue中keep-alive的魔法(上)中,咱們對keep-alive組件的初始渲染流程以及組件的配置信息進行了源碼分析。初始渲染流程最關鍵的一步是對渲染的組件Vnode進行緩存,其中也包括了組件的真實節點存儲。有了第一次的緩存,當再次渲染組件時,keep-alive又擁有哪些魔法呢?接下來咱們將完全揭開這一層面紗。vue

13.5 準備工做

上一節對keep-alive組件的分析,是從我畫的一個流程圖開始的。若是不想回過頭看上一節的內容,能夠參考如下的簡單總結。node

    1. keep-alive是源碼內部定義的組件選項配置,它會先註冊爲全局組件供開發者全局使用,其中render函數定義了它的渲染過程
    1. 和普通組件一致,當父在建立真實節點的過程當中,遇到keep-alive的組件會進行組件的初始化和實例化。
    1. 實例化會執行掛載$mount的過程,這一步會執行keep-alive選項中的render函數。
    1. render函數在初始渲染時,會將渲染的子Vnode進行緩存。同時對應的子真實節點也會被緩存起來

那麼,當再次須要渲染到已經被渲染過的組件時,keep-alive的處理又有什麼不一樣呢?react

13.5.1 基礎使用

爲了文章的完整性,我依舊把基礎的使用展現出來,其中加入了生命週期的使用,方便後續對keep-alive生命週期的分析。算法

<div id="app">
    <button @click="changeTabs('child1')">child1</button>
    <button @click="changeTabs('child2')">child2</button>
    <keep-alive>
        <component :is="chooseTabs">
        </component>
    </keep-alive>
</div>
var child1 = {
    template: '<div><button @click="add">add</button><p>{{num}}</p></div>',
    data() {
        return {
            num: 1
        }
    },
    methods: {
        add() {
            this.num++
        }
    },
    mounted() {
        console.log('child1 mounted')
    },
    activated() {
        console.log('child1 activated')
    },
    deactivated() {
        console.log('child1 deactivated')
    },
    destoryed() {
        console.log('child1 destoryed')
    }
}
var child2 = {
    template: '<div>child2</div>',
    mounted() {
        console.log('child2 mounted')
    },
    activated() {
        console.log('child2 activated')
    },
    deactivated() {
        console.log('child2 deactivated')
    },
    destoryed() {
        console.log('child2 destoryed')
    }
}

var vm = new Vue({
    el: '#app',
    components: {
        child1,
        child2,
    },
    data() {
        return {
            chooseTabs: 'child1',
        }
    },
    methods: {
        changeTabs(tab) {
            this.chooseTabs = tab;
        }
    }
})
複製代碼
13.5.2 流程圖

和首次渲染的分析一致,再次渲染的過程我依舊畫了一個簡單的流程圖。vue-router

13.6 流程分析

13.6.1 從新渲染組件

再次渲染的流程從數據改變提及,在這個例子中,動態組件中chooseTabs數據的變化會引發依賴派發更新的過程(這個系列有三篇文章詳細介紹了vue響應式系統的底層實現,感興趣的同窗能夠借鑑)。簡單來講,chooseTabs這個數據在初始化階段會收集使用到該數據的相關依賴。當數據發生改變時,收集過的依賴會進行派發更新操做。api

其中,父組件中負責實例掛載的過程做爲依賴會被執行,即執行父組件的vm._update(vm._render(), hydrating);_render_update分別表明兩個過程,其中_render函數會根據數據的變化爲組件生成新的Vnode節點,而_update最終會爲新的Vnode生成真實的節點。而在生成真實節點的過程當中,會利用vitrual domdiff算法對先後vnode節點進行對比,使之儘量少的更改真實節點,這一部份內容能夠回顧深刻剖析Vue源碼 - 來,跟我一塊兒實現diff算法!,裏面詳細闡述了利用diff算法進行節點差別對比的思路。數組

patch是新舊Vnode節點對比的過程,而patchVnode是其中核心的步驟,咱們忽略patchVnode其餘的流程,關注到其中對子組件執行prepatch鉤子的過程當中。緩存

function patchVnode (oldVnode,vnode,insertedVnodeQueue,ownerArray,index,removeOnly) {
    ···
    // 新vnode  執行prepatch鉤子
    if (isDef(data) && isDef(i = data.hook) && isDef(i = i.prepatch)) {
        i(oldVnode, vnode);
    }
    ···
}
複製代碼

執行prepatch鉤子時會拿到新舊組件的實例並執行updateChildComponent函數。而updateChildComponent會對針對新的組件實例對舊實例進行狀態的更新,包括props,listeners等,最終會調用vue提供的全局vm.$forceUpdate()方法進行實例的從新渲染。bash

var componentVNodeHooks = {
    // 以前分析的init鉤子 
    init: function() {},
    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
      );
    },
}

function updateChildComponent() {
    // 更新舊的狀態,不分析這個過程
    ···
    // 迫使實例從新渲染。
    vm.$forceUpdate();
}
複製代碼

先看看$forceUpdate作了什麼操做。$forceUpdate是源碼對外暴露的一個api,他們迫使Vue實例從新渲染,本質上是執行實例所收集的依賴,在例子中watcher對應的是keep-alivevm._update(vm._render(), hydrating);過程。app

Vue.prototype.$forceUpdate = function () {
    var vm = this;
    if (vm._watcher) {
      vm._watcher.update();
    }
  };
複製代碼
13.6.2 重用緩存組件

因爲vm.$forceUpdate()會強迫keep-alive組件進行從新渲染,所以keep-alive組件會再一次執行render過程。這一次因爲第一次對vnode的緩存,keep-alive在實例的cache對象中找到了緩存的組件。

// keepalive組件選項
var keepAlive = {
    name: 'keep-alive',
    abstract: true,
    render: function render () {
      // 拿到keep-alive下插槽的值
      var slot = this.$slots.default;
      // 第一個vnode節點
      var vnode = getFirstComponentChild(slot);
      // 拿到第一個組件實例
      var componentOptions = vnode && vnode.componentOptions;
      // keep-alive的第一個子組件實例存在
      if (componentOptions) {
        // check pattern
        //拿到第一個vnode節點的name
        var name = getComponentName(componentOptions);
        var ref = this;
        var include = ref.include;
        var exclude = ref.exclude;
        // 經過判斷子組件是否知足緩存匹配
        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;
        var key = vnode.key == null ? componentOptions.Ctor.cid + (componentOptions.tag ? ("::" + (componentOptions.tag)) : '')
          : vnode.key;
          // ==== 關注點在這裏 ====
        if (cache[key]) {
          // 直接取出緩存組件
          vnode.componentInstance = cache[key].componentInstance;
          // keys命中的組件名移到數組末端
          remove(keys, key);
          keys.push(key);
        } else {
        // 初次渲染時,將vnode緩存
          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])
    }
}

複製代碼

render函數前面邏輯能夠參考前一篇文章,因爲cache對象中存儲了再次使用的vnode對象,因此直接經過cache[key]取出緩存的組件實例並賦值給vnodecomponentInstance屬性。可能在讀到這裏的時候,會對源碼中keys這個數組的做用,以及pruneCacheEntry的功能有疑惑,這裏咱們放到文章末尾講緩存優化策略時解答。

13.6.3 真實節點的替換

執行了keep-alive組件的_render過程,接下來是_update產生真實的節點,一樣的,keep-alive下有child1子組件,因此_update過程會調用createComponent遞歸建立子組件vnode,這個過程在初次渲染時也有分析過,咱們能夠對比一下,再次渲染時流程有哪些不一樣。

function createComponent (vnode, insertedVnodeQueue, parentElm, refElm) {
    // vnode爲緩存的vnode
      var i = vnode.data;
      if (isDef(i)) {
        // 此時isReactivated爲true
        var isReactivated = isDef(vnode.componentInstance) && i.keepAlive;
        if (isDef(i = i.hook) && isDef(i = i.init)) {
          i(vnode, false /* hydrating */);
        }
        if (isDef(vnode.componentInstance)) {
          // 其中一個做用是保留真實dom到vnode中
          initComponent(vnode, insertedVnodeQueue);
          insert(parentElm, vnode.elm, refElm);
          if (isTrue(isReactivated)) {
            reactivateComponent(vnode, insertedVnodeQueue, parentElm, refElm);
          }
          return true
        }
      }
    }
複製代碼

此時的vnode是緩存取出的子組件vnode,而且因爲在第一次渲染時對組件進行了標記vnode.data.keepAlive = true;,因此isReactivated的值爲true,i.init依舊會執行子組件的初始化過程。可是這個過程因爲有緩存,因此執行過程也不徹底相同。

var componentVNodeHooks = {
    init: function init (vnode, hydrating) {
      if (
        vnode.componentInstance &&
        !vnode.componentInstance._isDestroyed &&
        vnode.data.keepAlive
      ) {
        // 當有keepAlive標誌時,執行prepatch鉤子
        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);
      }
    },
}
複製代碼

顯然由於有keepAlive的標誌,因此子組件再也不走掛載流程,只是執行prepatch鉤子對組件狀態進行更新。而且很好的利用了緩存vnode以前保留的真實節點進行節點的替換。

13.7 生命週期

咱們經過例子來觀察keep-alive生命週期和普通組件的不一樣。

在咱們從child1切換到child2,再切回child1過程當中,chil1不會再執行mounted鉤子,只會執行activated鉤子,而child2也不會執行destoryed鉤子,只會執行deactivated鉤子,這是爲何?child2deactivated鉤子又要比child1activated提早執行,這又是爲何?

13.7.1 deactivated

咱們先從組件的銷燬開始提及,當child1切換到child2時,child1會執行deactivated鉤子而不是destoryed鉤子,這是爲何? 前面分析patch過程會對新舊節點的改變進行對比,從而儘量範圍小的去操做真實節點,當完成diff算法並對節點操做完畢後,接下來還有一個重要的步驟是對舊的組件執行銷燬移除操做。這一步的代碼以下:

function patch(···) {
  // 分析過的patchVnode過程
  // 銷燬舊節點
  if (isDef(parentElm)) {
    removeVnodes(parentElm, [oldVnode], 0, 0);
  } else if (isDef(oldVnode.tag)) {
    invokeDestroyHook(oldVnode);
  }
}

function removeVnodes (parentElm, vnodes, startIdx, endIdx) {
  // startIdx,endIdx都爲0
  for (; startIdx <= endIdx; ++startIdx) {
    // ch 會拿到須要銷燬的組件
    var ch = vnodes[startIdx];
    if (isDef(ch)) {
      if (isDef(ch.tag)) {
        // 真實節點的移除操做
        removeAndInvokeRemoveHook(ch);
        invokeDestroyHook(ch);
      } else { // Text node
        removeNode(ch.elm);
      }
    }
  }
}
複製代碼

removeAndInvokeRemoveHook會對舊的節點進行移除操做,其中關鍵的一步是會將真實節點從父元素中刪除,有興趣能夠自行查看這部分邏輯。invokeDestroyHook是執行銷燬組件鉤子的核心。若是該組件下存在子組件,會遞歸去調用invokeDestroyHook執行銷燬操做。銷燬過程會執行組件內部的destory鉤子。

function invokeDestroyHook (vnode) {
    var i, j;
    var data = vnode.data;
    if (isDef(data)) {
      if (isDef(i = data.hook) && isDef(i = i.destroy)) { i(vnode); }
      // 執行組件內部destroy鉤子
      for (i = 0; i < cbs.destroy.length; ++i) { cbs.destroy[i](vnode); }
    }
    // 若是組件存在子組件,則遍歷子組件去遞歸調用invokeDestoryHook執行鉤子
    if (isDef(i = vnode.children)) {
      for (j = 0; j < vnode.children.length; ++j) {
        invokeDestroyHook(vnode.children[j]);
      }
    }
  }
複製代碼

組件內部鉤子前面已經介紹了initprepatch鉤子,而destroy鉤子的邏輯更加簡單。

var componentVNodeHooks = {
  destroy: function destroy (vnode) {
    // 組件實例
    var componentInstance = vnode.componentInstance;
    // 若是實例還未被銷燬
    if (!componentInstance._isDestroyed) {
      // 不是keep-alive組件則執行銷燬操做
      if (!vnode.data.keepAlive) {
        componentInstance.$destroy();
      } else {
        // 若是是已經緩存的組件
        deactivateChildComponent(componentInstance, true /* direct */);
      }
    }
  }
}
複製代碼

當組件是keep-alive緩存過的組件,即已經用keepAlive標記過,則不會執行實例的銷燬,即componentInstance.$destroy()的過程。$destroy過程會作一系列的組件銷燬操做,其中的beforeDestroy,destoryed鉤子也是在$destory過程當中調用,而deactivateChildComponent的處理過程卻徹底不一樣。

function deactivateChildComponent (vm, direct) {
  if (direct) {
    // 
    vm._directInactive = true;
    if (isInInactiveTree(vm)) {
      return
    }
  }
  if (!vm._inactive) {
    // 已經被停用
    vm._inactive = true;
    // 對子組件一樣會執行停用處理
    for (var i = 0; i < vm.$children.length; i++) {
      deactivateChildComponent(vm.$children[i]);
    }
    // 最終調用deactivated鉤子
    callHook(vm, 'deactivated');
  }
}
複製代碼

_directInactive是用來標記這個被打上停用標籤的組件是不是最頂層的組件。而_inactive是停用的標誌,一樣的子組件也須要遞歸去調用deactivateChildComponent,打上停用的標記。最終會執行用戶定義的deactivated鉤子。

13.7.2 activated

如今回過頭看看activated的執行時機,一樣是patch過程,在對舊節點移除並執行銷燬或者停用的鉤子後,對新節點也會執行相應的鉤子。這也是停用的鉤子比啓用的鉤子先執行的緣由。

function patch(···) {
  // patchVnode過程
  // 銷燬舊節點
  {
    if (isDef(parentElm)) {
      removeVnodes(parentElm, [oldVnode], 0, 0);
    } else if (isDef(oldVnode.tag)) {
      invokeDestroyHook(oldVnode);
    }
  }
  // 執行組件內部的insert鉤子
  invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch);
}

function invokeInsertHook (vnode, queue, initial) {
  // delay insert hooks for component root nodes, invoke them after the
  // 當節點已經被插入時,會延遲執行insert鉤子
  if (isTrue(initial) && isDef(vnode.parent)) {
    vnode.parent.data.pendingInsert = queue;
  } else {
    for (var i = 0; i < queue.length; ++i) {
      queue[i].data.hook.insert(queue[i]);
    }
  }
}
複製代碼

一樣的組件內部的insert鉤子邏輯以下:

// 組件內部自帶鉤子
  var componentVNodeHooks = {
    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) {
          // vue-router#1212
          // During updates, a kept-alive component's child components may // change, so directly walking the tree here may call activated hooks // on incorrect children. Instead we push them into a queue which will // be processed after the whole patch process ended. queueActivatedComponent(componentInstance); } else { activateChildComponent(componentInstance, true /* direct */); } } }, } 複製代碼

當第一次實例化組件時,因爲實例的_isMounted不存在,因此會調用mounted鉤子,當咱們從child2再次切回child1時,因爲child1只是被停用而沒有被銷燬,因此不會再調用mounted鉤子,此時會執行activateChildComponent函數對組件的狀態進行處理。有了分析deactivateChildComponent的基礎,activateChildComponent的邏輯也很好理解,一樣的_inactive標記爲已啓用,而且對子組件遞歸調用activateChildComponent作狀態處理。

function activateChildComponent (vm, direct) {
  if (direct) {
    vm._directInactive = false;
    if (isInInactiveTree(vm)) {
      return
    }
  } else if (vm._directInactive) {
    return
  }
  if (vm._inactive || vm._inactive === null) {
    vm._inactive = false;
    for (var i = 0; i < vm.$children.length; i++) {
      activateChildComponent(vm.$children[i]);
    }
    callHook(vm, 'activated');
  }
}
複製代碼

13.8 緩存優化 - LRU

程序的內存空間是有限的,因此咱們沒法無節制的對數據進行存儲,這時候須要有策略去淘汰不那麼重要的數據,保持最大數據存儲量的一致。這種類型的策略稱爲緩存優化策略,根據淘汰的機制不一樣,經常使用的有如下三類。

  • 1. FIFO: 先進先出策略,咱們經過記錄數據使用的時間,當緩存大小即將溢出時,優先清除離當前時間最遠的數據。

  • 2. LRU: 最近最少使用。LRU策略遵循的原則是,若是數據最近被訪問(使用)過,那麼未來被訪問的概率會更高,若是以一個數組去記錄數據,當有一數據被訪問時,該數據會被移動到數組的末尾,代表最近被使用過,當緩存溢出時,會刪除數組的頭部數據,即將最不頻繁使用的數據移除。

  • 3. LFU: 計數最少策略。用次數去標記數據使用頻率,次數最少的會在緩存溢出時被淘汰。

這三種緩存算法各有優劣,各自適用不一樣場景,而咱們看keep-alive在緩存時的優化處理,很明顯利用了LRU的緩存策略。咱們看關鍵的代碼

var keepAlive = {
  render: function() {
    ···
    if (cache[key]) {
      vnode.componentInstance = cache[key].componentInstance;
      remove(keys, key);
      keys.push(key);
    } else {
      cache[key] = vnode;
      keys.push(key);
      if (this.max && keys.length > parseInt(this.max)) {
        pruneCacheEntry(cache, keys[0], keys, this._vnode);
      }
    }
  }
}

function remove (arr, item) {
  if (arr.length) {
    var index = arr.indexOf(item);
    if (index > -1) {
      return arr.splice(index, 1)
    }
  }
}
複製代碼

結合一個實際的例子分析緩存邏輯的實現。

    1. 有三個組件child1,child2,child3,keep-alive的最大緩存個數設置爲2
    1. cache對象去存儲組件vnode,key爲組件名字,value爲組件vnode對象,用keys數組去記錄組件名字,因爲是數組,因此keys爲有序。
    1. child1,child2組件依次訪問,緩存結果爲
keys = ['child1', 'child2']
cache = {
  child1: child1Vnode,
  child2: child2Vnode
}
複製代碼
    1. 再次訪問到child1組件,因爲命中了緩存,會調用remove方法把keys中的child1刪除,並經過數組的push方法將child1推到尾部。緩存結果修改成
keys = ['child2', 'child1']
cache = {
  child1: child1Vnode,
  child2: child2Vnode
}
複製代碼
    1. 訪問到child3時,因爲緩存個數限制,初次緩存會執行pruneCacheEntry方法對最少訪問到的數據進行刪除。pruneCacheEntry的定義以下
function pruneCacheEntry (cache,key,keys,current) {
    var cached$$1 = cache[key];
    // 銷燬實例
    if (cached$$1 && (!current || cached$$1.tag !== current.tag)) {
      cached$$1.componentInstance.$destroy();
    }
    cache[key] = null;
    remove(keys, key);
  }

複製代碼

刪除緩存時會把keys[0]表明的組件刪除,因爲以前的處理,最近被訪問到的元素會位於數組的尾部,因此頭部的數據每每是最少訪問的,所以會優先刪除頭部的元素。而且會再次調用remove方法,將keys的首個元素刪除。

這就是vue中對keep-alive緩存處理的優化過程。


相關文章
相關標籤/搜索