修改vue源码实现动态路由缓存

3,363 阅读5分钟

修改vue源码实现动态路由缓存

动态路由

官网解读:我们经常需要把某种模式匹配到的所有路由,全都映射到同个组件。例如,我们有一个 User 组件,对于所有 ID 各不相同的用户,都要使用这个组件来渲染。那么,我们可以在 vue-router 的路由路径中使用“动态路径参数”(dynamic segment) 来达到这个效果。

即如果你有一个盘点录入单路由,但你想通过不同的传不同的ID来加载CheckInputInfo这个组件,若采用params方式,这时只需要在path后面配置/:taskId即可实现CheckInputInfo/1CheckInputInfo/2这样的路由,同时可以通过this.$route.params.taskId来获取当前路由的taskId

{
    path: 'CheckInputInfo/:taskId',
    meta: {
      title: '盘点录入单'
    },
    name: 'CheckInputInfo',
    component: () => import('@/view/Check/CheckInputInfo.vue')
  }

类似的,同样也可使用query方式,这时只需要在path后面配置:taskId即可实现CheckInputInfo?taskId=1CheckInputInfo?taskId=2这样的路由,同时可以通过this.$route.query.taskId来获取当前路由的taskId

{
    path: 'CheckInputInfo:taskId',
    meta: {
      title: '盘点录入单'
    },
    name: 'CheckInputInfo',
    component: () => import('@/view/Check/CheckInputInfo.vue')
  }

vue-router通过配置paramsquery来实现动态路由,并可通过this.$route.xx来获取当前的paramsquery,省去了直接操作或处理window.location,还是挺方便的。

注意:当使用路由参数时,例如从 /user/foo 导航到 /user/bar,原来的组件实例会被复用。因为两个路由都渲染同个组件,比起销毁再创建,复用则显得更加高效。不过,这也意味着组件的生命周期钩子不会再被调用。

解读:在不使用keep-alive的情况下,我们每次加载路由,这时会重新render当前路由挂载的component,但若这两个路由是同一个路由组件配置的动态路由,vue为了性能设计了不会重新render

这显然不符合我们的预期,那么该如何在动态路由下拥有完整的生命周期呢?答案是keep-alive

keep-alive

官网解读:keep-alive 包裹动态组件时,会缓存不活动的组件实例,而不是销毁它们。和 transition 相似,keep-alive 是一个抽象组件:它自身不会渲染一个 DOM 元素,也不会出现在组件的父组件链中。在 2.2.0 及其更高版本中,activated 和 deactivated 将会在 树内的所有嵌套组件中触发。当组件在 内被切换,它的 activated 和 deactivated 这两个生命周期钩子函数将会被对应执行。

keep-alive通过缓存Vnode的方式解决了SPA最为关键的性能问题。以下,我就按步骤来分析以下:

一、路由触发路由组件重新render的问题

1、不缓存模式:
<router-view></router-view>

每次切换都会重新render,执行整个生命周期,每次切换时,重新render,重新请求,,必然不满足需求。

2、缓存模式:
<keep-alive>
  <router-view></router-view>
</keep-alive>

只是在进入当前路由的第一次render,来回切换不会重新执行生命周期,且能缓存router-view的数据。

二、router-view 数据缓存问题

keep-alive采用render函数来创建Vnode,一下是vue v2.5.10keep-alive.jsrender()

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

render是获取到Vnode,若cache[key]存在,则:

vnode.componentInstance = cache[key].componentInstance

否则,将Vnode保存在cache里:

cache[key] = vnode

于是当时用keep-alive时,我们就可以保存每个route-view的数据。

动态路由缓存问题及如何实现

一、bug表象

最开始其实是不知道这个bug的,也是通过现象反推,然后由源码解决这个问题的,那就先从现象说起:

动态路由缓存的的具体表现在:

  • 由动态路由配置的路由只能缓存一份数据。
  • keep-alive动态路由只有第一个会有完整的生命周期,之后的路由只会触发 activeddeactivated这两个钩子。
  • 一旦更改动态路由的某个路由数据,期所有同路由下的动态路由数据都会同步更新。

我们的期望其实是在使用keep-alive的情况下,动态路由能有非动态的表现,即拥有完整的生命周期各自的数据缓存

二、发掘问题关键

入手keep-alive源码发现,其实问题就出现在这一步:

if (
  // not included
  (include && (!name || !matches(include, name))) ||
  // excluded
  (exclude && name && matches(exclude, name))
) {
  return vnode
}

通过上面的表象其实可以探究出,router-view其实是已经缓存了,而且一个动态路由的router-view都是通过了if判断返回了Vnode。那么再看一下这个name是什么:

function getComponentName (opts: ?VNodeComponentOptions): ?string {
  return opts && (opts.Ctor.options.name || opts.tag)
}

const name: ?string = getComponentName(componentOptions)

这里的opts其实对应的就是VueComponent$options,而this.$options.name不就是对应着得.vue文件里声明的name属性。然后又想到,怪不得配置路由的时候要求提供的name属性要和组件内部的name值保持一致。

看到这里,问题已经水落石出了,因为动态路由配置的组件相同,getComponentName每次返回相同name,然后render()去缓存了相同的Vnode,且只能缓存了一份。既然如此,只要能正确的缓存Vnode和取出Vnode,动态路由情况下,keep-alive依然能正常运行。

修改Vue源码

上面说到了是因为动态路由组件名的问题,如果将缓存的key设置为唯一不就行了吗?

于是在router-view上配置key,key取得师path,永远唯一:

<keep-alive :include="cacheList">
  <router-view :key="$route.path"></router-view>
</keep-alive>

然后修改keep-alive.js源码,如下(因为放假的关系不详细说了,直接贴源码,实现的人就是我,也是第一个,github上此BUG目前还是open状态):

/* 
*@flow
*modify by LK 20190624
*/


import { isRegExp, remove } from 'shared/util'
import { getFirstComponentChild } from 'core/vdom/helpers/index'

type VNodeCache = { [key: string]: ?VNode };

function getComponentName (opts: ?VNodeComponentOptions): ?string {
  return opts && (opts.Ctor.options.name || opts.tag)
}

function matches (pattern: string | RegExp | Array<string>, key: string | Number): boolean {
  if (Array.isArray(pattern)) {
    return pattern.indexOf(key) > -1
  } else if (typeof pattern === 'string') {
    return pattern.split(',').indexOf(key) > -1
  } else if (isRegExp(pattern)) {
    return pattern.test(key)
  }
  /* istanbul ignore next */
  return false
}

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 (key && !filter(key)) {
        pruneCacheEntry(cache, key, keys, _vnode)
      }
    }
  }
}

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

const patternTypes: Array<Function> = [StringRegExpArray]

export default {
  name'keep-alive',
  abstracttrue,

  props: {
    include: patternTypes,
    exclude: patternTypes,
    max: [StringNumber]
  },

  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, key => matches(val, key))
    })
    this.$watch('exclude', val => {
      pruneCache(this, key => !matches(val, key))
    })
  },

  render () {
    const slot = this.$slots.default
    const vnode: VNode = getFirstComponentChild(slot)
    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
    const componentOptions: ?VNodeComponentOptions = vnode && vnode.componentOptions
    if (componentOptions) {
      // check pattern
      const name: ?string = getComponentName(componentOptions)
      const { include, exclude } = this
      if (
        // not included
        (include && (!key || !matches(include, key))) ||
        // excluded
        (exclude && key && matches(exclude, key))
      ) {
        return vnode
      }

      const { cache, keys } = this

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

如何集成

因为放假赶车的关系,粗略说一下,有问题直接在底下评论:

一、修改package.json:

npm install 时不下载vue,修改packjson.js改为本地的vue:"vue": "file:./vue2.5.0/"

"dependencies": {
  "axios""^0.18.0",
  "clipboard""^2.0.0",
  "codemirror""^5.38.0",
  "countup""^1.8.2",
  "cropperjs""^1.2.2",
  "dayjs""^1.7.7",
  "echarts""^4.0.4",
  "html2canvas""^1.0.0-alpha.12",
  "iview""^3.2.2",
  "iview-area""^1.5.17",
  "js-cookie""^2.2.0",
  "simplemde""^1.11.2",
  "sortablejs""^1.7.0",
  "tree-table-vue""^1.1.0",
  "v-org-tree""^1.0.6",
  "vue""file:./vue2.5.0/",
  "vue-i18n""^7.8.0",
  "vue-router""^3.0.1",
  "vuedraggable""^2.16.0",
  "vuex""^3.0.1",
  "wangeditor""^3.1.1",
  "xlsx""^0.13.3"
},

二、修改所有本地import vue 为本地文件:

// import Vue from 'vue'
import Vue from '../vue-2.5.10/src/core/index'