由浅入深,彻底弄懂 Teleport 组件的实现原理

833 阅读15分钟

Teleport 组件解决的问题

Teleport 组件是 Vue3 新增的内置组件,用于实现将组件内部的一部分模板“传送”到该组件的 DOM 结构外层的位置去,类似于 React 的 Portals 。

比如我们在实现弹窗组件的时候,通常都需要实现弹窗的蒙层。实现弹窗的蒙层通常会用到固定定位(position: fixed;)。

👇 一个实现蒙层的 css 代码

.overlay-dialog {
  position: fixed;
  top: 0;
  right: 0;
  bottom: 0;
  left: 0;
  overflow: auto;
}

css 固定定位一般情况下是根据浏览器视口来定位,但是当固定定位的元素的祖先元素的 transformperspectivefilterbackdrop-filter 属性非 none 时,固定定位的元素会由浏览器视口改为该祖先元素。

如下面简单的例子,.wrap 元素没有使用 transform 属性时,.fixed-dom 元素是正常根据浏览器视口来定位的。

<div class="wrap">
  <div class="fixed-dom"></div>
</div>
.wrap {
  width: 1000px;
  height: 800px;
  margin: 50px auto;
  background-color: gray;
}

.fixed-dom {
  position: fixed;
  top: 0;
  left: 0;
  width: 500px;
  height: 500px;
  background-color: pink;
}

pic3.png

.wrap 元素使用 transform 属性时,.fixed-dom 元素便不再根据浏览器视口来定位了,而是根据其祖先元素 .wrap 来进行定位。

.wrap {
  width: 1000px;
  height: 800px;
  margin: 50px auto;
  background-color: gray;
  /* 注意! */
  transform: translate3d(0, 0, 1px);
}

pic4.png

所以当我们的弹窗组件的祖先组件的 dom 中如果存在 transformperspectivefilterbackdrop-filter 属性非 none 时,则会打乱弹窗组件蒙层的层级,从而产生样式问题。

因此,Vue.js / React 均提供了一种能力,可以将指定的 dom 渲染到特定容器中,而不受 dom 层级的限制。比如我们可以调整蒙层到 body 层级下面,这样蒙层样式便不会受到其祖先元素样式的影响

pic5.png

Teleport 组件的用法

Teleport 组件会将其插槽内容渲染到 dom 中的另一个位置。

Teleport 组件接收 todisabled 两个 props 。

  • to 指定目标容器,可以是选择器或实际元素。

  • disabled 为 true 时,插槽内容将保留在其原始位置,而不是移动到目标容器中。可动态更改。

指定目标容器:

<Teleport to="#some-id" />
<Teleport to=".some-class" />
<Teleport to="[data-teleport]" />

有条件地禁用:

<Teleport to="#popup" :disabled="displayVideoInline">
  <video src="./my-movie.mp4">
</Teleport>

Teleport 组件源码分析

从整体上了解 Teleport 组件源码实现

与其他内置组件一样,Teleport 组件的实现也依赖于渲染器的底层支持。但是为了避免渲染器逻辑代码“膨胀”当用户没有使用 Teleport 组件时,可以利用 TreeShaking 机制在最终的 bundle 中删除 Teleport 相关的代码,使得最终构建包的体积变小,Vue 已将 Teleport 组件的渲染逻辑从渲染器中分离出来。

// packages/runtime-core/src/renderer.ts

const patch: PatchFn = (
  n1,
  n2,
  container,
  anchor = null,
  parentComponent = null,
  parentSuspense = null,
  isSVG = false,
  slotScopeIds = null,
  optimized = __DEV__ && isHmrUpdating ? false : !!n2.dynamicChildren
) => {
  // ...
  else if (shapeFlag & ShapeFlags.TELEPORT) {
    // 调用 Teleport 组件自身的 process 方法实现组件的创建或更新
    ;(type as typeof TeleportImpl).process(
      n1 as TeleportVNode,
      n2 as TeleportVNode,
      container,
      anchor,
      parentComponent,
      parentSuspense,
      isSVG,
      slotScopeIds,
      optimized,
      internals
    )
  }
  // ...
}
// packages/runtime-core/src/renderer.ts

const unmount: UnmountFn = (
  vnode,
  parentComponent,
  parentSuspense,
  doRemove = false,
  optimized = false
) => {
  // ...
  if (shapeFlag & ShapeFlags.TELEPORT) {
    // 调用 Teleport 组件自身的 remove 方法实现组件的删除
    ;(vnode.type as typeof TeleportImpl).remove(
      vnode,
      parentComponent,
      parentSuspense,
      optimized,
      internals,
      doRemove
    )
  }
}
// packages/runtime-core/src/renderer.ts

const move: MoveFn = (
  vnode,
  container,
  anchor,
  moveType,
  parentSuspense = null
) => {
  // ...
  if (shapeFlag & ShapeFlags.TELEPORT) {
    // 调用 Teleport 组件自身的 move 方法实现组件的移动
    ;(type as typeof TeleportImpl).move(vnode, container, anchor, internals)
    return
  }
  // ...
}

Teleport 组件其实就是对象,该对象包含 1 个属性,4 个方法

pic6.png

Tips: 本文中的源码均摘自 Vue.js 3.2.45,为了方便理解,会省略与本文主题无关的代码

  • __isTeleport 属性值为 true ,是 Teleport 组件独有标识,可通过该属性来判断组件是否为 Teleport 组件
// packages/runtime-core/src/components/Teleport.ts

export const isTeleport = (type: any): boolean => type.__isTeleport
  • process 方法负责组件的创建和更新逻辑

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

  • move 方法负责组件的移动逻辑

  • hydrate 负责同构渲染过程中的客户端激活

process 方法分析

process 方法负责组件的创建和更新逻辑。

当旧的虚拟节点 n1 不存在时,走创建逻辑,否则走更新逻辑

// packages/runtime-core/src/components/Teleport.ts

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 {
    // 更新逻辑
  }
}

在创建或更新组件时,会依赖一些渲染器的方法

// packages/runtime-core/src/components/Teleport.ts

const {
  mc: mountChildren,
  pc: patchChildren,
  pbc: patchBlockChildren,
  o: { insert, querySelector, createText, createComment }
} = internals
  • mc 用来挂载子节点

  • pc 用来更新节点

  • pbc 用来更新块节点

  • o 作为渲染器的配置项,提供了插入节点、查询选择器、创建文本节点、创建注释节点四个功能

然后获取 Teleport 组件 props 中 disabled 的值

// packages/runtime-core/src/components/Teleport.ts

const disabled = isTeleportDisabled(n2.props)

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

由于在热更新时,会出现重复挂载/卸载的问题,具体 issue 为 HMR adds changes twice when using teleport,所以这里会进行一下判断,当在 DEV 环境,并且在热更新,则将 optimized 设为 false ,dynamicChildren 设为 null ,强制走全量 diff ,避免热更新带来的问题。

// packages/runtime-core/src/components/Teleport.ts

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

创建节点

process 方法执行的过程中,会先判断旧的虚拟节点(n1)是否存在,如果不存在就会创建节点

// packages/runtime-core/src/components/Teleport.ts

if (n1 == null) {
  // 创建节点
}

在创建过程中,会先判断是否是开发环境,如果是开发环境,则创建注释节点,否则创建空的文本节点

// packages/runtime-core/src/components/Teleport.ts

// insert anchors in the main view
const placeholder = (n2.el = __DEV__
  ? createComment('teleport start')
  : createText(''))
const mainAnchor = (n2.anchor = __DEV__
  ? createComment('teleport end')
  : createText(''))

然后向原容器(Teleport 组件存在的地方)插入注释节点或空文本节点,作为占位和锚点

// packages/runtime-core/src/components/Teleport.ts

insert(placeholder, container, anchor)
insert(mainAnchor, container, anchor)

具体看这个例子:

<script src="../../dist/vue.global.js"></script>

<div id="demo">
  <div>
    <Teleport to="body">
      <p>俺在 body 标签下面</p>
    </Teleport>
  </div>
</div>

<script>
Vue.createApp().mount('#demo')
</script>

在这个例子中,使用 Teleport 组件将 p 标签传送到 body 标签下。可以看到在 Teleport 组件的存在的地方,发现了两个注释节点,这两个注释节点就是在此时被创建的。

<!--teleport start-->
<!--teleport end-->

pic7.png

创建完用于占位和定位的锚点后,会依据用户传入的 to 属性获取目标节点

// packages/runtime-core/src/components/Teleport.ts

const target = (n2.target = resolveTarget(n2.props, querySelector))
// packages/runtime-core/src/components/Teleport.ts

const resolveTarget = <T = RendererElement>(
  props: TeleportProps | null,
  select: RendererOptions['querySelector']
): T | null => {
  const targetSelector = props && props.to
  if (isString(targetSelector)) {
    if (!select) {
      __DEV__ &&
        warn(
          `Current renderer does not support string target for Teleports. ` +
            `(missing querySelector renderer option)`
        )
      return null
    } else {
      const target = select(targetSelector)
      if (!target) {
        __DEV__ &&
          warn(
            `Failed to locate Teleport target with selector "${targetSelector}". ` +
              `Note the target element must exist before the component is mounted - ` +
              `i.e. the target cannot be rendered by the component itself, and ` +
              `ideally should be outside of the entire Vue component tree.`
          )
      }
      return target as T
    }
  } else {
    if (__DEV__ && !targetSelector && !isTeleportDisabled(props)) {
      warn(`Invalid Teleport target: ${targetSelector}`)
    }
    return targetSelector as T
  }
}

获取到目标节点(target)后,会创建一个目标节点的锚点节点(空文本元素),在 Teleport 组件没有 disabled 的情况下,该锚点会被用于 Teleport 组件的子节点插入到目标节点时的定位节点。

const targetAnchor = (n2.targetAnchor = createText(''))

接着判断目标节点是否存在,存在的话则将锚点(targetAnchor)插入到目标节点上

if (target) {
  // 将描点节点插入到目标节点上
  insert(targetAnchor, target)
  isSVG = isSVG || isTargetSVG(target)
} else if (__DEV__ && !disabled) {
  warn('Invalid Teleport target on mount:', target, `(${typeof target})`)
}

这里对 svg 类型的目标节点作了额外判断。原因是在挂载节点之前需要先创建对应的元素,svg 元素相对于普通元素需要通过 doc.createElementNS ,所以需要这一步判断做区分。具体可见这个 PR: fix: Teleport SVG elements

// packages/runtime-dom/src/nodeOps.ts

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
}

接着定义 mount 方法,并且要挂载的新节点(n2)是数组类型的才会进行挂载。并且在注释中也说明, Teleport 组件的子节点必须是数组类型,且会被强制运用于编译器和虚拟子节点的标准化中。

// packages/runtime-core/src/components/Teleport.ts

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
    )
  }
}

最后对 disabled 变量进行判断,如果为 true ,则挂载在原先位置(container),为 false ,则挂载到目标位置(target)下。

// packages/runtime-core/src/components/Teleport.ts

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

为了让大家有更加直观的了解,看这个例子:

<script src="../../dist/vue.global.js"></script>


<div id="demo">
  <div>
    <Teleport to="body" disabled>
      <p>俺不在 body 标签下面</p>
    </Teleport>
  </div>
</div>

<script>
Vue.createApp().mount('#demo')
</script>

在这里例子中,Teleport 组件被 disabled 了,p 标签最终挂载在 Teleport 组件原先的位置上:

pic8.png

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

pic10.png

Teleport 组件创建节点的流程结束,接下来看更新节点的流程

更新节点

当旧的虚拟节点(n1)不为 null 时会进行更新操作。在更新操作中,首先是一些初始化,将旧节点中绑定的元素、锚点元素和目标节点直接赋值给新节点:

// packages/runtime-core/src/components/Teleport.ts

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

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

根据旧虚拟节点(n1)的 disabled 属性判断目标容器和目标锚点,如果 disabled 为 true ,挂载点就是周围父组件,否则就是 to 指定的目标挂载点。同样也会通过调用 isTargetSVG 判断目标挂载点是否是 svg 元素,避免在 svg 元素中使用 Teleport 组件出现问题:

// packages/runtime-core/src/components/Teleport.ts

const wasDisabled = isTeleportDisabled(n1.props)
const currentContainer = wasDisabled ? container : target
const currentAnchor = wasDisabled ? mainAnchor : targetAnchor
isSVG = isSVG || isTargetSVG(target)

当新的节点(n2)中存在动态子节点(dynamicChildren),就可以通过patchBlockChildren 仅对动态子节点部分进行更新(静态节点就不更新),此处是 Vue3.x 相对于 Vue2.x 在 diff 上做的一个比较大的性能优化处理。

// packages/runtime-core/src/components/Teleport.ts

if (dynamicChildren) {
  // fast path when the teleport happens to be a block root
  patchBlockChildren(
    n1.dynamicChildren!,
    dynamicChildren,
    currentContainer,
    parentComponent,
    parentSuspense,
    isSVG,
    slotScopeIds
  )
  // even in block tree mode we need to make sure all root-level nodes
  // in the teleport inherit previous DOM references so that they can
  // be moved in future patches.
  traverseStaticChildren(n1, n2, true)
}

Vue3.x 对 Vue2.x 在 diff 上做了一个性能优化,就是 Vue3.x 会对模板进行分析,分析出动态的节点和静态的节点,然后在 diff 的时候仅对动态的节点进行 diff ,从而提升 diff 的性能。对于动态节点会存储在虚拟节点的 dynamicChildren 属性中。

traverseStaticChildren 函数用于让新节点(n2)继承旧节点(n1)上的静态节点,主要为了热更新后,让静态节点一直维持之前的层级结构。

// packages/runtime-core/src/renderer.ts

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++) {
      // this is only called in the optimized path so array children are
      // guaranteed to be vnodes
      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)
      }
      // #6852 also inherit for text nodes
      if (c2.type === Text) {
        c2.el = c1.el
      }
      // also inherit for comment nodes, but not placeholders (e.g. v-if which
      // would have received .el during block patch)
      if (__DEV__ && c2.type === Comment && !c2.el) {
        c2.el = c1.el
      }
    }
  }
}

当没有 dynamicChildren 并且没有开启优化模式 optimized ,就使用 patchChildren 走全量 diff :

// packages/runtime-core/src/components/Teleport.ts

if (dynamicChildren) {
  // ...
} else if (!optimized) {
  // 走全量 diff
  patchChildren(
    n1,
    n2,
    currentContainer,
    currentAnchor,
    parentComponent,
    parentSuspense,
    isSVG,
    slotScopeIds,
    false
  )
}

出来完 Teleport 组件“内容”的更新,就要处理 Teleport 组件中属性变更的更新了。

Teleport 组件的 props 有两个,分别是 disabledto

disabled 属性从 false 变为 true 时(此时就不需要考虑 to 属性的变更了),只需调用 moveTeleport 函数,将 Teleport 组件的子节点移动到容器原来的位置,即 Teleport 组件下面:

// packages/runtime-core/src/components/Teleport.ts

if (disabled) {
  if (!wasDisabled) {
    // enabled -> disabled
    // move into main container
    moveTeleport(
      n2,
      container,
      mainAnchor,
      internals,
      TeleportMoveTypes.TOGGLE
    )
  }
}

pic9.png

当新虚拟节点(n2) disabled 为 false,并且 to 属性发生变更,则重新获取目标节点,然后将新虚拟节点(n2)移动到新目标节点:

// packages/runtime-core/src/components/Teleport.ts

// target changed
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})`
    )
  }
}

如果 to 属性没有变化,而是 disabled 由 true 变为 false ,则将新虚拟节点移动到目标节点:

// packages/runtime-core/src/components/Teleport.ts

else if (wasDisabled) {
  // disabled -> enabled
  // move into teleport target
  moveTeleport(
    n2,
    target,
    targetAnchor,
    internals,
    TeleportMoveTypes.TOGGLE
  )
}

整个 Teleport 组件的更新流程如下:

pic11.png

remove 方法分析

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

// packages/runtime-core/src/components/Teleport.ts

remove(
  vnode: VNode,
  parentComponent: ComponentInternalInstance | null,
  parentSuspense: SuspenseBoundary | null,
  optimized: boolean,
  { um: unmount, o: { remove: hostRemove } }: RendererInternals,
  doRemove: Boolean
) {
  // ...
}

首先会判断是否存在目标节点 target,存在的话,移除目标节点 target 挂载的锚点节点 targetAnchor

// packages/runtime-core/src/components/Teleport.ts

const { shapeFlag, children, anchor, targetAnchor, target, props } = vnode

if (target) {
  hostRemove(targetAnchor!)
}

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

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

// packages/runtime-core/src/components/Teleport.ts

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
      )
    }
  }
}

卸载组件都会调用渲染器的 unmount 函数 ,因此说是递归的方式删除 Teleport 组件的全部子节点。

在渲染器的 unmount 函数中,调用 Teleport 组件自身的 remove 方法删除自身:

// packages/runtime-core/src/renderer.ts

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 组件的整体删除流程总结如下:

pic12.png

move 方法分析

move 方法用于负责 Teleport 组件的移动逻辑

// packages/runtime-core/src/components/Teleport.ts

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

在渲染器内部可以看到,对于渲染器的移动节点的方法,Teleport 组件会调用自己内部的 move 方法:

// packages/runtime-core/src/renderer.ts

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

Teleport 内部实现 move 方法的函数是 moveTeleport

// packages/runtime-core/src/components/Teleport.ts

function moveTeleport(
  vnode: VNode,
  container: RendererElement,
  parentAnchor: RendererNode | null,
  { o: { insert }, m: move }: RendererInternals,
  moveType: TeleportMoveTypes = TeleportMoveTypes.REORDER
) {
  // ...
}

细心的读者也许发现,在 Teleport 组件的更新逻辑(process)中,已经用到了 moveTeleport 函数:

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

moveTeleport 会根据 TeleportMoveTypes 的枚举值判断节点的移动类型

// packages/runtime-core/src/components/Teleport.ts

export const enum TeleportMoveTypes {
  TARGET_CHANGE,
  TOGGLE, // enable / disable
  REORDER // moved in the main view
}
  • TARGET_CHANGETeleport 组件的 to 属性发生变更,目标节点 target 发生改变

  • TOGGLETeleport 组件的 disabled 属性发生变更

  • REORDER,在 diff 过程中,对于非新增的节点不在最长递增子序列或当前节点索引不在最长递增子序列中的情况,就需要移动该节点

对于 Vue3 Diff 算法更多内容可见笔者的另一篇文章:Vue3 提升 Diff 算法性能的关键是什么?

moveTeleport 根据上述不同情况做不同的处理。

首先会判断目标节点(target)是否有变更(即 to 属性发生变更),有变更的话将目标节点的锚点(targetAnchor)插入到新的容器位置:

// packages/runtime-core/src/components/Teleport.ts

if (moveType === TeleportMoveTypes.TARGET_CHANGE) {
  insert(vnode.targetAnchor!, container, parentAnchor)
}

接着判断是否是 REORDER 类型,是的话将对应元素、锚点节点(注释节点)插入主视图中即可:

// packages/runtime-core/src/components/Teleport.ts

const { el, anchor, shapeFlag, children, props } = vnode
const isReorder = moveType === TeleportMoveTypes.REORDER
// move main view anchor if this is a re-order.
if (isReorder) {
  insert(el!, container, parentAnchor)
}

// ...

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

与之相反的,当移动类型不是 REORDER,或者 Teleport 组件被禁用(disabled 属性为 true)时,需要移动所有的子节点到原容器 container 中:

// packages/runtime-core/src/components/Teleport.ts

if (!isReorder || isTeleportDisabled(props)) {
  // Teleport has either Array children or no children.
  if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
    for (let i = 0; i < (children as VNode[]).length; i++) {
      move(
        (children as VNode[])[i],
        container,
        parentAnchor,
        MoveType.REORDER
      )
    }
  }
}

Teleport 组件 move 方法整体流程如下:

pic13.png

hydrate 方法分析

hydrate 方法是 Vue.js 进行同构渲染过程中对 Teleport 组件的激活处理。

同构渲染 就是初始加载页面的时候采用服务端渲染,提升首屏加载速度和提供友好的 SEO ,接着使用 hydrate 激活已经渲染的静态页面,将服务端渲染的应用变成客户端渲染的应用,提升页面的交互体验。

可以说,同构渲染将服务端渲染和客户端渲染的优点结合在了一起。

// packages/runtime-core/src/hydration.ts

const hydrateNode = (
  node: Node,
  vnode: VNode,
  parentComponent: ComponentInternalInstance | null,
  parentSuspense: SuspenseBoundary | null,
  slotScopeIds: string[] | null,
  optimized = false
): Node | null => {
  // ...
  else if (shapeFlag & ShapeFlags.TELEPORT) {
    if (domType !== DOMNodeTypes.COMMENT) {
      nextNode = onMismatch()
    } else {
      // 调用 Teleport 组件自身的 hydrate 方法进行客户端激活
      nextNode = (vnode.type as typeof TeleportImpl).hydrate(
        node,
        vnode as TeleportVNode,
        parentComponent,
        parentSuspense,
        slotScopeIds,
        optimized,
        rendererInternals,
        hydrateChildren
      )
    }
  }
}

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

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

  • 为页面中的 DOM 元素与虚拟节点对象之间建立联系

  • 为页面中的 DOM 元素添加事件绑定

Teleport 组件的 hydrate 方法是由 hydrateTeleport 函数实现的。

// packages/runtime-core/src/components/Teleport.ts

function hydrateTeleport(
  node: Node,
  vnode: TeleportVNode,
  parentComponent: ComponentInternalInstance | null,
  parentSuspense: SuspenseBoundary | null,
  slotScopeIds: string[] | null,
  optimized: boolean,
  {
    o: { nextSibling, parentNode, querySelector }
  }: RendererInternals<Node, Element>,
  hydrateChildren: (
    node: Node | null,
    vnode: VNode,
    container: Element,
    parentComponent: ComponentInternalInstance | null,
    parentSuspense: SuspenseBoundary | null,
    slotScopeIds: string[] | null,
    optimized: boolean
  ) => Node | null
): Node | null {
  // ...
}

首先通过 resolveTarget ,获取目标节点 target

// packages/runtime-core/src/components/Teleport.ts

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

如果目标节点(target)存在,则获取上一个传送完成的目标节点 _lpa,不存在上一个传送完成的目标节点才获取目标节点的第一个子节点

// packages/runtime-core/src/components/Teleport.ts

// if multiple teleports rendered to the same target element, we need to
// pick up from where the last teleport finished instead of the first node
const targetNode =
  (target as TeleportTargetElement)._lpa || target.firstChild

lpa 是 last teleport target 的缩写,意为上一个传送完成的目标节点

为啥要从上一个传送完成的目标节点开始处理?

因为当页面有多个 Teleport 组件将内容渲染到同一个目标节点时,从上一个传送完成的目标节点做激活,才能将所有的 Teleport 组件激活。

<script src="../../dist/vue.global.js"></script>

<div id="demo">
  <div>
    <Teleport to="body">
      <p>段落1</p>
    </Teleport>
    <Teleport to="body">
      <p>段落2</p>
    </Teleport>    
  </div>
</div>

<script>
Vue.createApp().mount('#demo')
</script>

pic14.png

接着就是激活 Teleport 组件中的子节点了,这里分为两种情况:

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

    // packages/runtime-core/src/components/Teleport.ts
    
    if (vnode.shapeFlag & ShapeFlags.ARRAY_CHILDREN) {  
      if (isTeleportDisabled(vnode.props)) {
        // Teleport 组件开启了禁用属性(disabled 为 true)
        vnode.anchor = hydrateChildren(
          nextSibling(node),
          vnode,
          parentNode(node)!,
          parentComponent,
          parentSuspense,
          slotScopeIds,
          optimized
        )
        vnode.targetAnchor = targetNode
      } else {
        // ...
      }
    }
    
  • Teleport 组件没开启禁用属性时(disabled 为 false),就先找到目标节点的锚点节点(即注释节点),向后遍历获取目标节点的锚点节点 targetAnchor

    // packages/runtime-core/src/components/Teleport.ts
    
    vnode.anchor = nextSibling(node)
    
    // lookahead until we find the target anchor
    // we cannot rely on return value of hydrateChildren() because there
    // could be nested teleports
    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
      }
    }
    

    在服务端渲染期间,目标节点的锚点会被替换为 <!--teleport anchor--> 注释文本,因此可通过判断是否为注释节点(nodeType 为 8),节点的 data 属性是否为 teleport anchor 来判断是否为目标节点的锚点节点:

    // packages/server-renderer/src/helpers/ssrRenderTeleport.ts
    
    export function ssrRenderTeleport(
    parentPush: PushFn,
    contentRenderFn: (push: PushFn) => void,
    target: string,
    disabled: boolean,
    parentComponent: ComponentInternalInstance
    ) {
      parentPush('<!--teleport start-->')
    
      const context = parentComponent.appContext.provides[
        ssrContextKey as any
      ] as SSRContext
      const teleportBuffers =
        context.__teleportBuffers || (context.__teleportBuffers = {})
      const targetBuffer = teleportBuffers[target] || (teleportBuffers[target] = [])
      // record current index of the target buffer to handle nested teleports
      // since the parent needs to be rendered before the child
      const bufferIndex = targetBuffer.length
    
      let teleportContent: SSRBufferItem
    
      if (disabled) {
        contentRenderFn(parentPush)
        // 将目标节点的锚点节点替换为 `<!--teleport anchor-->` 文本
        teleportContent = `<!--teleport anchor-->`
      } else {
        const { getBuffer, push } = createBuffer()
        contentRenderFn(push)
        // 将目标节点的锚点节点替换为 `<!--teleport anchor-->` 文本
        push(`<!--teleport anchor-->`)
        teleportContent = getBuffer()
      }
    
      targetBuffer.splice(bufferIndex, 0, teleportContent)
      parentPush('<!--teleport end-->')
    }
    

然后再激活目标节点:

// packages/runtime-core/src/components/Teleport.ts

hydrateChildren(
  targetNode, // 激活目标节点
  vnode,
  target,
  parentComponent,
  parentSuspense,
  slotScopeIds,
  optimized
)

最后根据锚点节点判断是否返回下一个需要处理的兄弟节点,有利于后面节点的激活:

// packages/runtime-core/src/components/Teleport.ts

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

Teleport 组件激活的整体流程如下:

pic15.png

Vue2 怎么办?

Teleport 组件是 Vue3 新增的组件,那 Vue2 中要怎么实现渲染脱离当前组件 dom 结构的内容?

只能通过原生 DOM API 来手动搬运 DOM 元素实现需求或借助开源的组件例如 vue-dom-portal 来实现

其实 vue-dom-portal 组件的原理和 Teleport 组件是类似的,都是通过 DOM API 手动搬运 DOM 实现脱离当前组件 dom 结构的渲染。

来看看 vue-dom-portal 组件的核心源码:

// 获取目标节点
function getTarget (node = document.body) {
  if (node === true) return document.body
  return node instanceof window.Node ? node : document.querySelector(node)
}
const homes = new Map()
const directive = {
  inserted (el, { value }, vnode) {
    // 获取 v-dom-portal 指令绑定元素的父节点
    const { parentNode } = el
    // 创建空的注释节点
    const home = document.createComment('')
    let hasMovedOut = false

    // v-dom-portal 指令传入的 value 不为 false
    if (value !== false) {
      // 用空注释节点替换 v-dom-portal 指令绑定的元素
      parentNode.replaceChild(home, el) // moving out, el is no longer in the document
      // 将 v-dom-portal 指令绑定的元素移动到目标节点下
      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
  },
  // ...
}

可以看到,vue-dom-portal 定义了自定义指令 v-dom-portal,通过该指令可以实现脱离当前组件 dom 结构渲染内容,本质其实就是使用原生 DOM API 来手动搬运 DOM 元素来实现的,包括 Teleport 组件,其内部也是通过原生 DOM API 来手动搬运 DOM 元素实现脱离当前组件 dom 结构渲染内容的。

可以说 Teleport 组件给开发者提供了一定程度上的便利,对标 React 的 Portals

总结

Teleport 组件用于实现将组件内部的一部分模板“传送”到该组件的 DOM 结构外层的位置去,类似于 React 的 Portals ,当需要实现一个通用的弹窗组件时,Teleport 组件会非常地有用。

在源码实现上,Teleport 组件的渲染逻辑从渲染器中分离出来,这样做主要是为了:

  • 避免渲染器逻辑代码“膨胀”

  • 利用 Tree-Shaking 机制在最终的 bundle 中删除 Teleport 相关的代码,使得最终构建包的体积变小

Teleport 组件本质上是个对象,该对象包含 1 个属性,4 个方法。

1 个属性是 __isTeleport ,值为 true ,为 Teleport 组件的标识。

4 个方法分别为:

  • process 方法,用于负责 Teleport 组件的创建和更新的逻辑

  • remove 方法,用于负责 Teleport 组件的删除逻辑

  • move 方法,用于负责 Teleport 组件的移动逻辑

  • hydrate 方法,用于负责在 Vue.js 同构渲染过程中对 Teleport 组件的激活处理

pic6.png