Vue3探秘系列— 脱离当前组件:Teleport的实现原理(十四)

627 阅读8分钟

前言

🎉 欢迎来到 Vue3探秘系列专栏!

在这里,我们将深入探索 Vue3 的各种奥秘,从源码到实践,一步步揭开它的神秘面纱。📚

🚀 以下是本系列文章的导航目录,方便你快速找到感兴趣的内容:

  1. 虚拟结点 vnode 的页面挂载之旅(一)
    不止响应式:Vue3探秘系列— 虚拟结点vnode的页面挂载之旅(一)
    🌟 探索虚拟 DOM 如何变成页面上的真实内容,开启 Vue3 的渲染之旅!
  2. 组件更新会发生什么(二)
    不止响应式:Vue3探秘系列— 组件更新会发生什么(二)
    🔃 深入组件更新的内部机制,看看 Vue3 是如何高效更新界面的。
  3. diff 算法的完整过程(三)
    不止响应式:Vue3探秘系列— diff算法的完整过程(三)
    🧩 揭秘 Vue3 的 diff 算法,理解它是如何高效比较和更新 DOM 的。
  4. 组件的初始化过程(四)
    不止响应式:Vue3探秘系列— 组件的初始化过程(四)
    🌱 从零开始,了解 Vue3 组件是如何初始化的,掌握组件生命周期的关键步骤。
  5. 响应式设计(五)
    终于轮到你了:Vue3探秘系列— 响应式设计(五)
    🔗 深入 Vue3 的响应式系统,探索 Proxy 如何实现高效的数据响应机制。

这只是系列的一部分,更多精彩内容还在持续更新中!🔍

Hello~大家好。我是秋天的一阵风

有时我们可能会遇到这样的场景:一个组件模板的一部分在逻辑上从属于该组件,但从整个应用视图的角度来看,它在 DOM 中应该被渲染在整个 Vue 应用外部的其他地方。

这类场景最常见的例子就是 全屏的模态框 。理想情况下,我们希望 触发模态框的按钮 和 模态框本身 是在同一个组件中,因为它们都与组件的开关状态有关。但这意味着该模态框将与按钮一起渲染在应用 DOM 结构里很深的地方。这会导致该模态框的CSS布局代码很难写。比如下面的代码:

<div class="outer">
  <h3>Tooltips with Vue 3 Teleport</h3>
  <div>
    <MyModal />
  </div>
</div>

接下来我们来看看 <MyModal> 的实现:

<script setup>
import { ref } from 'vue'

const open = ref(false)
</script>

<template>
  <button @click="open = true">Open Modal</button>

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

<style scoped>
.modal {
  position: fixed;
  z-index: 999;
  top: 20%;
  left: 50%;
  width: 300px;
  margin-left: -150px;
}
</style>

这个组件中有一个 <button> 按钮来触发打开模态框,和一个 class 名为 .modal 的 <div>,它包含了模态框的内容和一个用来关闭的按钮。

当在初始 HTML 结构中使用这个组件时,会有一些潜在的问题:

  • position: fixed 能够相对于浏览器窗口放置有一个条件,那就是不能有任何祖先元素设置了 transform、perspective 或者 filter 样式属性。也就是说如果我们想要用 CSS transform 为祖先节点 <div class="outer"> 设置动画,就会不小心破坏模态框的布局!
  • 这个模态框的 z-index 受限于它的容器元素。如果有其他元素与 <div class="outer"> 重叠并有更高的 z-index,则它会覆盖住我们的模态框。

一、<Teleport>的使用

在Vue3中新增了一个内置组件 <Teleport>, 它提供了一个更简单的方式来解决此类问题,让我们不需要再顾虑 DOM 结构的问题。让我们用 <Teleport> 改写一下 <MyModal>:

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

<Teleport> 接收一个 to 来指定传送的目标。to 的值可以是一个 CSS 选择器字符串,也可以是一个 DOM 元素对象。这段代码的作用就是告诉 Vue“把以下模板片段传送到 body 标签下”。

了解了使用方式,接下来,我们就来分析它的实现原理,看看 Teleport 是如何脱离当前组件渲染子组件的。

二、Teleport 实现原理

我们先来看一下前面示例模板编译后的结果:

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 */)
    ]))
  ]))
}

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

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
}

Teleport 组件的实现就是一个对象,对外提供了几个方法。其中 process 方法负责组件的创建和更新逻辑,remove 方法负责组件的删除逻辑,接下来我们就从这三个方面来分析Teleport的实现原理。

1. process 创建组件

说到组件的创建或更新,同学们现在应该是已经非常熟悉了,首先就是去找patch方法,找到处理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
      }
  }
}

其实我们之前就可以猜到,在teleport对象定义的方法一定是在合适的时候执行。

这个teleport的分支也非常简单:如果 type 是一个 Teleport 组件,则会执行它的 process 方法。

现在我就需要详细地去process方法里面去查看具体逻辑

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) 插入注释节点或空白文本结点

如果在非生产环境下,就会在Teleport组件当前位置插入一个注释节点,组件的el对象指向teleport start注释节点且组件的 anchor 对象指向teleport end 注释节点

如果是生产环境,就会插入空白文本节点。

(2) 获取目标移动的 DOM 节点

通过 resolveTarget 方法从props中的 to 属性以及DOM选择器拿到对应要移动到的目标元素 target。

(3) 判断disabled情况

判断 disabled 变量的值,它是在 Teleport 组件中通过 prop 传递的,如果 disabled 为 true,那么子节点仍然挂载到 Teleport 原本视图的位置,如果为 false,那么子节点则挂载到 target 目标元素位置。

2. process 更新组件

更新情况下其实也是走process方法,只不过里面分支不同

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) 更新子节点

第一步是更新 Teleport 组件的子节点,这里更新分为优化更新和普通的全量比对更新两种情况,这一块不是本篇的重点,我们不再花时间去研究。只需要知道结果是更新子节点。

(2) 处理 disabled 属性变化的情况

如果新节点 disabled 为 true,且旧节点的disabled为 false 的话,说明我们需要把 Teleport 的子节点从目标元素内部移回到主视图内部了。

(3) 处理 to 属性变化的情况

如果新节点 disabled 为 false,那么先通过 to 属性是否改变来判断目标元素 target 有没有变化。

如果有变化,则把Teleport的子节点移动到新的target内部;

如果目标元素没变化,则判断旧节点的 disabled 是否为 true,如果是则把 Teleport 的子节点从主视图内部移动到目标元素内部了

3. unmount 卸载组件

当组件移除的时候会执行 unmount 方法,它的内部会判断如果移除的组件是一个 Teleport 组件,就会执行组件的 remove 方法

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

Teleport 的 remove 方法实现很简单,首先通过 hostRemove 移除主视图渲染的锚点 teleport start 注释节点,然后再去遍历 Teleport 的子节点执行remove移除。

执行完 Teleport 的 remove 方法,会继续执行remove方法移除 Teleport 主视图的元素 teleport end 注释节点,至此,Teleport 组件完成了移除。

总结

本篇文章我们学习了teleport的使用以及基本实现原理,对于teleport组件,它首先是一个对象,对外提供process和remove方法,当组件在创建、更新、删除时去执行teleport对象里这三个方法。

  • 如果是创建,需要判断是否生产环境来插入空白文本结点或注释节点,接着通过工具方法获取target元素和to属性,最后根据disabled参数来决定是否挂载到目标元素位置

  • 如果是更新,则会patchChildren或patchBlockChildren来更新子节点。接着会判断新旧disabled值和新旧to属性来决定做什么操作

  • 如果是删除,就通过hostRemove删除锚点节点和reomve删除子节点