在 Vue 中,利用 VueRouter 可以轻松的实现两个组件(页面)之间的切换,有个常用的设计就是需要在登录页登录后跳转至一个内容页,通常的做法是在登录校验完成之后立即切换路由至内容页,接着内容页发送网络请求获取渲染需要的数据然后渲染带有业务数据的 DOM:
上图中,由于内容页的核心数据都是需要通过网络请求来获取,在数据获取回来之前页面处于空白(或 loading)状态,这里并没有什么逻辑问题,只是有时候可能会想,怎么将这个等待过程提前一点比如放置路由跳转之前,让内容页的初始数据准备好了再进行路由跳转?如下图示:
因此,这篇文章将主要讨论实现这个需求
通过数据缓存
这是一个容易想到的实现方式,因为上述需求无非就是需要等待内容页中列表的数据返回花费时间,那我们可以在跳转之前进行内容页初始数据的获取放置缓存中:
// contentLogic.ts
export async function loadContentRecords(params: Record<string, any>) {
// 逻辑 A
// 逻辑 B
// 逻辑 C
const result = await axios.post('...', params)
// 逻辑 D
return result
}
<!-- Login.vue -->
<script setup lang="ts">
import { loadContentRecords } from './contentLogic'
import router from './router'
const onSubmit = async () => {
// 1. 登录
await axios.post('/login', { /** ... */ })
// 2. 登录通过后,预加载 content 的数据
const data = await loadContentRecords({ A: false }) // 1
// 3. 将预加载的数据放置在某一个地方
window.data = data
// 4. 数据加载完成并保存后,跳转至 content 页面
router.push('/content')
}
</script>
<template>
<button @click="onSubmit">登录</button>
</template>
<!-- Content.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import { loadContentRecords } from './contentLogic'
const A = ref(false) // 2
const data = ref([])
if (window.data) {
// 如果有数据源,直接使用
data.value = window.data
delete window.data
} else {
// 否则通过接口获取
loadContentRecords({
A
})
}
</script>
<template>
<input v-model="A" type="checkbox" />
<table>
<!-- ... -->
</table>
</template>
该方案有一些缺陷:
- 为了实现能在内容页之外预先发送网络请求来获取数据,需要将内容页的数据加载逻辑(
loadContentRecords
方法)抽离至公共文件中(contentLogic.ts
),但显然这部分逻辑不应该被多余的抽离维护成单独的文件,因为它是只属于内容页的逻辑,在其它地方没有使用场景; - 内容页组件的内部其它状态需要同步维护,在上面的 Content.vue 中,有一个默认的过滤条件 A,这个过滤条件的初始值不得不维护在两个地方:
- 标记
1
:组件外部为了保证预加载的数据正确性,需要同步组件内部的默认过滤条件; - 标记
2
:组件内部为了配合 UI 展示,定义一个 Ref 来与视图进行绑定。
- 标记
- 随着“下一页面“的选择可能性变多,如可能会跳转至内容页1、内容页2... 这时每个不同选择都会有第 1、2 步,变得更加难以维护。
通过预加载 Vue 组件
上述方案中,导致种种缺陷的原因是我们在一个功能完整的组件中,只把其中一部分的逻辑抽离出来单独执行,且这部分逻辑丢失了组件中的上下文(如过滤条件 A,或者一些分页参数等),所以不得不再维护一份意义相同的上下文来正确执行预加载操作
该方案则通过预加载 Vue 组件,也就是提前将组件渲染至内存中来实现
在 Vue3 中,可以通过 h
方法来创建一个 VNode ,参数是一个组件对象,如下面示例:
import { h } from 'vue'
import Content from './Content.vue'
const vnode = h(Content)
通过 render 方法将一个 VNode 渲染至 DOM 中,但我们的目的是需要执行组件的逻辑,不需要将组件渲染进页面的 DOM 树中,因此只需要在内存中准备一个空的容器放置组件的 DOM 即可
<!-- Login.vue -->
<script setup lang="ts">
import { h, render, getCurrentInstance } from 'vue'
import Content from './Content.vue'
const instance = getCurrentInstance()!
const container = window.document.createElement('div')
const vnode = h(Content)
// 需要注意的是需要手动指定 vnode 的 appContext 属性,这样以便在组件内部可以使用到 app 中 use 的各种插件
vnode.appContext = instance.appContext
render(vnode, container)
</script>
但目前为止,仅仅是内存中加载了 Content 组件,在路由跳转后,VueRouter 又会重新渲染一个全新的 Content 组件,和我们在内存中预加载的 Content 没有任何联系,可以借用 KeepAlive 组件的思想,在路由跳转后渲染 Content 组件时,让 Vue 知道 “这个 Content 组件有缓存,读缓存就完事了“
给预加载的 vnode 加上有缓存标识,也就是给 vnode 的 shapeFlag 属性添加已被缓存的标识
<!-- Login.vue -->
<script setup lang="ts">
import { h, render, getCurrentInstance } from 'vue'
import Content from './Content.vue'
import router from './router'
const instance = getCurrentInstance()!
const onSubmit = async () => {
// 1. 登录
await axios.post('/login', { /** ... */ })
// 2. 预加载 Content 组件
const container = window.document.createElement('div')
const vnode = h(Content)
vnode.appContext = instance.appContext
render(vnode, window.document.createElement('div'))
// 找个地方保存这个预加载的 VNode
window.contentVNode = vnode
// +++++++++++++++ 这里是添加的一行。ShapeFlag 是 @vue/shared 包定义的枚举, 1 << 9 是其中的一项
// +++++++++++++++ 标识这个 vnode 是有缓存的(这里是借助 KeepAlive 组件的实现)
vnode.shapeFlag |= 1 << 9
// 假设,Content 组件总是在 1s 的时间完成初始数据的获取
window.setTimeout(() => {
router.push('/content')
}, 1000)
}
</script>
<template>
<button @click="onSubmit">登录</button>
</template>
最后,在 RouterView 组件插槽拿到了路由匹配到的组件之后,通过自定义一个“代理”组件,来判断是否有缓存的组件可以读取
<!-- App.vue -->
<script setup lang="ts">
import { getCurrentInstance, h } from 'vue'
import type { Component } from 'vue'
const MyComponent: Component = {
props: ['active'],
setup(props) {
const instance = getCurrentInstance()!
// Vue 在对一个 VNode 进行挂载操作时,会判断此 VNode 是否有缓存(通过上面给的 "1 << 9" 标识)
// 如有缓存,则会调用 VNode 的父元素此方法 (源码中这种情况父元素就是 KeepAlive,但此时借助 KeepAlive 的思想,当前这个组件也手动实现这个方法)
// 如没有缓存,Vue 就会从 0 挂载一个组件
instance.ctx.activate = (vnode: VNode, container: HTMLElement, anchor: ChildNode | null) => {
// 只需要将缓存的 VNode 里的 DOM 结构插入到文档中即可
container.insertBefore(vnode.component!.subTree.el! as any, anchor)
}
// setup 可返回一个函数,表示此组件的 render 函数
return () => {
const { active } = props
if (!active) return
// 找到预先加载的 VNode 了,返回这个内存中的 VNode,且这个 VNode 的 shapeFlag 是带有 “1 << 9” 标识的
// 接着进入 Vue 后续的挂载逻辑后,就会走上面的 `activate` 方法
if (window.contentVNode) return window.contentVNode
// 不是缓存的组件,原样返回即可
else return active
}
},
}
</script>
<template>
<RouterView v-slot="{ Component }">
<MyComponent :active="Component"></MyComponent>
</RouterView>
</template>
这样就实现了一个简陋的预加载 Vue 组件,也是这篇文章主要想表达的方案,感兴趣的同学可以提出问题并讨论~
另外根据这个思路进行了一个相对完善的版本,代码托管在 vue-presetup,欢迎访问~