前言

在性能优化上,最常见的手段就是缓存。对需要经常访问的资源进行缓存,减少请求或者是初始化的过程,从而降低时间或内存的消耗。Vue 为我们提供了缓存组件 keep-alive,它可用于路由级别或组件级别的缓存。

但其中的缓存原理你是否了解,组件缓存渲染又是如何工作。那么本文就来解析 keep-alive 的原理。

LRU策略

在使用 keep-alive 时,可以添加 prop 属性 includeexcludemax 允许组件有条件的缓存。既然有限制条件,旧的组件需要删除缓存,新的组件就需要加入到最新缓存,那么要如何制定对应的策略?

LRU(Least recently used,最近最少使用)策略根据数据的历史访问记录来进行淘汰数据。LRU 策略的设计原则是,如果一个数据在最近一段时间没有被访问到,那么在将来它被访问的可能性也很小。也就是说,当限定的空间已存满数据时,应当把最久没有被访问到的数据淘汰。

  1. 现在缓存最大只允许存3个组件,ABC三个组件依次进入缓存,没有任何问题
  2. 当D组件被访问时,内存空间不足,A是最早进入也是最旧的组件,所以A组件从缓存中删除,D组件加入到最新的位置
  3. 当B组件被再次访问时,由于B还在缓存中,B移动到最新的位置,其他组件相应的往后一位
  4. 当E组件被访问时,内存空间不足,C变成最久未使用的组件,C组件从缓存中删除,E组件加入到最新的位置

keep-alive 缓存机制便是根据LRU策略来设置缓存组件新鲜度,将很久未访问的组件从缓存中删除。了解完缓存机制,接下来进入源码,看看keep-alive组件是如何实现的。

组件实现原理

// 源码位置:src/core/components/keep-alive.js
export default {
name: 'keep-alive',
abstract: true,
props: {
include: patternTypes,
exclude: patternTypes,
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: VNode = getFirstComponentChild(slot)
const componentOptions: ?VNodeComponentOptions = vnode && vnode.componentOptions
if (componentOptions) {
// check pattern
const name: ?string = getComponentName(componentOptions)
const { include, exclude } = this
if (
// not included
(include && (!name || !matches(include, name))) ||
// excluded
(exclude && name && matches(exclude, name))
) {
return vnode
} const { cache, keys } = this
const key: ?string = 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])
}
}

kepp-alive 实际是一个抽象组件,只对包裹的子组件做处理,并不会和子组件建立父子关系,也不会作为节点渲染到页面上。在组件开头就设置 abstracttrue,代表该组件是一个抽象组件。

// 源码位置: src/core/instance/lifecycle.js
export function initLifecycle (vm: Component) {
const options = vm.$options // locate first non-abstract parent
let parent = options.parent
if (parent && !options.abstract) {
while (parent.$options.abstract && parent.$parent) {
parent = parent.$parent
}
parent.$children.push(vm)
}
vm.$parent = parent
// ...
}

那么抽象组件是如何忽略这层关系的呢?在初始化阶段会调用 initLifecycle,里面判断父级是否为抽象组件,如果是抽象组件,就选取抽象组件的上一级作为父级,忽略与抽象组件和子组件之间的层级关系。

回到 keep-alive 组件,组件是没有编写

template 模板,而是由 render 函数决定渲染结果。

const slot = this.$slots.default
const vnode: VNode = getFirstComponentChild(slot)

如果 keep-alive 存在多个子元素,keep-alive 要求同时只有一个子元素被渲染。所以在开头会获取插槽内的子元素,调用 getFirstComponentChild 获取到第一个子元素的 VNode

// check pattern
const name: ?string = getComponentName(componentOptions)
const { include, exclude } = this
if (
// not included
(include && (!name || !matches(include, name))) ||
// excluded
(exclude && name && matches(exclude, name))
) {
return vnode
} function matches (pattern: string | RegExp | Array<string>, name: string): boolean {
if (Array.isArray(pattern)) {
return pattern.indexOf(name) > -1
} else if (typeof pattern === 'string') {
return pattern.split(',').indexOf(name) > -1
} else if (isRegExp(pattern)) {
return pattern.test(name)
}
return false
}

接着判断当前组件是否符合缓存条件,组件名与include不匹配或与exclude匹配都会直接退出并返回 VNode,不走缓存机制。

const { cache, keys } = this
const key: ?string = 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

匹配条件通过会进入缓存机制的逻辑,如果命中缓存,从 cache 中获取缓存的实例设置到当前的组件上,并调整 key 的位置将其放到最后。如果没命中缓存,将当前 VNode 缓存起来,并加入当前组件的 key。如果缓存组件的数量超出 max 的值,即缓存空间不足,则调用 pruneCacheEntry 将最旧的组件从缓存中删除,即 keys[0] 的组件。之后将组件的 keepAlive 标记为 true,表示它是被缓存的组件。

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)
}

pruneCacheEntry 负责将组件从缓存中删除,它会调用组件 $destroy 方法销毁组件实例,缓存组件置空,并移除对应的 key

mounted () {
this.$watch('include', val => {
pruneCache(this, name => matches(val, name))
})
this.$watch('exclude', val => {
pruneCache(this, name => !matches(val, name))
})
} function pruneCache (keepAliveInstance: any, filter: Function) {
const { cache, keys, _vnode } = keepAliveInstance
for (const key in cache) {
const cachedNode: ?VNode = cache[key]
if (cachedNode) {
const name: ?string = getComponentName(cachedNode.componentOptions)
if (name && !filter(name)) {
pruneCacheEntry(cache, key, keys, _vnode)
}
}
}
}

keep-alivemounted 会监听 includeexclude 的变化,属性发生改变时调整缓存和 keys 的顺序,最终调用的也是 pruneCacheEntry

小结cache 用于缓存组件,keys 存储组件的 key,根据LRU策略来调整缓存组件。keep-aliverender 中最后会返回组件的 VNode,因此我们也可以得出一个结论,keep-alive 并非真的不会渲染,而是渲染的对象是包裹的子组件。

组件渲染流程

温馨提示:这部分内容需要对 renderpatch 过程有了解

渲染过程最主要的两个过程就是 renderpatch,在 render 之前还会有模板编译,render 函数就是模板编译后的产物,它负责构建 VNode 树,构建好的 VNode 会传递给 patchpatch 根据 VNode 的关系生成真实dom节点树。

这张图描述了 Vue 视图渲染的流程:

VNode构建完成后,最终会被转换成真实dom,而 patch 是必经的过程。为了更好的理解组件渲染的过程,假设 keep-alive 包括的组件有A和B两个组件,默认展示A组件。

初始化渲染

组件在 patch 过程是会执行 createComponent 来挂载组件的,A组件也不例外。

// 源码位置:src/core/vdom/patch.js
function createComponent (vnode, insertedVnodeQueue, parentElm, refElm) {
let i = vnode.data
if (isDef(i)) {
const isReactivated = isDef(vnode.componentInstance) && i.keepAlive
if (isDef(i = i.hook) && isDef(i = i.init)) {
i(vnode, false /* hydrating */)
}
// after calling the init hook, if the vnode is a child component
// it should've created a child instance and mounted it. the child
// component also has set the placeholder vnode's elm.
// in that case we can just return the element and be done.
if (isDef(vnode.componentInstance)) {
initComponent(vnode, insertedVnodeQueue)
insert(parentElm, vnode.elm, refElm)
if (isTrue(isReactivated)) {
reactivateComponent(vnode, insertedVnodeQueue, parentElm, refElm)
}
return true
}
}
}

isReactivated 标识组件是否重新激活。在初始化渲染时,A组件还没有初始化构造完成,componentInstance 还是 undefined。而A组件的 keepAlivetrue,因为 keep-alive 作为父级包裹组件,会先于A组件挂载,也就是 kepp-alive 会先执行 render 的过程,A组件被缓存起来,之后对插槽内第一个组件(A组件)的 keepAlive 赋值为 true,不记得这个过程请看上面组件实现的代码。所以此时的 isReactivatedfalse

接着会调用 init 函数进行组件初始化,它是组件的一个钩子函数:

// 源码位置:src/core/vdom/create-component.js
const componentVNodeHooks = {
init (vnode: VNodeWithData, hydrating: boolean): ?boolean {
if (
vnode.componentInstance &&
!vnode.componentInstance._isDestroyed &&
vnode.data.keepAlive
) {
// kept-alive components, treat as a patch
const mountedNode: any = vnode // work around flow
componentVNodeHooks.prepatch(mountedNode, mountedNode)
} else {
const child = vnode.componentInstance = createComponentInstanceForVnode(
vnode,
activeInstance
)
child.$mount(hydrating ? vnode.elm : undefined, hydrating)
}
},
// ...
}

createComponentInstanceForVnode 内会 new Vue 构造组件实例并赋值到 componentInstance,随后调用 $mount 挂载组件。

createComponent,继续走下面的逻辑:

if (isDef(vnode.componentInstance)) {
initComponent(vnode, insertedVnodeQueue)
insert(parentElm, vnode.elm, refElm)
if (isTrue(isReactivated)) {
reactivateComponent(vnode, insertedVnodeQueue, parentElm, refElm)
}
return true
}

调用 initComponentvnode.elm 赋值为真实dom,然后调用 insert 将组件的真实dom插入到父元素中。

所以在初始化渲染中,keep-alive 将A组件缓存起来,然后正常的渲染A组件。

缓存渲染

当切换到B组件,再切换回A组件时,A组件命中缓存被重新激活。

再次经历 patch 过程,keep-alive 是根据插槽获取当前的组件,那么插槽的内容又是如何更新实现缓存?

const isRealElement = isDef(oldVnode.nodeType)
if (!isRealElement && sameVnode(oldVnode, vnode)) {
// patch existing root node
patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)
}

非初始化渲染时,patch 会调用 patchVnode 对比新旧节点。

// 源码位置:src/core/vdom/patch.js
function patchVnode (
oldVnode,
vnode,
insertedVnodeQueue,
ownerArray,
index,
removeOnly
) {
// ...
let i
const data = vnode.data
if (isDef(data) && isDef(i = data.hook) && isDef(i = i.prepatch)) {
i(oldVnode, vnode)
}
// ...
}

patchVnode 内会调用钩子函数 prepatch

// 源码位置: src/core/vdom/create-component.js
prepatch (oldVnode: MountedComponentVNode, vnode: MountedComponentVNode) {
const options = vnode.componentOptions
const child = vnode.componentInstance = oldVnode.componentInstance
updateChildComponent(
child,
options.propsData, // updated props
options.listeners, // updated listeners
vnode, // new parent vnode
options.children // new children
)
},

updateChildComponent 就是更新的关键方法,它里面主要是更新实例的一些属性:

// 源码位置:src/core/instance/lifecycle.js
export function updateChildComponent (
vm: Component,
propsData: ?Object,
listeners: ?Object,
parentVnode: MountedComponentVNode,
renderChildren: ?Array<VNode>
) {
// ... // Any static slot children from the parent may have changed during parent's
// update. Dynamic scoped slots may also have changed. In such cases, a forced
// update is necessary to ensure correctness.
const needsForceUpdate = !!(
renderChildren || // has new static slots
vm.$options._renderChildren || // has old static slots
hasDynamicScopedSlot
) // ... // resolve slots + force update if has children
if (needsForceUpdate) {
vm.$slots = resolveSlots(renderChildren, parentVnode.context)
vm.$forceUpdate()
}
} Vue.prototype.$forceUpdate = function () {
const vm: Component = this
if (vm._watcher) {
// 这里最终会执行 vm._update(vm._render)
vm._watcher.update()
}
}

从注释中可以看到 needsForceUpdate 是有插槽才会为 truekeep-alive 符合条件。首先调用 resolveSlots 更新 keep-alive 的插槽,然后调用 $forceUpdatekeep-alive 重新渲染,再走一遍 render。因为A组件在初始化已经缓存了,keep-alive 直接返回缓存好的A组件 VNodeVNode 准备好后,又来到了 patch 阶段。

function createComponent (vnode, insertedVnodeQueue, parentElm, refElm) {
let i = vnode.data
if (isDef(i)) {
const isReactivated = isDef(vnode.componentInstance) && i.keepAlive
if (isDef(i = i.hook) && isDef(i = i.init)) {
i(vnode, false /* hydrating */)
}
// after calling the init hook, if the vnode is a child component
// it should've created a child instance and mounted it. the child
// component also has set the placeholder vnode's elm.
// in that case we can just return the element and be done.
if (isDef(vnode.componentInstance)) {
initComponent(vnode, insertedVnodeQueue)
insert(parentElm, vnode.elm, refElm)
if (isTrue(isReactivated)) {
reactivateComponent(vnode, insertedVnodeQueue, parentElm, refElm)
}
return true
}
}
}

A组件再次经历 createComponent 的过程,调用 init

const componentVNodeHooks = {
init (vnode: VNodeWithData, hydrating: boolean): ?boolean {
if (
vnode.componentInstance &&
!vnode.componentInstance._isDestroyed &&
vnode.data.keepAlive
) {
// kept-alive components, treat as a patch
const mountedNode: any = vnode // work around flow
componentVNodeHooks.prepatch(mountedNode, mountedNode)
} else {
const child = vnode.componentInstance = createComponentInstanceForVnode(
vnode,
activeInstance
)
child.$mount(hydrating ? vnode.elm : undefined, hydrating)
}
},
}

这时将不再走 $mount 的逻辑,只调用 prepatch 更新实例属性。所以在缓存组件被激活时,不会执行 createdmounted 的生命周期函数。

回到 createComponent,此时的 isReactivatedtrue,调用 reactivateComponent:

function reactivateComponent (vnode, insertedVnodeQueue, parentElm, refElm) {
let i
// hack for #4339: a reactivated component with inner transition
// does not trigger because the inner node's created hooks are not called
// again. It's not ideal to involve module-specific logic in here but
// there doesn't seem to be a better way to do it.
let innerNode = vnode
while (innerNode.componentInstance) {
innerNode = innerNode.componentInstance._vnode
if (isDef(i = innerNode.data) && isDef(i = i.transition)) {
for (i = 0; i < cbs.activate.length; ++i) {
cbs.activate[i](emptyNode, innerNode)
}
insertedVnodeQueue.push(innerNode)
break
}
}
// unlike a newly created component,
// a reactivated keep-alive component doesn't insert itself
insert(parentElm, vnode.elm, refElm)
}

最后调用 insert 插入组件的dom节点,至此缓存渲染流程完成。

小结:组件首次渲染时,keep-alive 会将组件缓存起来。等到缓存渲染时,keep-alive 会更新插槽内容,之后 $forceUpdate 重新渲染。这样在 render 时就获取到最新的组件,如果命中缓存则从缓存中返回 VNode

总结

keep-alive 组件是抽象组件,在对应父子关系时会跳过抽象组件,它只对包裹的子组件做处理,主要是根据LRU策略缓存组件 VNode,最后在 render 时返回子组件的 VNode。缓存渲染过程会更新 keep-alive 插槽,重新再 render 一次,从缓存中读取之前的组件 VNode 实现状态缓存。

往期相关文章:

手摸手带你理解Vue响应式原理

手摸手带你理解Vue的Computed原理

手摸手带你理解Vue的Watch原理

Vue你不得不知道的异步更新机制和nextTick原理

Vue视图渲染原理解析,从构建VNode到生成真实节点树

Vue源码解析,keep-alive是如何实现缓存的?的更多相关文章

  1. 【vuejs深入二】vue源码解析之一,基础源码结构和htmlParse解析器

    写在前面 一个好的架构需要经过血与火的历练,一个好的工程师需要经过无数项目的摧残. vuejs是一个优秀的前端mvvm框架,它的易用性和渐进式的理念可以使每一个前端开发人员感到舒服,感到easy.它内 ...

  2. 【vuejs深入三】vue源码解析之二 htmlParse解析器的实现

    写在前面 一个好的架构需要经过血与火的历练,一个好的工程师需要经过无数项目的摧残. 昨天博主分析了一下在vue中,最为基础核心的api,parse函数,它的作用是将vue的模板字符串转换成ast,从而 ...

  3. Vue源码解析---数据的双向绑定

    本文主要抽离Vue源码中数据双向绑定的核心代码,解析Vue是如何实现数据的双向绑定 核心思想是ES5的Object.defineProperty()和发布-订阅模式 整体结构 改造Vue实例中的dat ...

  4. Vue源码解析之nextTick

    Vue源码解析之nextTick 前言 nextTick是Vue的一个核心功能,在Vue内部实现中也经常用到nextTick.但是,很多新手不理解nextTick的原理,甚至不清楚nextTick的作 ...

  5. 【VUE】Vue 源码解析

    Vue 源码解析 Vue 的工作机制 在 new vue() 之后,Vue 会调用进行初始化,会初始化生命周期.事件.props.methods.data.computed和watch等.其中最重要的 ...

  6. Vue源码解析(一):入口文件

    在学习Vue源码之前,首先要做的一件事情,就是去GitHub上将Vue源码clone下来,目前我这里分析的Vue版本是V2.5.21,下面开始分析: 一.源码的目录结构: Vue的源码都在src目录下 ...

  7. Vue源码解析之数组变异

    力有不逮的对象 众所周知,在 Vue 中,直接修改对象属性的值无法触发响应式.当你直接修改了对象属性的值,你会发现,只有数据改了,但是页面内容并没有改变. 这是什么原因? 原因在于: Vue 的响应式 ...

  8. Vue源码解析:AST语法树转render函数

    开始 今天要说的代码全在codegen文件夹中,在说实现原理前,还是先看个简单的例子! <div class="container"> <span>{{ms ...

  9. VUE源码解析心得

    解读vue源码比较好奇的几个点: VUE MVVM 原理 http://www.cnblogs.com/guwei4037/p/5591183.html https://cn.vuejs.org/v2 ...

随机推荐

  1. 回文树(回文自动机)(PAM)

    第一个能看懂的论文:国家集训队2017论文集 这是我第一个自己理解的自动机(AC自动机不懂KMP硬背,SAM看不懂一堆引理定理硬背) 参考文献:2017国家集训队论文集 回文树及其应用 翁文涛 参考博 ...

  2. XML转Bean

    XML转Bean有很多方式,我使用的是xtream方式实现xml与bean的互转. 下面是简单的xml转bean /** * XML转换成bean * @param obj * @return yuy ...

  3. web自动化 -- HTMLreport(二)测试报告输出内容居左对齐

    一.需求痛点 1.报告输出内容是居中对齐,很难看 2.我们希望输出内容是居左对齐 3.痛点截图 二.解决办法 1.原因分析 HTMLreport的内容对齐方式,自然是修改HTMLreport的内容了 ...

  4. yum下载软件包

    方法一: downloadonly插件有一个yum的插件叫做downloadonly,就是只下载不安装的意思.1. 安装插件yum install yum-download2. 下载yum updat ...

  5. SpringBoot整合Mail发送邮件&发送模板邮件

    整合mail发送邮件,其实就是通过代码来操作发送邮件的步骤,编辑收件人.邮件内容.邮件附件等等.通过邮件可以拓展出短信验证码.消息通知等业务. 一.pom文件引入依赖 <dependency&g ...

  6. ASP.NET Core 监听SQL Server数据库的实时信息

    1.开发环境: 开发工具:Visual Studio 2019 数据库:SQL Server2012 开发环境:.Net Core 3.1 2.使用技术: Signalr:实现消息推送 SqlDepe ...

  7. luogu P2525 Uim的情人节礼物 其之壱

    LINK:Uim的情人节礼物·其之壱 壱 古代通壹 常在日文中出现. 完全可以使用STL -->prev_permutation来解决. 不过我简单了解了一下康托展开. 这是一个一个排列对应一个 ...

  8. 实践录丨如何在鲲鹏服务器OpenEuler操作系统中快速部署OpenGauss数据库

    本文适合需要快速了解OpenGauss基本使用和操作的单机用户,可以短时间内完成安装体验.对于企业级生产使用或者需要部署多台服务器的,不适合本文. 因为业务需要,要在鲲鹏架构里安装单机版的OpenGa ...

  9. day2. 六大基本数据类型简介

    一.基本数据类型 Number 数字类型 (int float bool complex) str 字符串类型 list 列表类型 tuple 元组类型 set 集合类型 dict 字典类型 二.Nu ...

  10. 【USACO02FEB】Rebuilding Roads 重建道路 题解(树形DP)

    题目链接 题目大意:问使含有$p$个节点的子树分离至少需要去掉几条边. ------------------ 设$f[i][j]$表示以$i$为根的子树保留$j$个节点所去掉的最少边数. 初始化$f[ ...