Vue3.0 新增了一个Teleport组件,开发者可以使用它将其所在组件模板的部分内容移动到特定的DOM位置,譬如body或者其他任意位置。

Vue 2.0要实现对应的功能则需要使用portal-vue三方库,或者使用$el操作DOM等来实现。

接下来我们就从使用方式和实现原理两个方面来分别介绍。

Teleport组件的使用

Teleport组件的使用很简单,把需要移动的内容包起来即可:
<teleport :to="body" :disabled="false">
    <div>需要移动的内容</div>
</teleport>

上面这些代码的表现结果是<div>需要移动的内容</div>会渲染在body上,而不是所在的组件的模板所在的位置。

Teleport有两个参数:

  1. to为需要移动的位置,可以是选择器也可以是DOM节点;
  2. disabled如果为true,内容不进行移动,disabled如果为false, 则Teleport包裹的元素节点会被移动到to的节点下。
例子:实现某部分内容在 组件的模板内, 子组件的模板内 和 body 间切换。
  • 子组件有一个#teleport1节点
<!--SubContainer.vue-->

<template>
  <div id="teleport1">
    <h4>子组件</h4>
  </div>
</template>
  • APP组件包含子组件,有一个按钮button切换位置 和 需要传送的内容 <div class="send_content">{{ showingString }}</div>
<template>
    <sub-container />
    <button class="btn" @click="changePosition">传送门</button>
    <teleport :to="to" :disabled="disabled">
      <div class="send_content">{{ showingString }}</div>
    </teleport>
</template>

<script lang="ts">
import SubContainer from "./components/SubContainer.vue";
import { defineComponent, ref } from "vue";

enum TeleportPosition {
  currentInstance, // 当前组件
  subInstance, // 子组件
  body, // body
}

export default defineComponent({
  name: "App",
  components: {
    SubContainer,
  },
  setup() {
    // 位置
    let position = ref(TeleportPosition.currentInstance);
    // 显示的字符串内容
    let showingString = ref("内容显示在APP组件内");
    // 是否禁用teleport
    let disabled = ref(true);
    // 挂载的DOM节点
    let to = ref("body");

    // 切换位置
    let changePosition = () => {
      if (position.value == TeleportPosition.currentInstance) {
        position.value = TeleportPosition.subInstance;
        showingString.value = "内容显示在子组件内";
        disabled.value = false;
        to.value = "#teleport1";
      } else if (position.value == TeleportPosition.subInstance) {
        position.value = TeleportPosition.body;
        showingString.value = "内容显示在body内";
        disabled.value = false;
        to.value = "body";
      } else {
        position.value = TeleportPosition.currentInstance;
        showingString.value = "内容显示在APP组件内";
        disabled.value = true;
        to.value = "body";
      }
    };

    return { showingString, to, disabled, changePosition };
  },
});
</script>
  • 上面这些代码就实现了 <div class="send_content">{{ showingString }}</div> 这部分DOM内容可以在 APP组件的DOM节点,子组件的DOM节点 和 body 上选择挂载。

elementuiplus vue3 router模板框架_属性值

Teleport组件的实现原理

Teleport组件的挂载

我们知道组件的挂载首先会进入patch函数:

<!-- render.ts -->
const patch: PatchFn = (
) => {
  // 省略其他...
  // 处理TELEPORT组件
  if (shapeFlag & ShapeFlags.TELEPORT) {
    ;(type as typeof TeleportImpl).process(
      n1 as TeleportVNode,
      n2 as TeleportVNode,
      container,
      anchor,
      parentComponent,
      parentSuspense,
      isSVG,
      slotScopeIds,
      optimized,
      internals
    )
  }
}

patch函数执行时如果发现VNodeTeleport组件,则执行对应TeleportImplprocess方法。

// 1. 在主视图插入注释节点或者空白文本节点
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(''))
if (target) {
  insert(targetAnchor, target)
  isSVG = isSVG || isTargetSVG(target)
}

const mount = (container: RendererElement, anchor: RendererNode) => {
  if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
    mountChildren(
      children as VNodeArrayChildren,
      container,
      anchor,
      parentComponent,
      parentSuspense,
      isSVG,
      slotScopeIds,
      optimized
    )
  }
}

// 3. 在目标元素插入`Teleport`组件的子节点
if (disabled) {
  mount(container, mainAnchor)
} else if (target) {
  mount(target, targetAnchor)
}

具体逻辑如下:

  1. 创建一个节点mainAnchor, 开发环境下是一个注释节点,在发布环境是一个空文本节点, 将这个创建的mainAnchor节点挂载在父组件对应的DOM节点下;
    2.使用querySelector找到Teleport组件to属性指定的节点target目标节点,然后在targetAnchor节点下创建一个空文本节点做为锚定节点;
    3.如果Teleport组件disabled属性值为true,将Teleport组件的子节点挂载在mainAnchorh,如果disabled属性值为false,将Teleport组件的子节点挂载在目标节点targetAnchor

elementuiplus vue3 router模板框架_子节点_02

elementuiplus vue3 router模板框架_属性值_03

Teleport组件的更新
// 数据
n2.el = n1.el
const mainAnchor = (n2.anchor = n1.anchor)!
const target = (n2.target = n1.target)!
const targetAnchor = (n2.targetAnchor = n1.targetAnchor)!
const wasDisabled = isTeleportDisabled(n1.props)
const currentContainer = wasDisabled ? container : target
const currentAnchor = wasDisabled ? mainAnchor : targetAnchor
isSVG = isSVG || isTargetSVG(target)

// 1. 更新子节点
if (dynamicChildren) {
  // fast path when the teleport happens to be a block root
  patchBlockChildren(
    n1.dynamicChildren!,
    dynamicChildren,
    currentContainer,
    parentComponent,
    parentSuspense,
    isSVG,
    slotScopeIds
  )
  traverseStaticChildren(n1, n2, true)
} else if (!optimized) {
  patchChildren(
    n1,
    n2,
    currentContainer,
    currentAnchor,
    parentComponent,
    parentSuspense,
    isSVG,
    slotScopeIds,
    false
  )
}

// 根据disabled 和 to 进行分别操作
if (disabled) {
  if (!wasDisabled) {
    moveTeleport(n2, container, mainAnchor, internals, TeleportMoveTypes.TOGGLE)
  }
} else {
  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 (wasDisabled) {
    moveTeleport(n2, target, targetAnchor, internals, TeleportMoveTypes.TOGGLE)
  }
}

具体流程如下:

  1. 更新子节点,分为全量更新和优化更新;
  2. 如果新节点disabledtrue,而旧节点disabledfalse,把新节点移回到主视图节点mainAnchor;
  3. 如果新节点disabledfalseto节点有变化,则把新节点移动到to节点;
  4. 如果新节点disabledfalseto节点没有变化,如果旧节点disabledtrue, 新节点从到主视图节点移动到目标节点targetAnchor;
    至此,更新节点完成。
Teleport组件的移除

我们知道组件的卸载首先会进入unmount方法:

if (shapeFlag & ShapeFlags.TELEPORT) {
  ;(vnode.type as typeof TeleportImpl).remove(
    vnode,
    parentComponent,
    parentSuspense,
    optimized,
    internals,
    doRemove
  )
}

如果是Teleport组件,则直接调用TeleportImplremove方法;

remove(
    vnode: VNode,
    parentComponent: ComponentInternalInstance | null,
    parentSuspense: SuspenseBoundary | null,
    optimized: boolean,
    { um: unmount, o: { remove: hostRemove } }: RendererInternals,
    doRemove: Boolean
  ) {
    const { shapeFlag, children, anchor, targetAnchor, target, props } = vnode
    
    // 1. 
    if (target) {
      hostRemove(targetAnchor!)
    }

    // an unmounted teleport should always remove its children if not disabled
    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
          )
        }
      }
    }
  }

具体流程如下:

  1. 如果有目标元素,则先移除目标元素;
  2. 移除主视图的元素;
  3. 移除子节点元素;
    至此,移除节点完成。

一个思考题

<template>
    <button class="btn" @click="changePosition">传送门</button>
    <teleport :to="to" :disabled="disabled">
      <div class="send_content">{{ showingString }}</div>
    </teleport>
    <sub-container />
</template>

如果我们的案例中,子组件在Teleport组件的后面,此时Teleport组件是否能正常的显示?