Vue頁面級緩存解決方案feb-alive (上)

feb-alive

使用理由

  • 開發者無需由於動態路由或者普通路由的差別而將數據初始化邏輯寫在不一樣的鉤子裏beforeRouteUpdate或者activated
  • 開發者無需手動緩存頁面狀態,例如經過localStorage或者sessionStorage緩存當前頁面的數據
  • feb-alive會幫你處理路由meta信息的存儲與恢復

爲何開發feb-laive?

當咱們經過Vue開發項目時候,是否會有如下場景需求?javascript

  1. /a跳轉到/b
  2. 後退到/a時候,但願從緩存中恢復頁面
  3. 再次跳轉到/b時,分兩種狀況
    1. 狀況一: 經過連接或者push跳轉,則但願從新建立/b頁面,而不是從緩存中讀取
    2. 狀況二: 若是點擊瀏覽器自帶前進按鈕,則仍是從緩存中讀取頁面。

這個場景需求着重強調了緩存,緩存帶來的好處是,我上次頁面的數據及狀態都被保留,無需在從服務器拉取數據,使用戶體驗大大提升。html

嘗試用keep-alive實現頁面緩存

<keep-alive>
  <router-view></router-view>
</keep-alive>
複製代碼

so easy可是理想很完美,現實很殘酷vue

存在問題

-/a跳到/b,再跳轉到/a 的時候,頁面中的數據是第一次訪問的/a頁面,明明是連接跳轉,確出現了緩存的效果,而咱們指望的是像app同樣開啓一個新的頁面。java

  • 同理動態路由跳轉/page/1->/page/2由於兩個頁面引用的是同一個組件,因此跳轉時頁面就不會有任何改變,由於keep-alive的緩存的key是根據組件來生成的(固然Vue提供了beforeRouteUpdate鉤子供咱們刷新數據)
  • 總結:keep-alive的緩存是==組件級別==的,而不是==頁面級別==的。

舉個應用場景node

例如瀏覽文章頁面,依次訪問3篇文章react

  1. /artical/1
  2. /artical/2
  3. /artical/3

當我從/artical/3後退到/artical/2時候,因爲組件緩存,此時頁面仍是文章3的內容,因此必須經過beforeRouteUpdate來從新拉取頁面2的數據。(注意此處後退不會觸發組件的activated鉤子,由於兩個路由都渲染同個組件,因此實例會被複用,不會執行reactivateComponentgit

若是你想從/artical/3後退到/artical/2時,同時想恢復以前在/artical/2中的一些狀態,那麼你還須要本身針對/artical/2中的全部狀態數據進行存儲和恢復。github

綜上: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)}
}
複製代碼

可以使用在線編譯:cn.vuejs.org/v2/guide/re…

根據上面的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
      }
    }
  }
複製代碼

接下去分兩步介紹

  1. keep-alive組件自己的渲染
  2. keep-alive包裹組件的渲染,本例中的Foo組件和Bar組件

先講講本例中針對keep-alive組件自己的渲染

  1. 根組件實例化
  2. 根組件$mount
  3. 根組件調用mountComponent
  4. 根組件生成renderWatcher
  5. 根組件調用updateComponent
  6. 根組件調用vm.render()生成根組件vnode
  7. 根組件調用vm.update(vnode)
  8. 根組件調用vm.patch(oldVnode, vnode)
  9. 根組件調用createElm(vnode)
  10. 在children渲染的時候,若是遇到組件類型的vnode則調用createComponent(vnode),而正是在這個過程當中,進行了子組件的實例化及掛載($mount)

因此在執行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實例的slots以後,直接觸發了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組件邏輯仍是和上面講述的同樣

  1. 執行prepatch
  2. 複用keep-alive組件實例
  3. 執行updateChildComponent,更新$slots
  4. 觸發vm.$forceUpdate
  5. 觸發keep-alive組件render函數

再次進入到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實現的。

Vue頁面級緩存解決方案feb-alive (下)

參考文檔

相關文章
相關標籤/搜索