• 开始时间:2020-01-10
  • 目标主要版本:3.x
  • 引用 issue:N/A
  • 实现的 PR:N/A

摘要

  • 为 Vue core 添加一个 组件。
  • 该组件需要一个目标元素,通过一个期望 HTMLElement 或 querySelector 字符串的 prop 提供。
  • 该组件将其子元素移动到 DOM 选择器所确定的元素上。
  • 在虚拟 DOM 层面上,虽然子元素是 的后代,但它们可以访问来自其祖先的注入。

基本范例

  1. <body>
  2. <div id="app">
  3. <h1>Move the #content with the portal component</h1>
  4. <teleport to="#endofbody">
  5. <div id="content">
  6. <p>
  7. this will be moved to #endofbody.<br />
  8. Pretend that it's a modal
  9. </p>
  10. <Child />
  11. </div>
  12. </teleport>
  13. </div>
  14. <div id="endofbody"></div>
  15. <script>
  16. new Vue({
  17. el: "#app",
  18. components: {
  19. Child: { template: "<div>Placeholder</div>" }
  20. }
  21. });
  22. </script>
  23. </body>

这将导致以下行为:

  1. <teleport> 的所有孩子 —— 在这个例子中。<div id="content"><Child /> —— 将被添加到 <div id="endofbody" > 中。
  2. 作为这些节点之一的 <Child /> 组件将保持为 <teleport> 的父节点的子节点(<teleport> 是透明的)。
    1. <div id="app">
    2. <!-- -->
    3. </div>
    4. <div id="endofbody">
    5. <div id="content">
    6. <p>
    7. this will be moved to #endofbody.<br />
    8. Pretend that it's a modal
    9. </p>
    10. <div>Placeholder</div>
    11. </div>
    12. </div>

动机

Vue 鼓励我们通过将 UI 和相关行为封装到组件中来构建我们的 UI,我们可以将这些组件相互嵌套,从而构建一个组件树,构建成你的应用程序 UI。这种模式在 Vue 和其他框架中得到了很多证明,但本 RFC 试图解决一个弱点:

有时候,一个组件的一部分模版在逻辑上属于这个组件,而从技术角度上来看(即:样式要求),最好把这部分模版移到 DOM 的其他地方,把它从 DOM 树中的深度嵌套位置打破。

用例

z-index

这种行为的主要用例通常与样式有关。各种常见的 UI 模式,如模态、对话框、下拉菜单、通知等,需要 fixed 或者 absolute 定位和管理它们的 z-index。

为了解决 z-index 堆叠上下文 行为的问题,一个常见的模式是将这些组件的 DOM 元素放在 标签之前,以便将它们移出任何父元素的 z-index 堆叠上下文。

Widgets

很多应用程序都有 widgets 的概念,其中它们的 UI 有一个出口(即,在侧边栏或仪表盘中),应用程序的其他部分(即插件),可以注入一小块的 UI。

在单页应用程序中,我们的 JavaScript 基本上控制了整个页面,这通常不是一个挑战。但是在我们的 Vue 应用中只控制页面的一部分的情况下,目前证明在页面的其他部分挂载单个元素和组件是有挑战的(但不可能)。

有了 <teleport>,我们就有了一种直接的方法来将子组件安装到 DOM 的其他位置。

具体设计

作为内部组件实现

<teleport> “组件” 是一个像 <trasition><keep-alive> 一样的内部组件。它是 tree-shakable 的,所以如果你不使用这个功能,这个组件的代码就会从最终的 bundle 中删除。

在模版中使用

在模版中,编译器会在生成的代码中添加 <teleport> 组件的导入,所以可以像这样使用:

  1. export default {
  2. template: `
  3. <div>
  4. <teleport to="#endofbody">
  5. Some content.
  6. </teleport>
  7. <div>
  8. `
  9. };

当使用渲染函数或 JSX 时,组件必须先导入,就像任何其他组件一样:

  1. import { Teleport, h } from "vue";
  2. export default {
  3. render() {
  4. return h("div", [h(Teleport, { to: "#endofbody" }, ["Some content"])]);
  5. },
  6. // or with JSX:
  7. render() {
  8. <div>
  9. <Teleport to="#endofbody">Some content</Teleport>
  10. </div>;
  11. }
  12. };

在统一目标上使用多个 portals

一个常见的案例场景时一个可重用的 组件,它可能有多个实例同时活动。对于这种情况,多个 可以将它们的内容加载到同一个目标元素上。顺序将是一个简单的 append —— 在目标元素中,后面的挂载将位于前面的挂载之后。

  1. <teleport to="#modals">
  2. <div>A</div>
  3. </teleport>
  4. <teleport to="#modals">
  5. <div>B</div>
  6. </teleport>
  7. <!-- result-->
  8. <div id="modals">
  9. <div>A</div>
  10. <div>B</div>
  11. </div>

在迄今为止关于这个 RFC 的讨论中,讨论了更加复杂的行为(可选的预置,定义顺序 ……),但对复杂性的担忧和可预见的 SSR 和 hydration lead 问题导致我们将这种行为限制在简单的 append 上。

Props

to

该组件只有一个必要的 prop,名为 to。它接受一个字符串,它必须是一个有效的 query 选择器,或者一个 HTMLElement(如果在浏览器环境中使用)。

  1. <!-- ok -->
  2. <teleport to="#some-id" />
  3. <teleport to=".some-class" />
  4. <teleport to="[data-portal]" />
  5. <!--
  6. probably too unspecific, but technically valid
  7. should we allow this or block it?
  8. -->
  9. <teleport to="h1" />
  10. <!-- Wrong -->
  11. <teleport to="some-string" />
  12. ></teleport>

disable

这个可选的 prop 可以用来禁用 portal 的功能,这意味着它的插槽内容不会被移动到任何地方,而是在你指定的 <teleport> 的周围父组件中呈现。

  1. <teleport to="#popup" :disabled="displayVideoInline">
  2. <video src="./my-movie.mp4">
  3. </teleport>

动态改变它的值允许在 to prop 指定的目标和周围父组件中的实际位置之间移动相同的 DOM 元素。这意味着 <teleport> 内的任何组件都将保持 alive 并保持其内部状态。同样,<video> 元素将会在这些位置之间移动时保持其播放状态。

声明周期

Mounting

<teleport> 组件被它的父组件挂载时,它将使用 to 属性的值作为选择器。

  • 如果 query 返回一个元素,<teleport> 的插槽将作为该元素的子节点挂载在 DOM 中。
  • 如果在这个 <teleport> 被挂载的时候,这个元素在 DOM 中不存在,那么在开发过程中会产生一个类似下面的警告(在生产环境中不会发生什么)。 ``javascriptTeleport content could not be mounted to element with selector ‘${props.to}’: element not found.

// following would be a display where in the component tree this happened etc.

  1. <a name="Avudi"></a>
  2. #### $parent
  3. 如果 `<teleport>` 的子节点包含任何组件,它们的 `this.$parent` 属性应该引用 `<teleprot>` 的父组件。换句话说,这些组件会留在它们在组件树中的原始位置,即使它们最后被挂载在 DOM 树的其他地方。
  4. `<teleport>` 根本不是一个真正的组件,它是透明的,不会在 $parent 链中作为祖先出现。
  5. ```vue
  6. <template>
  7. <teleport v-bind:to="targetName">
  8. <Child />
  9. </teleport>
  10. </template>
  11. <script>
  12. export default {
  13. name: 'Parent'
  14. components: {
  15. Child: {
  16. template: '<div/>',
  17. mounted() {
  18. console.log(this.$parent.$options.name )
  19. // => 'Parent'
  20. }
  21. }
  22. },
  23. }
  24. </script>

同样的,在 Child 中使用 inject 应该能够从 Parent 或者其先祖之一注入任何提供的内容。

Updating

to 属性可以通过 v-bind 动态的改变。当该值改变时, 将从以前的目标中移除子元素,并将它们移到新的目标中。

如果子元素包含任何组件实例,这些实例将不会受到影响。这些实例将被保持 alive,保持它们的状态等。

  1. <template>
  2. <teleport v-bind:to="targetName">
  3. <p>This can be moved around with the button below</p>
  4. </teleport>
  5. <button v-on:click="toggleTarget">Toggle</button>
  6. <hr />
  7. <div id="A"></div>
  8. <div id="B"></div>
  9. </template>
  10. <script>
  11. export default {
  12. data: () => ({
  13. targetName: "A"
  14. }),
  15. methods: {
  16. toggleTarget() {
  17. this.targetName = this.targetName == "A" ? "B" : "A";
  18. }
  19. }
  20. };
  21. </script>

如果新的目标选择器不匹配任何元素:

  1. 在开发过程中应该记录一个警告。
  2. 内容将保持挂载之前的目标元素之上。

销毁(Destruction)

<teleport> 被销毁时(例如,因为它的父组件被销毁或因为 v-if),它的子组件被从 DOM 中移除,任何组件实例被销毁,就像它们仍然是父组件的子组件一样。

各种各样的

与原生 portal 的命名冲突

本 RFC 所引入的组件在本 RFC 的早期版本中被命名为 。但是有关于原生 portal 的提议:

由于我们不希望与未来可能被称为 <portal> 的 HTML 元素发生命名冲突,特别是由于它的功能与 Vue 或 React 等软件中的 portal 的含义完全不同,我们选择将该组件更名为 <teleport>

或者我们应该保留它,因为 Vue、React 等的 portal 概念已经是“常识”了,新的术语可能会使人们感到困惑而不是帮助?

dev-tools

<teleport> 不应该出现在父组件链中(this.$parent),但它应该在虚拟 DOM 中可以被识别,这样 Vue 的开发工具就可以在组件树的可视化中显示它们。

在 Vue 应用中的一个元素上使用 的问题

从技术上讲,这个建议允许选择 DOM 中的任何元素,包括由我们的 Vue 应用在组件树的其他部分渲染的元素。

但这使得 portal 的插槽内容处于其他组件声明周期的控制之下,这意味着如果该组件被销毁,内容可能会从 DOM 中被删除。

任何通过 <teleport> 而来的组件将有效地被删除其 DOM,而仍然在原来的虚拟 DOM 树中,这将导致这些组件试图更新出现补丁错误。

处理这个问题需要很多额外的逻辑,因此,这个用例被明确地排出在 RFC 之外。远程传输任何由 Vue 控制的 DOM 元素被认为是一种反模式,会导致真是 DOM 和虚拟 DOM 不同步。

缺点

我们看到的唯一明显的缺点是需要额外的代码来实现这一点。但从原型的实验来看,这段代码将是非常轻的,因为它只是在 virtualDOM 层挂载元素的一种稍微不同的方式,而且这个组件本身是可以 tree-shakable。、

由于这是一个附加功能,而且功能相当直接(一个属性定义为目标选择器),这也应该不会给 Vue 在文档或教学方面增加多少复杂性。

当考虑到目前的用户区解决方案是多么受欢迎,即使有其注意事项和局限性,成本/效益比似乎很明显。

备选方案

到目前为止,还没考虑其他设计。

如果我们不这样做会发生什么?

目前有几个用户区实现了这个功能,它们通常有一些注意事项和缺点,因为 Vue2 中的虚拟 DOM 级别不支持 portal。

人们可以继续使用这些具有现有限制和缺点的东西。

采纳策略

<teleport> 是一个新功能,因此纯粹是附加性质的。因此,该功能对 Vue 2.0 的应用程序迁移到 Vue 3.0 没有任何影响,除了那些可能选择将其某个组件命名为 的应用程序 —— 但这很容易通过将该组件的注册名改为其他名称来解决。

初次接触 Vue3.0 或一般 Vue 的用户能够以常用的方式从文档中了解这一功能。并且在有意义的地方逐渐将其引入他们的项目中。

现有的第三方解决方案

如前所述,现在有几个第三方 plugin/libs 实现了类似的功能。

其中一些可能与本 RFC 无关,而另一些提供的功能超出了本提议的描述,可以调整他们的实现,在内部使用本提议的“原生” <teleport> 组件

如果 RFC vuejs/vue-next#28(Render function change)被采纳,那么这些库无论如何都要重新开发,这时它们可以采用这个新功能。

没有解决的问题

N/A