Vue3 源码解读-Teleport 组件实现原理


💡 [本系列Vue3源码解读文章基于3.3.4版本](https://github.com/vuejs/core/tree/v3.3.4) 欢迎关注公众号:《前端 Talkking》

1、前言

Teleport组件是 Vue3 中内置的组件,可以将指定的内容渲染到特定的容器中,且不受 DOM 层级的限制,这在很多场景下非常有用,比如模态框、通知、弹出菜单等。例如,element-plus组件库中的 el-dialog组件底层就是使用 Teleport组件实现的,如下图所示:

2、Teleport 组件源码实现

2.1 实现原理

假如有以下模版内容:

html 复制代码
<template>
  <button @click="open = true">Open Modal</button>

  <Teleport to="body">
    <div v-if="open" class="modal">
      <p>Hello from the modal!</p>
      <button @click="open = false">Close</button>
    </div>
  </Teleport>
</template>

借助Vue SFC Playground平台,它会被编译成如下内容:

javascript 复制代码
return (_ctx, _cache) => {
  return (_openBlock(), _createElementBlock(_Fragment, null, [
    _createElementVNode("button", {
      onClick: _cache[0] || (_cache[0] = $event => (_isRef(open) ? open.value = true : open = true))
    }, "Open Modal"),
    (_openBlock(), _createBlock(_Teleport, { to: "body" }, [
      (_unref(open))
        ? (_openBlock(), _createElementBlock("div", _hoisted_1, [
            _hoisted_2,
            _createElementVNode("button", {
              onClick: _cache[1] || (_cache[1] = $event => (_isRef(open) ? open.value = false : open = false))
            }, "Close")
          ]))
        : _createCommentVNode("v-if", true)
    ]))
  ], 64 /* STABLE_FRAGMENT */))
}

可以看到,对于 Teleport标签,它是直接创建了 Teleport内置组件,我们来看它的实现:

javascript 复制代码
export const TeleportImpl = {
  name: 'Teleport',
  // Telport标识符
  __isTeleport: true,
  /**
   * 客户端渲染函数,优点:
   * 1.可以避免渲染器逻辑代码膨胀
   * 2.可以使用Tree-shaking删除代码,使得代码体积变小
   */
  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 {
      // 更新逻辑
    }
  },
  // 删除逻辑
  remove(
    vnode: VNode,
    parentComponent: ComponentInternalInstance | null,
    parentSuspense: SuspenseBoundary | null,
    optimized: boolean,
    { um: unmount, o: { remove: hostRemove } }: RendererInternals,
    doRemove: boolean
  ) {

  },
  // 移动Teleport
  move: moveTeleport,
  // 服务端渲染Teleport
  hydrate: hydrateTeleport
}

Teleport 组件其实就是一个对象,对外提供了 processremovemove方法,它们的作用分别是:

  • process :组件的创建和更新逻辑。将该逻辑剥离的原因有:1、避免渲染器的代码过多,2、可以利用 Tree-shaking删除 Teleport相关的代码,减小代码体积;
  • remove:组件的删除逻辑;
  • move:组件的移动逻辑。

接下来我们就从这三个函数的实现流程来分析 Teleport 组件的实现原理。

2.2 创建流程

patch函数中对 process函数的调用如下:

javascript 复制代码
// 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
  ) => {

    // 省略部分代码

    const { type, ref, shapeFlag } = n2
    switch (type) {
      // 省略部分代码
      default:

        // 省略部分代码

        // shapeFlag 的类型为 TELEPORT,则它是 Teleport 组件
        // 调用 Teleport 组件选项中的 process 函数将控制权交接出去
        // 传递给 process 函数的第五个参数是渲染器的一些内部方法
        else if (shapeFlag & ShapeFlags.TELEPORT) {
          ;(type as typeof TeleportImpl).process(
            n1 as TeleportVNode,
            n2 as TeleportVNode,
            container,
            anchor,
            parentComponent,
            parentSuspense,
            isSVG,
            slotScopeIds,
            optimized,
            internals
          )
        }
        // 省略部分代码
    }

    // 省略部分代码
  }

当判断 vnode的类型是 Teleport时,直接调用组件选项中定义的 process 方法将渲染控制权完全交接出去 ,这样就实现了渲染逻辑的分离。接下来我们来看看 Teleport中的 process方法实现逻辑:

Teleport 组件创建逻辑

以上代码处理流程如下:

  1. 创建并挂载注释节点

    1. 创建一个注释节点或文本节点作为占位符 placeholder,并将其赋值给 n2.el。如果是开发环境 (__DEV__true),则创建一个注释节点 "teleport start",否则创建一个空文本节点。

    2. 创建一个注释节点或文本节点作为锚点 mainAnchor,并将其赋值给 n2.anchor。如果是开发环境,创建一个注释节点 "teleport end",否则创建一个空文本节点。

    3. 使用 insert 函数将 placeholdermainAnchor 插入到 DOM 中的 container 元素里,位置是 anchor 指定的位置。

      第 1 步作用是:非生产环境往 Teleport 组件原本的位置插入注释节点,在生产环境插入空白文本节点

      typescript 复制代码
      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. 挂载 target 节点和占位节点

    1. 通过 resolveTarget 函数解析 Teleport 的目标元素,并将其赋值给 n2.target。同时创建一个空文本节点 targetAnchor 并赋值给 n2.targetAnchor

    2. 如果找到了有效的 target,则将 targetAnchor 插入到 target 中。如果在 SVG 上下文中,还会设置 isSVG 标志。

    3. 如果在开发环境中,且 target 无效且 disabled 未设置为 true,则会发出警告。

      第 2 步的作用是:拿到目标节点 target,并在目标节点位置创建了空的文本节点作为锚点

      typescript 复制代码
      // 获取容器,即挂载点
      const target = (n2.target = resolveTarget(n2.props, querySelector))
      const targetAnchor = (n2.targetAnchor = createText(''))
      // 如果挂载点存在
      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})`)
      }
  3. 定义渲染 Teleport 子节点的 mount 方法

    1. 定义 mount 函数,它接受 containeranchor 作为参数。如果 Teleport 的子节点是数组类型的子节点,则它会调用 mountChildren 函数来渲染这些子节点。

      第 3 步的作用是:定义渲染子节点的方法

      typescript 复制代码
      // 将n2.children渲染到指定挂载点
      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 方法渲染 Teleport 组件的插槽内容
          mountChildren(
            children as VNodeArrayChildren,
            container,
            anchor,
            parentComponent,
            parentSuspense,
            isSVG,
            slotScopeIds,
            optimized
          )
        }
      }
  4. 根据 disabled 处理不同的逻辑

    1. 如果 disabledtrue,则在当前的 containermainAnchor 上调用 mount 函数;

    2. 如果 disabledfalse 且存在有效的 target,则在 targettargetAnchor 上调用 mount 函数;

      第 4 步的作用是:根据 disabled 属性,实现真正的渲染子节点

      typescript 复制代码
      // 如果 Teleport 组件的 disabled 为 true,说明禁用了 <teleport> 的功能,Teleport 只会在 container 中渲染
      if (disabled) {
        mount(container, mainAnchor)
      } else if (target) {
        // 如果没有禁用 <teleport> 的功能,并且存在挂载点,则将其插槽内容渲染到target容中
        mount(target, targetAnchor)
      }

整体来看,这段代码负责创建 Teleport 组件的占位符和锚点,将它们插入到 DOM 中,并根据 disabled 属性决定是在原位置渲染子组件还是在 Teleport 的目标位置渲染子组件。

2.3 更新流程

Teleport 更新流程

typescript 复制代码
// update content
  n2.el = n1.el
  const mainAnchor = (n2.anchor = n1.anchor)!
  // 挂载点
  const target = (n2.target = n1.target)!
  // 锚点
  const targetAnchor = (n2.targetAnchor = n1.targetAnchor)!
  // 判断Telport组件是否禁用了
  const wasDisabled = isTeleportDisabled(n1.props)
  // 如果禁用了,那么挂载点就是周围父组件,否则就是to指定的目标挂载点
  const currentContainer = wasDisabled ? container : target
  const currentAnchor = wasDisabled ? mainAnchor : targetAnchor
  // 目标挂载点是否是SVG标签元素
  isSVG = isSVG || isTargetSVG(target)

  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)
  } else if (!optimized) {
    patchChildren(
      n1,
      n2,
      currentContainer,
      currentAnchor,
      parentComponent,
      parentSuspense,
      isSVG,
      slotScopeIds,
      false
    )
  }

  if (disabled) {
    if (!wasDisabled) {
      // enabled -> disabled
      // move into main container
      moveTeleport(
        n2,
        container,
        mainAnchor,
        internals,
        TeleportMoveTypes.TOGGLE
      )
    } else {
      // #7835
      // When `teleport` is disabled, `to` may change, making it always old,
      // to ensure the correct `to` when enabled
      if (n2.props && n1.props && n2.props.to !== n1.props.to) {
        n2.props.to = n1.props.to
      }
    }
  } else {
    // 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})`
        )
      }
    } else if (wasDisabled) {
      // disabled -> enabled
      // move into teleport target
      moveTeleport(
        n2,
        target,
        targetAnchor,
        internals,
        TeleportMoveTypes.TOGGLE
      )
    }
  }
}

Teleport组件更新主要做了以下几件事情:更新子节点、处理 disabled属性变化、处理 to属性变化的情况。更新流程的处理步骤拆解如下:

  1. 更新子节点:可以分为优化更新和普通的全量对比更新两种情况;
  2. 处理 disabled属性变化的情况:如果满足新节点 disabledtrue,且旧节点的 disabledfalse的话,说明我们需要把 Teleport 的子节点从目标元素内部移回到主视图内部了,此时执行相应的处理逻辑;
  3. 处理 to属性变化的情况:如果新节点的 disabedtrue,那么先通过 to属性是否改变来判断目标元素 target 有没有变化,如果有变化,则把 Teleport的子节点移动到新的 target内部;如果目标元素没变化,则判断旧节点的 disabled 是否为 true,如果是则把 Teleport的子节点从主视图内部移动到目标元素内部了。

2.3 卸载流程

当组件卸载的时候会执行 umount方法,它的内部会判断卸载的组件是否是 Teleport,如果是则会执行 Teleport组件的 remove方法,如下代码所示:

umount 卸载 Teleport 组件

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

我们来看看 Teleport组件 remove方法的实现:

typescript 复制代码
// 移除Telport
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

  if (target) {
    hostRemove(targetAnchor!)
  }

  // an unmounted teleport should always unmount its children whether it's disabled or not
  doRemove && hostRemove(anchor!)
  if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
    const shouldRemove = doRemove || !isTeleportDisabled(props)
    for (let i = 0; i < (children as VNode[]).length; i++) {
      const child = (children as VNode[])[i]
      unmount(
        child,
        parentComponent,
        parentSuspense,
        shouldRemove,
        !!child.dynamicChildren
      )
    }
  }
},

卸载流程中,首先调用 hostRemove移除主视图渲染的锚点"teleport start"注释节点,然后再去遍历 Teleport 的子节点调用 remove方法移除子节点。

3、总结

本篇详细解析了 Teleport组件的使用场景,剖析了创建流程、更新流程以及卸载流程。总体流程如下:

4、参考资料

相关推荐
abc80021170341 小时前
前端Bug 修复手册
前端·bug
Best_Liu~1 小时前
el-table实现固定列,及解决固定列导致部分滚动条无法拖动的问题
前端·javascript·vue.js
_斯洛伐克2 小时前
下降npm版本
前端·vue.js
苏十八3 小时前
前端进阶:Vue.js
前端·javascript·vue.js·前端框架·npm·node.js·ecmascript
st紫月4 小时前
用MySQL+node+vue做一个学生信息管理系统(四):制作增加、删除、修改的组件和对应的路由
前端·vue.js·mysql
乐容4 小时前
vue3使用pinia中的actions,需要调用接口的话
前端·javascript·vue.js
似水明俊德5 小时前
ASP.NET Core Blazor 5:Blazor表单和数据
java·前端·javascript·html·asp.net
至天6 小时前
UniApp 中 Web/H5 正确使用反向代理解决跨域问题
前端·uni-app·vue3·vue2·vite·反向代理
与墨学长6 小时前
Rust破界:前端革新与Vite重构的深度透视(中)
开发语言·前端·rust·前端框架·wasm
H-J-L6 小时前
Web基础与HTTP协议
前端·http·php