一文完全吃透 vue3 teleport

4,099 阅读14分钟

前言

开发过组件库,或者使用过具有“弹出层”的组件比如模态框、抽屉的小伙伴应该了解,为了避免组件被页面其他元素影响,有时需要将模态框的层级抽出来,避免被父级元素污染,比如:

有一个弹窗组件

<script lang="ts" setup>
    import { ref, defineExpose } from 'vue'
    
    // 控制弹窗是否显示
    const showDialog = ref(false)
    
    // 切换弹窗显示状态
    const toggleDialogStatus = (val) => {
        showDialog.value = val
    }
​
    defineExpose({
        toggleDialogStatus
    })
</script><template>
    <div v-show="showDialog" class="dialog">
        <div class="dialog-content">
            <div>我是一个弹窗</div>
            <button @click="toggleDialogStatus(false)">关闭弹窗</button>
        </div>
    </div>
</template><style>
.dialog {
    position: absolute;
    top: 0;
    right: 0;
    bottom: 0;
    left: 0;
    background-color: rgba(0, 0, 0, 0.5);
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
}
​
.dialog-content {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    background-color: #fff;
    width: 360px;
    height: 300px;
}
</style>

当我们直接使用这个弹窗组件的时候

<script lang="ts" setup>
    import { ref } from 'vue'
    import DialogComp from './dialog.vue'
​
    const dialogRef: any = ref(null)
    const openDialog = () => {
        dialogRef.value.toggleDialogStatus(true)
    }
</script><template>
    <button @click="openDialog">打开弹窗</button>
    <dialog-comp ref="dialogRef"></dialog-comp>
</template>

会发现效果差强人意:

2022-08-11 19.39.25.gif

打开控制台的 Elements 面板,可以看到,dialog 组件是渲染在其他节点内部的,因此弹窗的布局、样式都会受到父节点的影响。

image-20220812182351361.png

从逻辑角度上来看,dialog 组件是属于id 为app的节点,但从整个应用视图的角度来看,dialog 组件需要是脱离 app 节点而能够独立展示的。

在vue3中很贴心的直接提供了内置组件 Teleport,该组件可以将制定内容渲染到特定容器中,而不受 dom 层级的影响

<template>
    <button @click="openDialog">打开弹窗</button>
    <!-- 将 dialog-comp 组件直接移动到 body 下渲染  -->
    <teleport to="body">
        <dialog-comp ref="dialogRef"></dialog-comp>
    </teleport>
</template>

2022-08-11 19.57.26.gif

可以很直观的看到,dialog 包含的整个节点被移动到 body 下了,并且在原来的位置留下了 teleport start/end 的注释节点。

image-20220812182241793.png

词如其名,传送门,将需要的节点传送到指定的位置。

基本使用

<teleport />的定义和基本用法如下

属性值

Props是否必填描述
to指定目标容器,必须是有效的查询选择器或 HTMLElement
disabled当值为 true 时,内容将保留在其原始位置,而不是指定的目标位置中。可以动态更改该属性。

使用示例

<teleport to="body">
  <dialog-comp></dialog-comp>
</teleport><teleport to="#some-id">
  <dialog-comp></dialog-comp>
</teleport><teleport to=".some-class">
  <dialog-comp></dialog-comp>
</teleport><!-- 可用通过改变 isChangeView 来动态切换所包含组件的渲染位置 -->
<teleport to="[data-teleport]" :disabled="isChangeView">
  <dialog-comp></dialog-comp>
</teleport>

需要注意的是,Teleport 组件只改变了渲染的 DOM 结构,它不会影响组件间的逻辑。也就是说,如果 Teleport 包含了一个组件,那么该组件始终和这个使用了 Teleport 的组件保持逻辑上的父子关系。传入的 props 和触发的事件也会照常工作。

Teleport 仅仅是对视图上的改变,而不会影响逻辑代码所在的位置

多个 teleport 共享目标

同样支持多个 Teleport 组件挂载在同一个目标元素下,比如

<teleport to="#test">
  <div>A</div>
</teleport>
<teleport to="#test">
  <div>B</div>
</teleport>

会渲染出这样的结果

<div id="test">
  <div>A</div>
  <div>B</div>
</div>

核心原理

实现流程其实很简单,就是将 teleport 中包裹的子组件直接插入到目标位置渲染,整体流程可以抽象如下图:

image-20220819223409789.png

核心实现就是直接将 teleport 包裹的组件直接挂载到目标容器位置当中去,这种 vue 内部的原生支持相较于使用 dom api 直接移动节点到目标位置容易出现的各种问题好处不言而喻。

接下来,我们对源码再进一步地深挖吧

源码逐行解析

teleport 的源码位于core/packages/runtime-core/src/components/Teleport.ts

源码版本基于 vue 3.2.37

在看主流程前,我们需要先了解 vue 组件的渲染流程:

image-20220818172733750.png

在写完 vue 模板之后,编译器会将模板编译成 render 函数,此render 函数返回的就是创建好的 vnode,接着会调用渲染器中的 render 函数其中包含的 patch 函数(注意此 render 与编译后 render函数的区别),将 vnode 渲染成真实的 DOM

主流程

使用Vue Template Explorer模板导出工具对下列模板代码进行处理:

<teleport to="body">
    <div class="test">A</div>
</teleport>

编译后:

import { createElementVNode as _createElementVNode, Teleport as _Teleport, openBlock as _openBlock, createBlock as _createBlock } from "vue"
​
export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createBlock(_Teleport, { to: "body" }, [
    _createElementVNode("div", { class: "test" }, "A")
  ]))
}

可以很直观的看到:当我们使用<teleport />模板后,就是通过createBlock方法直接创建了Teleport块级节点

接着渲染器会使用 render 函数中的 patch 对 vnode 进行渲染

const render: RootRenderFunction = (vnode, container, isSVG) => {
  if (vnode == null) {
    if (container._vnode) {
      unmount(container._vnode, null, null, true)
    }
  } else {
    patch(container._vnode || null, vnode, container, null, null, null, isSVG) // 对 vnode 进行处理
  }
  flushPreFlushCbs()
  flushPostFlushCbs()
  container._vnode = vnode
}

在 patch 方法中,会对 vnode 类型进行判断,如果是 ShapeFlags.TELEPORT类型会执行 Teleport 自身的 process 方法去创建/更新 Teleport 组件。

const patch: PatchFn = (
    n1,
    n2,
    container,
    anchor = null,
    parentComponent = null,
    parentSuspense = null,
    isSVG = false,
    slotScopeIds = null,
    optimized = __DEV__ && isHmrUpdating ? false : !!n2.dynamicChildren
  ) => {
  ...
    switch (type) {
      ...
      if (shapeFlag & ShapeFlags.TELEPORT) {
          ;(type as typeof TeleportImpl).process(   // 此处调用 TeleportImpl 的 process 方法来创建组件
            n1 as TeleportVNode,
            n2 as TeleportVNode,
            container,
            anchor,
            parentComponent,
            parentSuspense,
            isSVG,
            slotScopeIds,
            optimized,
            internals
          )
        }
      ...

我们来看看 Teleport.ts 中的实现。

拉到文件最底部,可以看到 Teleport 组件就是一个 TeleportImpl对象

// 为了 h 函数和 TSX 的属性推断强制需要转换成公共类型
export const Teleport = TeleportImpl as any as {
  __isTeleport: true
  new (): { $props: VNodeProps & TeleportProps }
}

TeleportImpl对象具备一个属性,四个方法

export const TeleportImpl = {
    __isTeleport: true,
    process() {
        if (n1 == null) {
            // 创建逻辑
        } else {
            // 更新逻辑
        }
    },
    remove() {
        // 删除逻辑
    },
    // 移动节点逻辑
    move: moveTeleport,
    // 服务端渲染时 teleport 的特殊处理逻辑
    hydrate: hydrateTeleport
}

image-20220822173757656.png

一个属性

__isTeleport

该属性的值固定为 true,会通过暴露一个 isTeleport方法,用来判断是不是 teleport组件

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

该方法会在使用 _createVNode创建虚拟节点的过程中用到

const shapeFlag = isString(type)
    ? ShapeFlags.ELEMENT
    : __FEATURE_SUSPENSE__ && isSuspense(type)
    ? ShapeFlags.SUSPENSE
    : isTeleport(type)  // 用于判断 vnode 类型是不是 teleport
    ? ShapeFlags.TELEPORT
    : isObject(type)
    ? ShapeFlags.STATEFUL_COMPONENT
    : isFunction(type)
    ? ShapeFlags.FUNCTIONAL_COMPONENT
    : 0

这里是用于判断虚拟节点的节点类型是不是 teleport 类型,是的话会将节点类型信息编码标记为ShapeFlags.TELEPORT,也就是64

注:可以在packages/shared/src/shapeFlags.ts查看各节点类型编码

export const enum ShapeFlags {
    ELEMENT = 1,
    FUNCTIONAL_COMPONENT = 1 << 1,
    STATEFUL_COMPONENT = 1 << 2,
    TEXT_CHILDREN = 1 << 3,
    ARRAY_CHILDREN = 1 << 4,
    SLOTS_CHILDREN = 1 << 5,
    TELEPORT = 1 << 6,  // teleport节点类型编码
    SUSPENSE = 1 << 7,
    COMPONENT_SHOULD_KEEP_ALIVE = 1 << 8,
    COMPONENT_KEPT_ALIVE = 1 << 9,
    COMPONENT = ShapeFlags.STATEFUL_COMPONENT | ShapeFlags.FUNCTIONAL_COMPONENT
}

四个方法

  1. process 负责组件的创建和更新逻辑
  2. remove 负责组件的删除逻辑
  3. move 负责更新组件过程中的节点移动逻辑
  4. hydrate 负责同构渲染过程中的客户端激活

process

process 方法被用来负责组件的创建或者更新逻辑

process(
    n1: TeleportVNode | null, 
    n2: TeleportVNode,
    container: RendererElement,
    anchor: RendererNode | null,
    parentComponent: ComponentInternalInstance | null,
    parentSuspense: SuspenseBoundary | null,
    isSVG: boolean,
    slotScopeIds: string[] | null,
    optimized: boolean,
    internals: RendererInternals
) {
    if (n1 == null) {
        // 创建逻辑
    } else {
        // 更新逻辑
    }
},

作为创建/更新组件的方法,首先要做的就是去渲染器模块找”外援“:

const {
  mc: mountChildren,  // 挂载子节点
  pc: patchChildren,  // 更新子节点
  pbc: patchBlockChildren,  // 更新块节点
  o: { insert, querySelector, createText, createComment } // 插入节点、查询选择器、创建文本节点、创建注释节点
} = internals

mc 用来挂载子节点,pc用来更新节点,pbc用来更新块节点,o作为渲染器的配置项提供了插入节点、查询选择器、创建文本节点、创建注释节点四个功能。

并判断 teleport 中 disabled的值:

const disabled = isTeleportDisabled(n2.props)
​
const isTeleportDisabled = (props: VNode['props']): boolean =>
  props && (props.disabled || props.disabled === '')

因为在热更新的时候,会出现重复挂载/卸载的问题,所以需要通过走全量 diff 避免热更新可能带来的问题,对应 issure可以查看HMR adds changes twice when using teleport · Issue #3302 · vuejs/core

// HMR updated, force full diff
if (__DEV__ && isHmrUpdating) {
  optimized = false
  dynamicChildren = null
}

创建节点

执行 process 方法的过程中,先会对旧节点进行判断,当不存在旧节点时(n1 === null),执行创建的逻辑。

创建逻辑主要分为以下几个阶段

  1. 开发环境创建并向原有容器(teleport组件存在的地方)插入注释节点作为锚点节点(anchor)

    // insert anchors in the main view
    const placeholder = (n2.el = __DEV__
      ? createComment('teleport start')
      : createText(''))
    const mainAnchor = (n2.anchor = __DEV__
      ? createComment('teleport end')
      : createText(''))
    insert(placeholder, container, anchor)
    insert(mainAnchor, container, anchor)
    
  2. 获取目标节点,并创建一个目标节点的锚点节点(空文本元素)

    const target = (n2.target = resolveTarget(n2.props, querySelector))
    const targetAnchor = (n2.targetAnchor = createText(''))
    
  1. 判断目标节点是否有效,有效的话则将目标节点插入到锚点上

    if (target) {
      insert(targetAnchor, target)
      // #2652 we could be teleporting from a non-SVG tree into an SVG tree
      isSVG = isSVG || isTargetSVG(target)
    } else if (__DEV__ && !disabled) {
      warn('Invalid Teleport target on mount:', target, `(${typeof target})`)
    }
    

    此处对 svg 类型的目标节点作了额外判断

    createElement: (tag, isSVG, is, props): Element => {
      const el = isSVG
      ? doc.createElementNS(svgNS, tag)
      : doc.createElement(tag, is ? { is } : undefined)
    ​
      if (tag === 'select' && props && props.multiple != null) {
        ;(el as HTMLSelectElement).setAttribute('multiple', props.multiple)
      }
    ​
      return el
    }
    

    在挂载节点之前需要先创建对应的元素,svg 元素相较于普通元素需要通过doc.createElementNS去创建,所以需要这一步的判断做出区分。

    当然这步逻辑并不是一开始就有的,而是由他人提了 issure 后才弥补的。感兴趣的可以去看看整个 issure 的处理过程 fix: Teleport SVG elements by yassilah · Pull Request #2648 · vuejs/core

    分析源码我们可以看到,高楼大厦不是一日之间就能建成的,需要一步一步地积累,一步一步地试错。

  1. 接着定义了一个 mount 方法,当要挂载的新节点(n2)是个数组类型的子节点才会进行挂载

    const mount = (container: RendererElement, anchor: RendererNode) => {
      // Teleport *always* has Array children. This is enforced in both the
      // compiler and vnode children normalization.
      if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
        mountChildren(
          children as VNodeArrayChildren,
          container,
          anchor,
          parentComponent,
          parentSuspense,
          isSVG,
          slotScopeIds,
          optimized
        )
      }
    }
    

    注释中也有说明, Teleport 组件的子节点必须是数组类型,且会被强制运用于编译器和虚拟子节点的标准化中。

  1. 最后对 disabled 变量进行判断:如果为 true 则挂载在原先的位置,为 false 则挂载到目标位置

    if (disabled) {
      mount(container, mainAnchor)
    } else if (target) {
      mount(target, targetAnchor)
    }
    

整个创建的流程如下图所示:

image-20220819155503234.png

更新节点

当旧节点(n1)不为 null 时会进行更新操作,步骤如下:

  1. 将旧节点中绑定的元素、锚点和目标节点直接赋值给新节点

    n2.el = n1.el
    const mainAnchor = (n2.anchor = n1.anchor)!
    const target = (n2.target = n1.target)!
    const targetAnchor = (n2.targetAnchor = n1.targetAnchor)!
    

注:表达式后面的!是 ts 的非空断言

  1. 根据 disabled 属性判断目标容器和目标锚点

    const wasDisabled = isTeleportDisabled(n1.props)
    const currentContainer = wasDisabled ? container : target
    const currentAnchor = wasDisabled ? mainAnchor : targetAnchor
    isSVG = isSVG || isTargetSVG(target)
    
  1. 当需要更新的节点中存在动态子节点(dynamicChildren)的时候,就可以通过patchBlockChildren仅对动态子节点部分进行更新(静态节点就不更新),此处是 vue3.x相对于 vue2.x在 diff 上做的一个比较大的性能优化处理。

    if (dynamicChildren) {
      patchBlockChildren(
        n1.dynamicChildren!,
        dynamicChildren,
        currentContainer,
        parentComponent,
        parentSuspense,
        isSVG,
        slotScopeIds
      )
      traverseStaticChildren(n1, n2, true)  // 继承静态子节点
    }
    

    为了保证在动态块中的所有静态节点在热更新后依然能维持之前的层级结构,所以需要通过traverseStaticChildren方法做一些处理。简单看看它的代码,就是对n1中静态的子节点做了一个继承:

    export function traverseStaticChildren(n1: VNode, n2: VNode, shallow = false) {
      const ch1 = n1.children
      const ch2 = n2.children
      if (isArray(ch1) && isArray(ch2)) {
        for (let i = 0; i < ch1.length; i++) {
          const c1 = ch1[i] as VNode
          let c2 = ch2[i] as VNode
          if (c2.shapeFlag & ShapeFlags.ELEMENT && !c2.dynamicChildren) {
            if (c2.patchFlag <= 0 || c2.patchFlag === PatchFlags.HYDRATE_EVENTS) {
              c2 = ch2[i] = cloneIfMounted(ch2[i] as VNode)
              c2.el = c1.el
            }
            if (!shallow) traverseStaticChildren(c1, c2)
          }
          if (__DEV__ && c2.type === Comment && !c2.el) {
            c2.el = c1.el
          }
        }
      }
    }
    
  1. 当没有dynamicChildren时,并且没有开启优化模式(optimized),就使用patchChildren走全量的 diff,这就是上文提到的在热更新的时候需要将optimized = false;dynamicChildren = null这两个变量这样处理的原因

    patchChildren(
      n1,
      n2,
      currentContainer,
      currentAnchor,
      parentComponent,
      parentSuspense,
      isSVG,
      slotScopeIds,
      false
    )
    
  2. 处理完节点“内容”的更新,就要处理 teleport 中属性变更的更新了。

    Teleport 的属性有俩个,分别是 disabledto,因此需要处理对应的变更逻辑组合起来一共有五种

    • disabled属性从 false 改为 true(此时就不需要考虑 to 属性的变更了)

      if (disabled) {
        if (!wasDisabled) {
          moveTeleport(
            n2,
            container,
            mainAnchor,
            internals,
            TeleportMoveTypes.TOGGLE
          )
        }
      }
      

      此时需要将 teleport 移动到容器原来的位置

    • 仅将 disabled 属性从 true 改为 false,to 属性值不变;

      仅改变 to 属性值,disabled 一直为 false 没有改变;

      同时将 disabled 属性从 true 改为 false且改变 to 属性值

      注意:以上三种情况就会走以下逻辑

      if ((n2.props && n2.props.to) !== (n1.props && n1.props.to)) {
        const nextTarget = (n2.target = resolveTarget(
          n2.props,
          querySelector
        ))
        if (nextTarget) {
          moveTeleport(
            n2,
            nextTarget,
            null,
            internals,
            TeleportMoveTypes.TARGET_CHANGE
          )
        } else if (__DEV__) {
          warn(
            'Invalid Teleport target on update:',
            target,
            `(${typeof target})`
          )
        }
      }
      

      当然,判断更新后的目标节点是否有效也是必要的,无效的话开发环境会抛出警告

    • 仅将 disabled 属性从 true 改为 false

       else if (wasDisabled) {
         moveTeleport(
           n2,
           target,
           targetAnchor,
           internals,
           TeleportMoveTypes.TOGGLE
         )
       }
      

      此时只需要将 teleport 移动到目标位置即可

这样,我们就完成了 teleport 组件的更新部分,整个更新流程如下图所示:

image-20220819161016602.png

remove

remove方法负责组件的删除逻辑

移除组件是通过渲染器中的unmount方法,其中对于 teleport/keepalive/suspense 等内置组件都会走内置组件自身的卸载逻辑:

const unmount: UnmountFn = (
    vnode,
    parentComponent,
    parentSuspense,
    doRemove = false,
    optimized = false
  ) => {
  ...
  if (shapeFlag & ShapeFlags.TELEPORT) {
    ;(vnode.type as typeof TeleportImpl).remove(
      vnode,
      parentComponent,
      parentSuspense,
      optimized,
      internals,
      doRemove
    )
  }
  ...
}

Teleport 组件会调用 remove 方法来卸载它本身。

首先会将目标节点 target 挂载的锚点节点 targetAnchor 移除

const { shapeFlag, children, anchor, targetAnchor, target, props } = vnode
​
if (target) {
  hostRemove(targetAnchor!)
}

接着会去移除 Teleport 的锚点节点 anchor (即process中生成的注释节点),

并使用递归的方式将 Teleport 的子节点全部删除

if (doRemove || !isTeleportDisabled(props)) {
  hostRemove(anchor!) // 移除锚点节点
  if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
    for (let i = 0; i < (children as VNode[]).length; i++) {
      const child = (children as VNode[])[i]
      unmount(
        child,
        parentComponent,
        parentSuspense,
        true,
        !!child.dynamicChildren
      )
    }
  }
}

remove 的流程图如下:

image-20220819164558017.png

move

move 方法用来移动 Teleport 组件

export const TeleportImpl = {
  ...
  // 移动节点逻辑
  move: moveTeleport,
  ...
}

我们同样能在渲染器模块(renderer.ts)中看到,对于渲染器的移动节点方法,Teleport 仍然需要走自己内部的逻辑

const move: MoveFn = (
    vnode,
    container,
    anchor,
    moveType,
    parentSuspense = null
  ) => {
  ...
  if (shapeFlag & ShapeFlags.TELEPORT) {
    ;(type as typeof TeleportImpl).move(vnode, container, anchor, internals)
    return
  }
  ...
}

在组件内的方法名叫做moveTeleport,细心的小伙伴应该已经发现了,在上文 process中的更新节点逻辑会用到moveTeleport

process() {
  if(n1 === null) {
    ....
  } else {
    // 属性变更部分会调用 moveTeleport
    if (disabled) {
      if (!wasDisabled) {
        moveTeleport(
          n2,
          container,
          mainAnchor,
          internals,
          TeleportMoveTypes.TOGGLE  // 节点移动类型
        )
      }
    }
    ...
}

其中会根据 TeleportMoveTypes判断节点的移动类型,TeleportMoveTypes是个枚举值,一共三种情况:

export const enum TeleportMoveTypes {
  TARGET_CHANGE, // to 属性值:目标节点 target 发生改变
  TOGGLE, // disabled 属性值发生改变
  REORDER // 非新增元素的节点重排
}

moveTeleport就会对上述三种不同情况作不同的处理

  1. 首先会判断目标节点(target)是否有变更,有变更的话将目标节点的锚点(targetAnchor)插入到新的容器位置

    if (moveType === TeleportMoveTypes.TARGET_CHANGE) {
      insert(vnode.targetAnchor!, container, parentAnchor)
    }
    
  1. 接着判断是否是REORDER类型,是的话将对应元素插入主视图中即可

    const { el, anchor, shapeFlag, children, props } = vnode
    const isReorder = moveType === TeleportMoveTypes.REORDER
    if (isReorder) {
      insert(el!, container, parentAnchor)
    }
    

    什么是REORDER类型?

    我们在 DOM diff 过程中的patchKeyedChildren方法(renderer.ts)中可以找到如下代码:

    const patchKeyedChildren = () => {
      ...
       else if (moved) {
         if (j < 0 || i !== increasingNewIndexSequence[j]) {
           move(nextChild, container, anchor, MoveType.REORDER)
         } else {
           j--
         }
       }
      ...
    }
    

    这是在 dom diff 执行过程中处理未知子序列的一种情况:对于一个非新增的节点,当没有最长递增子序列(j < 0)或者当前的节点索引不在最长递增子序列中(i !== increasingNewIndexSequence[j]),就需要移动该节点

  1. 与之相反的,当移动类型不是REORDER,或者 Teleport 被禁用(disabled 属性设为 true)时,需要移动所有的子节点到 container

     if (!isReorder || isTeleportDisabled(props)) {
       if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
         for (let i = 0; i < (children as VNode[]).length; i++) {
           move(
             (children as VNode[])[i],
             container,
             parentAnchor,
             MoveType.REORDER
           )
         }
       }
     }
    
  1. 最后再对非REORDER类型节点的锚点节点(注释节点)同步插入到主视图(container)中即完成移动

    if (isReorder) {
      insert(anchor!, container, parentAnchor)
    }
    

move方法整体流程如下图:

image-20220819202024267.png

hydrate

hydrate函数是 vuejs 进行同构渲染(isomorphic) 过程中对 Teleport 节点的激活处理

SSR(服务端渲染) 和 CSR(客户端渲染)各有优劣, vuejs 就将这俩者的优点融为一体,就是同构渲染

同构渲染分为首次渲染和非首次渲染两者渲染方式。

即在首次进入页面/刷新的时候使用 ssr 的方式渲染页面,提升首屏加载速度和提供友好的 SEO;

接着使用htdration激活已经渲染的静态页面,激活完成后,整个应用就成为 CSR 应用了,对于用户来说能够拥有更好的交互体验

老规矩,先找到这个函数(hydrate)被调用的地方,是在runtime-core/src/hydration.ts中的hydrateNode函数里

const hydrateNode = (
    node: Node,
    vnode: VNode,
    parentComponent: ComponentInternalInstance | null,
    parentSuspense: SuspenseBoundary | null,
    slotScopeIds: string[] | null,
    optimized = false
) {
  const { type, ref, shapeFlag, patchFlag } = vnode
  ...
  switch (type) {
    default:
     if (shapeFlag & ShapeFlags.TELEPORT) {
       if (domType !== DOMNodeTypes.COMMENT) {
         nextNode = onMismatch()
       } else {
         nextNode = (vnode.type as typeof TeleportImpl).hydrate(  // 此处调用 hydratre 函数
           node,
           vnode as TeleportVNode,
           parentComponent,
           parentSuspense,
           slotScopeIds,
           optimized,
           rendererInternals,
           hydrateChildren
         )
       }
     }
    ...
    return  nextNode
}

同构渲染中首次渲染页面(服务端渲染)的过程中,会忽略页面中已存在 DOM 与 虚拟节点对象之间的关系,也会忽略虚拟节点中与事件相关的 props,所以当组件代码在客户端中运行时,我们需要将这些事件正确地绑定到元素上,这个过程被称之为客户端激活。

客户端激活需要两个处理:

  • 为页面中的 DOM 元素与虚拟节点对象之间建立联系
  • 为页面中的 DOM 元素添加事件绑定

hydrateNode函数就是用来客户端激活的。

接下来我们逐行解析下hydrateTeleport函数吧

  1. 首先会去解析 Teleport 中传入的 to 属性,从而获取目标节点 target

    const target = (vnode.target = resolveTarget<Element>(
      vnode.props,
      querySelector
    ))
    

    resolveTarget就是校验这个属性值是否合法的函数,如果合法就返回该值

    const resolveTarget = <T = RendererElement>(
      props: TeleportProps | null,
      select: RendererOptions['querySelector']
    ): T | null => {
      const targetSelector = props && props.to
      if (isString(targetSelector)) {
        // 此处省略对 targetSelector 是否合法的一系列校验代码
        return targetSelector as any
      }
    }
    
  1. 如果目标节点 target 存在且合法,首先需要做的事情就是让虚拟节点能够引用到对应的真实 DOM 节点

    不论是真实 DOM 元素还是虚拟 DOM 都是树形结构,并且节点之间存在一一对应的关系,因此在激活过程中需要从容器元素的第一个子节点开始,逐层往下。

    Teleport 需要从目标节点的尾结点(target._lpa)开始处理,当不存在尾节点(目标节点为空)时才会从第一个子节点开始

    const targetNode =
          (target as TeleportTargetElement)._lpa || target.firstChild
    

    为啥要从尾节点开始处理?

    还记得我们使用 Teleport 后页面的 DOM结构吗?

    image-20220822162419785.png

    可以看到 diablog 组件是放在 body 最尾端的,相当于 diablog 组件是后面插入到 body 中的,这里有个逻辑上的先后顺序,所以需要从目标节点的尾节点开始处理。

  1. 接着就是激活 Teleport 中的子节点了,这里分了俩种情况:

    • 当 Teleport 开启了禁用属性时(disabled = true),仅对兄弟节点进行激活:

      if (isTeleportDisabled(vnode.props)) {
        vnode.anchor = hydrateChildren(
          nextSibling(node),  // 激活下一个兄弟节点
          vnode,
          parentNode(node)!,
          parentComponent,
          parentSuspense,
          slotScopeIds,
          optimized
        )
        vnode.targetAnchor = targetNode
      }
      
    • 当没有禁用时需要先一个一个地向后找到目标节点的锚点节点(注释节点),并将其引入 vnode 中

      let targetAnchor = targetNode
      while (targetAnchor) {
        targetAnchor = nextSibling(targetAnchor)
        if (
          targetAnchor &&
          targetAnchor.nodeType === 8 &&
          (targetAnchor as Comment).data === 'teleport anchor'
        ) {
          vnode.targetAnchor = targetAnchor // 获取目标节点的锚点节点
          ;(target as TeleportTargetElement)._lpa =
            vnode.targetAnchor && nextSibling(vnode.targetAnchor as Node)
          break
        }
      }
      

      这里不能通过hydrateChildren()返回值来判断,因为 Teleport 是可以嵌套使用的。

      然后再激活目标节点

      hydrateChildren(
        targetNode,   // 激活目标节点
        vnode,        
        target,
        parentComponent,
        parentSuspense,
        slotScopeIds,
        optimized
      )
      
  1. 最后是根据锚点节点判断是是否返回下一个需要处理的兄弟节点,以便后续节点的激活

    return vnode.anchor && nextSibling(vnode.anchor as Node)
    

整体流程如下

image-20220822165627871.png

总结

本文我们介绍了 Teleport 的使用,以及背后设计过程与原理。

teleport 一个简简单单的组件,背后的代码却并不简单,涉及到组件的创建、更新、移除、客户端激活(hydrate)。

将整个 Teleport 源码流程图串联一下如下:

teleport 流程解析.drawio.png

扩展衍生

既然已经看到这里了,不妨再扩充下知识面吧!

那么在 vue3 出来之前,在 vue2 我要怎样实现 Teleport 的效果呢?

一般是依赖其他开源插件,比如 vue-dom-portal,哈哈,其实 teleport 之前就叫作 portal ,是在后面换的名

这个插件的核心实现也大同小异,是直接通过 DOM API 实现的 DOM 节点的转移:

// 获取目标节点
function getTarget (node = document.body) {
  if (node === true) return document.body
  return node instanceof window.Node ? node : document.querySelector(node)
}
​
inserted (el, { value }, vnode) {
  // 获取父节点
  const { parentNode } = el
  // 生成注释锚点节点
  const home = document.createComment('')
  let hasMovedOut = false
​
  if (value !== false) {
    // 将当前节点移入锚点节点
    parentNode.replaceChild(home, el) // moving out, el is no longer in the document
    // 将当前节点插入目标节点中
    getTarget(value).appendChild(el) // moving into new place
    hasMovedOut = true
  }
​
  if (!homes.has(el)) homes.set(el, { parentNode, home, hasMovedOut }) // remember where home is or should be
},

vue3 使用 Teleport 内置组件实现了这个功能可以说是在开发上提供了便利,也避免了直接移动 DOM 节点可能会带来的问题。

最后,创作不易,欢迎点赞收藏分享,笔者能力有限,如有错误或不当之处,恳请指正~

参考

Teleport | Vue.js

Teleport.ts | github

《Vue.js 设计与实现》