通俗易懂了解Vue內置組件keep-alive內部原理


1. 官方介紹及其用法

1.1 組件介紹

要想搞明白<keep-alive>組件的內部實現原理,首先我們得搞明白這個組件怎么用以及為什么要用它,關於<keep-alive>組件,官網如下介紹:

<keep-alive>Vue中內置的一個抽象組件,它自身不會渲染一個 DOM 元素,也不會出現在父組件鏈中。當它包裹動態組件時,會緩存不活動的組件實例,而不是銷毀它們。

這句話的意思簡單來說:就是我們可以把一些不常變動的組件或者需要緩存的組件用<keep-alive>包裹起來,這樣<keep-alive>就會幫我們把組件保存在內存中,而不是直接的銷毀,這樣做可以保留組件的狀態或避免多次重新渲染,以提高頁面性能。

1.2 用法

<keep-alive>組件可接收三個屬性:

  • include - 字符串或正則表達式。只有名稱匹配的組件會被緩存。
  • exclude - 字符串或正則表達式。任何名稱匹配的組件都不會被緩存。
  • max - 數字。最多可以緩存多少組件實例。

includeexclude 屬性允許組件有條件地緩存。二者都可以用逗號分隔字符串、正則表達式或一個數組來表示:

<!-- 逗號分隔字符串 -->
<keep-alive include="a,b">
  <component :is="view"></component>
</keep-alive>

<!-- 正則表達式 (使用 `v-bind`) -->
<keep-alive :include="/a|b/">
  <component :is="view"></component>
</keep-alive>

<!-- 數組 (使用 `v-bind`) -->
<keep-alive :include="['a', 'b']">
  <component :is="view"></component>
</keep-alive>

匹配時首先檢查組件自身的 name 選項,如果 name 選項不可用,則匹配它的局部注冊名稱 (父組件 components 選項的鍵值),也就是組件的標簽值。匿名組件不能被匹配。

max表示最多可以緩存多少組件實例。一旦這個數字達到了,在新實例被創建之前,已緩存組件中最久沒有被訪問的實例會被銷毀掉。

請讀者注意此處加粗的地方,暫時有個印象,后面我們會詳細說明。

<keep-alive :max="10">
  <component :is="view"></component>
</keep-alive>

OK,以上就是<keep-alive>組件的官方介紹及其用法,下面我們將着重介紹其內部實現原理。

2. 實現原理

<keep-alive>Vue 源碼中實現的一個組件,也就是說 Vue 源碼不僅實現了一套組件化的機制,也實現了一些內置組件,該組件的定義在 src/core/components/keep-alive.js 中:

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

  props: {
    include: [String, RegExp, Array],
    exclude: [String, RegExp, Array],
    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 = getFirstComponentChild(slot)
    /* 獲取該組件節點的componentOptions */
    const componentOptions = vnode && vnode.componentOptions

    if (componentOptions) {
      /* 獲取該組件節點的名稱,優先獲取組件的name字段,如果name不存在則獲取組件的tag */
      const name = getComponentName(componentOptions)

      const { include, exclude } = this
      /* 如果name不在inlcude中或者存在於exlude中則表示不緩存,直接返回vnode */
      if (
        (include && (!name || !matches(include, name))) ||
        // excluded
        (exclude && name && matches(exclude, name))
      ) {
        return vnode
      }

      const { cache, keys } = this
      const key = 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])
  }
}

這是該組件的源碼,是不是突然看到一大片代碼有點驚慌失措,哈哈哈,不要急不要慌,接下來我們抽絲剝繭,逐個擊破。

首先我們可以看到該組件內沒有常規的<template></template>等標簽,因為它不是一個常規的模板組件,取而代之的是它內部多了一個叫做render的函數,它是一個函數式組件,執行 <keep-alive> 組件渲染的時候,就會執行到這個 render 函數。了解了這個以后,接下來我們從上到下一步一步細細閱讀。

2.1 props

props選項內接收傳進來的三個屬性:includeexcludemax。如下:

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

include 表示只有匹配到的組件會被緩存,而 exclude 表示任何匹配到的組件都不會被緩存, max表示緩存組件的數量,因為我們是緩存的 vnode 對象,它也會持有 DOM,當我們緩存的組件很多的時候,會比較占用內存,所以該配置允許我們指定緩存組件的數量。

2.2 created

created 鈎子函數里定義並初始化了兩個屬性: this.cachethis.keys

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

this.cache是一個對象,用來存儲需要緩存的組件,它將以如下形式存儲:

this.cache = {
    'key1':'組件1',
    'key2':'組件2',
    // ...
}

this.keys是一個數組,用來存儲每個需要緩存的組件的key,即對應this.cache對象中的鍵值。

2.3 destroyed

<keep-alive>組件被銷毀時,此時會調用destroyed鈎子函數,在該鈎子函數里會遍歷this.cache對象,然后將那些被緩存的並且當前沒有處於被渲染狀態的組件都銷毀掉。如下:

destroyed () {
    for (const key in this.cache) {
        pruneCacheEntry(this.cache, key, this.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)
}

2.4 mounted

mounted鈎子函數中觀測 includeexclude 的變化,如下:

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

如果includeexclude 發生了變化,即表示定義需要緩存的組件的規則或者不需要緩存的組件的規則發生了變化,那么就執行pruneCache函數,函數如下:

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

在該函數內對this.cache對象進行遍歷,取出每一項的name值,用其與新的緩存規則進行匹配,如果匹配不上,則表示在新的緩存規則下該組件已經不需要被緩存,則調用pruneCacheEntry函數將其從this.cache對象剔除即可。

2.5 render

接下來就是重頭戲render函數,也是本篇文章中的重中之重。以上工作都是一些輔助工作,真正實現緩存功能的就在這個render函數里,接下來我們逐行分析它。

render函數中首先獲取第一個子組件節點的 vnode

/* 獲取默認插槽中的第一個組件節點 */
const slot = this.$slots.default
const vnode = getFirstComponentChild(slot)

由於我們也是在 <keep-alive> 標簽內部寫 DOM,所以可以先獲取到它的默認插槽,然后再獲取到它的第一個子節點。<keep-alive> 只處理第一個子元素,所以一般和它搭配使用的有 component 動態組件或者是 router-view

接下來獲取該組件節點的名稱:

/* 獲取該組件節點的名稱 */
const name = getComponentName(componentOptions)

/* 優先獲取組件的name字段,如果name不存在則獲取組件的tag */
function getComponentName (opts: ?VNodeComponentOptions): ?string {
  return opts && (opts.Ctor.options.name || opts.tag)
}

然后用組件名稱跟 includeexclude 中的匹配規則去匹配:

const { include, exclude } = this
/* 如果name與include規則不匹配或者與exclude規則匹配則表示不緩存,直接返回vnode */
if (
    (include && (!name || !matches(include, name))) ||
    // excluded
    (exclude && name && matches(exclude, name))
) {
    return vnode
}

如果組件名稱與 include 規則不匹配或者與 exclude 規則匹配,則表示不緩存該組件,直接返回這個組件的 vnode,否則的話走下一步緩存:

const { cache, keys } = this
/* 獲取組件的key */
const key = vnode.key == null
? componentOptions.Ctor.cid + (componentOptions.tag ? `::${componentOptions.tag}` : '')
: vnode.key

/* 如果命中緩存,則直接從緩存中拿 vnode 的組件實例 */
if (cache[key]) {
    vnode.componentInstance = cache[key].componentInstance
    /* 調整該組件key的順序,將其從原來的地方刪掉並重新放在最后一個 */
    remove(keys, key)
    keys.push(key)
} 
/* 如果沒有命中緩存,則將其設置進緩存 */
else {
    cache[key] = vnode
    keys.push(key)
    /* 如果配置了max並且緩存的長度超過了this.max,則從緩存中刪除第一個 */
    if (this.max && keys.length > parseInt(this.max)) {
        pruneCacheEntry(cache, keys[0], keys, this._vnode)
    }
}
/* 最后設置keepAlive標記位 */
vnode.data.keepAlive = true

首先獲取組件的key值:

const key = vnode.key == null? 
componentOptions.Ctor.cid + (componentOptions.tag ? `::${componentOptions.tag}` : '')
: vnode.key

拿到key值后去this.cache對象中去尋找是否有該值,如果有則表示該組件有緩存,即命中緩存:

/* 如果命中緩存,則直接從緩存中拿 vnode 的組件實例 */
if (cache[key]) {
    vnode.componentInstance = cache[key].componentInstance
    /* 調整該組件key的順序,將其從原來的地方刪掉並重新放在最后一個 */
    remove(keys, key)
    keys.push(key)
} 

直接從緩存中拿 vnode 的組件實例,此時重新調整該組件key的順序,將其從原來的地方刪掉並重新放在this.keys中最后一個。

如果this.cache對象中沒有該key值:

/* 如果沒有命中緩存,則將其設置進緩存 */
else {
    cache[key] = vnode
    keys.push(key)
    /* 如果配置了max並且緩存的長度超過了this.max,則從緩存中刪除第一個 */
    if (this.max && keys.length > parseInt(this.max)) {
        pruneCacheEntry(cache, keys[0], keys, this._vnode)
    }
}

表明該組件還沒有被緩存過,則以該組件的key為鍵,組件vnode為值,將其存入this.cache中,並且把key存入this.keys中。此時再判斷this.keys中緩存組件的數量是否超過了設置的最大緩存數量值this.max,如果超過了,則把第一個緩存組件刪掉。

那么問題來了:為什么要刪除第一個緩存組件並且為什么命中緩存了還要調整組件key的順序?

這其實應用了一個緩存淘汰策略LRU:

LRU(Least recently used,最近最少使用)算法根據數據的歷史訪問記錄來進行淘汰數據,其核心思想是“如果數據最近被訪問過,那么將來被訪問的幾率也更高”。

它的算法是這樣子的:

  1. 將新數據從尾部插入到this.keys中;

  2. 每當緩存命中(即緩存數據被訪問),則將數據移到this.keys的尾部;

  3. this.keys滿的時候,將頭部的數據丟棄;

LRU的核心思想是如果數據最近被訪問過,那么將來被訪問的幾率也更高,所以我們將命中緩存的組件key重新插入到this.keys的尾部,這樣一來,this.keys中越往頭部的數據即將來被訪問幾率越低,所以當緩存數量達到最大值時,我們就刪除將來被訪問幾率最低的數據,即this.keys中第一個緩存的組件。這也就是在1.2節中加粗強調的已緩存組件中最久沒有被訪問的實例會被銷毀掉的原因所在。

OK,言歸正傳,以上工作做完后設置 vnode.data.keepAlive = true ,最后將vnode返回。

以上就是render函數的整個過程。

3. 生命周期鈎子

組件一旦被 <keep-alive> 緩存,那么再次渲染的時候就不會執行 createdmounted 等鈎子函數,但是我們很多業務場景都是希望在我們被緩存的組件再次被渲染的時候做一些事情,好在 Vue 提供了 activateddeactivated 兩個鈎子函數,它的執行時機是 <keep-alive> 包裹的組件激活時調用和停用時調用,下面我們就通過一個簡單的例子來演示一下這兩個鈎子函數,示例如下:

let A = {
  template: '<div class="a">' +
  '<p>A Comp</p>' +
  '</div>',
  name: 'A',
  mounted(){
  	console.log('Comp A mounted')
  },
  activated(){
  	console.log('Comp A activated')  
  },
  deactivated(){
  	console.log('Comp A deactivated')  
  }
}

let B = {
  template: '<div class="b">' +
  '<p>B Comp</p>' +
  '</div>',
  name: 'B',
  mounted(){
  	console.log('Comp B mounted')
  },
  activated(){
  	console.log('Comp B activated')  
  },
  deactivated(){
  	console.log('Comp B deactivated')  
  }
}

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

在上述代碼中,我們定義了兩個組件AB並為其綁定了鈎子函數,並且在根組件中用 <keep-alive>組件包裹了一個動態組件,這個動態組件默認指向組件A,當點擊switch按鈕時,動態切換組件AB。我們來看下效果:

從圖中我們可以看到,當第一次打開頁面時,組件A被掛載,執行了組件Amountedactivated鈎子函數,當點擊switch按鈕后,組件A停止調用,同時組件B被掛載,此時執行了組件Adeactivated和組件Bmountedactivated鈎子函數。此時再點擊switch按鈕,組件B停止調用,組件A被再次激活,我們發現現在只執行了組件Aactivated鈎子函數,這就驗證了文檔中所說的組件一旦被 <keep-alive> 緩存,那么再次渲染的時候就不會執行 createdmounted 等鈎子函數。

4. 總結

以上就是從Vue源碼層面剖析了<keep-alive>組件的實現原理以及觀察了其對應的兩個鈎子函數的調用時機。讀完這篇文章相信在面試中被問到<keep-alive>組件的實現原理的時候就不慌不忙啦。

(完)


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM