Vue3 新特性 Teleport Suspense实现原理

3,244 阅读15分钟

2.jpg

前言

vue3更新了两个全新的内置组件,TeleportSuspense。让我们在实现某些效果的时候,如拟态框、异步加载等,变得十分方便,我就很好奇,其内部是如何实现的。在我研究了两天之后,发现其内部的实现非常巧妙。

源码位置:

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:服务端渲染是否完成

timeoutSuspense接受的一个参数,规定当前<suspense>超时时间,也是规定重新加载#fallback是立即加载(timeout的值是0的情况)还是等待timeout毫秒之后加载

钩子函数

Suspense有三个独有的钩子函数

onPending:挂载pendingBranch内容之前执行

onResolveresolve函数执行到最后,执行

onFallback:挂载#fallback的内容之前执行

异步组件

vue提供了一个API:defineAsyncComponent,用来创建一个只有在需要时才会加载的异步组件。

1.普通用法:defineAsyncComponnet(() => import('./xxx.vue'))

  1. 高阶用法:接受一个对象
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里的内容

image.png

在经过一段时间之后,异步的结果返回了,开始渲染#default的内容

image.png

但是呢,凡事都不可能会一定成功,所以异步执行的过程中或者是返回的结果,有可能是失败的,这个时候我们就需要去捕获错误,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

image.png

image.png

process函数是Suspense.ts内部实现的用来解析<Suspense>的方法,流程和普通的组件没有其实没有生命不同,同样的存在旧的vnode进行更新,不存在就是初始化挂载,但是呢Suspense的vnode和渲染函数其他组件差别就大了

image.png

image.png

不难发现,SuspenseChildren一定会是两个函数,或者说是两个小渲染块,上面还有两个属性:ssContent:是#default的内容的vnode,ssFallback:是#fallback的内容的vnode

code.png

初始化的入口:mountSuspense函数

image.png

初始化一个suspense对象,放到Suspense的vnode上,这个对象上记录了当前这个Suspense的所有信息,例如:1. 是否处于fallback阶段 2. 是服务端渲染吗等信息,还有一些操作Suspense的方法,后面的对Suspense的一系列操作都是以这个对象为中心,

image.png

会先对#default的内容进行解析,并将其设置为pendingBranch, hiddenContainer#default容器,在刚开始是不会展示的,或许在解析#default的过程中可能会产生异步的dep,

处理异步的dep:registerDep函数

image.png

这里有人可能会疑惑,产生了异步的dep,要怎么样去处理,其实在前面的setupStatefulComponent函数和mountComponent函数就已经做出了预判,对于返回的异步的setup函数的返回会进行特殊处理

setupStatefulComponent函数中会对产生的结果进行保存,等待后面执行,如果是服务端渲染,这里就会提前挂上.then().catch() 后面就不做处理

image.png

mountComponent函数中就会调用实例上的registerDep方法(这个方法是Suspense的操作方法之一,parentSuspense其实是suspense对象),下面一步是为了方便后面解析#default,请给他一个占位符 image.png

内部就会对异步的dep进行注册,每有一个异步的dep进来,suspense的deps会增加1,主要是使用.then().catch()进行处理,具体逻辑这里先不解释,因为这个是异步任务,并不会立即执行,我们继续去看mountSuspense的下一部分做了啥,

image.png

分为了两种情况,在解析#default的过程中没有去注册异步dep(suspense的deps不会大于0),当前的<Suspense>就会进入结束阶段,去执行resolve()。但通常都会产生的异步的dep,就会进入if中,挂载后备内容#fallback, 执行onPendingonFallback钩子函数,最后将#fallback设置为activeBranch

下面就是等待解析#default的异步返回结果了,触发已经准备好的.catch().then(),前面为什么可以使用onErrorCaptured去捕获抛出的错误,原因就在这里,.catch()调用了handlerError方法,内部就是掉了onErrorCaptured钩子。且也会去兼容v2的errorCaptured函数选项,

image.png

image.png

在返回的结果没有出现错误,就可以走正常的逻辑,首先就要确保组件不是已经被卸载或者是Suspense被卸载以及如果不是等待的异步和处理的异步不是同一个。

其他的和挂载普通的组件差别不大,同样的去处理结果,同样的去兼容v2,同样处理渲染依赖,唯一不同的是,一个<Suspense>结束需要去执行resolve

但是呢,不排除出现异步任务的嵌套和存在多个异步任务的情况,所以通过suspense.deps很好的控制到了最后一个异步结束才会让<Suspense>才进入结束阶段

code.png

结束阶段:resolve函数

开始执行resolve函数,到这一步基本预示着<Suspense>即将结束,开头先从suspense中取出一系列的数据方便后面使用,

image.png

下面就是一个挂载#default的操作,主要针对的是客户端渲染,处理离开和进入过渡,以便卸载#fallback和挂载#default,因为过渡可能不存在,在unmount执行的过程会顺带处理过渡(存在才会执行),所以在后面需要去根据delayEnter判断要不要自己执行挂载

如果是服务端渲染,将不会做任何操作,只会将其关闭,因为前面已经解析了。

code.png

剩下的流程就比较简单了,更新activeBranch,其他属性恢复默认值,处理嵌套Suspense的情况,一直往外找,找到了,将自己并入parent Suspense的异步任务中,找不到开始执行所有的异步任务,最后执行钩子函数onResolve,一个<Suspense>初始化流程走完。

image.png

Suspense的更新阶段

Suspense的更新比较复杂,有很多种情况,比如有没有开始挂起的pengindBranch、解析钱还是解析后,咱们一个分支一个分支的看。

更新的入口:patchSuspense函数

image.png

开头依旧是更新新的vnode和容器。新的#default#fallback,将下面的折叠一下可以下发现其实也就一个if else,但是里面的考虑了很多,先看if内部 pendingBranch还存在的情况。

pendingBranch还存在

进入内部,先将旧的pendingBranch覆盖,往下一看,还是有好多if else, 只能一个个看

  • 相同的根类型,但内容可能已更改。

image.png

<Suspense>初始化的流程比较像,不同点在于去更新旧的pendingBranch,但是由于render或者effect尚未执行或设置,只会更新propsslots

  • 不相同的根类型,在resolve结束之前,将整个pending tree替换掉。

code.png

大体上分为两种情况,pending tree有没有挂载到页面上,没有就是和初始化流程差不多,就是需要去更新新的#fallback

如果挂载了,那就更简单了,使用newBranch去更新activeBranch,之后因为是重新开始执行resolve,不需要去加载过渡(已经加载过一次了),

或许可能会出现第三种,大概是在切换到第二个,在第二个结束之前,切换到第三个。

pendingBranch不存在的情况

pendingBranch不存在,代表整个<Suspense>已经走完了所有流程,异步的内容已经显示完毕了,这种情况下开始更新。这样也分两种, code.png

第一种只是#default内部触发的更新,只是普通的patch。并不会产生任何的异步任务

第二种,根节点发生变化,会先触发onPending,修改pendingId(刷新挂载的任务)重新开始解析#default可能会产生异步的,需要去加载#fallback,展示后备内容。

加载#fallback也是存在的条件,不满足条件展示的还是旧的#default,条件1:timeout(#default加载任务最长时间)大于0,且是同一个挂起的#default,条件2:timeout等同于0,意味不会有超时,会一直等待#default加载。

小总结

Suspense挂载和更新考虑了很多种情况,有很多的边界判断。实际操作流程其实都差不多。都是去挂载或者更新#defaultfallback

<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>通过主要通过todisabled两个参数来控制

to:必须传递,必须是,必须是有效的查询选择器或 HTMLElement (如果在浏览器环境中使用)。指定将在其中移动 <teleport> 内容的目标元素。(这个元素可以是SVG和其他的元素)

disabled:此可选属性可用于禁用 <teleport> 的功能,这意味着其插槽内容将不会移动到任何位置,而是在你在周围父组件中指定了 <teleport> 的位置渲染。(true为禁用、false为启用)

请注意,这将移动实际的 DOM 节点,而不是被销毁和重新创建,并且它还将保持任何组件实例的活动状态。所有有状态的 HTML 元素 (即播放的视频) 都将保持其状态。

前置知识

code.png resloveTarget:找到渲染<teleport>内容的目标对象,找不到会有警告提示信息,原理是通过select接受一个选择器去树中获取节点,在浏览器环境下,select一般都是querySelector(渲染器renderer中的方法,或许有的平台不支持,可能无法使用Teleport)。

选择器推荐使用类选择器和id选择器,因为使用的是querySelector,传递时需要带.或者是#,这个容器最好在vue树之外,要在<teleport>被渲染前可以获取到,让<teleport>渲染到其中。

isTeleport:这是一个teleport吗?

export const isTeleport = (type: any): boolean => type.__isTeleport

isTeleportDisabledteleport的能力是否被禁用

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配置对象

image.png

这里可以看到熟悉的函数:process<teleport>同样是有一个process函数进行初始化。和Suspense不同的是,Teleport是依靠这个函数一路莽到底,基本上挂载和更新的处理都在这个函数里。

挂载前的准备

image.png

进入时,为了更方便的操作将一系列操作函数从渲染器中拿出来,disabled用于后面去验证是否禁用Teleport功能。初始化肯定要挂载内容,先看<teleport>是如何去挂载内容的,在看开始挂载之前有一些准备工作。

image.png 在主视图中插入定位符,可以在禁用Teleport功能的情况下,在默认容器中渲染。也就是渲染<teleport>所的父组件内部。

image.png

target是渲染<teleport>内容的目标容器,或许可能不存在和找不到,targetAnchor也是一个定位符,等待<teleport>的内容在内存构建完毕之后可以直接插入到目标容器中。

挂载开始

image.png

这里Teleport自己写有一个mount函数,为了确认<teleport>内部是否是array children,但是<teleport>一定是存在array children,不然到最后总不能渲染一个寂寞吧(空白内容)。

mount函数的参数anchor是作用是确认<teleport>渲染的容器,是渲染在目标容器呢,还是默认位置,这都是靠前面已经确定好的disabled判断,禁用就渲染在默认容器中,启用就渲染到指定的容器中。<teleport>的初始化流程结束。

更新阶段

能够触发Teleport更新的只有两种情况:1. 修改了to或者disabled的值, 2. <teleport>内部自己的内容发生了更新,一旦触发更新,全部的值都要换掉,需要注意的是disabledwasDisabled,大部分的新值都是依靠他们两个确定的,其余都是继承旧的

image.png

moveTeleport函数

image.png <teleport>的更新完全依靠这个函数,用来移动<teleport>的内容,刚进入需要先确认新的目标容器,在目标容器target改变的情况下会去移动定位符到新的容器中。剩下的代码也就两种流程。

  1. <teleport>不是因为重新排序的影响,只是<teleport>内的子节点在重新排序。会进入在中间的移动children的逻辑中
  2. <teleport>是某个节点的子节点,因为某种原因,导致该节点的子节点需要重新排序,<teleport>被迫移动,先移动<teleport>的开始标记,接下来就是重点,因为是重新排序(isReordertrue),所以只有在传送功能被禁用的情况下(<teleport>的字节渲染在Teleport的开始和结束标记中间),会去移动子节点,最后再将Teleport的结束标记移动。

image.png

image.png

isReorder是是否重新排序的标志,是由外界传递进来的moveType确定的,moveType等于TARGER_CHANGE相同时isReorder的值为true

teleport内容更新

image.png 主要分为直接更新一个块(一个元素内部包含了多个元素节点就可以被称为块,更新块后需要重新确认el,确保所有的根节点基础以前的DOM引用,方便在将来的更新中移动它们),或者更新块内部的子节点。

props改变更新

在这种情况,要么是改变了<teleport>的内容的渲染目标容器、要么是Teleport的传送被禁用或启用,现在disabled是新的功能状态,wasDisabled是旧的功能状态。依靠它们确定新的渲染目标。

  • 传送功能从启用切换成禁用

image.png 将渲染目标容器中的内容移动到默认的容器中

  • 目标容器改变

image.png<teleport>重新传递了一个选择器,Teleport会重新去或许目标容器,将内容移动过去

  • 传送功能从禁用切换成启用

image.png 将默认的容器中的内容移动到中初始化时指定的容器中

Teleport更新阶段内容分析完毕

服务端渲染的Teleport

01836399fed4c5d8b73e77f30c23299.png 服务端渲染其实和客户端渲染差不都,都是更新todisabled来确认渲染的位置,可能只是渲染数据的方式不同。具体的细节等到后面研究vue SSR的时候再来说。

小总结

Teleport主要原理是通过参数to进行选视图渲染在那个节点中,这个功能可以通过参数disabled开启或者关闭,Teleport可以让我们将一个组件的UI和行为封装在一起,方便将器嵌入其他的组件当中。

最后

TeleportSuspense的实现原理分析就到这里,有些地方可能分析不到位,希望各位大佬能够补充和纠正。