• 【Vue.js 3.0源码】Teleport 组件:如何脱离当前组件渲染子组件?


    一、前言

    Vue.js 的核心思想之一是组件化,组件就是 DOM 的映射,我们通过嵌套的组件构成了一个组件应用程序的树。但是,有些时候组件模板的一部分在逻辑上属于该组件,而从技术角度来看,最好将模板的这一部分移动到应用程序之外的其他位置。一个常见的场景是创建一个包含全屏模式的对话框组件。在大多数情况下,我们希望对话框的逻辑存在于组件中,但是对话框的定位 CSS 是一个很大的问题,它非常容易受到外层父组件的 CSS 影响。假设我们有这样一个 dialog 组件,用按钮来管理一个 dialog:

    
    
    
    
    
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61

    dialog 组件使用的是 position:absolute 绝对定位的方式,如果它的父级 DOM 有 position 不为 static 的布局方式,那么 dialog 的定位就受到了影响,不能按预期渲染了。所以一种好的解决方案是把 dialog 组件渲染的这部分 DOM 挂载到 body 下面,这样就不会受到父级样式的影响了。Vue.js 3.0 把这一能力内置到内核中,提供了一个内置组件 Teleport,它可以轻松帮助我们实现上述需求:

    
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19

    Teleport 组件使用起来非常简单,套在想要在别处渲染的组件或者 DOM 节点的外部,然后通过 to 这个 prop 去指定渲染到的位置,to 可以是一个 DOM 选择器字符串,也可以是一个 DOM 节点。

    二、实现原理

    import { createVNode as _createVNode, resolveComponent as _resolveComponent, Teleport as _Teleport, openBlock as _openBlock, createBlock as _createBlock } from "vue"
    export function render(_ctx, _cache, $props, $setup, $data, $options) {
      const _component_Dialog = _resolveComponent("Dialog")
      return (_openBlock(), _createBlock("template", null, [
        _createVNode("button", { onClick: _ctx.showDialog }, "Show dialog", 8 /* PROPS */, ["onClick"]),
        (_openBlock(), _createBlock(_Teleport, { to: "body" }, [
          _createVNode(_component_Dialog, { ref: "dialog" }, null, 512 /* NEED_PATCH */)
        ]))
      ]))
    }
    
    const Teleport = {
      __isTeleport: true,
      process(n1, n2, container, anchor, parentComponent, parentSuspense, isSVG, optimized, internals) {
        if (n1 == null) {
          // 创建逻辑
        }
        else {
          // 更新逻辑
        }
      },
      remove(vnode, { r: remove, o: { remove: hostRemove } }) {
        // 删除逻辑
      },
      move: moveTeleport,
      hydrate: hydrateTeleport
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27

    1.Teleport 组件创建

    const patch = (n1, n2, container, anchor = null, parentComponent = null, parentSuspense = null, isSVG = false, optimized = false) => {
      if (n1 && !isSameVNodeType(n1, n2)) {
        // 如果存在新旧节点, 且新旧节点类型不同,则销毁旧节点
      }
      const { type, shapeFlag } = n2
      switch (type) {
        case Text:
          // 处理文本节点
          break
        case Comment:
          // 处理注释节点
          break
        case Static:
          // 处理静态节点
          break
        case Fragment:
          // 处理 Fragment 元素
          break
        default:
          if (shapeFlag & 1 /* ELEMENT */) {
            // 处理普通 DOM 元素
          }
          else if (shapeFlag & 6 /* COMPONENT */) {
            // 处理组件
          }
          else if (shapeFlag & 64 /* TELEPORT */) {
            // 处理 TELEPORT
            type.process(n1, n2, container, anchor, parentComponent, parentSuspense, isSVG, optimized, internals);
          }
          else if (shapeFlag & 128 /* SUSPENSE */) {
            // 处理 SUSPENSE
          }
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34

    可以看到,在 patch 阶段,会判断如果 type 是一个 Teleport 组件,则会执行它的 process 方法,接下来我们来看 process 方法关于 Teleport 组件创建部分的逻辑:

    function process(n1, n2, container, anchor, parentComponent, parentSuspense, isSVG, optimized, internals) {
      const { mc: mountChildren, pc: patchChildren, pbc: patchBlockChildren, o: { insert, querySelector, createText, createComment } } = internals
      const disabled = isTeleportDisabled(n2.props)
      const { shapeFlag, children } = n2
      if (n1 == null) {
        // 在主视图里插入注释节点或者空白文本节点
        const placeholder = (n2.el = (process.env.NODE_ENV !== 'production')
          ? createComment('teleport start')
          : createText(''))
        const mainAnchor = (n2.anchor = (process.env.NODE_ENV !== 'production')
          ? createComment('teleport end')
          : createText(''))
        insert(placeholder, container, anchor)
        insert(mainAnchor, container, anchor)
        // 获取目标移动的 DOM 节点
        const target = (n2.target = resolveTarget(n2.props, querySelector))
        const targetAnchor = (n2.targetAnchor = createText(''))
        if (target) {
          insert(targetAnchor, target)
        }
        else if ((process.env.NODE_ENV !== 'production')) {
          // 查找不到 target 则报警告
          warn('Invalid Teleport target on mount:', target, `(${typeof target})`)
        }
        const mount = (container, anchor) => {
          if (shapeFlag & 16 /* ARRAY_CHILDREN */) {
            // 挂载子节点
            mountChildren(children, container, anchor, parentComponent, parentSuspense, isSVG, optimized)
          }
        }
        if (disabled) {
          // disabled 情况就在原先的位置挂载
          mount(container, mainAnchor)
        }
        else if (target) {
          // 挂载到 target 的位置
          mount(target, targetAnchor)
        }
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40

    Teleport 组件创建部分主要分为三个步骤,第一步在主视图里插入注释节点或者空白文本节点,第二步获取目标元素节点,第三步往目标元素插入 Teleport 组件的子节点。第一步,会在非生产环境往 Teleport 组件原本的位置插入注释节点,在生产环境插入空白文本节点。在开发环境中,组件的 el 对象指向 teleport start 注释节点,组件的 anchor 对象指向teleport end 注释节点。第二步,会通过 resolveTarget 方法从 props 中的 to 属性以及 DOM 选择器拿到对应要移动到的目标元素 target。第三步,会判断 disabled 变量的值,它是在 Teleport 组件中通过 prop 传递的,如果 disabled 为 true,那么子节点仍然挂载到 Teleport 原本视图的位置,如果为 false,那么子节点则挂载到 target 目标元素位置。

    2.Teleport 组件更新

    function process(n1, n2, container, anchor, parentComponent, parentSuspense, isSVG, optimized, internals) {
      const { mc: mountChildren, pc: patchChildren, pbc: patchBlockChildren, o: { insert, querySelector, createText, createComment } } = internals
      const disabled = isTeleportDisabled(n2.props)
      const { shapeFlag, children } = n2
      if (n1 == null) {
        // 创建逻辑
      }
      else {
        n2.el = n1.el
        const mainAnchor = (n2.anchor = n1.anchor)
        const target = (n2.target = n1.target)
        const targetAnchor = (n2.targetAnchor = n1.targetAnchor)
        // 之前是不是 disabled 状态
        const wasDisabled = isTeleportDisabled(n1.props)
        const currentContainer = wasDisabled ? container : target
        const currentAnchor = wasDisabled ? mainAnchor : targetAnchor
        // 更新子节点
        if (n2.dynamicChildren) {
          patchBlockChildren(n1.dynamicChildren, n2.dynamicChildren, currentContainer, parentComponent, parentSuspense, isSVG)
          if (n2.shapeFlag & 16 /* ARRAY_CHILDREN */) {
            const oldChildren = n1.children
            const children = n2.children
            for (let i = 0; i < children.length; i++) {
              if (!children[i].el) {
                children[i].el = oldChildren[i].el
              }
            }
          }
        }
        else if (!optimized) {
          patchChildren(n1, n2, currentContainer, currentAnchor, parentComponent, parentSuspense, isSVG)
        }
        if (disabled) {
          if (!wasDisabled) {
            // enabled -> disabled
            // 把子节点移动回主容器
            moveTeleport(n2, container, mainAnchor, internals, 1 /* 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, 0 /* TARGET_CHANGE */)
            }
            else if ((process.env.NODE_ENV !== 'production')) {
              warn('Invalid Teleport target on update:', target, `(${typeof target})`)
            }
          }
          else if (wasDisabled) {
            // disabled -> enabled
            // 移动到目标元素位置
            moveTeleport(n2, target, targetAnchor, internals, 1 /* TOGGLE */)
          }
        }
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59

    Teleport 组件更新无非就是做几件事情:更新子节点,处理 disabled 属性变化的情况,处理 to 属性变化的情况。首先,更新 Teleport 组件的子节点,这里更新分为优化更新和普通的全量比对更新两种情况。接着,判断 Teleport 组件新节点配置 disabled 属性的情况,如果满足新节点 disabled 为 true,且旧节点的 disabled 为 false 的话,说明我们需要把 Teleport 的子节点从目标元素内部移回到主视图内部。如果新节点 disabled 为 false,那么先通过 to 属性是否改变来判断目标元素 target 有没有变化,如果有变化,则把 Teleport 的子节点移动到新的 target 内部;如果目标元素没变化,则判断旧节点的 disabled 是否为 true,如果是则把 Teleport 的子节点从主视图内部移动到目标元素内部。

    3.Teleport 组件移除

    if (shapeFlag & 64 /* TELEPORT */) {
      vnode.type.remove(vnode, internals);
    }
    if (doRemove) {
      remove(vnode);
    }
    
    function remove(vnode, { r: remove, o: { remove: hostRemove } }) {
      const { shapeFlag, children, anchor } = vnode
      hostRemove(anchor)
      if (shapeFlag & 16 /* ARRAY_CHILDREN */) {
        for (let i = 0; i < children.length; i++) {
          remove(children[i])
        }
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16

    Teleport 的 remove 方法实现很简单,首先通过 hostRemove 移除主视图渲染的锚点 teleport start 注释节点,然后再去遍历 Teleport 的子节点执行 remove 移除。执行完 Teleport 的 remove 方法,会继续执行 remove 方法移除 Teleport 主视图的元素 teleport end 注释节点,至此,Teleport 组件完成了移除。

    三、总结

    理解Teleport 是如何把内部的子元素渲染到目标元素上,以及其如何创建,更新和移除的基本原理。

  • 相关阅读:
    JAVA线程池
    ELK 7.17.5 集群部署及使用
    虚拟试穿VTNFP论文笔记
    Lwip之SNMP协议与实现
    css的布局方式
    每年AP考试报名时间是什么时候?
    第十三章:项目相关方管理
    MySQL 2 环境搭建(MySQL5.7.43和8.0.34的下载;8.0.34的安装、配置教程 )
    四、Flask进阶
    基于SSH框架的学生信息管理系统
  • 原文地址:https://blog.csdn.net/qq_42451979/article/details/126028600