Teleport:渲染到任意DOM节点

0 阅读3分钟

在前面的文章中,我们学习了组件渲染、生命周期、依赖注入等核心概念。今天,我们将探索 Vue3 中一个特殊的组件:Teleport。它允许我们将一段 DOM 内容"传送"到指定的 DOM 节点,突破组件树的限制。理解它的实现原理,将帮助我们更好地处理模态框、全局提示等场景。

前言:Teleport 要解决的问题

在传统的 Vue 应用开发中,组件的渲染是严格按照组件树结构进行的,即组件的 DOM 树结构和组件树结构是完全一致的: 传统组件树结构 这种结构虽然直观,但会带来一些问题,比如下面一段代码,我们想创建一个模态对话框:

<template>
  <div class="main">
    <div class="content" id="content">
      <Modal class="modal">
        <!-- 模态框内容 -->
      </Modal>
    </div>
  </div>
</template>

在这段代码中,<Modal> 组件会被渲染到 idcontentdiv 标签下,但这其实并不是我们所想要的。因为对于模态对话框而言,其本质是一个“蒙层”组件,即该组件会渲染一个“蒙层”,并遮挡页面上的所有元素,此时最好的处理方式是:将<Modal> 组件的 z-index 设置到最高。

于是,问题就产生了:假如idcontentdiv 有一个内联样式:z-index: -1 ,此时即使把 <Modal> 组件的 z-index 设置成无穷大,也无法实现遮挡功能。

Teleport 的解决方案

Teleport 组件允许我们指定要渲染的目标,即 to 属性的值,该组件就会直接把 Teleport 组件的内容渲染到指定的目标下:

<template>
  <div class="main">
    <button @click="openModal">打开模态框</button>
    <Teleport to="body">
      <div class="modal">
        <h3>模态框标题</h3>
        <p>模态框内容</p>
      </div>
    </Teleport>
  </div>
</template>

Teleport 示例图

Teleport的目标定位

目标的多种形式

  1. CSS选择器:<Teleport to="body"></Teleport>
  2. DOM元素:<Teleport :to="targetElement"></Teleport>
  3. 动态目标:<Teleport :to="showModal ? 'body' : null"></Teleport>
  4. 禁用传送:<Teleport :to="target" :disabled="!isReady"></Teleport>

目标解析的实现

/**
 * 解析目标
 */
function resolveTarget(target, component) {
  if (typeof target === 'string') {
    // CSS选择器
    const el = document.querySelector(target);
    if (!el) {
      console.warn(`Teleport target "${target}" not found`);
      return document.body; // 降级到body
    }
    return el;
  } else if (target instanceof HTMLElement) {
    // 直接传入DOM元素
    return target;
  } else if (target?.$el) {
    // Vue组件实例
    return target.$el;
  } else if (target === null) {
    return null; // 禁用传送
  }
  
  return document.body; // 默认降级
}

/**
 * Teleport组件定义
 */
const Teleport = {
  name: 'Teleport',
  __isTeleport: true,
  
  props: {
    to: {
      type: [String, Object],
      required: true
    },
    disabled: {
      type: Boolean,
      default: false
    }
  },
  
  setup(props, { slots }) {
    const target = ref(null);
    
    // 监听to变化
    watch(() => props.to, (newTo) => {
      target.value = resolveTarget(newTo);
    }, { immediate: true });
    
    // 返回插槽内容
    return () => {
      if (props.disabled || !target.value) {
        // 禁用时在当前位置渲染
        return slots.default?.();
      }
      
      // 启用时使用Teleport渲染
      return h(TeleportImpl, {
        to: target.value,
        disabled: false
      }, slots.default?.());
    };
  }
};

目标容器的缓存

/**
 * 目标容器缓存
 */
class TargetCache {
  constructor() {
    this.targets = new Map();
  }
  
  /**
   * 获取目标容器
   */
  getTarget(to, component) {
    const key = typeof to === 'string' ? to : to?.__v_skip ? null : to;
    
    if (key && this.targets.has(key)) {
      return this.targets.get(key);
    }
    
    const target = this.resolveTarget(to, component);
    
    if (key) {
      this.targets.set(key, target);
    }
    
    return target;
  }
  
  /**
   * 解析目标容器
   */
  resolveTarget(to, component) {
    if (typeof to === 'string') {
      // 尝试在组件上下文中查找
      if (to.startsWith('#')) {
        const id = to.slice(1);
        // 先在当前组件的模板中查找
        const contextEl = component?.vnode?.el?.ownerDocument;
        if (contextEl) {
          const el = contextEl.getElementById(id);
          if (el) return el;
        }
      }
      
      return document.querySelector(to) || document.body;
    }
    
    if (to instanceof HTMLElement) {
      return to;
    }
    
    if (to?.$el) {
      return to.$el;
    }
    
    return document.body;
  }
  
  /**
   * 清空缓存
   */
  clear() {
    this.targets.clear();
  }
}

const targetCache = new TargetCache();

父子组件关系维护

组件树 vs DOM树

Teleport 的一个重要特性就是:可以保持组件树的关系不变,仅仅只改变 DOM 树的关系。我们可以看下面一个示例:

// 父组件
const Parent = {
  setup() {
    const count = ref(0);
    
    provide('parentCount', count);
    
    return { count };
  },
  template: `
    <div class="parent">
      <button @click="count++">增加</button>
      <Teleport to="body">
        <Child />
      </Teleport>
    </div>
  `
};

// 子组件(被传送到body)
const Child = {
  inject: ['parentCount'],
  template: `
    <div class="child">
      父组件count: {{ parentCount }}
    </div>
  `
};

上述示例的 DOM 树与组件树关系图如下: 组件树 vs DOM树

组件实例的关联

/**
 * 维护组件实例关系
 */
function createTeleportVNode(component, props, children) {
  const vnode = {
    type: Teleport,
    props,
    children,
    shapeFlag: ShapeFlags.TELEPORT,
    
    // 组件实例(即使DOM分离,组件关系仍在)
    component: null,
    parent: component,
    
    // DOM引用
    el: null,
    anchor: null,
    
    // Teleport特有属性
    target: null,
    disabled: false
  };
  
  return vnode;
}

/**
 * 在渲染器中处理Teleport的父子关系
 */
class Renderer {
  patch(oldVNode, newVNode, container, anchor) {
    const { type } = newVNode;
    
    if (type === Teleport) {
      // Teleport特殊处理
      if (oldVNode == null) {
        this.mountTeleport(newVNode, container, anchor);
      } else {
        this.updateTeleport(oldVNode, newVNode, container, anchor);
      }
      
      // 维护组件实例关系
      if (newVNode.component) {
        newVNode.component.parent = this.currentInstance;
      }
      
      return;
    }
    
    // 普通节点处理...
  }
  
  /**
   * 挂载Teleport
   */
  mountTeleport(vnode, container, anchor) {
    // 创建组件实例(如果vnode包含组件)
    if (vnode.type !== Teleport && vnode.shapeFlag & ShapeFlags.COMPONENT) {
      const instance = createComponentInstance(vnode);
      vnode.component = instance;
      
      // 设置父组件
      if (this.currentInstance) {
        instance.parent = this.currentInstance;
      }
    }
    
    // 调用Teleport的处理逻辑
    Teleport.process(null, vnode, container, anchor, {
      patch: this.patch.bind(this),
      move: this.move.bind(this),
      unmount: this.unmount.bind(this)
    });
  }
}

事件冒泡的处理

/**
 * Teleport中的事件冒泡
 */
<template>
  <div class="parent" @click="handleParentClick">
    <Teleport to="body">
      <div class="child" @click="handleChildClick">
        <button @click.stop="handleButtonClick">按钮</button>
      </div>
    </Teleport>
  </div>
</template>

<script>
export default {
  methods: {
    handleParentClick() {
      console.log('父组件点击'); // 仍然会被触发
    },
    handleChildClick() {
      console.log('子组件点击'); // 会被触发
    },
    handleButtonClick() {
      console.log('按钮点击'); // 会被触发,且冒泡到child和parent
    }
  }
}
</script>

手写实现:完整Teleport组件

完整实现

/**
 * Teleport 完整实现
 */
const Teleport = {
  name: 'Teleport',
  __isTeleport: true,
  
  props: {
    to: {
      type: [String, Object],
      required: true,
      validator(value) {
        if (typeof value === 'string') return true;
        if (value instanceof HTMLElement) return true;
        if (value && value.$el) return true;
        return false;
      }
    },
    disabled: {
      type: Boolean,
      default: false
    }
  },
  
  setup(props, { slots }) {
    // 目标容器
    const target = ref(null);
    
    // 目标解析错误处理
    const error = ref(null);
    
    // 解析目标
    const resolveTarget = () => {
      try {
        if (props.disabled) return null;
        
        if (typeof props.to === 'string') {
          const el = document.querySelector(props.to);
          if (!el) {
            throw new Error(`Teleport target "${props.to}" not found`);
          }
          return el;
        }
        
        if (props.to instanceof HTMLElement) {
          return props.to;
        }
        
        if (props.to?.$el) {
          return props.to.$el;
        }
        
        return null;
      } catch (err) {
        error.value = err.message;
        return null;
      }
    };
    
    // 监听to变化
    watch(() => props.to, () => {
      target.value = resolveTarget();
    }, { immediate: true, deep: true });
    
    // 提供错误信息(可选)
    provide('teleportError', error);
    
    // 返回渲染函数
    return () => {
      if (error.value) {
        console.error(`Teleport error: ${error.value}`);
      }
      
      // 返回一个占位注释节点和实际内容
      // 这样可以在DOM中标记位置
      return [
        h(Comment, `teleport-start-${props.to}`),
        props.disabled || !target.value
          ? slots.default?.()
          : h(TeleportWrapper, {
              to: target.value,
              disabled: false
            }, slots.default?.()),
        h(Comment, `teleport-end-${props.to}`)
      ];
    };
  }
};

/**
 * Teleport包装器(内部使用)
 */
const TeleportWrapper = {
  name: 'TeleportWrapper',
  __isTeleportWrapper: true,
  
  props: {
    to: {
      type: HTMLElement,
      required: true
    },
    disabled: Boolean
  },
  
  setup(props, { slots }) {
    const container = ref(null);
    const teleportContent = ref(null);
    
    onMounted(() => {
      if (!props.disabled && props.to && teleportContent.value) {
        // 将内容移动到目标容器
        while (teleportContent.value.firstChild) {
          props.to.appendChild(teleportContent.value.firstChild);
        }
      }
    });
    
    onUpdated(() => {
      if (!props.disabled && props.to && teleportContent.value) {
        // 更新时重新移动
        while (teleportContent.value.firstChild) {
          props.to.appendChild(teleportContent.value.firstChild);
        }
      }
    });
    
    onBeforeUnmount(() => {
      // 清理
      if (teleportContent.value) {
        teleportContent.value.innerHTML = '';
      }
    });
    
    return () => {
      if (props.disabled) {
        // 禁用时直接渲染
        return slots.default?.();
      }
      
      // 渲染到一个隐藏容器,然后移动到目标
      return h('div', {
        ref: teleportContent,
        style: { display: 'none' }
      }, slots.default?.());
    };
  }
};

// 注册Teleport
app.component('Teleport', Teleport);

增强版本(支持多个目标)

/**
 * 多目标Teleport
 */
const MultiTeleport = {
  name: 'MultiTeleport',
  
  props: {
    targets: {
      type: Array,
      required: true
    },
    distribution: {
      type: Array,
      default: () => [] // 指定每个子节点去哪个target
    }
  },
  
  setup(props, { slots }) {
    const children = slots.default?.() || [];
    
    // 分配子节点到不同target
    const distributions = props.distribution.length
      ? props.distribution
      : children.map((_, i) => i % props.targets.length);
    
    // 为每个target创建Teleport
    const teleports = props.targets.map((target, index) => {
      const targetChildren = children.filter((_, i) => distributions[i] === index);
      
      return h(Teleport, {
        to: target,
        key: index
      }, () => targetChildren);
    });
    
    return () => teleports;
  }
};

条件 Teleport

/**
 * 条件Teleport
 */
const ConditionalTeleport = {
  name: 'ConditionalTeleport',
  
  props: {
    to: [String, Object],
    condition: {
      type: Function,
      default: () => true
    },
    fallbackTo: {
      type: [String, Object],
      default: null
    }
  },
  
  setup(props, { slots }) {
    const currentTarget = ref(null);
    
    const updateTarget = () => {
      const shouldTeleport = props.condition();
      currentTarget.value = shouldTeleport ? props.to : props.fallbackTo;
    };
    
    // 初始更新
    updateTarget();
    
    // 监听条件变化(需要在组件中触发)
    // 可以通过事件或响应式数据触发
    
    return () => {
      if (!currentTarget.value) {
        return slots.default?.();
      }
      
      return h(Teleport, {
        to: currentTarget.value
      }, slots.default?.());
    };
  }
};

应用场景:模态框

基础模态框

<template>
  <div class="app">
    <button @click="showModal = true">打开模态框</button>
    
    <Teleport to="body">
      <div v-if="showModal" class="modal-overlay" @click="showModal = false">
        <div class="modal-container" @click.stop>
          <div class="modal-header">
            <h3>{{ title }}</h3>
            <button class="close" @click="showModal = false">×</button>
          </div>
          <div class="modal-body">
            <slot></slot>
          </div>
          <div class="modal-footer">
            <button @click="showModal = false">取消</button>
            <button class="primary" @click="confirm">确认</button>
          </div>
        </div>
      </div>
    </Teleport>
  </div>
</template>

<script>
export default {
  props: ['title'],
  emits: ['confirm'],
  data() {
    return {
      showModal: false
    };
  },
  methods: {
    open() {
      this.showModal = true;
    },
    close() {
      this.showModal = false;
    },
    confirm() {
      this.$emit('confirm');
      this.close();
    }
  }
};
</script>

<style scoped>
.modal-overlay {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background-color: rgba(0, 0, 0, 0.5);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 1000;
}

.modal-container {
  background-color: white;
  border-radius: 8px;
  min-width: 400px;
  max-width: 90%;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
</style>

可拖拽模态框

const DraggableModal = {
  props: ['title'],
  setup(props, { emit }) {
    const show = ref(false);
    const position = ref({ x: 100, y: 100 });
    const dragging = ref(false);
    const dragStart = ref({ x: 0, y: 0 });
    
    const startDrag = (e) => {
      dragging.value = true;
      dragStart.value = {
        x: e.clientX - position.value.x,
        y: e.clientY - position.value.y
      };
    };
    
    const onDrag = (e) => {
      if (!dragging.value) return;
      position.value = {
        x: e.clientX - dragStart.value.x,
        y: e.clientY - dragStart.value.y
      };
    };
    
    const stopDrag = () => {
      dragging.value = false;
    };
    
    onMounted(() => {
      window.addEventListener('mousemove', onDrag);
      window.addEventListener('mouseup', stopDrag);
    });
    
    onUnmounted(() => {
      window.removeEventListener('mousemove', onDrag);
      window.removeEventListener('mouseup', stopDrag);
    });
    
    const open = () => show.value = true;
    const close = () => show.value = false;
    
    return {
      show,
      position,
      dragging,
      startDrag,
      open,
      close
    };
  }
};

模态框管理器

class ModalManager {
  constructor() {
    this.modals = [];
    this.container = null;
  }
  
  init() {
    // 创建容器
    this.container = document.createElement('div');
    this.container.id = 'modal-manager';
    document.body.appendChild(this.container);
  }
  
  open(component, props = {}) {
    const id = Symbol('modal');
    const modal = {
      id,
      component,
      props,
      resolve: null,
      reject: null
    };
    
    const promise = new Promise((resolve, reject) => {
      modal.resolve = resolve;
      modal.reject = reject;
    });
    
    this.modals.push(modal);
    this.update();
    
    return promise;
  }
  
  close(id, result) {
    const index = this.modals.findIndex(m => m.id === id);
    if (index !== -1) {
      const modal = this.modals[index];
      modal.resolve(result);
      this.modals.splice(index, 1);
      this.update();
    }
  }
  
  update() {
    // 触发重新渲染
    if (this.app) {
      this.app.config.globalProperties.$modals = this.modals;
    }
  }
}

结语

Teleport 是 Vue3 中一个强大的特性,它打破了 DOM 树的限制,让我们可以更灵活地组织组件。理解它的实现原理,不仅能帮助我们更好地使用它,也能在遇到复杂场景时找到合适的解决方案。

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!