19_第_13_章_异步组件与函数式组件
约 13068 字大约 44 分钟
2026-10-05
第 12 章我们把“组件”这件事从头讲了一遍:组件大体上就是一个“能返回界面的东西”。这一章我们再往前走两步,看两种“不那么标准”的组件——一种要先下载才能用的组件(异步组件),一种干脆就是一个普通函数的组件(函数式组件)。
在 Vue.js 2 中,相比有状态组件,函数式组件具有明显的性能优势;但在 Vue.js 3 中,函数式组件与有状态组件的性能差距不大,都非常好。正如 Vue.js RFC 的原文所述:“在 Vue.js 3 中使用函数式组件,主要是因为它的简单性,而不是因为它的性能好。”
这一章有两个词要先认下来。它们在原书里是直接使用的,但对零基础的人来说每一个都像黑话,所以先集中解释一遍,后面再出现时就不再重复解释。
异步组件(async component):
它不是一种“新种类”的组件,而是一种加载方式——先不下载组件代码,等真正需要显示的时候再临时去拿。就像点外卖:餐厅不会提前把菜做好塞冰箱,而是你下单后才开始做,用多少做多少。
“异步”二字,指的就是以异步的方式加载并渲染一个组件。这在代码分割、服务端下发组件等场景中尤为重要——后者是指组件代码压根不在前端工程里,而是运行期由后端推送下来,只有知道要显示哪个组件之后才去取。
代码分割(Code Splitting):
把一个大 JS 文件拆成好几块,浏览器先下载当前用得上的那几块,其余的等需要时再去下载。就像吃火锅时“先下你夹得动的那几样菜”——不用一次把整个超市搬进厨房。
函数式组件(functional component):
一个普通函数充当组件。它接收 props,返回值就是要渲染的内容。React 里也有同名概念,但 Vue 的实现思路略有不同。
函数式组件则走了另一条路:允许你用一个普通函数定义组件,并把该函数的返回值作为组件要渲染的内容。它的特点是无状态、编写简单且直观。
13.1 异步组件要解决的问题
在动手写框架代码之前,先明确一件事:异步组件这件事本身并不需要框架帮忙——浏览器给了你 import() 和 Promise,你完全可以自己手写出来。框架要解决的,是“手写起来太麻烦”的那部分。先看最普通的写法:把 App 组件渲染到页面上。
import App from 'App.vue'
createApp(App).mount('#app')我们逐行看:
import App from 'App.vue':把App.vue这个组件文件同步地加载进来。这里的import写在文件顶层,JS 引擎必须等它彻底下载完、解析完,才能往下执行。createApp(App).mount('#app'):先用App创建一个应用实例,再把它挂载到页面上id="app"的元素里。“挂载”(mount)的意思是“把组件生成的真实 DOM 塞进这个容器,从此这个容器归 Vue 管”——就像给花盆装上土、把秧苗移进去。箭头函数
() => ...:一段“没有名字、没有参数表”的小函数,写法比function () {}短,专门用来说“等下要干的活”。下面代码里到处都是它。
同步渲染(synchronous render):
不等任何网络请求,组件代码本地就有,加载 → 渲染一气呵成。就像在厨房备好菜再上桌。
上面这段代码展示的就是同步渲染。而要把它改成异步渲染,也很容易:
const loader = () => import('App.vue')
loader().then(App => {
createApp(App).mount('#app')
})我们逐行看:
const loader = () => import('App.vue'):定义一个“加载器”(loader)。名字里的 load 就是“加载”——它是个函数,你不调用它就一动不动。可以把它理解成一张“取件码”:地址写在上面,人到了才去取。import('App.vue'):这是 ES2020 引入的动态导入语法。它和顶层的import形状很像,行为却相反——顶层的import是同步的(必须等下载完才往下走),而import()是异步的:它立刻返回一个Promise对象,下载在后台进行,好了再通知你。Promise:JS 里表示“未来某个时刻会给你一个结果”的对象。你把回调挂到它身上(.then()),等它准备好了再触发。就像你点了外卖,外卖平台给你一个“取餐码”——现在还没好,但你不用一直盯着屏幕,菜好了它会通知你。- 回调:一段“将来交给别人调用”的函数。就像点外卖时备注“不要葱”——备注先交上去,骑手送到时才会看到。
loader().then(App => {...}):把回调挂到Promise上。下载完成后,App这个参数就是真正的组件对象了,我们在.then里面再用createApp把它挂上页面。
加载器(loader):
唯一负责“把组件代码弄到手”的函数。它可以是 () => import(...)(走网络),也可以是 () => new Promise(...)(比如先发个 HTTP 请求拿数据)。框架只认“它最终给我一个 Promise”这一点,不关心你 Promise 里面到底干了什么。就像快递的“配送流程”——你只管等开门,货车走哪条路随他。
也就是说:用 import() 加载组件,它会返回一个 Promise 实例;组件加载成功后,再调用 createApp 完成挂载——整个页面就这样“异步”地渲染出来了。“异步”并不是什么魔法,只是“把 mount 挪到了 .then 里面”。
可是,上面只做到了“整个页面异步”。真实项目里,一个页面往往由很多组件拼成,每个组件负责渲染其中一块。那么,如果只想异步渲染部分页面,要怎么办呢?
这就要求我们:只要有“异步加载某一个具体组件”的能力就够了。 假设下面的代码是 App.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>我们逐行看(重点看脚本块):
<CompA />和<component :is="asyncComp" />:页面由两部分拼成。CompA是同步渲染的普通组件标签;<component>是一个动态组件——它渲染成什么,由:is后面绑定的那个变量决定。就像<component :is="x">是一块“万能牌位”,牌面写什么就渲染什么。import { shallowRef } from 'vue':从 Vue 里取出shallowRef。- 响应式(reactive)与
ref:Vue 的招牌能力——数据变了,视图自动跟着变。ref就是把一个普通变量“包”成一个响应式的盒子,你读写的是xxx.value。就像给房间装了一个“体感灯”:温度一变,灯自己亮,不用你每天去开灯。 shallowRef:ref的“浅”版本。ref会把传入的整个对象变成响应式(每个属性都追踪),而shallowRef只追踪“这个变量本身被赋值”,不深入对象内部。这里要装的是一整个组件对象,没必要把组件内部每处细节都变成响应式的——它换来的是性能更友好。import CompA from 'CompA.vue':同步地先把CompA组件准备好。const asyncComp = shallowRef(null):声明一个“动态组件”占位变量,初始为null(还没加载好)。import('CompB.vue').then(CompB => asyncComp.value = CompB):异步加载CompB,加载成功就把变量重新赋值成CompB。因为它是响应式的,模板里<component :is="asyncComp" />会自动重渲染,CompB就上屏了。
.value:
ref / shallowRef 包装出来的那个“响应式盒子”。东西装在 xxx.value 里,改它就是“改数据”。
把这条链路连起来看:CompA 同步就绪;CompB 走 import() 在后台下载,下载完把 asyncComp 指向它,<component> 随之刷新。这就是 CompB 组件的异步加载和渲染。
不过你也看到了:用户虽然可以自行实现组件的异步加载和渲染,但整体实现还是比较复杂的——因为一个完善的异步组件所涉及的内容,要比上面这个例子复杂得多。通常在异步加载组件时,我们还要考虑以下几个方面。
- 如果组件加载失败或加载超时,是否要渲染 Error 组件?
- 组件在加载时,是否要展示占位的内容?例如渲染一个 Loading 组件。
- 组件加载的速度可能很快,也可能很慢,是否要设置一个延迟展示 Loading 组件的时间?如果组件在 200ms 内没有加载成功才展示 Loading 组件,这样可以避免由组件加载过快所导致的闪烁。
- 组件加载失败后,是否需要重试?
超时(timeout):
给一段等待时间设上限。超过这个时间还没等到结果,就当它“失败”处理。就像外卖备注“超过 40 分钟我不要了”——它本身不是一个错误,而是一道时间闸门。
Error 组件(错误组件):
专门用来显示“这块内容没加载出来”的组件,常见的就是一句“网络异常,点击重试”。就像贴在配电箱上的那张“请找电工”纸条——它自己不修电器,只负责把“出事了”这件事体面地告诉用户。
Loading 组件(加载中组件):
等待异步内容时显示的占位组件,通常是一圈转圈的小图标或“加载中…”文字。它的作用只有一个:让用户知道“程序没死机,只是在等”。
上面四条是“问题”,那么框架提供的能力就必须一一对应上:
- 允许用户指定加载出错时要渲染的组件。
- 允许用户指定 Loading 组件,以及展示该组件的延迟时间。
- 允许用户设置加载组件的超时时长。
- 组件加载失败时,为用户提供重试的能力。
以上这些内容,就是异步组件真正要解决的问题。 接下来的 13.2 节,我们就把它们一个个实现出来。
13.2 异步组件的实现原理
13.2.1 封装 defineAsyncComponent 函数
先看用户最终要写的代码。异步组件本质上就是通过封装手段来实现友好的用户接口,从而降低用户层面的使用复杂度,如下面的用户代码所示:
<template>
<AsyncComp />
</template>
<script>
export default {
components: {
// 使用 defineAsyncComponent 定义一个异步组件,它接收一个加载器作为参数
AsyncComp: defineAsyncComponent(() => import('CompA'))
}
}
</script>我们逐行看:
components: { AsyncComp: ... }:把AsyncComp注册进“组件注册表”。components是选项式 API 里专门用来登记组件的选项,登记完你才能在模板里直接写<AsyncComp />。就像公司的门禁名单——登记过的人才能进。defineAsyncComponent(() => import('CompA')):这就是“用defineAsyncComponent定义异步组件”的完整写法。这个包装函数自己是什么、返回什么,见下面这张卡。
defineAsyncComponent:
Vue.js 3 内建的包装函数。它接收一个加载器(一个返回 Promise 的函数),返回给你一个“包装后的组件对象”。你拿到它,就像拿到一个普通组件那样用,但内部悄悄帮你处理了整个加载过程。
用户接口(user-facing API):
框架主动暴露给使用者的那部分参数/写法。13.3 节讲函数式组件时还会再遇到这个词。就像餐厅菜单——后厨怎么忙是内部的事,你只管照菜单点。
在上面这段代码中,用户可以用 defineAsyncComponent 来定义异步组件了。和 13.1 节里我们手写的那套(shallowRef + 动态组件)相比,它简单直接得多。
不过,上面这个用法太简单了——一个完善的 defineAsyncComponent 还得能接超时、Error 组件、Loading 组件这些配置。而在动手写实现之前,我们先做一件更基础的事:写一个最朴素的版本,一个只能做到“加载成功就渲染、没成功就留白”的版本。先有骨架,再长肉,这是读框架源码最舒服的顺序:
// defineAsyncComponent 函数用于定义一个异步组件,接收一个异步组件加载器作为参数
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 的骨架都在这里):
function defineAsyncComponent(loader):定义这个包装函数,参数就是加载器。用户调它一次,它返回一个组件对象。let InnerComp = null:一个变量,用来“记下”加载到的真正组件。- 闭包变量:内层函数能一直访问外层函数里的局部变量,哪怕外层函数早就返回了。这里
InnerComp声明在defineAsyncComponent内部,而真正读写它的是setup里那两个箭头函数——defineAsyncComponent返回之后,它们依然看得见InnerComp。就像你在门外留了个“抽屉”,里面那间屋子的钥匙一直还能用。 return { name: 'AsyncComponentWrapper', setup() {...} }:返回的就是那个“包装组件”。name给它取个名,调试时一眼能认出来;setup是这个包装组件自己的逻辑。const loaded = ref(false):一个响应式开关,标记“异步组件是否加载成功”。loader().then(c => { InnerComp = c; loaded.value = true }):开始加载。loader()返回一个Promise;一旦成功,c就是加载到的组件,把它存进InnerComp,并把loaded置为true。return () => {...}:包装组件的渲染函数。它每次重新渲染都会跑一遍,根据loaded决定显示什么。loaded.value ? { type: InnerComp } : { type: Text, children: '' }:整个最简版的灵魂一行。 加载好了就渲染真正的组件,没好就渲染一个空的文本节点占位。Text:Vue 内部表示“纯文本节点”的特殊类型。{ type: Text, children: '' }就是“一个内容为空的文本节点”——它在页面上什么都看不见,但确实占了位,让后面的兄弟节点有个确定的位置。
渲染函数(render function):
一个“返回虚拟 DOM 的函数”,它的返回值就是这一次要画出来的界面。就像照着图纸施工——图纸(虚拟 DOM)画完,施工队(patch)才真的去动 DOM。
虚拟节点(vnode):
一份“界面的说明书”,就是个普通 JS 对象,比如 { type: 'h1', children: '你好' }——type 说明渲染成什么,children 是里面的内容。它是描述,不是真的 DOM 节点。
所以这个最简版的意义在于:它已经交代清了异步组件最难想通的那层关系——defineAsyncComponent 自己不渲染任何界面,它只是递给你一个组件;这个组件内部盯着一句 loaded 来决定画什么。
这里有以下几个关键点。
defineAsyncComponent函数本质上是一个高阶组件,它的返回值是一个包装组件。- 包装组件会根据加载器的状态来决定渲染什么内容。 如果加载器成功地加载了组件,则渲染被加载的组件,否则会渲染一个占位内容。
- 通常占位内容是一个注释节点。 组件没有被加载成功时,页面中会渲染一个注释节点来占位。注释节点在 HTML 里长这样:
<!-- 这里将来会有组件 -->,它什么都不显示,但在源代码里占着一行位置。就像合租时你在客厅地板上贴一条胶带“这里是王二的位置”——东西还没搬来,但位置先划好了,别的家具就不敢随便摆过来。
高阶组件(higher-order component,简称 HOC):
“接受组件/函数作为参数,并返回一个新组件/函数”的函数。就像一件外套:你把毛衣(组件)穿进去,它给你返回一件带袖子的大衣(新组件)——大衣自己不带体温,只是把毛衣包起来、改变了样子。
小细节:不过这里我们使用了一个空文本节点来占位,而不是注释节点。两者的效果几乎一样(都不显示内容),代码上只是把 type 从“注释”换成了 Text。
这个版本已经能跑了,但离“完善”还差得远。接下来我们只在它上面一层一层地加东西,每加一层就多解决一个问题:
13.2.2 超时与 Error 组件
首先得想明白一件事:异步组件通常以网络请求的形式进行加载。前端发送一个 HTTP 请求,去下载组件的 JavaScript 资源,或者从服务端直接获取组件数据。
HTTP 请求:
浏览器向服务器“要东西”的动作。就像你在微信里发一条消息向外卖店要地址——发出去之后要等对方回应,这段等待就是“网络往返”。
既然存在网络请求,那么必然要考虑网速较慢的情况。尤其是在弱网环境下(地铁里、电梯里、信号只有一格),加载一个组件可能需要很长时间。因此,我们需要为用户提供指定超时时长的能力:当加载组件的时间超过了指定时长后,就会触发超时错误。这时如果用户配置了 Error 组件,则会渲染该组件。
弱网:
网络带宽很小的状态。就像外卖高峰期在电梯里,手机上的圈圈一直转个不停。
首先,我们来设计用户接口。为了让用户能够指定超时时长,defineAsyncComponent 函数需要接收一个配置对象作为参数:
const AsyncComp = defineAsyncComponent({
loader: () => import('CompA.vue'),
timeout: 2000, // 超时时长,其单位为 ms
errorComponent: MyErrorComp // 指定出错时要渲染的组件
})loader:指定异步组件的加载器。() => import(...)就是“按需去拿”的箭头函数,告诉框架组件代码在哪里、怎么去拿。timeout:单位为ms,指定超时时长。 1 秒 = 1000 毫秒,所以2000就是 2 秒;超过这个时间还没加载好,就当作“出错”。errorComponent:指定一个 Error 组件,当错误发生时会渲染它。 比如“网络异常,请重试”那种提示。
用户接口定下来了,接下来就是把它实现出来。记住这次只做一件事:加超时。所以你可以在最简版的基础上找到三处变化——多了 timeout 标志、多了定时器、渲染时多了一个分支。如下面的代码所示:
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)
}
// 包装组件被卸载时清除定时器
onUnmounted(() => 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
}
}
}
}我们逐行看(和最简版比,只多了“超时”这一块):
if (typeof options === 'function'):typeof求一个值的“类型名字”。用户可能传两种东西——直接传一个加载器(最简用法),或者传一个配置对象。这里统一处理:传的是函数,就给它补成{ loader: options }。就像前台登记处——不管你递过来的是一张身份证还是一张表,都给你转成同一种单子。const { loader } = options:解构赋值——从配置里把loader这个字段取出来,直接当地名用。就像从一沓资料里抽出“加载方式”那一页。let InnerComp = null:和最简版一样,用闭包变量记下“加载到的真正组件”。name: 'AsyncComponentWrapper':给“包装组件”取个名字,方便调试时一眼认出来。setup() { ... }:包装组件自己的逻辑。const loaded = ref(false):标记“异步组件是否加载成功”。const timeout = ref(false):新增的标志变量,代表是否超时,默认false(即没有超时)。loader().then(c => {...}):和最简版一样,开始异步加载。if (options.timeout) { timer = setTimeout(() => { timeout.value = true }, options.timeout) }:如果用户指定了超时时长,就同时开启一个定时器计时;时间一到,就把timeout置为true。setTimeout:浏览器提供的“延时执行”函数。给它一个时间和一段代码(或函数),时间到了就执行一次。就像你设了一个 10 分钟后的闹钟。 配套的clearTimeout则用来把这个闹钟取消掉。onUnmounted(() => clearTimeout(timer)):包装组件被卸载时清掉定时器,否则这个闹钟会一直挂在那儿,占着内存不放(这叫内存泄漏,就像退房了却忘了关水龙头)。onUnmounted(生命周期钩子):Vue 提供的“组件卸载时会触发的钩子”,专门用来做善后:清理定时器、取消事件订阅、断开连接。就像房客退房时的“走前检查单”。const placeholder = { type: Text, children: '' }:占位内容(一个空文本节点)。在加载完成前,页面上就先显示这个空白。
return () => {...}:包装组件的渲染函数,每次重新渲染都会跑一遍,根据loaded/timeout决定显示什么。if (loaded.value):加载好了,渲染真正的InnerComp。else if (timeout.value):超时了,并且用户指定了 Error 组件,就渲染它;没指定就显示空白。return placeholder:否则渲染占位内容。
整体实现并不复杂,关键在于三件事:用 timeout.value 这个标志位标识是否超时;加载的同时开启定时器计时,组件卸载时记得清掉;再由 loaded 和 timeout 两个标志一起决定渲染什么。
⚠️ 注意
loaded和timeout是同时在跑的:正常情况下先加载成功,loaded变成true,谁也管不着谁;只有加载慢到超时时,timeout才会抢先变成true。
这样,我们就实现了对加载超时的兼容,以及对 Error 组件的支持。
但是——超时只是错误的原因之一。网络断了、404 了、文件解析报错了……这些都不叫“超时”,可它们同样让用户看不到组件。所以,我们还希望为用户提供以下能力。
当错误发生时,把错误对象作为 Error 组件的
props传递过去,以便用户后续能自行进行更细粒度的处理。除了超时之外,有能力处理其他原因导致的加载错误,例如网络失败等。
错误对象:
new Error('...')造出来的一个对象,里面带着一句说明和一堆调试信息。就像一张事故报告单——它不只说“出事了”,还写着“什么时候、在哪、什么原因”。
为了实现这两个目标,我们需要对代码做一些调整,如下所示:
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
}
}
}
}
}我们挑变化的地方看:
const error = shallowRef(null):不再用布尔标志表示“出错了”,而是新增一个变量存具体的错误对象——这样我们才能把错误信息传给 Error 组件。.catch((err) => error.value = err):在Promise链末尾加catch语句。网络失败、404、解析错误……这些都会让Promise进入“拒绝”状态,catch负责把错误信息接住。then/catch:Promise就像接力赛。then是“顺利交棒给下一棒”,catch是“绊倒了,交给专门善后的人”。多个.then、.catch可以一路串下去。要是不给失败的Promise配上catch,这次失败就会变成一条“未处理的报错”直接打到控制台上。const err = new Error('Async component timed out after ...'):超时也当成“收到一个错误对象”,和网络失败走同一条路。这样判断逻辑就只有一套,不必为超时单开一个分支。return { type: options.errorComponent, props: { error: error.value } }:渲染 Error 组件时,把错误对象作为error这个 prop 传过去,Error 组件内部就能根据错误类型显示不同提示。
观察上面的代码,我们对之前的实现做了一些调整。首先,为加载器添加 catch 语句来捕获所有加载错误。接着,当加载超时后,我们会创建一个新的错误对象,并将其赋值给 error.value 变量。在组件渲染时,只要 error.value 的值存在,且用户配置了 errorComponent 组件,就直接渲染 errorComponent 组件,并将 error.value 的值作为该组件的 props 传递。这样,用户就可以在自己的 Error 组件上,通过定义名为 error 的 props 来接收错误对象,从而实现细粒度的控制。
13.2.3 延迟与 Loading 组件
异步加载的组件受网络影响较大,加载过程可能很慢,也可能很快。这时我们就会很自然地想到:对于第一种情况,我们能否通过展示 Loading 组件来提供更好的用户体验。这样,用户就不会有“卡死”的感觉了。
这确实是个好想法,但展示 Loading 组件的时机是一个需要仔细考虑的问题。通常,我们会从加载开始的那一刻起就展示 Loading 组件。但在网络状况良好的情况下,异步组件的加载速度会非常快,这会导致 Loading 组件刚完成渲染就立即进入卸载阶段,于是出现闪烁的情况。
闪烁(flicker):
界面元素一闪而过。这里就是“Loading 转了半圈就没了”,用户压根没看清,纯属噪音。就像你在便利店门口举着“正在取货”的牌子,结果取货员两秒钟就把袋子递给你了——牌子才举起来就得放下,用户只会觉得莫名其妙。
因此,我们需要为 Loading 组件设置一个延迟展示的时间。例如,当超过 200ms 没有完成加载,才展示 Loading 组件。这样,对于在 200ms 内能够完成加载的情况来说,就避免了闪烁问题的出现。
我们来看个时序图,说明“立刻显示” vs “延迟 200ms 显示”的区别:
不过,我们首先要考虑的仍然是用户接口的设计,如下面的代码所示:
defineAsyncComponent({
loader: () => new Promise(r => { /* ... */ }),
// 延迟 200ms 展示 Loading 组件
delay: 200,
// Loading 组件
loadingComponent: {
setup() {
return () => {
return { type: 'h2', children: 'Loading...' }
}
}
}
})loader:异步组件的加载器,返回一个Promise(这里只是占位示意,真实项目里就是() => import('...'))。delay,用于指定延迟展示 Loading 组件的时长。 单位是毫秒,200就是 0.2 秒。loadingComponent,类似于errorComponent选项,用于配置 Loading 组件。 这里直接写了一个组件选项对象:setup里返回一个渲染函数,渲染一个写着Loading...的大号标题。
延迟时间和 Loading 组件的接口也定好了,下面轮到实现。这一次要做的是:在上一版的基础上多记一件事——“现在该不该显示 Loading”。具体实现如下:
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
}
}
}
}
}我们挑新的部分看:
const loading = ref(false):新增一个标志变量,代表组件是否正在加载,默认为false。if (options.delay) { loadingTimer = setTimeout(...) } else { loading.value = true }:核心的“延迟”逻辑。- 如果用户指定了
delay,就先不显示 Loading,而是开启一个延迟定时器;等时间到了,再把loading置为true。 - 如果用户没指定
delay,就立刻标记为加载中,Loading 会直接显示。
- 如果用户指定了
.finally(() => { loading.value = false; clearTimeout(loadingTimer) }):Promise的finally不管成功失败都会跑(就像“不管考得好不好,毕业典礼你都要参加”)。这里在加载结束时关掉 Loading、清掉延迟定时器(不然 Loading 会“停不下来”)。else if (loading.value && options.loadingComponent):渲染函数新增一个分支——“如果正在加载,且用户指定了 Loading 组件,则渲染该 Loading 组件”。注意它排在error分支后面:错误比“还在加载”更重要,所以先判错误。
这一版的整体实现思路,和前面“超时时长与 Error 组件”几乎是一模一样的套路——加一个标志变量,加一个定时器,渲染时多判一个分支:用 loading 标志位表示组件是否正在加载;用户指定了 delay 就先不开 Loading、等定时器到点再置 true(没指定则立刻置 true);加载结束后无论成败都要清掉延迟定时器,否则会出现“组件已经加载成功,却还在转圈”的问题;渲染时若正在加载且用户指定了 Loading 组件,就渲染它。
另外有一点需要注意,当异步组件加载成功后,会卸载 Loading 组件并渲染异步加载的组件。
卸载(unmount):
把某个组件从页面上彻底摘掉,同时把它占用的资源(定时器、事件监听、订阅)都释放掉。就像搬家退租:不是只把家具搬走,还得把水电、宽带都销户。
为了支持 Loading 组件的卸载,我们需要修改 unmount 函数,如以下代码所示:
function unmount(vnode) {
if (vnode.type === Fragment) {
vnode.children.forEach(c => unmount(c))
return
} else if (typeof vnode.type === 'object') {
// 对于组件的卸载,本质上是要卸载组件所渲染的内容,即 subTree
unmount(vnode.component.subTree)
return
}
const parent = vnode.el.parentNode
if (parent) {
parent.removeChild(vnode.el)
}
}我们逐行看:
if (vnode.type === Fragment):片段(Fragment)是一种“没有自己 DOM 容器的虚拟节点”,要遍历它的children逐个卸载。就像拆开一包由许多独立小件组成的东西,把每件都收走。else if (typeof vnode.type === 'object'):要卸载的是“组件”。组件本身没有 DOM(它只是一份描述),真正产生 DOM 的是它渲染出来的那棵子树component.subTree,所以我们递归卸载它。- 递归:函数一层套一层地调用自己。就像一级一级往下爬楼梯——每一级看起来都一样,但你确实是在一层层往下走。
subTree:组件的“渲染产物”。一个组件可以嵌套很多层,但最终落到 DOM 上的就是它的subTree。就像一栋楼其实是一层层盖出来的,subTree就是它最底下真正压着地的那一层结构。const parent = vnode.el.parentNode/parent.removeChild(vnode.el):普通元素(标签)自己就带着 DOM,直接从父节点上摘掉就行。
所以对于组件的卸载,本质上是要卸载组件所渲染的内容,即 subTree。在上面的代码中,我们通过组件实例的 vnode.component 属性得到组件实例,再递归地调用 unmount 函数,完成 vnode.component.subTree 的卸载。
13.2.4 重试机制
重试指的是当加载出错时,有能力重新发起加载组件的请求。在加载组件的过程中,发生错误的情况非常常见,尤其是在网络不稳定的情况下。因此,提供开箱即用的重试机制,会提升用户的开发体验。
开箱即用(out of the box):
装好就能直接用,不用你再自己搭一遍。就像买回的自动麻将机,插上电就能打,不用你先学怎么造麻将机。
异步组件加载失败后的重试机制,和请求服务端接口失败后的重试机制是一模一样的。所以我们先退一步,不谈组件,只谈接口:先看看“接口请求失败了怎么办”是怎么实现的。为此,我们需要封装一个 fetch 函数,用来模拟一次接口请求:
function fetch() {
return new Promise((resolve, reject) => {
// 请求会在 1 秒后失败
setTimeout(() => {
reject('err')
}, 1000);
})
}我们逐行看:
return new Promise((resolve, reject) => {...}):返回一个Promise实例,resolve和reject是它自带的两个“信使”——resolve派发好消息(值)交给.then处理,reject派发坏消息(错误对象)交给.catch处理;两者你只能调其中一个,调完之后这个Promise就“定型”了。setTimeout(() => reject('err'), 1000):1 秒后宣布请求失败。(浏览器真正发请求的函数也叫fetch,这里我们写了个同名函数来模拟它。)
假设调用 fetch 会真的发出一个 HTTP 请求,而且这个请求会在 1 秒后失败。光有 fetch 还不够——失败之后怎么重试?为此我们再封装一个 load 函数,如下面的代码所示:
// load 函数接收一个 onError 回调函数
function load(onError) {
// 请求接口,得到 Promise 实例
const p = fetch()
// 捕获错误
return p.catch(err => {
// 当错误发生时,返回一个新的 Promise 实例,并调用 onError 回调,
// 同时将 retry 函数作为 onError 回调的参数
return new Promise((resolve, reject) => {
// retry 函数,用来执行重试的函数,执行该函数会重新调用 load 函数并发送请求
const retry = () => resolve(load(onError))
const fail = () => reject(err)
onError(retry, fail)
})
})
}我们逐行看:
function load(onError):定义“加载函数”,接收一个错误回调函数。const p = fetch():请求接口,得到一个Promise实例。return p.catch(err => {...}):捕获错误。在错误发生时,我们不直接把错误抛上去,而是返回一个新的Promise实例,把“重试权”交给用户。return new Promise((resolve, reject) => {...}):构造这个“待定的Promise实例”。const retry = () => resolve(load(onError)):retry函数,用来执行重试。用户调用它,就重新调用load函数并发送一次请求,并把这次的Promise接到外层的resolve上。const fail = () => reject(err):fail让用户主动放弃,把原始错误抛出去。onError(retry, fail):把“重试”和“放弃”两个按钮交给用户。
小细节:这里的递归比 13.2.3 更进一步:每一层重试都会重新进入 load 自己的 .catch,所以“重试再失败”时,用户的 onError 还能被再问一次。
我们把 load 拆开看,它其实只做了三件事。第一,内部调用 fetch 去发请求,拿到一个 Promise 实例。第二,给它加一个 catch 语句块,专门在出错时接手。第三——也是最关键的一步:捕获到错误时,我们有两条路可走。
- 要么把错误直接抛出去,让调用方自己去处理。
- 要么返回一个全新的
Promise实例,并且把这个实例的resolve和reject方法暴露给用户,让用户自己决定下一步该怎么办。
这里我们选的是第二条:把新 Promise 的 resolve 和 reject 分别封装成 retry 函数和 fail 函数,再把它们作为 onError 回调函数的参数递出去。于是用户在错误发生时,就能主动选择“重试”或“直接抛出错误”了。下面的代码展示了用户是如何进行重试加载的:
// 调用 load 函数加载资源
load(
// onError 回调
(retry) => {
// 失败后重试
retry()
}
).then(res => {
// 成功
console.log(res)
})我们逐行看:
load((retry) => { retry() }):把“碰到错误怎么办”的策略交给load。这里的策略很简单——失败后重试,直接调用retry()。.then(res => { console.log(res) }):成功后的处理。
上面这套“出错就把选择权交回用户”的打法,和组件的加载流程是同一套,所以把它搬进 defineAsyncComponent 并不困难。具体实现如下:
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 = () => {
retries++
resolve(load())
}
// 失败
const fail = () => reject(err)
// 作为 onError 回调函数的参数,让用户来决定下一步怎么做
options.onError(retry, fail, retries)
})
} else {
throw err
}
})
}
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)
})
// 省略部分代码
}
}
}我们挑新的部分看:
let retries = 0:在外层用一个闭包变量记下“已经重试了几次”。function load() { return loader().catch(err => {...}) }:把原来直接调用的loader()换成了“包装过的load()”——它的特点是:出错就把重试权交给用户。if (options.onError) { ... } else { throw err }:用户配了onError回调,就把控制权交给他;没配,就直接把错误抛出去。options.onError(retry, fail, retries):把当前已重试的次数也告诉用户,方便他判断“重试 3 次就别再试了”之类的逻辑。resolve(load()):用户调retry时,递归地再次进入load,而不是直接重新调loader()——这样retries才能持续累加下去。
如上面的代码及注释所示,它的整体思路与普通接口请求的重试机制是类似的:都是“发请求 → 挂 .catch → 出错就把 retry / fail 交给用户”。
13.3 函数式组件
函数式组件的实现相对容易。前面【函数式组件】那张卡已经解释过它是什么,本节直接看代码怎么写。一个函数式组件本质上就是一个普通函数,该函数的返回值是虚拟 DOM。本章章首曾提到:“在 Vue.js 3 中使用函数式组件,主要是因为它的简单性,而不是因为它的性能好。”这是因为在 Vue.js 3 中,即使是有状态组件,其初始化性能消耗也非常小——能省下来的那点性能,根本不值得你为此多写一堆代码。
有状态组件(stateful component):
Vue.js 里“标准”的组件写法——一个对象,里面有 data、setup、生命周期钩子等。它可以拥有自己的“状态”(会变化的数据),数据一变,组件就自动重新渲染。就像一个能记事的小本子——写进去新内容,它自己就能翻到对应那页给你看。
无状态组件(stateless component):
没有自己的状态,渲染什么完全靠外部传入的 props。函数式组件就是这种。它很像一个“纯函数”——给同样的输入,永远输出同样的渲染结果,就像一台只认参数、不记事的计算器。
在用户接口层面,一个函数式组件就是一个返回虚拟 DOM 的函数,如下面的代码所示:
function MyFuncComp(props) {
return { type: 'h1', children: props.title }
}我们逐行看:
function MyFuncComp(props):定义一个普通的 JS 函数。它就是函数式组件的全部——没有对象,没有setup。return { type: 'h1', children: props.title }:返回一个虚拟节点对象——渲染一个h1,里面的文字是props.title。
函数式组件没有自身状态,但它仍然可以接收由外部传入的 props。普通函数又没有“声明参数”的地方,所以为了给函数式组件定义 props,我们需要在这个组件函数上添加静态的 props 属性,如下面的代码所示:
function MyFuncComp(props) {
return { type: 'h1', children: props.title }
}
// 定义 props
MyFuncComp.props = {
title: String
}我们逐行看:
function MyFuncComp(props) { ... }:组件主体,接收外部传入的props。MyFuncComp.props = { title: String }:定义 props——在函数对象上挂一个props属性,告诉框架“我这个组件要接收一个title字段,类型是字符串”。- 静态属性:直接挂在函数/对象身上的属性。它不是
this上的(普通函数里根本没有this这回事),而是“挂在函数上”——就像给一件外套别了个胸针,胸针归衣服所有,但不用穿在身上才存在。 props选项:一般用数组或对象来声明。{ title: String }是对象写法,意思是“title这个字段的类型是String”。就像一张登记表——每个字段都写清了叫什么、该填什么类型。
在有状态组件的基础上,实现函数式组件将变得非常简单,因为挂载组件的逻辑可以复用 mountComponent 函数。为此,我们需要在 patch 函数内支持函数类型的 vnode.type,如下面 patch 函数的代码所示:
patch:
“打补丁”。patch(旧 vnode, 新 vnode, 容器, 锚点) 是框架里负责把虚拟 DOM 变成真实 DOM、或者对比新旧后做最小改动的那个总入口。就像装修队:先看新旧图纸差在哪,只动工要改的那几处。
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)
}
}
}我们逐行看:
if (n1 && n1.type !== n2.type):n1是旧节点、n2是新节点。新旧type不同,说明已经不是同一个东西了——先把旧的unmount掉,然后当作新挂载处理(n1 = null)。const { type } = n2:把新节点的type取出来。if (typeof type === 'string'):type是字符串,说明是普通 HTML 标签('div'、'h1')。else if (type === Text):Text特殊类型,文本节点。else if (type === Fragment):Fragment特殊类型,片段。else if (typeof type === 'object' || typeof type === 'function'):统一处理有状态组件和函数式组件——不管type是对象还是函数,都当成“组件”来对待;这一支内部同样没有旧的走mountComponent、有旧的就走patchComponent。
在 patch 函数内部,我们通过检测 vnode.type 的类型来判断组件的类型:
- 如果
vnode.type是一个对象,则它是一个有状态组件,并且vnode.type是组件选项对象; - 如果
vnode.type是一个函数,则它是一个函数式组件。
好消息是:不管是哪种组件,我们都能用 mountComponent 来挂载,也都能用 patchComponent 来更新——下游那套逻辑完全不用分叉。
所以真正要改的,只有 mountComponent 里面的开头几行——下面是修改后的 mountComponent 函数,它已经支持挂载函数式组件了:
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
}
}
// 省略部分代码
}我们逐行看:
const isFunctional = typeof vnode.type === 'function':用一行类型判断,把“是不是函数式组件”这个事标记出来。let componentOptions = vnode.type:默认情况下,componentOptions就是vnode.type(有状态组件的选项对象)。if (isFunctional) { componentOptions = { render: vnode.type, props: vnode.type.props } }:核心思路来了——把“函数”伪装成“有render字段的对象”。这样后面那一大段有状态组件的挂载逻辑就能完全复用,一行都不用改。render: vnode.type:组件函数本身就是渲染函数。props: vnode.type.props:把函数上挂的那个静态props,当作组件的props选项。
可以看到,实现对函数式组件的兼容非常简单。首先,在 mountComponent 函数内检查组件的类型,如果是函数式组件,则直接将组件函数作为组件选项对象的 render 选项,并将组件函数的静态 props 属性作为组件的 props 选项即可,其他逻辑保持不变。
不过,出于更加严谨的考虑,我们还需要通过 isFunctional 变量实现选择性地执行初始化逻辑——因为对于函数式组件来说,它无须初始化 data(它自己那份“记事本”从一开始就不存在),也无须挂生命周期钩子(它没有自己的状态,谈不上“状态变化时要更新”)。从这一点可以看出,函数式组件的初始化性能消耗小于有状态组件;只是在 Vue.js 3 中,这点优势已经小到不值得你为此放弃有状态组件那套方便的写法。
data:
选项式 API 里存放“组件自己那份会变化的数据”的地方,声明了它的组件就是有状态的。就像一本能随时被撕页重写的笔记本——而函数式组件压根没有这本子。
13.4 总结
在本章中,我们首先讨论了异步组件要解决的问题。异步组件在页面性能、拆包以及服务端下发组件等场景中尤为重要。从根本上来说,异步组件的实现可以完全在用户层面实现,而无须框架支持。但一个完善的异步组件仍需要考虑诸多问题,例如:
- 允许用户指定加载出错时要渲染的组件;
- 允许用户指定 Loading 组件,以及展示该组件的延迟时间;
- 允许用户设置加载组件的超时时长;
- 组件加载失败时,为用户提供重试的能力。
因此,框架有必要内建异步组件的实现。 Vue.js 3 提供了 defineAsyncComponent 函数,用来定义异步组件。
接着,我们讲解了异步组件的加载超时问题,以及当加载错误发生时如何指定 Error 组件。通过为 defineAsyncComponent 函数指定配置对象参数,允许用户通过 timeout 选项设置超时时长。当加载超时后,会触发加载错误,这时会渲染用户通过 errorComponent 选项指定的 Error 组件。再往后一步,我们给加载器接上了 catch,让超时和网络失败等所有错误都变成“错误对象”,并通过 props 传给 Error 组件,以便做细粒度处理。
在加载异步组件的过程中,受网络状况的影响较大。当网络状况较差时,加载过程可能很漫长。为了提供更好的用户体验,我们需要展示 Loading 组件。所以,我们设计了 loadingComponent 选项,以允许用户配置自定义的 Loading 组件。但展示 Loading 组件的时机是一个需要仔细考虑的问题。为了避免 Loading 组件导致的闪烁问题,我们还设计了一个接口,让用户能指定延迟展示 Loading 组件的时间,即 delay 选项。
在加载组件的过程中,发生错误的情况非常常见。所以,我们设计了组件加载发生错误后的重试机制。在讲解异步组件的重试加载机制时,我们类比了接口请求发生错误时的重试机制:两者思路类似,都是出错后把“重试 / 放弃”两个选择交还给用户,并用 retries 记录已重试的次数。
最后,我们讨论了函数式组件。它本质上是一个函数,其内部实现逻辑可以复用有状态组件的实现逻辑。为了给函数式组件定义 props,我们允许开发者在函数式组件的主函数上添加静态的 props 属性。出于更加严谨的考虑,函数式组件没有自身状态,也没有生命周期的概念。所以,在初始化函数式组件时,需要选择性地复用有状态组件的初始化逻辑。
名词速查
| 词 | 一句话 |
|---|---|
| 异步组件 | 一种加载方式:先不下载组件代码,等真正要显示时再临时去拿 |
| 加载器 loader | 唯一负责把组件代码弄到手的函数,框架只认它最终给出一个 Promise |
| 包装组件 | defineAsyncComponent 返回的东西,自己不画界面,只盯着 loaded 决定画什么 |
| 注释节点 / 空文本节点 | 占位用,页面上不显示,但确实占着位置 |
| 函数式组件 | 一个普通函数,接收 props,返回值就是要渲染的内容 |
本章小结
- 异步组件 = 延迟加载的组件。代码分割(Code Splitting)可以避免一次性下载全部组件代码,按需加载能减少首屏体积。
defineAsyncComponent是一个高阶组件:加载器(返回Promise的函数)进去,包装组件出来。包装组件根据加载器的状态决定渲染什么——成功就渲染被加载的组件,没成功就渲染占位内容(框架里通常是注释节点)。- 它的实现是一层层长出来的:最简版(只认加载成功/失败)→ 加
timeout挂定时器实现超时 → 加catch与error,把所有加载错误变成错误对象并通过props传给errorComponent→ 加delay延迟展示loadingComponent→ 加onError/retries提供重试。 delay的存在是为了避免“组件秒加载却闪一下 Loading”这种糟糕体验。核心思想是:只要延迟期内加载完,就不显示 Loading。- 重试机制复用接口请求重试的思路:出错时把“重试 / 放弃”包装成
retry和fail交给用户决定,并用retries把已重试的次数告诉用户,方便他自己设上限。 - 函数式组件就是一个普通函数。Vue.js 3 里它不再是性能优化,而是“写起来简单”。
- 实现函数式组件的关键技巧:在
patch里判断typeof type === 'function',再在mountComponent里把它伪装成{ render: 函数, props: 静态 props },这样就能复用有状态组件的全部挂载逻辑。 - 函数式组件没有自身状态,也没有生命周期钩子。如果你要的是“自己记事、随数据变化自动更新”这类能力,请用有状态组件。
| 静态 props | 挂在函数身上的 props 选项,让框架知道这个函数式组件要接收哪些字段 |
