記一次 <keep-alive> 快取及其快取優化

  • 2020 年 4 月 10 日
  • 筆記

快取淘汰策略

★由於 <keep-alive> 中的快取優化遵循 LRU 原則,所以首先了解下快取淘汰策略的相關介紹。 」

由於快取空間是有限的,所以不能無限制的進行數據存儲,當存儲容量達到一個閥值時,就會造成記憶體溢出,因此在進行數據快取時,就要根據情況對快取進行優化,清除一些可能不會再用到的數據。所以根據快取淘汰的機制不同,常用的有以下三種:

  1. FIFO(fisrt-in-fisrt-out)- 先進先出策略 我們通過記錄數據使用的時間,當快取大小即將溢出時,優先清楚離當前時間最遠的數據。
  1. LRU (least-recently-used)- 最近最少使用策略 以時間作為參考,如果數據最近被訪問過,那麼將來被訪問的幾率會更高,如果以一個數組去記錄數據,當有一數據被訪問時,該數據會被移動到數組的末尾,表明最近被使用過,當快取溢出時,會刪除數組的頭部數據,即將最不頻繁使用的數據移除。(keep-alive 的優化處理)
  1. LFU (least-frequently-used)- 計數最少策略 以次數作為參考,用次數去標記數據使用頻率,次數最少的會在快取溢出時被淘汰。

<keep-alive> 簡單示例

首先我們看一個動態組件使用 <keep-alive> 的例子。

<div id="dynamic-component-demo">    <button v-on:click="currentTab = 'Posts'">Posts</button>   <button v-on:click="currentTab = 'Archive'">Archive</button>    <keep-alive>      <component        v-bind:is="currentTabComponent"        class="tab"      ></component>    </keep-alive>  </div>  
Vue.component('tab-posts', {    data: function () {     return {        count: 0      }    },   template: `     <div class="posts-tab">       <button @click="count++">Click Me</button>     <p>{{count}}</p>      </div>`  })    Vue.component('tab-archive', {   template: '<div>Archive component</div>'  })    new Vue({    el: '#dynamic-component-demo',    data: {      currentTab: 'Posts',    },    computed: {      currentTabComponent: function () {        return 'tab-' + this.currentTab.toLowerCase()      }    }  })  

我們可以看到,動態組件外層包裹著 <keep-alve> 標籤。

<keep-alive>    <component      v-bind:is="currentTabComponent"      class="tab"    ></component>  </keep-alive>  

那就意味著,當選項卡 PostsArchive 在來回切換時,所對應的組件實例會被快取起來,所以當再次切換到 Posts 選項時,其對應的組件 tab-posts 會從快取中獲取,計數器 count 也會保留上一次的狀態。

<keep-alive> 快取及優化處理

就此,我們看完 <keep-alive> 的簡單示例之後,讓我們一起來分析下源碼中它是如何進行組件快取和快取優化處理的。

首次渲染

vue模板 -> AST -> render() -> vnode -> 真實Dom 這個轉化過程中,會進入 patch 階段,在patch 階段,會調用 createElm 函數中會將 vnode 轉化為真實 dom

function createPatchFunction (backend) {    ...    //生成真實dom    function createElm (      vnode,      insertedVnodeQueue,      parentElm,      refElm,      nested,      ownerArray,      index    ) {      // 返回 true 代表為 vnode 為組件 vnode,將停止接下來的轉換過程      if (createComponent(vnode, insertedVnodeQueue, parentElm, refElm)) {        return;      }      ...    }  }  

在轉化節點的過程中,因為 <keep-alive>vnode 會視為組件 vnode,因此一開始會調用 createComponent() 函數,createComponent() 會執行組件初始化內部鉤子 init(), 對組件進行初始化和實例化。

function createComponent (vnode, insertedVnodeQueue, parentElm, refElm) {      var i = vnode.data;      if (isDef(i)) {        // isReactivated 用來判斷組件是否快取        var isReactivated = isDef(vnode.componentInstance) && i.keepAlive;        if (isDef(i = i.hook) && isDef(i = i.init)) {          // 執行組件初始化的內部鉤子 init()          i(vnode, false /* hydrating */);        }        // after calling the init hook, if the vnode is a child component        // it should've created a child instance and mounted it. the child        // component also has set the placeholder vnode's elm.        // in that case we can just return the element and be done.        if (isDef(vnode.componentInstance)) {          initComponent(vnode, insertedVnodeQueue);          // 將真實 dom 添加到父節點,insert 操作 dom api          insert(parentElm, vnode.elm, refElm);          if (isTrue(isReactivated)) {            reactivateComponent(vnode, insertedVnodeQueue, parentElm, refElm);          }          return true        }      }    }  

<keep-alive> 組件通過調用內部鉤子 init() 方法進行初始化操作。

★註:源碼中通過函數 installComponentHooks() 可追蹤到內部鉤子的定義對象 componentVNodeHooks。 」

// inline hooks to be invoked on component VNodes during patch  var componentVNodeHooks = {    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 {        // 第一次運行時,vnode.componentInstance 不存在 ,vnode.data.keepAlive 不存在        // 將組件實例化,並賦值給 vnode 的 componentInstance 屬性        var child = vnode.componentInstance = createComponentInstanceForVnode(          vnode,          activeInstance        );        // 進行掛載        child.$mount(hydrating ? vnode.elm : undefined, hydrating);      }    },    // prepatch 是 patch 過程的核心步驟    prepatch: function prepatch (oldVnode, vnode) { ... },    insert: function insert (vnode) { ... },    destroy: function destroy (vnode) { ... }  };  

第一次執行時,很明顯組件 vnode 沒有 componentInstance 屬性,vnode.data.keepAlive 也沒有值,所以會調用 createComponentInstanceForVnode() 將組件進行實例化並將組件實例賦值給 vnodecomponentInstance 屬性,最後執行組件實例的 $mount 方法進行實例掛載。

createComponentInstanceForVnode()是組件實例化的過程,組件實例化無非就是一系列選項合併,初始化事件,生命周期等初始化操作。 」

快取 vnode 節點

<keep-alive> 在執行組件實例化之後會進行組件的掛載(如上程式碼所示)。

...  // 進行掛載  child.$mount(hydrating ? vnode.elm : undefined, hydrating);  ...  

掛載 $mount 階段會調用 mountComponent() 函數進行 vm._update(vm._render(), hydrating); 操作。

Vue.prototype.$mount = function (el, hydrating) {    el = el && inBrowser ? query(el) : undefined;    return mountComponent(this, el, hydrating)  };    function mountComponent (vm, el, hydrating) {    vm.$el = el;   ...    callHook(vm, 'beforeMount');    var updateComponent;    if (process.env.NODE_ENV !== 'production' && config.performance && mark) {      ...    } else {      updateComponent = function () {        // vm._render() 會根據數據的變化為組件生成新的 Vnode 節點        // vm._update() 最終會為 Vnode 生成真實 DOM 節點        vm._update(vm._render(), hydrating);      }    }    ...    return vm  }  

vm._render() 函數最終會調用組件選項中的 render() 函數,進行渲染。

function renderMixin (Vue) {    ...    Vue.prototype._render = function () {      var vm = this;      var ref = vm.$options;      var render = ref.render;      ...      try {        ...        // 調用組件的 render 函數        vnode = render.call(vm._renderProxy, vm.$createElement);      }      ...      return vnode    };  }  

由於keep-alive 是一個內置組件,因此也擁有自己的 render() 函數,所以讓我們一起來看下 render() 函數的具體實現。

var KeepAlive = {    ...    props: {      include: patternTypes,  // 名稱匹配的組件會被快取,對外暴露 include 屬性 api      exclude: patternTypes,  // 名稱匹配的組件不會被快取,對外暴露 exclude 屬性 api      max: [String, Number]  // 可以快取的組件最大個數,對外暴露 max 屬性 api    },    created: function created () {},    destroyed: function destroyed () {},   mounted: function mounted () {},      // 在渲染階段,進行快取的存或者取    render: function render () {      // 首先拿到 keep-alve 下插槽的默認值 (包裹的組件)      var slot = this.$slots.default;      // 獲取第一個 vnode 節點      var vnode = getFirstComponentChild(slot); // # 3802 line      // 拿到第一個子組件實例      var componentOptions = vnode && vnode.componentOptions;      // 如果 keep-alive 第一個組件實例不存在      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;        var key = vnode.key == null          // same constructor may get registered as different local components          // so cid alone is not enough (#3269)          // 獲取本地組件唯一key          ? componentOptions.Ctor.cid + (componentOptions.tag ? ("::" + (componentOptions.tag)) : '')          : vnode.key;        if (cache[key]) {          vnode.componentInstance = cache[key].componentInstance;          // make current key freshest          // 使用 LRU 最近最少快取策略,將命中的 key 從快取數組中刪除,並將當前最新 key 存入快取數組的末尾          remove(keys, key); // 刪除命中已存在的組件          keys.push(key); // 將當前組件名重新存入數組最末端        } else {          // 進行快取          cache[key] = vnode;          keys.push(key);          // prune oldest entry          // 根據組件名與 max 進行比較          if (this.max && keys.length > parseInt(this.max)) { // 超出組件快取最大數的限制            // 執行 pruneCacheEntry 對最少訪問數據(數組的第一項)進行刪除            pruneCacheEntry(cache, keys[0], keys, this._vnode);          }        }        // 為快取組件打上標誌        vnode.data.keepAlive = true;      }      // 返回 vnode      return vnode || (slot && slot[0])    }  };  

從上可得知,在 keep-alive 的源碼定義中, render() 階段會快取 vnode 和組件名稱 key 等操作。

  • 首先會判斷是否存在快取,如果存在,則直接從快取中獲取組件的實例,並進行快取優化處理(稍後會介紹到)。
  • 如果不存在快取,會將 vnode 作為值存入 cache 對象對應的 key 中。還會將組件名稱存入 keys 數組中。
if (cache[key]) {    vnode.componentInstance = cache[key].componentInstance;    // make current key freshest    // 使用 LRU 最近最少快取策略,將命中的 key 從快取數組中刪除,並將當前最新 key 存入快取數組的末尾    remove(keys, key); // 刪除命中已存在的組件    keys.push(key); // 將當前組件名重新存入數組最末端  } else {    // 進行快取    cache[key] = vnode;    keys.push(key);    // prune oldest entry    // 根據組件名與 max 進行比較    if (this.max && keys.length > parseInt(this.max)) { // 超出組件快取最大數的限制      // 執行 pruneCacheEntry 對最少訪問數據(數組的第一項)進行刪除      pruneCacheEntry(cache, keys[0], keys, this._vnode);    }  }  

快取真實 DOM

回顧之前提到的首次渲染階段,會調用 createComponent()函數, createComponent()會執行組件初始化內部鉤子 init(),對組件進行初始化和實例化等操作。

function createComponent (vnode, insertedVnodeQueue, parentElm, refElm) {    var i = vnode.data;    if (isDef(i)) {      // isReactivated 用來判斷組件是否快取      var isReactivated = isDef(vnode.componentInstance) && i.keepAlive;      if (isDef(i = i.hook) && isDef(i = i.init)) {          // 執行組件初始化的內部鉤子 init()        i(vnode, false /* hydrating */);      }      if (isDef(vnode.componentInstance)) {        initComponent(vnode, insertedVnodeQueue);        // 將真實 dom 添加到父節點,insert 操作 dom api        insert(parentElm, vnode.elm, refElm);        if (isTrue(isReactivated)) {          reactivateComponent(vnode, insertedVnodeQueue, parentElm, refElm);        }        return true      }    }  }  

createComponet() 函數還會我們通過 vnode.componentInstance 拿到了 <keep-alive> 組件的實例,然後執行 initComponent()initComponent() 函數的作用就是將真實的 dom 保存再 vnode 中。

...  if (isDef(vnode.componentInstance)) {    // 其中的一個作用就是保存真實 dom 到 vnode 中    initComponent(vnode, insertedVnodeQueue);    // 將真實 dom 添加到父節點,(insert 操作 dom api)    insert(parentElm, vnode.elm, refElm);    if (isTrue(isReactivated)) {     reactivateComponent(vnode, insertedVnodeQueue, parentElm, refElm);    }    return true  }  ...  
function initComponent (vnode, insertedVnodeQueue) {      if (isDef(vnode.data.pendingInsert)) {        insertedVnodeQueue.push.apply(insertedVnodeQueue, vnode.data.pendingInsert);        vnode.data.pendingInsert = null;      }      // 保存真是 dom 節點到 vnode      vnode.elm = vnode.componentInstance.$el;      ...  }  

快取優化處理

在文章開頭,我們介紹了三種快取優化策略(它們各有優劣),而在 vue 中對 <keep-alive> 的快取優化處理的實現上,便用到了上述的 LRU 快取策略 。

上面介紹到,<keep-alive> 組件在存取快取的過程中,是在渲染階段進行的,所以我們回過頭來看 render() 函數的實現。

var KeepAlive = {    ...    props: {      include: patternTypes,  // 名稱匹配的組件會被快取,對外暴露 include 屬性 api      exclude: patternTypes,  // 名稱匹配的組件不會被快取,對外暴露 exclude 屬性 api      max: [String, Number]  // 可以快取的組件最大個數,對外暴露 max 屬性 api    },    // 創建節點生成快取對象    created: function created () {      this.cache = Object.create(null); // 快取 vnode      this.keys = []; // 快取組件名    },      // 在渲染階段,進行快取的存或者取    render: function render () {      // 首先拿到 keep-alve 下插槽的默認值 (包裹的組件)      var slot = this.$slots.default;      // 獲取第一個 vnode 節點      var vnode = getFirstComponentChild(slot); // # 3802 line      // 拿到第一個子組件實例      var componentOptions = vnode && vnode.componentOptions;      // 如果 keep-alive 第一個組件實例不存在      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;        var key = vnode.key == null          // same constructor may get registered as different local components          // so cid alone is not enough (#3269)          // 獲取本地組件唯一key          ? componentOptions.Ctor.cid + (componentOptions.tag ? ("::" + (componentOptions.tag)) : '')          : vnode.key;        if (cache[key]) {          vnode.componentInstance = cache[key].componentInstance;          // make current key freshest          // 使用 LRU 最近最少快取策略,將命中的 key 從快取數組中刪除,並將當前最新 key 存入快取數組的末尾          remove(keys, key); // 刪除命中已存在的組件          keys.push(key); // 將當前組件名重新存入數組最末端        } else {          // 進行快取          cache[key] = vnode;          keys.push(key);          // prune oldest entry          // 根據組件名與 max 進行比較          if (this.max && keys.length > parseInt(this.max)) { // 超出組件快取最大數的限制            // 執行 pruneCacheEntry 對最少訪問數據(數組的第一項)進行刪除            pruneCacheEntry(cache, keys[0], keys, this._vnode);          }        }        // 為快取組件打上標誌        vnode.data.keepAlive = true;      }      // 返回 vnode      return vnode || (slot && slot[0])    }  };  

<keep-alive> 組件會在創建階段生成快取對象,在渲染階段對組件進行快取,並進行快取優化。我們重點來看下段程式碼。

if (cache[key]) {    ...    // 使用 LRU 最近最少快取策略,將命中的 key 從快取數組中刪除,並將當前最新 key 存入快取數組的末尾    remove(keys, key); // 刪除命中已存在的組件    keys.push(key); // 將當前組件名重新存入數組最末端  } else {    // 進行快取    cache[key] = vnode;    keys.push(key);    // 根據組件名與 max 進行比較    if (this.max && keys.length > parseInt(this.max)) { // 超出組件快取最大數的限制      // 執行 pruneCacheEntry 對最少訪問數據(數組的第一項)進行刪除      pruneCacheEntry(cache, keys[0], keys, this._vnode);    }  }  

從注釋中我們可以得知,當 keep-alive 被激活時(觸發 activated 鉤子),會執行 remove(keys, key) 函數,從快取數組中 keys 刪除已存在的組件,之後會進行 push 操作,將當前組件名重新存入 keys 數組的最末端,正好符合 LRU

★LRU:以時間作為參考,如果數據最近被訪問過,那麼將來被訪問的幾率會更高,如果以一個數組去記錄數據,當有一數據被訪問時,該數據會被移動到數組的末尾,表明最近被使用過,當快取溢出時,會刪除數組的頭部數據,即將最不頻繁使用的數據移除。 」

remove(keys, key); // 刪除命中已存在的組件  keys.push(key); // 將當前組件名重新存入數組最末端    function remove (arr, item) {    if (arr.length) {      var index = arr.indexOf(item);      if (index > -1) {        return arr.splice(index, 1)      }    }  }  

至此,我們可以回過頭看我們上邊的 <keep-alive> 示例,示例中包含 tab-poststab-archive 兩個組件,通過 componentis 屬性動態渲染。當 tab 來回切換時,會將兩個組件的 vnode 和組件名稱存入快取中,如下。

keys = ['tab-posts', 'tab-archive']  cache = {   'tab-posts':   tabPostsVnode,   'tab-archive': tabArchiveVnode  }  

假如,當再次激活到 tabPosts 組件時,由於命中了快取,會調用源碼中的 remove()方法,從快取數組中 keystab-posts 刪除,之後會使用 push 方法將 tab-posts 推到末尾。這時快取結果變為:

keys = ['tab-archive', 'tab-posts']  cache = {   'tab-posts':   tabPostsVnode,   'tab-archive': tabArchiveVnode  }  

現在我們可以得知,keys 用開快取組件名是用來記錄快取數據的。那麼當快取溢出時, <keep-alive>又是如何 處理的呢?

★我們可以通過 max 屬性來限制最多可以快取多少組件實例。 」

在上面源碼中的 render() 階段,還有一個 pruneCacheEntry(cache, keys[0], keys, this._vnode) 函數,根據 LRU 淘汰策略,會在快取溢出時,刪除快取中的頭部數據,所以會將 keys[0] 傳入pruneCacheEntry()

if (this.max && keys.length > parseInt(this.max)) { // 超出組件快取最大數的限制    // 執行 pruneCacheEntry 對最少訪問數據(數組的第一項)進行刪除    pruneCacheEntry(cache, keys[0], keys, this._vnode);  }  

pruneCacheEntry() 具體邏輯如下:

  • 首先,通過cached
1 = cache[key] 獲取頭部數據對應的值 vnode,執行 cached

1.componentInstance.

  • 其次,執行 cache[key] = null 清空組件對應的快取節點。
  • 最後,執行 remove(keys, key) 刪除快取中的頭部數據 keys[0]

至此,關於 <keep-alive> 組件的首次渲染、組件快取和快取優化處理相關的實現就到這裡。

最後記住這幾個點:

  • <keep-alive>vue 內置組件,在源碼定義中,也具有自己的組件選項如 datamethodcomputedprops 等。
  • <keep-alive> 具有抽象組件標識 abstract,通常會與動態組件一同使用。
  • <keep-alive> 包裹動態組件時,會快取不活動的組件實例,將它們停用,而不是銷毀它們。
  • <keep-alive> 快取的組件會觸發 activateddeactivated 生命周期鉤子。
  • <keep-alive> 會快取組件實例的 vnode 對象 ,和真實 dom 節點,所以會有 max 屬性設置。
  • <keep-alive> 不會在函數式組件中正常工作,因為它們沒有快取實例。