1. 官方介紹及其用法
1.1 組件介紹
要想搞明白<keep-alive>
組件的內部實現原理,首先我們得搞明白這個組件怎么用以及為什么要用它,關於<keep-alive>
組件,官網如下介紹:
<keep-alive>
是Vue
中內置的一個抽象組件,它自身不會渲染一個DOM
元素,也不會出現在父組件鏈中。當它包裹動態組件時,會緩存不活動的組件實例,而不是銷毀它們。
這句話的意思簡單來說:就是我們可以把一些不常變動的組件或者需要緩存的組件用<keep-alive>
包裹起來,這樣<keep-alive>
就會幫我們把組件保存在內存中,而不是直接的銷毀,這樣做可以保留組件的狀態或避免多次重新渲染,以提高頁面性能。
1.2 用法
<keep-alive>
組件可接收三個屬性:
include
- 字符串或正則表達式。只有名稱匹配的組件會被緩存。exclude
- 字符串或正則表達式。任何名稱匹配的組件都不會被緩存。max
- 數字。最多可以緩存多少組件實例。
include
和 exclude
屬性允許組件有條件地緩存。二者都可以用逗號分隔字符串、正則表達式或一個數組來表示:
<!-- 逗號分隔字符串 -->
<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
選項內接收傳進來的三個屬性:include
、exclude
和max
。如下:
props: {
include: [String, RegExp, Array],
exclude: [String, RegExp, Array],
max: [String, Number]
}
include
表示只有匹配到的組件會被緩存,而 exclude
表示任何匹配到的組件都不會被緩存, max
表示緩存組件的數量,因為我們是緩存的 vnode
對象,它也會持有 DOM,當我們緩存的組件很多的時候,會比較占用內存,所以該配置允許我們指定緩存組件的數量。
2.2 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
對象中的鍵值。
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
鈎子函數中觀測 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)
}
}
}
}
在該函數內對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)
}
然后用組件名稱跟 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
中第一個緩存的組件。這也就是在1.2節中加粗強調的已緩存組件中最久沒有被訪問的實例會被銷毀掉的原因所在。
OK,言歸正傳,以上工作做完后設置 vnode.data.keepAlive = true
,最后將vnode
返回。
以上就是render
函數的整個過程。
3. 生命周期鈎子
組件一旦被 <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
等鈎子函數。
4. 總結
以上就是從Vue
源碼層面剖析了<keep-alive>
組件的實現原理以及觀察了其對應的兩個鈎子函數的調用時機。讀完這篇文章相信在面試中被問到<keep-alive>
組件的實現原理的時候就不慌不忙啦。
(完)