写在之前
最近参加面试,面试官问到了关于 keep-alive
的问题。当时我大概回答了一下生命周期和使用方法,但是面试官显然不太满意,后来我们又讨论了一些其他的场景,感觉很受益,趁着热乎了,就记录下来,顺便搜了下相关文章,把这些内容整理一下,温故而知新。
参考大多是 vue2 的文章,vue3 的 keep-alive 的 API 和用法差不多,多了个 scrollBehavior,先占坑,后续有其他补充再完善。
什么是 keep-alive
<keep-alive>
是 Vue 的内置组件,可以使被包裹的组件保留状态
,或者避免重新渲染
,从而实现组件缓存
。它可以保留组件的状态在内存中,避免重复渲染 DOM。
当我们使用 <keep-alive>
包裹动态组件时,会缓存不活动的组件实例,而不是销毁它们。这和使用 <transition>
有些类似,<keep-alive>
是一个内置的抽象组件:它自身不会渲染 DOM 元素
,也不会出现在父组件中。
keep-alive 的生命周期执行
关于 keep-alive 的生命周期执行,页面第一次进入时,钩子的触发顺序是:
- created
- mounted
- activated
当页面退出时,会触发 deactivated 钩子。而当再次进入(前进或者后退)时,只会触发 activated 钩子。
关于 keep-alive 的一些最佳实践:
- 将只执行一次的事件挂载方法都放到
mounted
中
- 将组件每次进入时需要执行的方法放在
activated
中
基本用法
1 2 3 4
| <keep-alive> <component /> </keep-alive>
|
被 keep-alive
包裹的组件不会重新初始化,这意味着它们不会重新触发生命周期函数。
但有时我们希望缓存的组件能够再次进行渲染,Vue 为我们解决了这个问题。被包裹在 keep-alive 中创建的组件,会多出两个生命周期的钩子: activated
与 deactivated
activated
当 keep-alive 包裹的组件再次渲染
时触发
deactivated
当 keep-alive 包裹的组件被销毁
时触发
参数
keep-alive 可以接收 3 个属性做为参数,用于匹配相应的组件进行缓存:
exclude
要排除的组件(以为字符串,数组,以及正则表达式,任何匹配的组件都不会被缓存)
include
要缓存的组件(可以是字符串、数组或正则表达式,任何匹配的组件都不会被缓存)
max
缓存组件的最大值(类型为字符或数字,可以控制缓存组件的个数)
- 当使用正则表达式或数组时,务必使用 v-bind
- exclude、include 同时存在时,exclude 优先级更高
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
| <keep-alive include="a,b"> <component></component> </keep-alive>
<keep-alive exclude="c"> <component></component> </keep-alive>
<keep-alive :include="/a|b/"> <component :is="view"></component> </keep-alive>
<keep-alive :include="includedComponents"> <router-view></router-view> </keep-alive>
<keep-alive include="a,b" exclude="b"> <component></component> </keep-alive>
<keep-alive exclude="c" max="5"> <component></component> </keep-alive>
|
keep-alive 组件的渲染
使用 keep-alive 时,并不会生成真正的 DOM 节点,(当时问了这个细节,答出来了,但后续就没扯对 … 于是就有了这篇)
那是如何实现的呢:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| export function initLifecycle(vm: Component) { const options = vm.$options let parent = options.parent if (parent && !options.abstract) { while (parent.$options.abstract && parent.$parent) { parent = parent.$parent } parent.$children.push(vm) } vm.$parent = parent }
|
大致可以回答上面的问题了:
前提:在 Vue 初始化生命周期时,建立组件实例的父子关系会根据 abstract
属性来决定是否忽略某个组件。
在 keep-alive 中,设置了 abstract: true
,这意味着 Vue 会在构建的组件树中跳过
该 keep-alive 组件实例
。
因此,最终渲染成的 DOM 树
中自然也不会包含 keep-alive 相关的节点
;
构建的组件树中就不会包裹 keep-alive 组件;
那么由组件树渲染成的 DOM 树自然也不会有 keep-alive 相关的节点了。
被包裹组件如何使用缓存
在 patch 阶段
,会执行 createComponent
函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| 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 ) }
if (isDef(vnode.componentInstance)) { initComponent(vnode, insertedVnodeQueue) insert(parentElm, vnode.elm, refElm) if (isTrue(isReactivated)) { reactivateComponent(vnode, insertedVnodeQueue, parentElm, refElm) } return true } } }
|
在首次加载被包裹组件时,根据 keep-alive.js 中的 render 函数可知,vnode.componentInstance
的值是 undefined
,而 keepAlive 的值是 true。由于 keep-alive 组件作为父组件,它的 render 函数会先于被包裹组件执行,这样只会执行到 i(vnode, false /* hydrating */
),后面的逻辑就不再执行。
当再次访问
被包裹组件时,vnode.componentInstance 的值就是被缓存的组件实例,于是会执行 insert(parentElm, vnode.elm, refElm) 逻辑,这样就直接把上一次的 DOM 插入到了父元素中。
keep-alive 与对应的钩子函数
一般情况下,组件每次加载都会完整执行整个生命周期,即生命周期中对应的钩子函数都会被触发。那么为什么被 keep-alive 包裹的组件不会呢?这是因为被缓存的组件实例会被设置为 keepAlive = true
。在组件初始化阶段
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| const componentVNodeHooks = { init(vnode: VNodeWithData, hydrating: boolean): ?boolean { if (vnode.componentInstance && !vnode.componentInstance._isDestroyed && vnode.data.keepAlive) { const mountedNode: any = vnode componentVNodeHooks.prepatch(mountedNode, mountedNode) } else { const child = (vnode.componentInstance = createComponentInstanceForVnode( vnode, activeInstance )) child.$mount(hydrating ? vnode.elm : undefined, hydrating) } }, }
|
可以看到,当 vnode.componentInstance
和 keepAlive
同时为 true
时,将不再执行 $mount 过程,因此在 mounted 钩子之前
的所有钩子函数(beforeCreate、created、mounted)都不会执行
。
可重复的 activated
在 patch 阶段的最后,会执行 invokeInsertHook 函数,这个函数会调用组件实例(VNode)自身的 insert 钩子:
1 2 3 4 5 6 7 8 9 10 11
| function invokeInsertHook(vnode, queue, initial) { if (isTrue(initial) && isDef(vnode.parent)) { vnode.parent.data.pendingInsert = queue } else { for (let i = 0; i < queue.length; ++i) { queue[i].data.hook.insert(queue[i]) } } }
|
再看 insert
钩子:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| 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) { queueActivatedComponent(componentInstance) } else { activateChildComponent(componentInstance, true ) } } }
|
在 insert 钩子中,调用了 activateChildComponent
函数来递归地执行所有子组件的 activated
钩子函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| 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') } }
|
相反地,deactivated 钩子函数也是类似的原理,在组件实例(VNode)的 destroy
钩子函数中调用 deactivateChildComponent
函数。
结合 vue-router 使用
例:所有路径下的视图组件都会被缓存
1 2 3
| <keep-alive> <router-view></router-view> </keep-alive>
|
在 router-view 中缓存特定组件
使用 include 属性(exclude 的使用方式类似)
- 要先设置组件的 name 属性
- 使用时要知道组件的 name 值
- 所以,在项目较为复杂时可能不是最佳选择
1 2 3 4 5
| <keep-alive include="a"> <router-view> </router-view> </keep-alive>
|
1 2 3 4
| <keep-alive> <router-view v-if="$route.meta.keepAlive"> </router-view> </keep-alive> <router-view v-if="!$route.meta.keepAlive"> </router-view>
|
配合动画更搭:
1 2 3 4 5 6 7 8 9 10 11 12
| <transition enter-active-class="animated zoomInLeft" leave-active-class="animated zoomOutRight"> <keep-alive> <router-view v-if="$route.meta.keepAlive"> </router-view> </keep-alive> </transition> <transition enter-active-class="animated zoomInLeft" leave-active-class="animated zoomOutRight"> <router-view v-if="!$route.meta.keepAlive"> </router-view> </transition>
|
这样做的话更加简单明了:
- 不需要例举出需要被缓存组件名称
- 但是要在 route 的 meta 里面添加 {keepAlive:true} 字段
- 延伸,权限控制路由等相关的内容可以使用 meta 属性来实现
1 2 3 4
| <keep-alive> <router-view v-if="$route.meta.keepAlive"></router-view> </keep-alive> <router-view v-if="!$route.meta.keepAlive"></router-view>
|
需要在 router 中设置 router 的元信息 meta:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| export default new Router({ routes: [ { path: '/', name: 'Hello', component: Hello, meta: { keepAlive: false, }, }, { path: '/page1', name: 'Page1', component: Page1, meta: { keepAlive: true, }, }, ], })
|
但是,当路由是由后台控制时,就会存在一些隐患:
在页面跳转前将滚动高度缓存起来,并在每次返回时将滚动高度重新赋值。
如果需要在多个页面中使用缓存,路由提供了一个解决方案:scrollBehavior
方法。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
| const router = new VueRouter({ mode: 'hash', routes, scrollBehavior(to, from, savedPosition) { console.log(savedPosition); if (savedPosition) { return savedPosition } else { if (from.meta.keepAlive) { from.meta.scrollTop = document.documentElement.scrollTop; } return {x: 0, y: to.meta.scrollTop || 0} } }, });
export default router
scrollBehavior(to, from, savedPosition) { if (savedPosition) { return new Promise((resolve, reject) => { setTimeout(() => { resolve(savedPosition) }, 20) }) } else { if (from.meta.keepAlive) { from.meta.scrollTop = document.documentElement.scrollTop; } return {x: 0, y: to.meta.scrollTop || 0} } }
|
Vue Router 中的 scrollBehavior 方法,它支持异步滚动支持
的功能。这个功能允许你在切换路由时保存滚动位置,并在返回时恢复滚动位置。
在代码中,scrollBehavior 方法会接收 to 和 from 路由对象以及 savedPosition 作为参数。如果 savedPosition 存在,则会将其返回,否则会通过获取 to 和 from 路由对象的元信息中的滚动高度来决定滚动位置。
另外,在新版本中,支持通过返回一个 Promise
来异步滚动
,这对于页面中存在异步请求的情况非常有用,可以确保在异步请求完成后仍能正确恢复滚动位置
。
此外,对于自定义滚动容器,可以在 scrollBehavior
方法中根据实际情况自行处理滚动高度的记录和恢复。
通过 keepAlive: true 和 beforeRouteLeave 钩子实现定制
写到这里发现其实面试官应该就是想问这个 - - 遂一同记录下来
假设这里有 3 个路由: A、B、C。
需求:
- 默认显示 A
- B 跳到 A,A 不刷新
- C 跳到 A,A 刷新
实现思路:
- 在 A 路由里面设置 meta 属性
1 2 3 4 5 6 7 8 9
| { path: '/', name: 'A', component: A, meta: { keepAlive: true } }
|
- 在 B 组件里面设置
beforeRouteLeave
1 2 3 4 5 6 7 8 9 10 11 12 13
| export default { data() { return {} }, methods: {}, beforeRouteLeave(to, from, next) { to.meta.keepAlive = true next() }, }
|
- 在 C 组件里面设置
beforeRouteLeave
1 2 3 4 5 6 7 8 9 10 11 12 13
| export default { data() { return {} }, methods: {}, beforeRouteLeave(to, from, next) { to.meta.keepAlive = false next() }, }
|
这样便能实现 B 回到 A,A 不刷新;而 C 回到 A 则刷新。
实现返回不刷新、其他菜单进入刷新
原文的方式一就是上述 meta 设置 keepAlive 实现。但是当 A->C->A->B->A 发现列表页 A 不会再缓存了,每次都是新的页面,作者通过在 router.afterEach((to,from)=>{})
钩子中写了进一步的判断实现了相关逻辑,就不再赘述了。
关于方式二和三,可以参考文章 这大概是最全乎的 keep-alive 踩坑指南
值得学习,这里就搬过来了。
方式二
- 使用 v-if 配合 $route.meta.keepAlive
- 使用 beforeRouteLeave 钩子,判断是否要进行局部刷新
- actived 钩子里进行对应的局部刷新逻辑:数据获取,位置设置等
这里一定要使用 v-if,好处是你可以使用 $nextTick 体验更好,另一方面是在使用 v-show 之后,他就相当于隐藏了该页面,但是如果里面有一些不会 diff 的 dom,就会展示出来,模拟刷新的体验就不太好。例如使用 input:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40
| <template> <div v-if="isRouterAlive"> <div>{{ddd}}</div> <input v-model="ddd" type="text" /> <table-list ref="table" :multiple="true" :otherTableParams="otherTableParams" :tableColumn="column" /> </div> </template> <script> export default { activated() { if (this.$route.meta.isRefresh) { const resetData = this.$options.data() delete resetData.column
Object.assign(this.$data, resetData) this.isRouterAlive = false this.$nextTick(function () { window.scroll(0, 0) this.isRouterAlive = true }) setTimeout(() => { this.queryList() }) } }, beforeRouteLeave(to, from, next) { from.meta.isRefresh = to.name !== 'table-detail' next() }, } </script>
|
现在的代码有两个问题:
- 从详情页到列表页,数据不会更新,如果在详情页修改了某个数据,然后再到列表页就会滞后;
- 从详情页跳转到别的列表页然后在跳转到缓存的列表页,然后他还是会缓存之前的数据,不会更新当前页面
继续优化如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
| activated() { if (this.$route.meta.isRefresh) { const data = this.$options.data() delete data.column
Object.assign(this.$data, data) this.isRouterAlive = false this.$nextTick(function () { window.scroll(0, 0) this.isRouterAlive = true }) setTimeout(() => { this.queryList() }) } else if (this.$route.meta.isRefresh === false) { this.queryList() } }
beforeRouteEnter(to, from, next) { to.meta.isRefresh = from.name && from.name !== 'table-detail'; next() },
|
方式三
用 keep-alive 提供的 include
和 exclude
,然后配合 vuex
实现动态控制
。
路由入口页面
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| <keep-alive :include="includes" :exclude="" :max="3"> <router-view></router-view> </keep-alive>
<script> import { mapGetters } from 'vuex' export default { computed: { ...mapGetters(['includes']), }, methods: { changeStore() { this.$store.commit('change', 'tableLists') }, }, } </script>
|
Vuex
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| const keepalive = { state: { includes: ['tableLists'], }, mutations: { change(state, payload) { state.includes = payload }, }, getters: { includes(state) { return state.includes }, }, }
export default keepalive
|
Views 部分伪代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| activated() { this.queryList() }, beforeRouteEnter(to, from, next) { window._store.commit('change', ['tableLists']); next() }, beforeRouteLeave(to, from, next) { if (to.name !== 'table-detail') { this.$store.commit('change', []); } next() }
|
路由页面
因为 includes 没有在路由里面定义 keepAlive,所以上面的 scrollBehavior 这个方法当使用合成事件跳转的时候,需要做额外的处理
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| scrollBehavior(to, from, savedPosition) { if (savedPosition) { return new Promise((resolve) => { setTimeout(() => { resolve(savedPosition) }, 20) }) } else { const ary = ['Invest', 'Store'];
if (ary.includes(from.name)) {
from.meta.scrollTop = document.documentElement.scrollTop; } return {x: 0, y: to.meta.scrollTop || 0} } }
|
上面的代码比较琐碎,需要添加到每一个页面,所以在实际项目中大家可添加一个 keepalive 的 mixins ,方便大家管理。
总结
为了方便使用 keep-alive 的 include 和 exclude 属性,建议在项目中为组件都设置 name
属性。
当使用 keep-alive 组件时,会首先匹配被包裹组件的 name 字段,如果 name 不可用,则匹配当前组件 components 配置中的注册名称。
keep-alive 包裹组件时并不会生成真正的 DOM 节点;
由于函数式组件没有缓存实例,因此 keep-alive 在函数式组件中不会正常工作。
当 include 和 exclude 中同时存在匹配条件时,以 exclude
的优先级最高
(基于当前 vue 2.4.2 版本)。例如,如果被包裹的组件同时匹配了 exclude 条件,则该组件将不会被缓存。
如果一个组件被包裹在 keep-alive 中,但符合了 exclude 条件,则 activated 和 deactivated 钩子不会被调用。
注意,如果将 include
设置为空字符串
‘ ‘,则会导致每个页面都被缓存
,需要谨慎使用
。
参考