在第12章,我们深入探讨了组件的基本含义和实现方式。而在本章,我们将重点关注异步组件和函数式组件两个关键概念。
异步组件的特性在于它可以异步地加载和渲染组件,这在进行代码分割或服务端下发组件时尤其重要。
另一方面,函数式组件则允许我们用一个普通函数定义组件,并以该函数的返回值作为组件的渲染内容。函数式组件特点在于无状态、简洁且直观。
虽然在 Vue2 中,函数式组件相比于有状态组件有明显的性能优势,但在 Vue3 中,这两者的性能差距已非常小。
如 Vue.js RFC 所言,我们在 Vue3 中使用函数式组件主要是因为其简单易用,而非性能优越。
13.1 异步组件的问题与解决方法
从核心角度看,用户完全可以自主实现异步组件,而无需依赖框架。例如,App 组件的同步渲染如下:
javascript
import App from 'App.vue'
createApp(App).mount('#app')
上述代码可以轻松地改为异步渲染:
javascript
const loader = () => import('App.vue')
loader().then(App => {
createApp(App).mount('#app')
})
此处,我们使用 import() 动态加载组件,返回一个 Promise 实例。组件加载成功后,使用 createApp 函数挂载,实现页面的异步渲染。
如果我们想要部分页面异步渲染,我们只需能够异步加载某个组件。假设下面的代码是 App.vue 组件:
vue
<template>
<CompA />
<component :is="asyncComp" />
</template>
<script>
import { shallowRef } from 'vue'
import CompA from 'CompA.vue'
export default {
components: { CompA },
setup() {
const asyncComp = shallowRef(null)
// 异步加载 CompB 组件
import('CompB.vue').then(CompB => asyncComp.value = CompB)
return {
asyncComp
}
}
}
</script>
此代码模板中,页面由同步渲染的 和动态组件构成,动态组件绑定了 asyncComp 变量。
脚本块异步加载 CompB 组件,加载成功后,设定 asyncComp 变量的值为 CompB,实现了 CompB 组件的异步加载和渲染。
虽然用户可以自定义异步组件,但其实现有复杂性,因为完整的异步组件设计包括以下考虑:
- 如果组件加载失败或超时,是否展示 Error 组件?
- 是否需要占位内容,例如 Loading 组件,于何时加载时展示?
- 是否设定延迟展示 Loading 组件的时间,避免由于组件加载过快导致的闪烁?
- 如果组件加载失败,是否需要重试?
为了更优雅地解决这些问题,我们需在框架层面为异步组件提供封装支持:
- 允许用户指定加载错误时的渲染组件。
- 允许用户指定 Loading 组件及其展示延迟。
- 允许用户设置组件加载超时时长。
- 提供组件加载失败后的重试功能。
总的来说,这些都是异步组件需要解决的核心问题。
13.2 异步组件的实现原理
3.2.1 封装 defineAsyncComponent 函数
异步组件基于封装理念设计,其目标是提供易用的接口以减轻用户使用难度。参考以下用户代码示例:
vue
<template>
<AsyncComp />
</template>
<script>
export default {
components: {
// 用 defineAsyncComponent 函数定义异步组件,接收一个加载器作为参数
AsyncComp: defineAsyncComponent(() => import('CompA'))
}
}
</script>
上述代码,通过 defineAsyncComponent 定义了异步组件,并在 components 组件选项中注册。
这样,异步组件可以像普通组件一样在模板中使用。这种使用 defineAsyncComponent 的方式比我们在 13.1 节中自行实现的异步组件更直观、简洁。
defineAsyncComponent 是一个高阶组件,它的基本实现如下:
javascript
function defineAsyncComponent(loader) {
// 存储异步加载的组件的变量
let InnerComp = null
// 返回一个包装组件
return {
name: 'AsyncComponentWrapper',
setup() {
// 异步组件加载成功的标记
const loaded = ref(false)
// 执行加载器函数,返回一个 Promise 实例
// 加载成功后,将组件赋值给 InnerComp,并将 loaded 标记为 true
loader().then(c => {
InnerComp = c
loaded.value = true
})
return () => {
// 如果异步组件加载成功,则渲染该组件,否则渲染一个占位内容
return loaded.value ? { type: InnerComp } : { type: Text, children: '' }
}
}
}
}
关键点如下:
- defineAsyncComponent 本质是一个高阶组件,返回一个包装组件。
- 包装组件根据加载器的状态决定渲染内容。如果成功加载组件,渲染该组件;否则,渲染占位内容。
- 通常占位内容是注释节点。在组件未成功加载时,页面渲染一个注释节点作占位。但在这里,我们用一个空文本节点作为占位。
13.2.2 超时与 Error 组件
异步组件加载通常涉及网络请求,可能因网速慢导致加载时间过长,特别是在弱网环境下。
为了优化体验,我们可以允许用户设定超时时长。超过此时长仍未加载完成,则触发超时错误。如果用户配置了错误组件,此时则渲染错误组件。
为了实现这个功能,defineAsyncComponent 函数可以接受一个配置对象:
javascript
const AsyncComp = defineAsyncComponent({
loader: () => import('CompA.vue'), // 指定异步组件的加载器
timeout: 2000, // 设定超时时间(ms)
errorComponent: MyErrorComp // 指定在发生错误时要渲染的组件
})
有了用户接口后,我们可以给出实现代码:
javascript
function defineAsyncComponent(options) {
// options 可以是配置项,也可以是加载器
if (typeof options === 'function') {
// 如果 options 是加载器,则将其格式化为配置项形式
options = {
loader: options
}
}
const { loader } = options
let InnerComp = null
return {
name: 'AsyncComponentWrapper',
setup() {
const loaded = ref(false)
// 代表是否超时,默认为 false,即没有超时
const timeout = ref(false)
loader().then(c => {
InnerComp = c
loaded.value = true
})
let timer = null
if (options.timeout) {
// 如果指定了超时时长,则开启一个定时器计时
timer = setTimeout(() => {
// 超时后将 timeout 设置为 true
timeout.value = true
}, options.timeout)
}
// 包装组件被卸载时清除定时器
onUmounted(() => clearTimeout(timer))
// 占位内容
const placeholder = { type: Text, children: '' }
return () => {
if (loaded.value) {
// 如果组件异步加载成功,则渲染被加载的组件
return { type: InnerComp }
} else if (timeout.value) {
// 如果加载超时,并且用户指定了 Error 组件,则渲染该组件
return options.errorComponent ? { type: options.errorComponent } : placeholder
}
return placeholder
}
}
}
}
关键点如下:
- 异步加载是否超时由 timeout.value 标志。
- 开始加载组件的同时,启动一个定时器进行计时。如果超时,将 timeout.value 设置为 true。当包装组件被卸载时,需要清除定时器。
- 包装组件根据 loaded 和 timeout 的值来决定渲染内容。如果加载成功,渲染被加载的组件;如果超时并且用户指定了错误组件,渲染错误组件。
为了更全面地处理异步组件加载过程中的错误(超时只是其中一种),我们希望为用户提供以下能力:
- 当错误发生时,将错误对象作为错误组件的 props 传递,以便用户做进一步处理。
- 除了超时,还能处理其他原因导致的加载错误,例如网络失败等。
为了达到这两个目标,我们需要对代码进行一些调整:
javascript
function defineAsyncComponent(options) {
if (typeof options === 'function') {
options = {
loader: options
}
}
const { loader } = options
let InnerComp = null
return {
name: 'AsyncComponentWrapper',
setup() {
const loaded = ref(false)
// 定义 error,当错误发生时,用来存储错误对象
const error = shallowRef(null)
loader()
.then(c => {
InnerComp = c
loaded.value = true
})
// 添加 catch 语句来捕获加载过程中的错误
.catch(err => (error.value = err))
let timer = null
if (options.timeout) {
timer = setTimeout(() => {
// 超时后创建一个错误对象,并复制给 error.value
const err = new Error(`Async component timed out after ${options.timeout}ms.`)
error.value = err
}, options.timeout)
}
const placeholder = { type: Text, children: '' }
return () => {
if (loaded.value) {
return { type: InnerComp }
} else if (error.value && options.errorComponent) {
// 只有当错误存在且用户配置了 errorComponent 时才展示 Error 组件,同时将 error 作为 props 传递
return { type: options.errorComponent, props: { error: error.value } }
} else {
return placeholder
}
}
}
}
}
上述代码,我们添加了对加载过程中的错误捕获。当加载超时时,我们创建一个新的错误对象。
在渲染组件时,如果存在 error.value 并且用户配置了错误组件,就直接渲染错误组件并将 error.value 传递作为 props。
这样,用户可以在他们的错误组件中接收错误对象,从而实现更精细的错误处理。
13.2.3 延迟与 Loading 组件
异步加载的组件因网络因素影响,加载速度可能较慢或非常快。
在网络环境良好的情况下,异步组件可能很快加载,这可能导致 Loading 组件刚出现就立即消失,导致页面闪烁,这对用户体验来说是不好的。
我们可以通过设置延迟展示 Loading 组件的时间来解决这个问题,比如在超过 200ms 后才展示 Loading 组件。以下是这种策略的实现方法。
首先,定义异步组件时,我们添加 delay 和 loadingComponent 两个选项:
javascript
defineAsyncComponent({
loader: () => new Promise(r => { /* ... */ }),
delay: 200, // 延迟 200ms 展示 Loading 组件
loadingComponent: { // Loading 组件
setup() {
return () => {
return { type: 'h2', children: 'Loading...' }
}
}
}
})
然后在 defineAsyncComponent 函数中实现这两个选项:
javascript
function defineAsyncComponent(options) {
if (typeof options === 'function') {
options = {
loader: options
}
}
const { loader } = options
let InnerComp = null
return {
name: 'AsyncComponentWrapper',
setup() {
const loaded = ref(false)
const error = shallowRef(null)
// 一个标志,代表是否正在加载,默认为 false
const loading = ref(false)
let loadingTimer = null
// 如果配置项中存在 delay,则开启一个定时器计时,当延迟到时后将 loading.value 设置为 true
if (options.delay) {
loadingTimer = setTimeout(() => {
loading.value = true
}, options.delay)
} else {
// 如果配置项中没有 delay,则直接标记为加载中
loading.value = true
}
loader()
.then(c => {
InnerComp = c
loaded.value = true
})
.catch(err => (error.value = err))
.finally(() => {
loading.value = false
// 加载完毕后,无论成功与否都要清除延迟定时器
clearTimeout(loadingTimer)
})
let timer = null
if (options.timeout) {
timer = setTimeout(() => {
const err = new Error(`Async component timed out after ${options.timeout}ms.`)
error.value = err
}, options.timeout)
}
const placeholder = { type: Text, children: '' }
return () => {
if (loaded.value) {
return { type: InnerComp }
} else if (error.value && options.errorComponent) {
return { type: options.errorComponent, props: { error: error.value } }
} else if (loading.value && options.loadingComponent) {
// 如果异步组件正在加载,并且用户指定了 Loading 组件,则渲染 Loading 组件
return { type: options.loadingComponent }
} else {
return placeholder
}
}
}
}
}
关键改动:
- 新增了一个状态 loading,代表是否正在加载。
- 如果用户设置了 delay,则在该延迟时间后,如果仍未加载完成,就将 loading 设置为 true。
- 不论加载是否成功,都要将 loading 设置为 false,并清除定时器。
- 在渲染函数中,如果正在加载且用户设置了 loadingComponent ,就渲染该组件。
此外,我们需要支持 loadingComponent 的卸载。因此需要更新 unmount 函数,使其能够卸载组件实例的渲染内容(即 subTree ):
javascript
function unmount(vnode) {
if (vnode.type === Fragment) {
vnode.children.forEach(c => unmount(c))
return
} else if (typeof vnode.type === 'object') {
// 对于组件的卸载,本质上是卸载其所渲染的内容
unmount(vnode.component.subTree)
return
}
const parent = vnode.el.parentNode
if (parent) {
parent.removeChild(vnode.el)
}
}
组件的卸载,本质上是要卸载组件所渲染的内容,即 subTree。
所以在上面的代码中,我们通过组件实例的 vnode.component 属性得到组件实例,再递归地调用 unmount 函数完成 vnode.component.subTree 的卸载。
13.2.4 重试机制
重试机制在异步操作(如网络请求)中非常常见。当异步加载组件失败,尤其是在网络不稳定的情况下,我们希望提供一种方式让加载操作可以自动重试,这有助于提升用户体验。
首先,我们创建一个模拟网络请求的 fetch 函数,该函数在1秒后返回一个错误。
javascript
function fetch() {
return new Promise((resolve, reject) => {
// 请求在1秒后失败
setTimeout(() => {
reject('err')
}, 1000);
})
}
接着,创建一个 load 函数来实现请求失败后的重试:
javascript
function load(onError) {
const p = fetch() // 发起请求,得到 Promise 实例
return p.catch(err => {
return new Promise((resolve, reject) => {
const retry = () => resolve(load(onError)) // 重试
const fail = () => reject(err) // 失败
onError(retry, fail) // 当错误发生时,调用 onError 回调
})
})
}
用户可以使用 load 函数进行资源加载,并在发生错误时重试:
javascript
load(
(retry) => {
retry() // 失败后重试
}
).then(res => {
console.log(res) // 成功
})
最后,我们可以将这个重试机制应用到异步组件加载中:
javascript
function defineAsyncComponent(options) {
if (typeof options === 'function') {
options = {
loader: options
}
}
const { loader } = options
let InnerComp = null
// 记录重试次数
let retries = 0
// 封装 load 函数用来加载异步组件
function load() {
return (
loader()
// 捕获加载器的错误
.catch(err => {
// 如果用户指定了 onError 回调,则将控制权交给用户
if (options.onError) {
// 返回一个新的 Promise 实例
return new Promise((resolve, reject) => {
// 重试
const retry = () => {
resolve(load())
retries++
}
// 失败
const fail = () => reject(err)
// 作为 onError 回调函数的参数,让用户来决定下一步怎么做
options.onError(retry, fail, retries)
})
} else {
throw error
}
})
)
}
return {
name: 'AsyncComponentWrapper',
setup() {
const loaded = ref(false)
const error = shallowRef(null)
const loading = ref(false)
let loadingTimer = null
if (options.delay) {
loadingTimer = setTimeout(() => {
loading.value = true
}, options.delay)
} else {
loading.value = true
}
// 调用 load 函数加载组件
load()
.then(c => {
InnerComp = c
loaded.value = true
})
.catch(err => {
error.value = err
})
.finally(() => {
loading.value = false
clearTimeout(loadingTimer)
})
// 省略部分代码
}
}
}
这段代码与接口请求的重试机制很类似。在异步组件加载过程中,如果加载失败,我们提供一个新的 Promise 实例,并将重试(retry)和失败(fail)的处理函数作为 onError 回调的参数,让用户决定下一步怎么做。
13.3 函数式组件
函数式组件简单易实现,其本质是一个返回虚拟 DOM 的函数。
在 Vue3 中,函数式组件的优点主要在于其简洁性,而非性能。即使是有状态组件,在 Vue3 中,其初始化的性能消耗也相对较低。
定义函数式组件如下:
javascript
function MyFuncComp(props) {
return { type: 'h1', children: props.title }
}
// 定义 props
MyFuncComp.props = {
title: String
}
函数式组件无自身状态,但仍能接收外部传入的 props。对于 props 的定义,我们在组件函数上添加静态的 props 属性。
我们可以复用 mountComponent 函数来实现函数式组件的挂载,此外需要支持函数类型的 vnode.type。这可以在 patch 函数内实现:
javascript
function patch(n1, n2, container, anchor) {
if (n1 && n1.type !== n2.type) {
unmount(n1)
n1 = null
}
const { type } = n2
if (typeof type === 'string') {
// 省略部分代码
} else if (type === Text) {
// 省略部分代码
} else if (type === Fragment) {
// 省略部分代码
} else if (
// type 是对象 --> 有状态组件
// type 是函数 --> 函数式组件
typeof type === 'object' ||
typeof type === 'function'
) {
// component
if (!n1) {
mountComponent(n2, container, anchor)
} else {
patchComponent(n1, n2, anchor)
}
}
}
vnode.type 的类型用以判断组件类型:对象类型表示有状态组件,函数类型则表示函数式组件。
不论是哪种类型,都可以通过 mountComponent 完成挂载和通过 patchComponent 完成更新。
函数式组件的挂载可以在 mountComponent 函数中实现:
javascript
function mountComponent(vnode, container, anchor) {
// 检查是否是函数式组件
const isFunctional = typeof vnode.type === 'function'
let componentOptions = vnode.type
if (isFunctional) {
// 如果是函数式组件,则将 vnode.type 作为渲染函数,将 vnode.type.props 作为 props 选项定义
componentOptions = {
render: vnode.type,
props: vnode.type.props
}
}
// 省略部分代码
}
在 mountComponent 函数内,如果组件类型是函数式组件,则直接将组件函数作为组件选项对象的 render 选项,同时将组件函数的静态 props 属性作为组件的 props 选项。
从这里可以看出,由于函数式组件无需初始化 data 和生命周期钩子,其初始化性能消耗会比有状态组件少。
13.4 总结
本章首先深入探讨了异步组件的作用和需求。异步组件在提高页面性能、进行包裹分解和服务端下发组件等场景中发挥了重要作用。尽管异步组件的实现可以完全在用户层面完成,但为考虑到加载失败、加载中的显示、超时设定以及重试机制等复杂问题,框架的内置支持成为了必要。因此,Vue3 提供了 defineAsyncComponent 函数来定义异步组件。
我们接着讨论了异步组件加载超时和加载错误的处理。通过给 defineAsyncComponent 函数指定参数,我们可以设置超时时间并指定错误发生时展示的组件。
考虑到网络状况的不稳定,加载异步组件可能耗时较长。为了提供更好的用户体验,我们需要在加载时展示 Loading 组件。同时,我们提供了 loadingComponent 选项和 delay 选项,允许用户自定义 Loading 组件并设置展示的延迟时间,避免 Loading 组件的闪烁问题。
在面对加载错误时,我们设计了重试机制。这种处理方式与处理接口请求错误时的重试机制相似。
最后,我们讨论了函数式组件。它只是一个返回虚拟 DOM 的函数,它的实现可以复用有状态组件的逻辑。对于函数式组件,我们在主函数上添加静态 props 属性来定义 props。同时,由于函数式组件无状态且无生命周期概念,我们在初始化函数式组件时选择性地复用有状态组件的初始化逻辑。