时间:2021-09-26 10:17:34 | 栏目:vue | 点击:次
feb-alive
使用理由
为什么开发feb-laive?
当我们通过Vue开发项目时候,是否会有以下场景需求?
这个场景需求着重强调了缓存,缓存带来的好处是,我上次页面的数据及状态都被保留,无需在从服务器拉取数据,使用户体验大大提高。
尝试用keep-alive实现页面缓存
<keep-alive> <router-view></router-view> </keep-alive>
so easy但是理想很完美,现实很残酷
存在问题
-/a跳到/b,再跳转到/a 的时候,页面中的数据是第一次访问的/a页面,明明是链接跳转,确出现了缓存的效果,而我们期望的是像app一样开启一个新的页面。
举个应用场景
例如浏览文章页面,依次访问3篇文章
当我从/artical/3后退到/artical/2时候,由于组件缓存,此时页面还是文章3的内容,所以必须通过beforeRouteUpdate来重新拉取页面2的数据。(注意此处后退不会触发组件的activated钩子,因为两个路由都渲染同个组件,所以实例会被复用,不会执行reactivateComponent)
如果你想从/artical/3后退到/artical/2时,同时想恢复之前在/artical/2中的一些状态,那么你还需要自己针对/artical/2中的所有状态数据进行存储和恢复。
综上:keep-alive实现的组件级别的缓存和我们想象中的缓存还是有差距的,keep-alive并不能满足我们的需求。
==针对这些问题,所以feb-alive插件诞生了==
由于feb-alive是基于keep-alive实现的,所以我们先简单分析一下keep-alive是如何实现缓存的
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 // 获取第一个组件,也就和官方说明的一样,keep-alive要求同时只有一个子元素被渲染,如果你在其中有 v-for 则不会工作。 const vnode: VNode = getFirstComponentChild(slot) // 判断是否存在组件选项,也就是说只对组件有效,对于普通的元素则直接返回对应的vnode const componentOptions: ?VNodeComponentOptions = vnode && vnode.componentOptions if (componentOptions) { // 检测include和exclude 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 // 如果指定了子组件的key则使用,否则通过cid+tag生成一个key const key: ?string = vnode.key == null ? componentOptions.Ctor.cid + (componentOptions.tag ? `::${componentOptions.tag}` : '') : vnode.key // 判断是否存在缓存 if (cache[key]) { // 直接复用组件实例,并更新key的位置 vnode.componentInstance = cache[key].componentInstance remove(keys, key) keys.push(key) } else { // 此处存储的vnode还没有实例,在之后的流程中通过在createComponent中会生成实例 cache[key] = vnode keys.push(key) // 当缓存数量大于阈值时,删除最早的key if (this.max && keys.length > parseInt(this.max)) { pruneCacheEntry(cache, keys[0], keys, this._vnode) } } // 设置keepAlive属性,createComponent中会判断是否已经生成组件实例,如果是且keepAlive为true则会触发actived钩子。 vnode.data.keepAlive = true } return vnode || (slot && slot[0]) } }
keep-alive是一个抽象组件,组件实例中维护了一份cache,也就是以下代码部分
created () { // 存储组件缓存 this.cache = Object.create(null) this.keys = [] }
由于路由切换并不会销毁keep-alive组件,所以缓存是一直存在的(嵌套路由中,子路由外层的keep-alive情况会不一样,后续会提到)
继续看下keep-alive在缓存的存储和读取的具体实现,先用一个简单的demo来描述keep-alive对于组件的缓存以及恢复缓存的过程
let Foo = { template: '<div class="foo">foo component</div>', name: 'Foo' } let Bar = { template: '<div class="bar">bar component</div>', name: 'Bar' } let gvm = new Vue({ el: '#app', template: ` <div id="#app"> <keep-alive> <component :is="renderCom"></component> </keep-alive> <button @click="change">切换组件</button> </div> `, components: { Foo, Bar }, data: { renderCom: 'Foo' }, methods: { change () { this.renderCom = this.renderCom === 'Foo' ? 'Bar': 'Foo' } } })
上面例子中,根实例的template会被编译成如下render函数
function anonymous( ) { with(this){return _c('div',{attrs:{"id":"#app"}},[_c('keep-alive',[_c(renderCom,{tag:"component"})],1),_c('button',{on:{"click":change}})],1)} }
可使用在线编译:https://cn.vuejs.org/v2/guide/render-function.html#模板编译
根据上面的render函数可以知道,vnode生成的过程是深度递归的,先创建子元素的vnode再创建父元素的vnode。
所以首次渲染的时候,在生成keep-alive组件vnode的时候,Foo组件的vnode已经生成好了,并且作为keep-alive组件vnode构造函数(_c)的参数传入。
_c('keep-alive',[_c(renderCom,{tag:"component"})
生成的keep-alive组件的vnode如下
{ tag: 'vue-component-2-keep-alive', ... children: undefined, componentInstance: undefined, componentOptions: { Ctor: f VueComponent(options), children: [Vnode], listeners: undefined, propsData: {}, tag: 'keep-alive' }, context: Vue {...}, // 调用 $createElement/_c的组件实例, 此处是根组件实例对象 data: { hook: { init: f, prepatch: f, insert: f, destroy: f } } }
此处需要注意组件的vnode是没有children的,而是将原本的children作为vnode的componentOptions的children属性,componentOptions在组件实例化的时候会被用到,同时在初始化的时候componentOptions.children最终会赋值给vm.$slots,源码部分如下
// createComponent函数 function createComponent (Ctor, data, context, children, tag) { // 此处省略部分代码 ... var vnode = new VNode( ("vue-component-" + (Ctor.cid) + (name ? ("-" + name) : '')), data, undefined, undefined, undefined, context, { Ctor: Ctor, propsData: propsData, listeners: listeners, tag: tag, children: children }, asyncFactory ); return vnode }
Vue最后都会通过patch函数进行渲染,将vnode转换成真实的dom,对于组件则会通过createComponent进行渲染
function createComponent (vnode, insertedVnodeQueue, parentElm, refElm) { var i = vnode.data; if (isDef(i)) { var 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 } } }
接下去分两步介绍
先讲讲本例中针对keep-alive组件本身的渲染
所以在执行createElm(keepAliveVnode)的过程中会对keep-alive组件的实例化及挂载,而在实例化的过程中,keep-alive包裹的子组件的vnode会赋值给keep-alive组件实例的$slot属性,所以在keep-alive实例调用render函数时,可以通过this.$slot拿到包裹组件的vnode,在demo中,就是Foo组件的vnode,具体分析下keep-alive组件的render函数
render () { const slot = this.$slots.default const vnode: VNode = getFirstComponentChild(slot) const componentOptions: ?VNodeComponentOptions = vnode && vnode.componentOptions if (componentOptions) { 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 ? componentOptions.Ctor.cid + (componentOptions.tag ? `::${componentOptions.tag}` : '') : vnode.key 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) } } vnode.data.keepAlive = true } return vnode || (slot && slot[0]) }
上面分析到,在执行createElm(keepAliveVnode)的过程中,会执行keep-alive组件的实例化及挂载($mount),而在挂载的过程中,会执行keep-alive的render函数,之前分析过,在render函数中,可以通过this.$slot获取到子组件的vnode,从上面源码中,可以知道,keep-alive只处理默认插槽的第一个子组件,言外之意如果在keep-alive中包裹多个组件的话,剩下的组件会被忽略,例如:
<keep-alive> <Foo /> <Bar /> </keep-alive> // 只会渲染Foo组件
继续分析,在拿到Foo组件vnode后,判断了componentOptions,由于我们的Foo是一个组件,所以这里componentOptions是存在的,进到if逻辑中,此处include 表示只有匹配的组件会被缓存,而 exclude 表示任何匹配的组件都不会被缓存,demo中并没有设置相关规则,此处先忽略。
const { cache, keys } = this cache, keys是在keep-alive组件的create钩子中生成的,用来存储被keep-alive缓存的组件的实例以及对应vnode的key created () { this.cache = Object.create(null) this.keys = [] }
继续下面
const key: ?string = vnode.key == null ? componentOptions.Ctor.cid + (componentOptions.tag ? `::${componentOptions.tag}` : '') : vnode.key 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) } }
首先,取出vnode的key,如果vnode.key存在则使用vnode.key,不存在则用componentOptions.Ctor.cid + (componentOptions.tag ?::${componentOptions.tag}: '')
作为存储组件实例的key,据此可以知道,如果我们不指定组件的key的话,对于相同的组件会匹配到同一个缓存,这也是为什么最开始在描述keep-alive的时候强调它是一个组件级的缓存方案。
那么首次渲染的时候,cache和keys都是空的,这里就会走else逻辑
cache[key] = vnode keys.push(key) if (this.max && keys.length > parseInt(this.max)) { pruneCacheEntry(cache, keys[0], keys, this._vnode) }
以key作为cache的健进行存储Foo组件vnode(注意此时vnode上面还没有componentInstance),
这里利用了对象存储的原理,之后进行Foo组件实例化的时候会将其实例赋值给vnode.componentInstance,那么在下次keep-alive组件render的时候就可以获取到vnode.componentInstance。
所以首次渲染仅仅是在keep-alive的cache上面,存储了包裹组件Foo的vnode。
针对包裹组件的渲染
上面已经讲到执行了keep-alive的render函数,根据上面的源码可以知道,render函数返回了Foo组件的vnode,那么在keep-alive执行patch的时候,会创建Foo组件的实例,然后再进行Foo组件的挂载,这个过程与普通组件并没有区别,在此不累述。
当组件从Foo切换到Bar时
本例中由于renderCom属性的变化,会触发根组件的renderWatcher,之后会执行patch(oldVnode, vnode)
在进行child vnode比较的时候,keep-alive的新老vnode比较会被判定为sameVnode,之后会进入到patchVnode的逻辑
function patchVnode (oldVnode, vnode, insertedVnodeQueue, removeOnly) { if (oldVnode === vnode) { return } // 此处省略代码 ... var i; var data = vnode.data; if (isDef(data) && isDef(i = data.hook) && isDef(i = i.prepatch)) { i(oldVnode, vnode); } // 此处省略代码 ... }
由于我们的keep-alive是组件,所以在vnode创建的时候,会注入一些生命周期钩子,其中就包含prepatch钩子,其代码如下
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 ); }
由此可知,keep-alive组件的实例在此次根组件重渲染的过程中会复用,这也保证了keep-alive组件实例上面之前存储cache还是存在的
var child = vnode.componentInstance = oldVnode.componentInstance;
下面的updateChildComponent这个函数非常关键,这个函数担任了Foo组件切换到Bar组件的关键任务。我们知道,由于keep-alive组件是在此处是复用的,所以不会再触发initRender,所以vm.$slot不会再次更新。所以在updateChildComponent函数担起了slot更新的重任
function updateChildComponent ( vm, propsData, listeners, parentVnode, renderChildren ) { if (process.env.NODE_ENV !== 'production') { isUpdatingChildComponent = true; } // determine whether component has slot children // we need to do this before overwriting $options._renderChildren var hasChildren = !!( renderChildren || // has new static slots vm.$options._renderChildren || // has old static slots parentVnode.data.scopedSlots || // has new scoped slots vm.$scopedSlots !== emptyObject // has old scoped slots ); // ... // resolve slots + force update if has children if (hasChildren) { vm.$slots = resolveSlots(renderChildren, parentVnode.context); vm.$forceUpdate(); } if (process.env.NODE_ENV !== 'production') { isUpdatingChildComponent = false; } }
updateChildComponent函数主要更新了当前组件实例上的一些属性,这里包括props,listeners,slots。我们着重讲一下slots更新,这里通过resolveSlots获取到最新的包裹组件的vnode,也就是demo中的Bar组件,之后通过vm.$forceUpdate强制keep-alive组件进行重新渲染。(小提示:当我们的组件有插槽的时候,该组件的父组件re-render时会触发该组件实例$fourceUpdate,这里会有性能损耗,因为不管数据变动是否对slot有影响,都会触发强制更新,根据vueConf上尤大的介绍,此问题在3.0会被优化),例如
// Home.vue <template> <Artical> <Foo /> </Artical> </tempalte>
此例中当Home组件更新的时候,会触发Artical组件的强制刷新,而这种刷新是多余的。
继续,在更新了keep-alive实例的forceUpdate,之后再次进入到keep-alive的render函数中
render () { const slot = this.$slots.default const vnode: VNode = getFirstComponentChild(slot) // ... }
此时render函数中获取到vnode就是Bar组件的vnode,接下去的流程和Foo渲染一样,只不过也是把Bar组件的vnode缓存到keep-alive实例的cache对象中。
当组件从Bar再次切换到Foo时
针对keep-alive组件逻辑还是和上面讲述的一样
再次进入到render函数,这时候cache[key]就会匹配到Foo组件首次渲染时候缓存的vnode了,看下这部分逻辑
const key: ?string = vnode.key == null ? componentOptions.Ctor.cid + (componentOptions.tag ? `::${componentOptions.tag}` : '') : vnode.key 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) } }
由于keep-alive包裹的组件是Foo组件,根据规则,此时生成的key和第一此渲染Foo组件时生成的key是一样的,所以本次keep-alive的render函数进入到了第一个if分支,也就是匹配到了cache[key],把缓存的componentInstance赋值给当前vnode,然后更新keys(当存在max的时候,能够保证被删除的是比较老的缓存)。
很多同学可能会问,这里设置vnode.componentInstance会有什么作用。这里涉及到vue的源码部分。
由于是从Bar组件切换到Foo组件,所以在patch的时候,比对到此处,并不会被判定为sameVnode,所以自然而然走到createElm,由于Foo是Vue组件,所以会进入到createComponent,所以最终进入到下面函数片段
function createComponent (vnode, insertedVnodeQueue, parentElm, refElm) { var i = vnode.data; if (isDef(i)) { var 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 } } }
可以根据上面对于keep-alive源码的分析,此处isReactivated为true,接下去会进入到vnode生成的时候挂在的生命周期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 { var child = vnode.componentInstance = createComponentInstanceForVnode( vnode, activeInstance ); child.$mount(hydrating ? vnode.elm : undefined, hydrating); } }, 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 ); }, ... }
此时由于实例已经存在,且keepAlive为true,所以会走第一个if逻辑,会执行prepatch,更新组件属性及一些监听器,如果存在插槽的话,还会更新插槽,并执行$forceUpdate,此处在前面已经分析过,不做累述。
继续createComponent,在函数内部会执行initComponent和insert
if (isDef(vnode.componentInstance)) { // 将实例上的dom赋值给vnode initComponent(vnode, insertedVnodeQueue); // 插入dom insert(parentElm, vnode.elm, refElm); if (isTrue(isReactivated)) { reactivateComponent(vnode, insertedVnodeQueue, parentElm, refElm); } return true }
至此,当组件从Bar再次切换到Foo时,实例与dom都得到了复用,达到一个很高的体验效果!而我们之后要实现的feb-alive就是基于keep-alive实现的。
参考文档