前言
vue3更新了两个全新的内置组件,Teleport和Suspense。让我们在实现某些效果的时候,如拟态框、异步加载等,变得十分方便,我就很好奇,其内部是如何实现的。在我研究了两天之后,发现其内部的实现非常巧妙。
源码位置:
Suspense vue-next/packages/runtime-core/src/components/Suspense.ts
Teleport vue-next/packages/runtime-core/src/components/Teleport.ts
Suspense
Suspense在官方文档中的定义是:在正确渲染组件之前进行一些异步请求(其实也可以是异步任务,比如Promise)是很常见的事。组件通常会在本地处理这种逻辑,绝大多数情况下这是非常完美的做法。该 <suspense> 组件提供了另一个方案,允许将等待过程提升到组件树中处理,而不是在单个组件中。更详细的请查看:v3.cn.vuejs.org/guide/migra…
前置介绍
属性
activeBranch:激活分支,已经挂载到页面中的vnode,在更新的时候会被卸载,且重新被赋值为新挂载的vnode
pendingBranch:挂起分支,大部分情况是指#default中的内容。因为存在异步任务,在异步任务处理完毕之后,将activeBranch卸载完毕后,进入挂载。
isInFallback:是否处于#fallback阶段
isHydrating:服务端渲染是否完成
timeout:Suspense接受的一个参数,规定当前<suspense>超时时间,也是规定重新加载#fallback是立即加载(timeout的值是0的情况)还是等待timeout毫秒之后加载
钩子函数
Suspense有三个独有的钩子函数
onPending:挂载pendingBranch内容之前执行
onResolve:resolve函数执行到最后,执行
onFallback:挂载#fallback的内容之前执行
异步组件
vue提供了一个API:defineAsyncComponent,用来创建一个只有在需要时才会加载的异步组件。
1.普通用法:defineAsyncComponnet(() => import('./xxx.vue'))
- 高阶用法:接受一个对象
import { defineAsyncComponent } from 'vue'
const AsyncComp = defineAsyncComponent({
// 工厂函数
loader: () => import('./Foo.vue'),
// 加载异步组件时要使用的组件
loadingComponent: LoadingComponent,
// 加载失败时要使用的组件
errorComponent: ErrorComponent,
// 在显示 loadingComponent 之前的延迟 | 默认值:200(单位 ms)
delay: 200,
// 如果提供了 timeout ,并且加载组件的时间超过了设定值,将显示错误组件
// 默认值:Infinity(即永不超时,单位 ms)
timeout: 3000,
// 定义组件是否可挂起 | 默认值:true
suspensible: false,
/**
*
* @param {*} error 错误信息对象
* @param {*} retry 一个函数,用于指示当 promise 加载器 reject 时,加载器是否应该重试
* @param {*} fail 一个函数,指示加载程序结束退出
* @param {*} attempts 允许的最大重试次数
*/
onError(error, retry, fail, attempts) {
if (error.message.match(/fetch/) && attempts <= 3) {
// 请求发生错误时重试,最多可尝试 3 次
retry()
} else {
// 注意,retry/fail 就像 promise 的 resolve/reject 一样:
// 必须调用其中一个才能继续错误处理。
fail()
}
}
})
使用案例
等待一个Promise返回数据。
Children组件
<template>
{{data}}
</template>
<script>
import {ref} from 'vue'
export default {
name: 'Children',
async setup() {
const data = ref(null)
const promise = new Promise((resolve, reject) => {
setTimeout(() => {
resolve('success')
}, 2000)
})
data.value = await promise
return {
data
}
}
}
</script>
App组件
<template>
<div>
<!--`<suspense>` 组件有两个插槽。它们都只接收一个直接子节点。`default` 插槽里的节点会尽可能展示出来。如果不能,则展示 `fallback` 插槽里的节点。-->
<Suspense>
<template #default>
<!--这里的内容可能会带有异步请求或者产生异步任务-->
<AsyncComp />
</template>
<template #fallback>
<!--这里的内容会在#default内容加载的过程中进行渲染,并显示-->
<div>data Info Loading...</div>
</template>
</Suspense>
</div>
</template>
<script>
import {defineAsyncComponent} from 'vue'
// 如果使用了defineAsyncComponent进行引入组件 就不需要下面这样引入,
// defineAsyncComponent 可以动态加载组件
// 详细请看:https://v3.cn.vuejs.org/api/global-api.html#defineasynccomponent
// import Children from './components/Children.vue'
const AysncComp = defineAsyncComponent({
loader: () => import('./')
})
export default {
name: 'App'
setup() {},
components: {
AysncComp
}
}
</script>
效果如下:
刚开始加载,异步的结果还没返回,会先加载#fallback里的内容
在经过一段时间之后,异步的结果返回了,开始渲染#default的内容
但是呢,凡事都不可能会一定成功,所以异步执行的过程中或者是返回的结果,有可能是失败的,这个时候我们就需要去捕获错误,vue提供了一个生命周期钩子函数 onErrorCaptured 用来捕获子组件的错误,做法如下:
const error = ref(null)
onErrorCaptured(e => {
error.value = e
return false
})
模板结构改成如下,这样就会去处理加载时出现的错误
<template>
<div>
<div v-if="error">{{error}}</div>
<Suspense v-else>
<template #default>
<!--这里的内容可能会带有异步请求或者产生异步任务-->
<AsyncComp />
</template>
<template #fallback>
<!--这里的内容会在#default内容加载的过程中进行渲染,并显示-->
<div>data Info Loading...</div>
</template>
</Suspense>
</div>
</template>
实现原理解析
我们现在分为两部分去解析Suspense的原理:初始化和更新
Suspense的初始化
当patch解析Suspense组件时,他和其他组件不同的是,Suspense传入的type会是Suspense的所产生的配置对象,会进入SUSPENSE分支,执行process
process函数是Suspense.ts内部实现的用来解析<Suspense>的方法,流程和普通的组件没有其实没有生命不同,同样的存在旧的vnode进行更新,不存在就是初始化挂载,但是呢Suspense的vnode和渲染函数其他组件差别就大了
不难发现,Suspense的Children一定会是两个函数,或者说是两个小渲染块,上面还有两个属性:ssContent:是#default的内容的vnode,ssFallback:是#fallback的内容的vnode
初始化的入口:mountSuspense函数
初始化一个suspense对象,放到Suspense的vnode上,这个对象上记录了当前这个Suspense的所有信息,例如:1. 是否处于fallback阶段 2. 是服务端渲染吗等信息,还有一些操作Suspense的方法,后面的对Suspense的一系列操作都是以这个对象为中心,
会先对#default的内容进行解析,并将其设置为pendingBranch, hiddenContainer是#default容器,在刚开始是不会展示的,或许在解析#default的过程中可能会产生异步的dep,
处理异步的dep:registerDep函数
这里有人可能会疑惑,产生了异步的dep,要怎么样去处理,其实在前面的setupStatefulComponent函数和mountComponent函数就已经做出了预判,对于返回的异步的setup函数的返回会进行特殊处理
setupStatefulComponent函数中会对产生的结果进行保存,等待后面执行,如果是服务端渲染,这里就会提前挂上.then()和.catch() 后面就不做处理
mountComponent函数中就会调用实例上的registerDep方法(这个方法是Suspense的操作方法之一,parentSuspense其实是suspense对象),下面一步是为了方便后面解析#default,请给他一个占位符
内部就会对异步的dep进行注册,每有一个异步的dep进来,suspense的deps会增加1,主要是使用.then()和.catch()进行处理,具体逻辑这里先不解释,因为这个是异步任务,并不会立即执行,我们继续去看mountSuspense的下一部分做了啥,
分为了两种情况,在解析#default的过程中没有去注册异步dep(suspense的deps不会大于0),当前的<Suspense>就会进入结束阶段,去执行resolve()。但通常都会产生的异步的dep,就会进入if中,挂载后备内容#fallback, 执行onPending和onFallback钩子函数,最后将#fallback设置为activeBranch
下面就是等待解析#default的异步返回结果了,触发已经准备好的.catch()和.then(),前面为什么可以使用onErrorCaptured去捕获抛出的错误,原因就在这里,.catch()调用了handlerError方法,内部就是掉了onErrorCaptured钩子。且也会去兼容v2的errorCaptured函数选项,
在返回的结果没有出现错误,就可以走正常的逻辑,首先就要确保组件不是已经被卸载或者是Suspense被卸载以及如果不是等待的异步和处理的异步不是同一个。
其他的和挂载普通的组件差别不大,同样的去处理结果,同样的去兼容v2,同样处理渲染依赖,唯一不同的是,一个<Suspense>结束需要去执行resolve。
但是呢,不排除出现异步任务的嵌套和存在多个异步任务的情况,所以通过suspense.deps很好的控制到了最后一个异步结束才会让<Suspense>才进入结束阶段
结束阶段:resolve函数
开始执行resolve函数,到这一步基本预示着<Suspense>即将结束,开头先从suspense中取出一系列的数据方便后面使用,
下面就是一个挂载#default的操作,主要针对的是客户端渲染,处理离开和进入过渡,以便卸载#fallback和挂载#default,因为过渡可能不存在,在unmount执行的过程会顺带处理过渡(存在才会执行),所以在后面需要去根据delayEnter判断要不要自己执行挂载
如果是服务端渲染,将不会做任何操作,只会将其关闭,因为前面已经解析了。
剩下的流程就比较简单了,更新activeBranch,其他属性恢复默认值,处理嵌套Suspense的情况,一直往外找,找到了,将自己并入parent Suspense的异步任务中,找不到开始执行所有的异步任务,最后执行钩子函数onResolve,一个<Suspense>初始化流程走完。
Suspense的更新阶段
Suspense的更新比较复杂,有很多种情况,比如有没有开始挂起的pengindBranch、解析钱还是解析后,咱们一个分支一个分支的看。
更新的入口:patchSuspense函数
开头依旧是更新新的vnode和容器。新的#default和#fallback,将下面的折叠一下可以下发现其实也就一个if else,但是里面的考虑了很多,先看if内部 pendingBranch还存在的情况。
pendingBranch还存在
进入内部,先将旧的pendingBranch覆盖,往下一看,还是有好多if else, 只能一个个看
- 相同的根类型,但内容可能已更改。
和<Suspense>初始化的流程比较像,不同点在于去更新旧的pendingBranch,但是由于render或者effect尚未执行或设置,只会更新props和slots。
- 不相同的根类型,在
resolve结束之前,将整个pending tree替换掉。
大体上分为两种情况,pending tree有没有挂载到页面上,没有就是和初始化流程差不多,就是需要去更新新的#fallback
如果挂载了,那就更简单了,使用newBranch去更新activeBranch,之后因为是重新开始执行resolve,不需要去加载过渡(已经加载过一次了),
或许可能会出现第三种,大概是在切换到第二个,在第二个结束之前,切换到第三个。
pendingBranch不存在的情况
pendingBranch不存在,代表整个<Suspense>已经走完了所有流程,异步的内容已经显示完毕了,这种情况下开始更新。这样也分两种,
第一种只是#default内部触发的更新,只是普通的patch。并不会产生任何的异步任务
第二种,根节点发生变化,会先触发onPending,修改pendingId(刷新挂载的任务)重新开始解析#default可能会产生异步的,需要去加载#fallback,展示后备内容。
加载#fallback也是存在的条件,不满足条件展示的还是旧的#default,条件1:timeout(#default加载任务最长时间)大于0,且是同一个挂起的#default,条件2:timeout等同于0,意味不会有超时,会一直等待#default加载。
小总结
Suspense挂载和更新考虑了很多种情况,有很多的边界判断。实际操作流程其实都差不多。都是去挂载或者更新#default和fallback。
<Suspense>的出现让我们在vue中需要去处理异步的时候更加方便,不需要自己手动控制全局的变量或者是一个isLoading的变量。可以使用onErrorCapurted来配合处理加载错误的情况。
Teleport
Teleport在官方文档中的定义:“ Vue 鼓励我们通过将 UI 和相关行为封装到组件中来构建 UI。我们可以将它们嵌套在另一个内部,以构建一个组成应用程序 UI 的树。 ”。感觉和React中的容器组件以及UI组件的区分挺类似。
使用案例
创建一个包含全屏模式的组件。在大多数情况下,你希望模态框的逻辑存在于组件中,但是模态框的快速定位就很难通过 CSS 来解决,或者需要更改组件组合。使用Teleport实现如下
App组件
<template>
<div>Tooltips Vue 3 Teleport</div>
<ModalButton />
</template>
<script setup lang="ts">
import ModalButton from './components/ModalButton.vue'
</script>
<style scoped>
</style>
ModalButton组件
<template>
<button @click="open">open full screen modal</button>
<teleport to="body">
<div v-if="modalOpen" class="modal">
{{ modalOpen }}
<div>I'm a teleport modal!!!(My Parent is body)</div>
<button @click="clone">clone full screen modal</button>
</div>
</teleport>
</template>
<script setup lang="ts">
import { ref } from "vue";
const modalOpen = ref(true);
// 关闭函数
const clone = () => modalOpen.value = false
// 开启函数
const open = () => modalOpen.value = true
</script>
<style scoped>
</style>
props
<teleport>通过主要通过to和disabled两个参数来控制
to:必须传递,必须是,必须是有效的查询选择器或 HTMLElement (如果在浏览器环境中使用)。指定将在其中移动 <teleport> 内容的目标元素。(这个元素可以是SVG和其他的元素)
disabled:此可选属性可用于禁用 <teleport> 的功能,这意味着其插槽内容将不会移动到任何位置,而是在你在周围父组件中指定了 <teleport> 的位置渲染。(true为禁用、false为启用)
请注意,这将移动实际的 DOM 节点,而不是被销毁和重新创建,并且它还将保持任何组件实例的活动状态。所有有状态的 HTML 元素 (即播放的视频) 都将保持其状态。
前置知识
resloveTarget:找到渲染<teleport>内容的目标对象,找不到会有警告提示信息,原理是通过select接受一个选择器去树中获取节点,在浏览器环境下,select一般都是querySelector(渲染器renderer中的方法,或许有的平台不支持,可能无法使用Teleport)。
选择器推荐使用类选择器和id选择器,因为使用的是querySelector,传递时需要带.或者是#,这个容器最好在vue树之外,要在<teleport>被渲染前可以获取到,让<teleport>渲染到其中。
isTeleport:这是一个teleport吗?
export const isTeleport = (type: any): boolean => type.__isTeleport
isTeleportDisabled:teleport的能力是否被禁用
const isTeleportDisabled = (props: VNode['props']): boolean =>
props && (props.disabled || props.disabled === '')
isTargetSVG:传送目标对象是SVG吗
const isTargetSVG = (target: RendererElement): boolean =>
typeof SVGElement !== 'undefined' && target instanceof SVGElement
实现原理
初始化流程
在patch函数中,type将会是teleport配置对象
这里可以看到熟悉的函数:process,<teleport>同样是有一个process函数进行初始化。和Suspense不同的是,Teleport是依靠这个函数一路莽到底,基本上挂载和更新的处理都在这个函数里。
挂载前的准备
进入时,为了更方便的操作将一系列操作函数从渲染器中拿出来,disabled用于后面去验证是否禁用Teleport功能。初始化肯定要挂载内容,先看<teleport>是如何去挂载内容的,在看开始挂载之前有一些准备工作。
在主视图中插入定位符,可以在禁用
Teleport功能的情况下,在默认容器中渲染。也就是渲染<teleport>所的父组件内部。
target是渲染<teleport>内容的目标容器,或许可能不存在和找不到,targetAnchor也是一个定位符,等待<teleport>的内容在内存构建完毕之后可以直接插入到目标容器中。
挂载开始
这里Teleport自己写有一个mount函数,为了确认<teleport>内部是否是array children,但是<teleport>一定是存在array children,不然到最后总不能渲染一个寂寞吧(空白内容)。
mount函数的参数anchor是作用是确认<teleport>渲染的容器,是渲染在目标容器呢,还是默认位置,这都是靠前面已经确定好的disabled判断,禁用就渲染在默认容器中,启用就渲染到指定的容器中。<teleport>的初始化流程结束。
更新阶段
能够触发Teleport更新的只有两种情况:1. 修改了to或者disabled的值, 2. <teleport>内部自己的内容发生了更新,一旦触发更新,全部的值都要换掉,需要注意的是disabled和wasDisabled,大部分的新值都是依靠他们两个确定的,其余都是继承旧的
moveTeleport函数
<teleport>的更新完全依靠这个函数,用来移动<teleport>的内容,刚进入需要先确认新的目标容器,在目标容器target改变的情况下会去移动定位符到新的容器中。剩下的代码也就两种流程。
<teleport>不是因为重新排序的影响,只是<teleport>内的子节点在重新排序。会进入在中间的移动children的逻辑中<teleport>是某个节点的子节点,因为某种原因,导致该节点的子节点需要重新排序,<teleport>被迫移动,先移动<teleport>的开始标记,接下来就是重点,因为是重新排序(isReorder为true),所以只有在传送功能被禁用的情况下(<teleport>的字节渲染在Teleport的开始和结束标记中间),会去移动子节点,最后再将Teleport的结束标记移动。
isReorder是是否重新排序的标志,是由外界传递进来的moveType确定的,moveType等于TARGER_CHANGE相同时isReorder的值为true。
teleport内容更新
主要分为直接更新一个块(一个元素内部包含了多个元素节点就可以被称为块,更新块后需要重新确认el,确保所有的根节点基础以前的DOM引用,方便在将来的更新中移动它们),或者更新块内部的子节点。
props改变更新
在这种情况,要么是改变了<teleport>的内容的渲染目标容器、要么是Teleport的传送被禁用或启用,现在disabled是新的功能状态,wasDisabled是旧的功能状态。依靠它们确定新的渲染目标。
- 传送功能从启用切换成禁用
将渲染目标容器中的内容移动到默认的容器中
- 目标容器改变
向
<teleport>重新传递了一个选择器,Teleport会重新去或许目标容器,将内容移动过去
- 传送功能从禁用切换成启用
将默认的容器中的内容移动到中初始化时指定的容器中
Teleport更新阶段内容分析完毕
服务端渲染的Teleport
服务端渲染其实和客户端渲染差不都,都是更新
to和disabled来确认渲染的位置,可能只是渲染数据的方式不同。具体的细节等到后面研究vue SSR的时候再来说。
小总结
Teleport主要原理是通过参数to进行选视图渲染在那个节点中,这个功能可以通过参数disabled开启或者关闭,Teleport可以让我们将一个组件的UI和行为封装在一起,方便将器嵌入其他的组件当中。
最后
Teleport和Suspense的实现原理分析就到这里,有些地方可能分析不到位,希望各位大佬能够补充和纠正。