徹底搞懂Vue中keep-alive的魔法(上)


從這一節開始,將會對某個具體的內置組件進行分析。首先是keep-alive,它是我們日常開發中經常使用的組件,我們在不同組件間切換時,經常要求保持組件的狀態,以避免重複渲染組件造成的性能損耗,而keep-alive經常和上一節介紹的動態組件結合起來使用。由於內容過多,keep-alive的源碼分析將分為上下兩部分,這一節主要圍繞keep-alive的首次渲染展開。

13.1 基本用法

keep-alive的使用只需要在動態組件的最外層添加標籤即可。


<button>child1/<button>
<button>child2/<button>
<keep-alive>
<component>
/<component>
/<keep-alive>

var child1 = {
template: '
<button>add/<button>

{{num}}

',
data() {
return {
num: 1
}
},
methods: {
add() {
this.num++
}
},
}
var child2 = {

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

簡單的結果如下,動態組件在child1,child2之間來回切換,當第二次切到child1時,child1保留著原來的數據狀態,num = 5。

13.2 從模板編譯到生成vnode

按照以往分析的經驗,我們會從模板的解析開始說起,第一個疑問便是:內置組件和普通組件在編譯過程有區別嗎?答案是沒有的,不管是內置的還是用戶定義組件,本質上組件在模板編譯成render函數的處理方式是一致的,這裡的細節不展開分析,有疑惑的可以參考前幾節的原理分析。最終針對keep-alive的render函數的結果如下:

with(this){···_c('keep-alive',{attrs:{"include":"child2"}},[_c(chooseTabs,{tag:"component"})],1)}

有了render函數,接下來從子開始到父會執行生成Vnode對象的過程,_c('keep-alive'···)的處理,會執行createElement生成組件Vnode,其中由於keep-alive是組件,所以會調用createComponent函數去創建子組件Vnode,createComponent之前也有分析過,這個環節和創建普通組件Vnode不同之處在於,keep-alive的Vnode會剔除多餘的屬性內容,由於keep-alive除了slot屬性之外,其他屬性在組件內部並沒有意義,例如class樣式,<keep-alive>等,所以在Vnode層剔除掉多餘的屬性是有意義的。而<keep-alive>的寫法在2.6以上的版本也已經被廢棄。/<keep-alive>(其中abstract作為抽象組件的標誌,以及其作用我們後面會講到)

// 創建子組件Vnode過程
function createComponent(Ctordata,context,children,tag) {
// abstract是內置組件(抽象組件)的標誌
if (isTrue(Ctor.options.abstract)) {
// 只保留slot屬性,其他標籤屬性都被移除,在vnode對象上不再存在
var slot = data.slot;
data = {};
if (slot) {
data.slot = slot;
}
}
}
複製代碼

13.3 初次渲染

keep-alive之所以特別,是因為它不會重複渲染相同的組件,只會利用初次渲染保留的緩存去更新節點。所以為了全面瞭解它的實現原理,我們需要從keep-alive的首次渲染開始說起。

13.3.1 流程圖

為了理清楚流程,我大致畫了一個流程圖,流程圖大致覆蓋了初始渲染keep-alive所執行的過程,接下來會照著這個過程進行源碼分析。

和渲染普通組件相同的是,Vue會拿到前面生成的Vnode對象執行真實節點創建的過程,也就是熟悉的patch過程,patch執行階段會調用createElm創建真實dom,在創建節點途中,keep-alive的vnode對象會被認定是一個組件Vnode,因此針對組件Vnode又會執行createComponent函數,它會對keep-alive組件進行初始化和實例化。

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)) {
// 其中一個作用是保留真實dom到vnode中
initComponent(vnode, insertedVnodeQueue);
insert(parentElm, vnode.elm, refElm);
if (isTrue(isReactivated)) {
reactivateComponent(vnode, insertedVnodeQueue, parentElm, refElm);
}
return true
}
}
}
複製代碼

keep-alive組件會先調用內部鉤子init方法進行初始化操作,我們先看看init過程做了什麼操作。

// 組件內部鉤子
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屬性
var child = vnode.componentInstance = createComponentInstanceForVnode(
vnode,
activeInstance
);
child.$mount(hydrating ? vnode.elm : undefined, hydrating);
}
},
// 後面分析
prepatch: function() {}
}
複製代碼

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

createComponentInstanceForVnode就是組件實例化的過程,而組件實例化從系列的第一篇就開始說了,無非就是一系列選項合併,初始化事件,生命週期等初始化操作。

function createComponentInstanceForVnode (vnode, parent) {
var options = {
_isComponent: true,
_parentVnode: vnode,
parent: parent
};
// 內聯模板的處理,忽略這部分代碼
···
// 執行vue子組件實例化
return new vnode.componentOptions.Ctor(options)
}
複製代碼

13.3.2 內置組件選項

我們在使用組件的時候經常利用對象的形式定義組件選項,包括data,method,computed等,並在父組件或根組件中註冊。keep-alive同樣遵循這個道理,內置兩字也說明了keep-alive是在Vue源碼中內置好的選項配置,並且也已經註冊到全局,這一部分的源碼可以參考深入剖析Vue源碼 - Vue動態組件的概念,你會亂嗎?小節末尾對內置組件構造器和註冊過程的介紹。這一部分我們重點關注一下keep-alive的具體選項。

// keepalive組件選項
var KeepAlive = {
name: 'keep-alive',
// 抽象組件的標誌
abstract: true,
// keep-alive允許使用的props
props: {
include: patternTypes,
exclude: patternTypes,
max: [String, Number]
},

created: function created () {
// 緩存組件vnode
this.cache = Object.create(null);
// 緩存組件名
this.keys = [];
},
destroyed: function destroyed () {
for (var key in this.cache) {
pruneCacheEntry(this.cache, key, this.keys);
}
},
mounted: function mounted () {
var this$1 = this;
// 動態include和exclude
// 對include exclue的監聽
this.$watch('include', function (val) {
pruneCache(this$1, function (name) { return matches(val, name); });
});
this.$watch('exclude', function (val) {
pruneCache(this$1, function (name) { return !matches(val, name); });
});
},
// keep-alive的渲染函數
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;
// make current key freshest
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;
}
// 將渲染的vnode返回
return vnode || (slot && slot[0])
}
};
複製代碼

keep-alive選項跟我們平時寫的組件選項還是基本類似的,唯一的不同是keep-ailve組件沒有用template而是使用render函數。keep-alive本質上只是存緩存和拿緩存的過程,並沒有實際的節點渲染,所以使用render處理是最優的選擇。

13.3.3 緩存vnode

還是先回到流程圖的分析。上面說到keep-alive在執行組件實例化之後會進行組件的掛載。而掛載$mount又回到vm._render(),vm._update()的過程。由於keep-alive擁有render函數,所以我們可以直接將焦點放在render函數的實現上。

  1. 首先是獲取keep-alive下插槽的內容,也就是keep-alive需要渲染的子組件,例子中是chil1 Vnode對象,源碼中對應getFirstComponentChild函數
 function getFirstComponentChild (children) {
if (Array.isArray(children)) {
for (var i = 0; i < children.length; i++) {
var c = children[i];
// 組件實例存在,則返回,理論上返回第一個組件vnode
if (isDef(c) && (isDef(c.componentOptions) || isAsyncPlaceholder(c))) {
return c
}
}
}
}
複製代碼
  1. 判斷組件滿足緩存的匹配條件,在keep-alive組件的使用過程中,Vue源碼允許我們是用include, exclude來定義匹配條件,include規定了只有名稱匹配的組件才會被緩存,exclude規定了任何名稱匹配的組件都不會被緩存。更者,我們可以使用max來限制可以緩存多少匹配實例,而為什麼要做數量的限制呢?我們後文會提到。

拿到子組件的實例後,我們需要先進行是否滿足匹配條件的判斷,

其中匹配的規則允許使用數組,字符串,正則的形式。

var include = ref.include;
var exclude = ref.exclude;
// 通過判斷子組件是否滿足緩存匹配
if (
// not included
(include && (!name || !matches(include, name))) ||
// excluded
(exclude && name && matches(exclude, name))
) {
return vnode
}
// matches
function matches (pattern, name) {
// 允許使用數組['child1', 'child2']
if (Array.isArray(pattern)) {
return pattern.indexOf(name) > -1
} else if (typeof pattern === 'string') {
// 允許使用字符串 child1,child2
return pattern.split(',').indexOf(name) > -1
} else if (isRegExp(pattern)) {
// 允許使用正則 /^child{1,2}$/g
return pattern.test(name)
}
/* istanbul ignore next */
return false
}
複製代碼

如果組件不滿足緩存的要求,則直接返回組件的vnode,不做任何處理,此時組件會進入正常的掛載環節。

  1. render函數執行的關鍵一步是緩存vnode,由於是第一次執行render函數,選項中的cache和keys數據都沒有值,其中cache是一個空對象,我們將用它來緩存{ name: vnode }枚舉,而keys我們用來緩存組件名。
    因此我們在第一次渲染keep-alive時,會將需要渲染的子組件vnode進行緩存。
 cache[key] = vnode;
keys.push(key);
複製代碼
  1. 將已經緩存的vnode打上標記, 並將子組件的Vnode返回。 vnode.data.keepAlive = true

13.3.4 真實節點的保存

我們再回到createComponent的邏輯,之前提到createComponent會先執行keep-alive組件的初始化流程,也包括了子組件的掛載。並且我們通過componentInstance拿到了keep-alive組件的實例,而接下來重要的一步是將真實的dom保存再vnode中

function createComponent(vnode, insertedVnodeQueue) {
···
if (isDef(vnode.componentInstance)) {
// 其中一個作用是保留真實dom到vnode中
initComponent(vnode, insertedVnodeQueue);
// 將真實節點添加到父節點中
insert(parentElm, vnode.elm, refElm);
if (isTrue(isReactivated)) {
reactivateComponent(vnode, insertedVnodeQueue, parentElm, refElm);
}
return true
}
}
複製代碼

insert的源碼不列舉出來,它只是簡單的調用操作dom的api,將子節點插入到父節點中,我們可以重點看看initComponent關鍵步驟的邏輯。

function initComponent() {
···
// vnode保留真實節點
vnode.elm = vnode.componentInstance.$el;
···
}
複製代碼

因此,我們很清晰的回到之前遺留下來的問題,為什麼keep-alive需要一個max來限制緩存組件的數量。原因就是keep-alive緩存的組件數據除了包括vnode這一描述對象外,還保留著真實的dom節點,而我們知道真實節點對象是龐大的,所以大量保留緩存組件是耗費性能的。因此我們需要嚴格控制緩存的組件數量,而在緩存策略上也需要做優化,這點我們在下一篇文章也繼續提到。

由於isReactivated為false,reactivateComponent函數也不會執行。至此keep-alive的初次渲染流程分析完畢。

如果忽略步驟的分析,只對初次渲染流程做一個總結:內置的keep-alive組件,讓子組件在第一次渲染的時候將vnode和真實的elm進行了緩存。

13.4 抽象組件

這一節的最後順便提一下上文提到的抽象組件的概念。Vue提供的內置組件都有一個描述組件類型的選項,這個選項就是{ astract: true },它表明了該組件是抽象組件。什麼是抽象組件,為什麼要有這一類型的區別呢?我覺得歸根究底有兩個方面的原因。

  1. 抽象組件沒有真實的節點,它在組件渲染階段不會去解析渲染成真實的dom節點,而只是作為中間的數據過渡層處理,在keep-alive中是對組件緩存的處理。
  2. 在我們介紹組件初始化的時候曾經說到父子組件會顯式的建立一層關係,這層關係奠定了父子組件之間通信的基礎。我們可以再次回顧一下initLifecycle的代碼。
Vue.prototype._init = function() {
···
var vm = this;
initLifecycle(vm)
}
function initLifecycle (vm) {
var options = vm.$options;

var parent = options.parent;
if (parent && !options.abstract) {
// 如果有abstract屬性,一直往上層尋找,直到不是抽象組件
while (parent.$options.abstract && parent.$parent) {
parent = parent.$parent;
}
parent.$children.push(vm);
}

···
}
複製代碼

子組件在註冊階段會把父實例掛載到自身選項的parent屬性上,在initLifecycle過程中,會反向拿到parent上的父組件vnode,併為其$children屬性添加該子組件vnode,如果在反向找父組件的過程中,父組件擁有abstract屬性,即可判定該組件為抽象組件,此時利用parent的鏈條往上尋找,直到組件不是抽象組件為止。initLifecycle的處理,讓每個組件都能找到上層的父組件以及下層的子組件,使得組件之間形成一個緊密的關係樹。

有了第一次的緩存處理,當第二次渲染組件時,keep-alive又會有哪些魔法的存在呢,之前留下的緩存優化又是什麼?這些都會在下一小節一一解開。


分享到:


相關文章: