[Vue 源码]keep-alive 组件逻辑分析(下)
前言
阅读本章节前建议先查阅一下两章节
本章节主要对 keep-alive
组件的生命周期进行一个分析,组件的创建、销毁、挂载等;除此之外,对 keep-alive
所使用到的缓存算法进行一个简要分析, 从而对 keep-alive
在内存使用上的优化策略有一定的了解
生命周期
keep-alive
包裹的子组件在再次渲染时,并不会执行 mounted
生命周期钩子,只会执行 activated
钩子。而当子组件被掩藏时,也不会执行 destroyed
生命周期钩子,而是执行 deactivated
钩子
假设现在 keep-alive
包裹的动态组件中,可以在 child1
和 child2
两个组件之间进行切换,那么当从 child1
切换到 child2
时, child1
组件会执行 deactivated
钩子, 当从 child2
再次切回 child1
时,会执行 child2
的 deactivated
,然后执行 child1
的 activated
钩子
deactivated
先从组件销毁说起,当从 child1
切换到 child2
时, child1
会执行 deactivated
钩子而不是 destroyed
钩子。在前面分析 patch
过程中会对新旧节点的改变进行对比,从而尽可能范围小的去操作真实 DOM
,当 diff
完成对节点操作之后,接下来还有一个重要的步骤就是对旧的组件执行销毁移除操作。
function patch (oldVnode, vnode, hydrating, removeOnly) {
// destroy old node
if (isDef(parentElm)) {
removeVnodes(parentElm, [oldVnode], 0, 0)
} else if (isDef(oldVnode.tag)) {
invokeDestroyHook(oldVnode)
}
}
function removeVnodes (parentElm, vnodes, startIdx, endIdx) {
for (; startIdx <= endIdx; ++startIdx) {
// 拿到需要移除的组件
const ch = vnodes[startIdx]
if (isDef(ch)) {
if (isDef(ch.tag)) {
// 真实节点的移除操作
removeAndInvokeRemoveHook(ch)
invokeDestroyHook(ch)
} else { // Text node
removeNode(ch.elm)
}
}
}
}
removeAndInvokeRemoveHook
会对旧的节点进行移除操作,其中关键的一步就是会将真实节点从父元素中删除。 invokeDestroyHook
是销毁组件钩子的核心,如果该组件下存在子组件,会递归去调用 invokeDestroyHook
执行销毁操作。销毁过程会执行组件内部定义的 destroy
钩子
function invokeDestroyHook (vnode) {
let i, j
const 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)
}
// 如果组件存在子组件,则遍历子组件递归调用 invokeDestroyHook 执行钩子
if (isDef(i = vnode.children)) {
for (j = 0; j < vnode.children.length; ++j) {
invokeDestroyHook(vnode.children[j])
}
}
}
前面已经分析了组件 init
prePatch
两个内部钩子,接下来看看 destroy
钩子函数
const componentVNodeHooks = {
destroy (vnode: MountedComponentVNode) {
// 获取到组件实例
const { componentInstance } = vnode
if (!componentInstance._isDestroyed) {
if (!vnode.data.keepAlive) {
// 如果不是 keep-alive 组件,则执行销毁操作
componentInstance.$destroy()
} else {
// 如果是已经缓存的组件
deactivateChildComponent(componentInstance, true /* direct */)
}
}
}
}
当组件是 keep-alive
缓存过的组件时, 即用 keepAlive
标记过的组件,则不会执行实例的销毁了,即 componentInstance.$destroy()
过程。 $destroy()
过程会做一些列的组件销毁操作,其中 beforeDestroy
和 destroyed
钩子函数也是在 $destroy
过程中进行调用。而 deactivateChildComponent
的处理过程则和 $destroy
完全不同。
export function deactivateChildComponent (vm: Component, direct?: boolean) {
if (direct) {
vm._directInactive = true
if (isInInactiveTree(vm)) {
return
}
}
if (!vm._inactive) {
// 标记组件已经被停用
vm._inactive = true
for (let i = 0; i < vm.$children.length; i++) {
// 存在子组件时,递归调用。
deactivateChildComponent(vm.$children[i])
}
// 调用 deactivated 钩子
callHook(vm, 'deactivated')
}
}
_directInactive
用来标记这个被停用的组件是否时最顶层的组件。而 _inactive
是停用的标记,同样子组件也需要递归去调用 deactivateChildComponent
,打上停用标记。最终会执行用户定义的 deactivated
钩子函数
activated
同样是在 patch
过程中, 当旧组件移除并销毁或停用之后,对新的组件也会执行相应的钩子。这也是停用的钩子会比启用的钩子先执行的原因。
function patch (oldVnode, vnode, hydrating, removeOnly) {
{
// 销毁或停用节点
if (isDef(parentElm)) {
removeVnodes(parentElm, [oldVnode], 0, 0)
} else if (isDef(oldVnode.tag)) {
invokeDestroyHook(oldVnode)
}
}
// 插入节点
invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch)
return vnode.elm
}
function invokeInsertHook (vnode, queue, initial) {
// delay insert hooks for component root nodes, invoke them after the
// element is really inserted
// 当节点已经被插入是,会延迟执行 insert 钩子
if (isTrue(initial) && isDef(vnode.parent)) {
vnode.parent.data.pendingInsert = queue
} else {
for (let i = 0; i < queue.length; ++i) {
// 调用组件内部的 insert 钩子函数
queue[i].data.hook.insert(queue[i])
}
}
}
来看下组件的 insert
钩子函数的具体实现
const componentVNodeHooks = {
insert (vnode: MountedComponentVNode) {
const { context, componentInstance } = vnode
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
函数对组件的状态进行处理, activateChildComponent
和前面分析过的 deactivateChildComponent
方法类似,都是对组件的启用状态进行更新处理。
export function activateChildComponent (vm: Component, direct?: boolean) {
if (direct) {
vm._directInactive = false
if (isInInactiveTree(vm)) {
return
}
} else if (vm._directInactive) {
return
}
if (vm._inactive || vm._inactive === null) {
// 标记组件处于启用状态
vm._inactive = false
for (let i = 0; i < vm.$children.length; i++) {
// 递归处理子组件的启用状态。
activateChildComponent(vm.$children[i])
}
callHook(vm, 'activated')
}
}
缓存优化 - LRU 算法
程序的内存空间使用有限的,所以我们无法无节制的对数据进行储存,这需要有策略去淘汰不那么重要的数据,保持最大数据存储量的一致。
根据淘汰机制的不同,常用有以下三种:
-
- FIFO:先进先出策略 通过记录数据使用使用的使用,当缓存大小即将溢出时,优先清除里当前时间最远的数据
-
- LRU:最近最少使用
LRU
策略遵循的原则时, 如果数据最近被使用(访问)过,那么认为将来被访问的概率会更改。如果使用一个数组去记录数据,当一段数据被访问时,该数据会被移动到数组的末尾,表明该数据最近被使用过,当缓存溢出时,会删除数据头部的数据,即将最少使用的数据移除。
对于
LRU
算法,个人认为翻译成最近最久未使用会贴切一点,在算法的实现过程中,并没有关注数据在指定时间段的使用次数,而是直接淘汰上一次使用时间距离当前时间最久的数据 - LRU:最近最少使用
-
- LFU: 计数最少策略 记录每一个数据的使用次数,当缓存溢出时,淘汰使用次数最少的数据。
上面三种缓存算法各有优劣,使用与不同的场景。而对于 Vue
中 keep-alive
在缓存组件时的优化处理,很明显利用了 LRU
的缓存策略,来看下关键代码
export default {
// 渲染函数
render () {
if (cache[key]) {
// 命中缓存,
// 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)
}
}
}
}
export function remove (arr: Array<any>, item: any): Array<any> | void {
if (arr.length) {
const index = arr.indexOf(item)
if (index > -1) {
return arr.splice(index, 1)
}
}
}
每次执行 render
函数时,会先查找缓存是否存在对应组件的缓存,如果不存在缓存,则添加缓存,并将组件的 key
保存在 keys
数据末尾,表明该组件时 keep-alive
最近一次渲染的子组件。如果查找到缓存,则将组件的 key
从 keys
数组中删除,重新添加到数据末尾,每次都执行相同的操作。
当缓存的数组大小超过规定的大小之后,删除 keys
数组头部的数据,并删除对应 key
的缓存数据,因为 keys
头部 key
对应的组件就是最久未使用的组件数据,符合 LRU
算法策略。
转载自:https://juejin.cn/post/7205162789155946554