在上一節完全搞懂Vue中keep-alive的魔法(上)中,咱們對
keep-alive
組件的初始渲染流程以及組件的配置信息進行了源碼分析。初始渲染流程最關鍵的一步是對渲染的組件Vnode
進行緩存,其中也包括了組件的真實節點存儲。有了第一次的緩存,當再次渲染組件時,keep-alive
又擁有哪些魔法呢?接下來咱們將完全揭開這一層面紗。vue
上一節對keep-alive
組件的分析,是從我畫的一個流程圖開始的。若是不想回過頭看上一節的內容,能夠參考如下的簡單總結。node
keep-alive
是源碼內部定義的組件選項配置,它會先註冊爲全局組件供開發者全局使用,其中render
函數定義了它的渲染過程keep-alive
的組件會進行組件的初始化和實例化。$mount
的過程,這一步會執行keep-alive
選項中的render
函數。render
函數在初始渲染時,會將渲染的子Vnode
進行緩存。同時對應的子真實節點也會被緩存起來。那麼,當再次須要渲染到已經被渲染過的組件時,keep-alive
的處理又有什麼不一樣呢?react
爲了文章的完整性,我依舊把基礎的使用展現出來,其中加入了生命週期的使用,方便後續對keep-alive
生命週期的分析。算法
<div id="app">
<button @click="changeTabs('child1')">child1</button>
<button @click="changeTabs('child2')">child2</button>
<keep-alive>
<component :is="chooseTabs">
</component>
</keep-alive>
</div>
var child1 = {
template: '<div><button @click="add">add</button><p>{{num}}</p></div>',
data() {
return {
num: 1
}
},
methods: {
add() {
this.num++
}
},
mounted() {
console.log('child1 mounted')
},
activated() {
console.log('child1 activated')
},
deactivated() {
console.log('child1 deactivated')
},
destoryed() {
console.log('child1 destoryed')
}
}
var child2 = {
template: '<div>child2</div>',
mounted() {
console.log('child2 mounted')
},
activated() {
console.log('child2 activated')
},
deactivated() {
console.log('child2 deactivated')
},
destoryed() {
console.log('child2 destoryed')
}
}
var vm = new Vue({
el: '#app',
components: {
child1,
child2,
},
data() {
return {
chooseTabs: 'child1',
}
},
methods: {
changeTabs(tab) {
this.chooseTabs = tab;
}
}
})
複製代碼
和首次渲染的分析一致,再次渲染的過程我依舊畫了一個簡單的流程圖。vue-router
再次渲染的流程從數據改變提及,在這個例子中,動態組件中chooseTabs
數據的變化會引發依賴派發更新的過程(這個系列有三篇文章詳細介紹了vue響應式系統的底層實現,感興趣的同窗能夠借鑑)。簡單來講,chooseTabs
這個數據在初始化階段會收集使用到該數據的相關依賴。當數據發生改變時,收集過的依賴會進行派發更新操做。api
其中,父組件中負責實例掛載的過程做爲依賴會被執行,即執行父組件的vm._update(vm._render(), hydrating);
。_render
和_update
分別表明兩個過程,其中_render
函數會根據數據的變化爲組件生成新的Vnode
節點,而_update
最終會爲新的Vnode
生成真實的節點。而在生成真實節點的過程當中,會利用vitrual dom
的diff
算法對先後vnode
節點進行對比,使之儘量少的更改真實節點,這一部份內容能夠回顧深刻剖析Vue源碼 - 來,跟我一塊兒實現diff算法!,裏面詳細闡述了利用diff
算法進行節點差別對比的思路。數組
patch
是新舊Vnode
節點對比的過程,而patchVnode
是其中核心的步驟,咱們忽略patchVnode
其餘的流程,關注到其中對子組件執行prepatch
鉤子的過程當中。緩存
function patchVnode (oldVnode,vnode,insertedVnodeQueue,ownerArray,index,removeOnly) {
···
// 新vnode 執行prepatch鉤子
if (isDef(data) && isDef(i = data.hook) && isDef(i = i.prepatch)) {
i(oldVnode, vnode);
}
···
}
複製代碼
執行prepatch
鉤子時會拿到新舊組件的實例並執行updateChildComponent
函數。而updateChildComponent
會對針對新的組件實例對舊實例進行狀態的更新,包括props,listeners
等,最終會調用vue
提供的全局vm.$forceUpdate()
方法進行實例的從新渲染。bash
var componentVNodeHooks = {
// 以前分析的init鉤子
init: function() {},
prepatch: function prepatch (oldVnode, vnode) {
// 新組件實例
var options = vnode.componentOptions;
// 舊組件實例
var child = vnode.componentInstance = oldVnode.componentInstance;
updateChildComponent(
child,
options.propsData, // updated props
options.listeners, // updated listeners
vnode, // new parent vnode
options.children // new children
);
},
}
function updateChildComponent() {
// 更新舊的狀態,不分析這個過程
···
// 迫使實例從新渲染。
vm.$forceUpdate();
}
複製代碼
先看看$forceUpdate
作了什麼操做。$forceUpdate
是源碼對外暴露的一個api,他們迫使Vue
實例從新渲染,本質上是執行實例所收集的依賴,在例子中watcher
對應的是keep-alive
的vm._update(vm._render(), hydrating);
過程。app
Vue.prototype.$forceUpdate = function () {
var vm = this;
if (vm._watcher) {
vm._watcher.update();
}
};
複製代碼
因爲vm.$forceUpdate()
會強迫keep-alive
組件進行從新渲染,所以keep-alive
組件會再一次執行render
過程。這一次因爲第一次對vnode
的緩存,keep-alive
在實例的cache
對象中找到了緩存的組件。
// keepalive組件選項
var keepAlive = {
name: 'keep-alive',
abstract: true,
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;
// keys命中的組件名移到數組末端
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;
}
return vnode || (slot && slot[0])
}
}
複製代碼
render
函數前面邏輯能夠參考前一篇文章,因爲cache
對象中存儲了再次使用的vnode
對象,因此直接經過cache[key]
取出緩存的組件實例並賦值給vnode
的componentInstance
屬性。可能在讀到這裏的時候,會對源碼中keys
這個數組的做用,以及pruneCacheEntry
的功能有疑惑,這裏咱們放到文章末尾講緩存優化策略時解答。
執行了keep-alive
組件的_render
過程,接下來是_update
產生真實的節點,一樣的,keep-alive
下有child1
子組件,因此_update
過程會調用createComponent
遞歸建立子組件vnode
,這個過程在初次渲染時也有分析過,咱們能夠對比一下,再次渲染時流程有哪些不一樣。
function createComponent (vnode, insertedVnodeQueue, parentElm, refElm) {
// vnode爲緩存的vnode
var i = vnode.data;
if (isDef(i)) {
// 此時isReactivated爲true
var isReactivated = isDef(vnode.componentInstance) && i.keepAlive;
if (isDef(i = i.hook) && isDef(i = i.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
}
}
}
複製代碼
此時的vnode
是緩存取出的子組件vnode
,而且因爲在第一次渲染時對組件進行了標記vnode.data.keepAlive = true;
,因此isReactivated
的值爲true
,i.init
依舊會執行子組件的初始化過程。可是這個過程因爲有緩存,因此執行過程也不徹底相同。
var componentVNodeHooks = {
init: function init (vnode, hydrating) {
if (
vnode.componentInstance &&
!vnode.componentInstance._isDestroyed &&
vnode.data.keepAlive
) {
// 當有keepAlive標誌時,執行prepatch鉤子
var mountedNode = vnode; // work around flow
componentVNodeHooks.prepatch(mountedNode, mountedNode);
} else {
var child = vnode.componentInstance = createComponentInstanceForVnode(
vnode,
activeInstance
);
child.$mount(hydrating ? vnode.elm : undefined, hydrating);
}
},
}
複製代碼
顯然由於有keepAlive
的標誌,因此子組件再也不走掛載流程,只是執行prepatch
鉤子對組件狀態進行更新。而且很好的利用了緩存vnode
以前保留的真實節點進行節點的替換。
咱們經過例子來觀察keep-alive
生命週期和普通組件的不一樣。
在咱們從child1
切換到child2
,再切回child1
過程當中,chil1
不會再執行mounted
鉤子,只會執行activated
鉤子,而child2
也不會執行destoryed
鉤子,只會執行deactivated
鉤子,這是爲何?child2
的deactivated
鉤子又要比child1
的activated
提早執行,這又是爲何?
咱們先從組件的銷燬開始提及,當child1
切換到child2
時,child1
會執行deactivated
鉤子而不是destoryed
鉤子,這是爲何? 前面分析patch
過程會對新舊節點的改變進行對比,從而儘量範圍小的去操做真實節點,當完成diff
算法並對節點操做完畢後,接下來還有一個重要的步驟是對舊的組件執行銷燬移除操做。這一步的代碼以下:
function patch(···) {
// 分析過的patchVnode過程
// 銷燬舊節點
if (isDef(parentElm)) {
removeVnodes(parentElm, [oldVnode], 0, 0);
} else if (isDef(oldVnode.tag)) {
invokeDestroyHook(oldVnode);
}
}
function removeVnodes (parentElm, vnodes, startIdx, endIdx) {
// startIdx,endIdx都爲0
for (; startIdx <= endIdx; ++startIdx) {
// ch 會拿到須要銷燬的組件
var ch = vnodes[startIdx];
if (isDef(ch)) {
if (isDef(ch.tag)) {
// 真實節點的移除操做
removeAndInvokeRemoveHook(ch);
invokeDestroyHook(ch);
} else { // Text node
removeNode(ch.elm);
}
}
}
}
複製代碼
removeAndInvokeRemoveHook
會對舊的節點進行移除操做,其中關鍵的一步是會將真實節點從父元素中刪除,有興趣能夠自行查看這部分邏輯。invokeDestroyHook
是執行銷燬組件鉤子的核心。若是該組件下存在子組件,會遞歸去調用invokeDestroyHook
執行銷燬操做。銷燬過程會執行組件內部的destory
鉤子。
function invokeDestroyHook (vnode) {
var i, j;
var data = vnode.data;
if (isDef(data)) {
if (isDef(i = data.hook) && isDef(i = i.destroy)) { i(vnode); }
// 執行組件內部destroy鉤子
for (i = 0; i < cbs.destroy.length; ++i) { cbs.destroy[i](vnode); }
}
// 若是組件存在子組件,則遍歷子組件去遞歸調用invokeDestoryHook執行鉤子
if (isDef(i = vnode.children)) {
for (j = 0; j < vnode.children.length; ++j) {
invokeDestroyHook(vnode.children[j]);
}
}
}
複製代碼
組件內部鉤子前面已經介紹了init
和prepatch
鉤子,而destroy
鉤子的邏輯更加簡單。
var componentVNodeHooks = {
destroy: function destroy (vnode) {
// 組件實例
var componentInstance = vnode.componentInstance;
// 若是實例還未被銷燬
if (!componentInstance._isDestroyed) {
// 不是keep-alive組件則執行銷燬操做
if (!vnode.data.keepAlive) {
componentInstance.$destroy();
} else {
// 若是是已經緩存的組件
deactivateChildComponent(componentInstance, true /* direct */);
}
}
}
}
複製代碼
當組件是keep-alive
緩存過的組件,即已經用keepAlive
標記過,則不會執行實例的銷燬,即componentInstance.$destroy()
的過程。$destroy
過程會作一系列的組件銷燬操做,其中的beforeDestroy,destoryed
鉤子也是在$destory
過程當中調用,而deactivateChildComponent
的處理過程卻徹底不一樣。
function deactivateChildComponent (vm, direct) {
if (direct) {
//
vm._directInactive = true;
if (isInInactiveTree(vm)) {
return
}
}
if (!vm._inactive) {
// 已經被停用
vm._inactive = true;
// 對子組件一樣會執行停用處理
for (var i = 0; i < vm.$children.length; i++) {
deactivateChildComponent(vm.$children[i]);
}
// 最終調用deactivated鉤子
callHook(vm, 'deactivated');
}
}
複製代碼
_directInactive
是用來標記這個被打上停用標籤的組件是不是最頂層的組件。而_inactive
是停用的標誌,一樣的子組件也須要遞歸去調用deactivateChildComponent
,打上停用的標記。最終會執行用戶定義的deactivated
鉤子。
如今回過頭看看activated
的執行時機,一樣是patch
過程,在對舊節點移除並執行銷燬或者停用的鉤子後,對新節點也會執行相應的鉤子。這也是停用的鉤子比啓用的鉤子先執行的緣由。
function patch(···) {
// patchVnode過程
// 銷燬舊節點
{
if (isDef(parentElm)) {
removeVnodes(parentElm, [oldVnode], 0, 0);
} else if (isDef(oldVnode.tag)) {
invokeDestroyHook(oldVnode);
}
}
// 執行組件內部的insert鉤子
invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch);
}
function invokeInsertHook (vnode, queue, initial) {
// delay insert hooks for component root nodes, invoke them after the
// 當節點已經被插入時,會延遲執行insert鉤子
if (isTrue(initial) && isDef(vnode.parent)) {
vnode.parent.data.pendingInsert = queue;
} else {
for (var i = 0; i < queue.length; ++i) {
queue[i].data.hook.insert(queue[i]);
}
}
}
複製代碼
一樣的組件內部的insert
鉤子邏輯以下:
// 組件內部自帶鉤子
var componentVNodeHooks = {
insert: function insert (vnode) {
var context = vnode.context;
var componentInstance = vnode.componentInstance;
// 實例已經被掛載
if (!componentInstance._isMounted) {
componentInstance._isMounted = true;
callHook(componentInstance, 'mounted');
}
if (vnode.data.keepAlive) {
if (context._isMounted) {
// vue-router#1212
// During updates, a kept-alive component's child components may // change, so directly walking the tree here may call activated hooks // on incorrect children. Instead we push them into a queue which will // be processed after the whole patch process ended. queueActivatedComponent(componentInstance); } else { activateChildComponent(componentInstance, true /* direct */); } } }, } 複製代碼
當第一次實例化組件時,因爲實例的_isMounted
不存在,因此會調用mounted
鉤子,當咱們從child2
再次切回child1
時,因爲child1
只是被停用而沒有被銷燬,因此不會再調用mounted
鉤子,此時會執行activateChildComponent
函數對組件的狀態進行處理。有了分析deactivateChildComponent
的基礎,activateChildComponent
的邏輯也很好理解,一樣的_inactive
標記爲已啓用,而且對子組件遞歸調用activateChildComponent
作狀態處理。
function activateChildComponent (vm, direct) {
if (direct) {
vm._directInactive = false;
if (isInInactiveTree(vm)) {
return
}
} else if (vm._directInactive) {
return
}
if (vm._inactive || vm._inactive === null) {
vm._inactive = false;
for (var i = 0; i < vm.$children.length; i++) {
activateChildComponent(vm.$children[i]);
}
callHook(vm, 'activated');
}
}
複製代碼
程序的內存空間是有限的,因此咱們沒法無節制的對數據進行存儲,這時候須要有策略去淘汰不那麼重要的數據,保持最大數據存儲量的一致。這種類型的策略稱爲緩存優化策略,根據淘汰的機制不一樣,經常使用的有如下三類。
1. FIFO: 先進先出策略,咱們經過記錄數據使用的時間,當緩存大小即將溢出時,優先清除離當前時間最遠的數據。
2. LRU: 最近最少使用。LRU策略遵循的原則是,若是數據最近被訪問(使用)過,那麼未來被訪問的概率會更高,若是以一個數組去記錄數據,當有一數據被訪問時,該數據會被移動到數組的末尾,代表最近被使用過,當緩存溢出時,會刪除數組的頭部數據,即將最不頻繁使用的數據移除。
3. LFU: 計數最少策略。用次數去標記數據使用頻率,次數最少的會在緩存溢出時被淘汰。
這三種緩存算法各有優劣,各自適用不一樣場景,而咱們看keep-alive
在緩存時的優化處理,很明顯利用了LRU
的緩存策略。咱們看關鍵的代碼
var keepAlive = {
render: function() {
···
if (cache[key]) {
vnode.componentInstance = cache[key].componentInstance;
remove(keys, key);
keys.push(key);
} else {
cache[key] = vnode;
keys.push(key);
if (this.max && keys.length > parseInt(this.max)) {
pruneCacheEntry(cache, keys[0], keys, this._vnode);
}
}
}
}
function remove (arr, item) {
if (arr.length) {
var index = arr.indexOf(item);
if (index > -1) {
return arr.splice(index, 1)
}
}
}
複製代碼
結合一個實際的例子分析緩存邏輯的實現。
child1,child2,child3
,keep-alive
的最大緩存個數設置爲2cache
對象去存儲組件vnode
,key
爲組件名字,value
爲組件vnode
對象,用keys
數組去記錄組件名字,因爲是數組,因此keys
爲有序。child1,child2
組件依次訪問,緩存結果爲keys = ['child1', 'child2']
cache = {
child1: child1Vnode,
child2: child2Vnode
}
複製代碼
child1
組件,因爲命中了緩存,會調用remove
方法把keys
中的child1
刪除,並經過數組的push
方法將child1
推到尾部。緩存結果修改成keys = ['child2', 'child1']
cache = {
child1: child1Vnode,
child2: child2Vnode
}
複製代碼
child3
時,因爲緩存個數限制,初次緩存會執行pruneCacheEntry
方法對最少訪問到的數據進行刪除。pruneCacheEntry
的定義以下function pruneCacheEntry (cache,key,keys,current) {
var cached$$1 = cache[key];
// 銷燬實例
if (cached$$1 && (!current || cached$$1.tag !== current.tag)) {
cached$$1.componentInstance.$destroy();
}
cache[key] = null;
remove(keys, key);
}
複製代碼
刪除緩存時會把keys[0]
表明的組件刪除,因爲以前的處理,最近被訪問到的元素會位於數組的尾部,因此頭部的數據每每是最少訪問的,所以會優先刪除頭部的元素。而且會再次調用remove
方法,將keys
的首個元素刪除。
這就是vue
中對keep-alive
緩存處理的優化過程。