1.前言:
<keep-alive>是vue實現的一個內置組件,也就是說vue源碼不僅實現了一套組件化的機制,也實現了一些內置組件。
<keep-alive>官網介紹如下:<keep-alive>
是Vue
中內置的一個抽象組件,它自身不會渲染一個 DOM
元素,也不會出現在父組件鏈中。當它包裹動態組件時,會緩存不活動的組件實例,而不是銷毀它們。
這句話的意思是說,我們可以把一些不常變動的組件或者需要緩存的組件用<keep-alive>包裹起來,這樣
<keep-alive>就會幫我們把組件保存在內存中,而不是直接的銷毀,這樣做可以保留組件的狀態或避免多次重新渲染,以提高頁面性能;
<keep-alive>
組件到底是如何實現這個功能的呢?本篇記錄分析<keep-alive>
組件的內部實現原理。
2.用法回顧:
<keep-alive>
組件可接收三個屬性:
include
- 字符串或正則表達式。只有名稱匹配的組件會被緩存。exclude
- 字符串或正則表達式。任何名稱匹配的組件都不會被緩存。max
- 數字。最多可以緩存多少組件實例,一旦這個數字達到了,在新實例被創建之前,已緩存組件中最久沒有被訪問的實例會被銷毀掉。
3.實現原理:
<keep-alive>
組件的定義位於源碼的 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
函數。了解了這個以后,接下來我們從上到下一步一步細細閱讀。
#props
在props
選項內接收傳進來的三個屬性:include
、exclude
和max
。如下:
props: {
include: [String, RegExp, Array],
exclude: [String, RegExp, Array],
max: [String, Number]
}
include
表示只有匹配到的組件會被緩存,而 exclude
表示任何匹配到的組件都不會被緩存, max
表示緩存組件的數量,因為我們是緩存的 vnode
對象,它也會持有 DOM,當我們緩存的組件很多的時候,會比較占用內存,所以該配置允許我們指定緩存組件的數量。
#created
在 created
鈎子函數里定義並初始化了兩個屬性: this.cache
和 this.keys
。
created () { this.cache = Object.create(null) this.keys = [] }
this.cache
是一個對象,用來存儲需要緩存的組件,它將以如下形式存儲:
this.cache = { 'key1':'組件1', 'key2':'組件2', // ... }
this.keys
是一個數組,用來存儲每個需要緩存的組件的key
,即對應this.cache
對象中的鍵值。
#destroyed
當<keep-alive>
組件被銷毀時,此時會調用destroyed
鈎子函數,在該鈎子函數里會遍歷this.cache
對象,然后將那些被緩存的並且當前沒有處於被渲染狀態的組件都銷毀掉並將其從this.cache
對象中剔除。如下:
destroyed () { for (const key in this.cache) { pruneCacheEntry(this.cache, key, this.keys) } } // pruneCacheEntry函數 function pruneCacheEntry (cache,key,keys,current) { const cached = cache[key] /* 判斷當前沒有處於被渲染狀態的組件,將其銷毀*/ if (cached && (!current || cached.tag !== current.tag)) { cached.componentInstance.$destroy() } cache[key] = null remove(keys, key) }
#mounted
在mounted
鈎子函數中觀測 include
和 exclude
的變化,如下:
mounted () { this.$watch('include', val => { pruneCache(this, name => matches(val, name)) }) this.$watch('exclude', val => { pruneCache(this, name => !matches(val, name)) }) }
如果include
或exclude
發生了變化,即表示定義需要緩存的組件的規則或者不需要緩存的組件的規則發生了變化,那么就執行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) } } } } function pruneCacheEntry (cache,key,keys,current) { const cached = cache[key] if (cached && (!current || cached.tag !== current.tag)) { cached.componentInstance.$destroy() } cache[key] = null remove(keys, key) }
在該函數內對this.cache
對象進行遍歷,取出每一項的name
值,用其與新的緩存規則進行匹配,如果匹配不上,則表示在新的緩存規則下該組件已經不需要被緩存,則調用pruneCacheEntry
函數將這個已經不需要緩存的組件實例先銷毀掉,然后再將其從this.cache
對象中剔除。
#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) }
然后用組件名稱跟 include
、exclude
中的匹配規則去匹配:
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,最近最少使用)算法根據數據的歷史訪問記錄來進行淘汰數據,其核心思想是“如果數據最近被訪問過,那么將來被訪問的幾率也更高”。
它的算法是這樣子的:
- 將新數據從尾部插入到
this.keys
中; - 每當緩存命中(即緩存數據被訪問),則將數據移到
this.keys
的尾部; - 當
this.keys
滿的時候,將頭部的數據丟棄;
LRU的核心思想是如果數據最近被訪問過,那么將來被訪問的幾率也更高,所以我們將命中緩存的組件key
重新插入到this.keys
的尾部,這樣一來,this.keys
中越往頭部的數據即將來被訪問幾率越低,所以當緩存數量達到最大值時,我們就刪除將來被訪問幾率最低的數據,即this.keys
中第一個緩存的組件。這也就之前加粗強調的已緩存組件中最久沒有被訪問的實例會被銷毀掉的原因所在。
OK,言歸正傳,以上工作做完后設置 vnode.data.keepAlive = true
,最后將vnode
返回。
以上就是render
函數的整個過程。
#4. 生命周期鈎子
組件一旦被 <keep-alive>
緩存,那么再次渲染的時候就不會執行 created
、mounted
等鈎子函數,但是我們很多業務場景都是希望在我們被緩存的組件再次被渲染的時候做一些事情,好在Vue
提供了 activated
和deactivated
兩個鈎子函數,它的執行時機是 <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 } })
在上述代碼中,我們定義了兩個組件A
和B
並為其綁定了鈎子函數,並且在根組件中用 <keep-alive>
組件包裹了一個動態組件,這個動態組件默認指向組件A
,當點擊switch
按鈕時,動態切換組件A
和B
。我們來看下效果:
從圖中我們可以看到,當第一次打開頁面時,組件A
被掛載,執行了組件A
的mounted
和activated
鈎子函數,當點擊switch
按鈕后,組件A
停止調用,同時組件B
被掛載,此時執行了組件A
的deactivated
和組件B
的mounted
和activated
鈎子函數。此時再點擊switch
按鈕,組件B
停止調用,組件A
被再次激活,我們發現現在只執行了組件A
的activated
鈎子函數,這就驗證了文檔中所說的組件一旦被 <keep-alive>
緩存,那么再次渲染的時候就不會執行 created
、mounted
等鈎子函數。
#5. 總結
本篇文章介紹了Vue
中的內置組件<keep-alive>
組件。
首先,通過簡單例子介紹了<keep-alive>
組件的使用場景。
接着,根據官方文檔回顧了<keep-alive>
組件的具體用法。
然后,從源碼角度深入分析了<keep-alive>
組件的內部原理,並且知道了該組件使用了LRU
的緩存策略。
最后,觀察了<keep-alive>
組件對應的兩個生命周期鈎子函數的調用時機。
讀完這篇文章相信在面試中被問到<keep-alive>
組件的實現原理的時候就不慌不忙啦。